Merge branch 'main' into new-match-page

This commit is contained in:
Kalle 2026-04-27 07:16:07 +03:00
commit 5cc65fcf0c
102 changed files with 1660 additions and 2470 deletions

View File

@ -56,6 +56,7 @@ Another key objective is to bridge the gap between casual and competitive player
- [Git](https://git-scm.com/)
- [Node.js v22](https://nodejs.org/en)
- [pnpm](https://pnpm.io/installation)
Optionally [nvm](https://github.com/nvm-sh/nvm) can be convenient for managing multiple Node.js installs
@ -66,6 +67,7 @@ First verify you have Node.js and git installed:
```bash
node --version
git --version
pnpm --version
```
You should see something like:
@ -73,6 +75,7 @@ You should see something like:
```
v22.13.0
git version 2.39.5 (Apple Git-154)
10.33.0
```
(if not then go back to "Prerequisites" and install what is missing)

View File

@ -73,9 +73,10 @@ function CategoryMenu({
const [isOpen, setIsOpen] = useState(false);
const user = useUser();
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
const visibleItems = category.items.filter(
(item) => !("staffOnly" in item) || isStaff,
(item) => !("staffOnly" in item) || showStaffOnly,
);
return (

View File

@ -68,7 +68,7 @@ export const meta: MetaFunction = (args) => {
};
export default function ArtPage() {
const { t } = useTranslation(["art", "common"]);
const { t } = useTranslation(["art", "common", "forms"]);
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const switchId = React.useId();
@ -100,7 +100,7 @@ export default function ArtPage() {
id={switchId}
/>
<Label htmlFor={switchId} className="m-auto-0">
{t("art:openCommissionsOnly")}
{t("forms:labels.profileCommissionsOpen")}
</Label>
</div>
<div

View File

@ -147,6 +147,10 @@
"displayName": "Biggest School in the Sea",
"authorDiscordId": "629773997822967819"
},
"bubblyb": {
"displayName": "Bubbly's Birthdays",
"authorDiscordId": "752582395076673577"
},
"bucketmeow12": {
"displayName": "Token Of Appreciation",
"authorDiscordId": "1170249805373657093"
@ -863,6 +867,10 @@
"displayName": "Oktofest LAN (Second)",
"authorDiscordId": "751912670403362836"
},
"oocee": {
"displayName": "OCE Open Series",
"authorDiscordId": "1170249805373657093"
},
"order1": {
"displayName": "ORDR S1",
"authorDiscordId": "338806780446638082"
@ -1459,6 +1467,14 @@
"displayName": "Weapon Lockdown Special Edition",
"authorDiscordId": "338806780446638082"
},
"wellstringcustom": {
"displayName": "Wellstring Propoganda: Low Cut",
"authorDiscordId": "338806780446638082"
},
"wellstringregular": {
"displayName": "Wellstring Propoganda: Top Cut",
"authorDiscordId": "338806780446638082"
},
"whitecat": {
"displayName": "White Cat Achievement",
"authorDiscordId": "631246535560265749"
@ -1467,6 +1483,10 @@
"displayName": "Wi Wi Wi Cat",
"authorDiscordId": "530722502603833346"
},
"wings": {
"displayName": "Wings Up!",
"authorDiscordId": "752582395076673577"
},
"wiper": {
"displayName": "Squeaky Clean Scrap",
"authorDiscordId": "528851510222782474"

View File

@ -1,7 +1,11 @@
import { z } from "zod";
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
import { ability, modeShort, safeJSONParse } from "~/utils/zod";
import { MAX_BUILD_FILTERS } from "./builds-constants";
import {
BUILDS_PAGE_BATCH_SIZE,
BUILDS_PAGE_MAX_BUILDS,
MAX_BUILD_FILTERS,
} from "./builds-constants";
const abilityFilterSchema = z.object({
type: z.literal("ability"),
@ -37,3 +41,10 @@ export const buildFiltersSearchParams = z.preprocess(
export type BuildFiltersFromSearchParams = NonNullable<
z.infer<typeof buildFiltersSearchParams>
>;
export const buildsLimitSearchParam = z.coerce
.number()
.int()
.positive()
.catch(BUILDS_PAGE_BATCH_SIZE)
.transform((value) => Math.min(value, BUILDS_PAGE_MAX_BUILDS));

View File

@ -6,11 +6,13 @@ import { weaponNameSlugToId } from "~/utils/unslugify.server";
import { mySlugify } from "~/utils/urls";
import * as BuildRepository from "../BuildRepository.server";
import {
BUILDS_PAGE_BATCH_SIZE,
BUILDS_PAGE_MAX_BUILDS,
FILTER_SEARCH_PARAM_KEY,
} from "../builds-constants";
import { buildFiltersSearchParams } from "../builds-schemas.server";
import {
buildFiltersSearchParams,
buildsLimitSearchParam,
} from "../builds-schemas.server";
import { filterBuilds } from "../core/filter.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@ -25,10 +27,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
const url = new URL(request.url);
const limit = Math.min(
Number(url.searchParams.get("limit") ?? BUILDS_PAGE_BATCH_SIZE),
BUILDS_PAGE_MAX_BUILDS,
);
const limit = buildsLimitSearchParam.parse(url.searchParams.get("limit"));
const weaponName = t(`weapons:MAIN_${weaponId}`);

View File

@ -46,13 +46,13 @@ export interface ShowcaseCalendarEvent extends CommonEvent {
hidden: boolean;
isFinalized: boolean;
minMembersPerTeam: number;
firstPlacer: {
firstPlacers: Array<{
teamName: string;
logoUrl: string | null;
members: (CommonUser & { country: Tables["User"]["country"] })[];
notShownMembersCount: number;
div: string | null;
} | null;
}>;
hasVods?: boolean;
}

View File

@ -57,7 +57,8 @@ export function TournamentCard({
return (
<div
className={clsx(className, styles.container, {
[styles.containerTall]: isShowcase && tournament.firstPlacer,
[styles.containerTall]:
isShowcase && tournament.firstPlacers.length > 0,
})}
data-testid="tournament-card"
>
@ -117,15 +118,17 @@ export function TournamentCard({
<Tags tags={tournament.tags} small centered />
</div>
) : null}
{isShowcase && tournament.firstPlacer ? (
{isShowcase && tournament.firstPlacers.length > 0 ? (
<TournamentFirstPlacers
firstPlacer={tournament.firstPlacer}
firstPlacers={tournament.firstPlacers}
censored={isCensored(tournament.id)}
/>
) : null}
</Link>
<div className="stack horizontal justify-between items-center">
{isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? (
{isShowcase &&
tournament.firstPlacers.length > 0 &&
isCensored(tournament.id) ? (
<SpoilerRevealPill onReveal={() => reveal(tournament.id)} />
) : null}
{isShowcase && "hasVods" in tournament && tournament.hasVods ? (
@ -157,20 +160,52 @@ export function TournamentCard({
}
function TournamentFirstPlacers({
firstPlacer,
firstPlacers,
censored,
}: {
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
firstPlacers: ShowcaseCalendarEvent["firstPlacers"];
censored: boolean;
}) {
if (firstPlacers.length > 1) {
return (
<div className={styles.firstPlacers}>
<div className="stack md items-start">
{firstPlacers.map((placer) => (
<TournamentFirstPlacerTeamNameOnly
key={placer.div ?? placer.teamName}
placer={placer}
censored={censored}
/>
))}
</div>
</div>
);
}
const placer = firstPlacers[0];
return (
<div className={styles.firstPlacers}>
<TournamentFirstPlacerWithMembers placer={placer} censored={censored} />
</div>
);
}
function TournamentFirstPlacerWithMembers({
placer,
censored,
}: {
placer: ShowcaseCalendarEvent["firstPlacers"][number];
censored: boolean;
}) {
const { t } = useTranslation(["front"]);
return (
<div className={styles.firstPlacers}>
<>
<div className="stack xs horizontal items-center text-xs">
{!censored && firstPlacer.logoUrl ? (
{!censored && placer.logoUrl ? (
<img
src={firstPlacer.logoUrl}
src={placer.logoUrl}
alt=""
width={24}
className="rounded-full"
@ -178,16 +213,16 @@ function TournamentFirstPlacers({
) : null}{" "}
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{censored ? "???" : firstPlacer.teamName}
{censored ? "???" : placer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
{firstPlacer.div ? ` (${firstPlacer.div})` : null}
{placer.div ? ` (${placer.div})` : null}
</div>
</div>
</div>
<div className="text-xxs stack items-start mt-1">
{firstPlacer.members.map((member) => (
{placer.members.map((member) => (
<div key={member.id} className="stack horizontal xs items-center">
{!censored && member.country ? (
<Flag tiny countryCode={member.country} />
@ -195,12 +230,34 @@ function TournamentFirstPlacers({
{censored ? "???" : member.username}{" "}
</div>
))}
{!censored && firstPlacer.notShownMembersCount > 0 ? (
{!censored && placer.notShownMembersCount > 0 ? (
<div className="font-bold text-lighter">
+{firstPlacer.notShownMembersCount}
+{placer.notShownMembersCount}
</div>
) : null}
</div>
</>
);
}
function TournamentFirstPlacerTeamNameOnly({
placer,
censored,
}: {
placer: ShowcaseCalendarEvent["firstPlacers"][number];
censored: boolean;
}) {
const { t } = useTranslation(["front"]);
return (
<div className="stack items-start">
<span className={styles.firstPlacersTeamName}>
{censored ? "???" : placer.teamName}
</span>
<div className="text-xxxs text-lighter font-bold text-uppercase">
{t("front:showcase.card.winner")}
{placer.div ? ` (${placer.div})` : null}
</div>
</div>
);
}

View File

@ -141,6 +141,10 @@ export async function setMetadata(args: SetMetadataArgs) {
args.participantUserIds,
);
logger.debug(
`Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`,
);
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
method: "POST",
body: JSON.stringify({

View File

@ -1,10 +1,12 @@
import cachified from "@epic-web/cachified";
import * as R from "remeda";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
getBracketProgressionLabel,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import {
@ -186,16 +188,21 @@ function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
const threeDaysAgo = databaseTimestampThreeDaysAgo();
const nonResults = tournaments.filter(
(tournament) =>
!tournament.firstPlacer &&
tournament.firstPlacers.length === 0 &&
!tournament.isFinalized &&
tournament.startTime > threeDaysAgo,
);
const rankedResults = tournaments
.filter((tournament) => tournament.firstPlacer && tournament.isRanked)
.filter(
(tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const nonRankedResults = tournaments
.filter((tournament) => tournament.firstPlacer && !tournament.isRanked)
.filter(
(tournament) =>
tournament.firstPlacers.length > 0 && !tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const rankedResultsToKeep = rankedResults.slice(0, 4);
@ -283,7 +290,7 @@ const MEMBERS_TO_SHOW = 5;
function mapTournamentFromDB(
tournament: TournamentRepository.ForShowcase,
): ShowcaseCalendarEvent {
const highestDivWinners = resolveHighestDivisionWinners(tournament);
const firstPlacers = resolveFirstPlacers(tournament);
const tentativeTier =
tournament.tier === null &&
@ -320,41 +327,35 @@ function mapTournamentFromDB(
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
modes: null,
hasVods: (tournament.vodCount ?? 0) > 0,
firstPlacer:
highestDivWinners.length > 0
? {
teamName: highestDivWinners[0].teamName,
logoUrl:
highestDivWinners[0].teamLogoUrl ??
highestDivWinners[0].pickupAvatarUrl,
div: highestDivWinners[0].div,
members: highestDivWinners
.slice(0, MEMBERS_TO_SHOW)
.map((firstPlacer) => ({
customUrl: firstPlacer.customUrl,
discordAvatar: firstPlacer.discordAvatar,
discordId: firstPlacer.discordId,
id: firstPlacer.id,
username: firstPlacer.username,
country: firstPlacer.country,
})),
notShownMembersCount:
highestDivWinners.length > MEMBERS_TO_SHOW
? highestDivWinners.length - MEMBERS_TO_SHOW
: 0,
}
: null,
firstPlacers,
};
}
function resolveHighestDivisionWinners(
type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number];
function resolveFirstPlacers(
tournament: TournamentRepository.ForShowcase,
) {
): ShowcaseCalendarEvent["firstPlacers"] {
if (tournament.firstPlacers.length === 0) {
return [];
}
// not a "many starting brackets" tournament
if (
Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression)
) {
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");
return Object.values(byDiv)
.map((rows) => buildFirstPlacerEntry(rows, { withMembers: false }))
.sort((a, b) => (a.div ?? "").localeCompare(b.div ?? ""));
}
const winnerRows = winnersOfHighestDivision(tournament);
return [buildFirstPlacerEntry(winnerRows, { withMembers: true })];
}
function winnersOfHighestDivision(
tournament: TournamentRepository.ForShowcase,
): FirstPlacerRow[] {
if (tournament.firstPlacers.every((p) => p.div === null)) {
return tournament.firstPlacers;
}
@ -363,8 +364,6 @@ function resolveHighestDivisionWinners(
0,
tournament.settings.bracketProgression,
);
// Filter to only include winners from the highest division
const highestDivWinners = tournament.firstPlacers.filter(
(p) => p.div === highestDivName,
);
@ -374,6 +373,34 @@ function resolveHighestDivisionWinners(
: tournament.firstPlacers;
}
function buildFirstPlacerEntry(
rows: FirstPlacerRow[],
{ withMembers }: { withMembers: boolean },
): ShowcaseCalendarEvent["firstPlacers"][number] {
const first = rows[0];
const members = withMembers
? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({
customUrl: row.customUrl,
discordAvatar: row.discordAvatar,
discordId: row.discordId,
id: row.id,
username: row.username,
country: row.country,
}))
: [];
return {
teamName: first.teamName,
logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl,
div: first.div,
members,
notShownMembersCount:
withMembers && rows.length > MEMBERS_TO_SHOW
? rows.length - MEMBERS_TO_SHOW
: 0,
};
}
function databaseTimestampWeekFromNow() {
const now = new Date();

View File

@ -23,6 +23,7 @@ export const handle: SendouRouteHandle = {
};
const PROGRAMMERS = [
"hfcRed",
"DoubleCookies",
"ElementUser",
"remmycat",

View File

@ -1,5 +1,10 @@
import type { Rating, Team } from "node_modules/openskill/dist/types";
import { rate as openskillRate, ordinal, rating } from "openskill";
import {
rate as openskillRate,
ordinal,
type Rating,
rating,
type Team,
} from "openskill";
import invariant from "~/utils/invariant";
import type { TierName } from "./mmr-constants";
import { TIERS } from "./mmr-constants";

View File

@ -4,7 +4,6 @@ import { Link } from "react-router";
import { Image } from "~/components/Image";
import type { LoaderNotification } from "~/components/layout/NotificationPopover";
import {
mapMetaForTranslation,
notificationLink,
notificationNavIcon,
} from "~/features/notifications/notifications-utils";
@ -23,7 +22,7 @@ export function NotificationItem({
notification: LoaderNotification;
onClose?: () => void;
}) {
const { t, i18n } = useTranslation(["common"]);
const { t } = useTranslation(["common"]);
return (
<Link
@ -36,10 +35,7 @@ export function NotificationItem({
{!notification.seen ? <div className={styles.unseenDot} /> : null}
</NotificationImage>
<div className={styles.itemHeader}>
{t(
`common:notifications.text.${notification.type}`,
mapMetaForTranslation(notification, i18n.language),
)}
{t(`common:notifications.text.${notification.type}`, notification.meta)}
</div>
<div className={styles.timestamp}>
{formatDistance(

View File

@ -139,7 +139,7 @@ describe("notify()", () => {
userIds: [10, 11],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, at: 123 },
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
@ -147,7 +147,7 @@ describe("notify()", () => {
userIds: [10, 11],
notification: {
type: "SCRIM_CANCELED",
meta: { id: 1, at: 123 },
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
@ -346,7 +346,7 @@ describe("notify() - web push notifications", () => {
expect(mockSendNotification).not.toHaveBeenCalled();
});
test("formats timestamp for scrim notifications", async () => {
test("includes opponent team name for scrim notifications", async () => {
const mockSubscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/test",
keys: {
@ -367,13 +367,11 @@ describe("notify() - web push notifications", () => {
mockWebPushEnabled.value = true;
const testTimestamp = new Date("2024-01-15T15:30:00Z").getTime();
await notify({
userIds: [1],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, at: testTimestamp },
meta: { id: 1, opponentTeamName: "Sendou's pickup" },
},
});
@ -383,8 +381,6 @@ describe("notify() - web push notifications", () => {
const payload = JSON.parse(callArgs);
expect(payload.title).toBe("Scrim Scheduled");
expect(payload.body).toMatch(
/New scrim scheduled at \d+\/\d+, \d+:\d+ (AM|PM)/,
);
expect(payload.body).toBe("New scrim scheduled vs. Sendou's pickup");
});
});

View File

@ -7,10 +7,7 @@ import { i18next } from "../../../modules/i18n/i18next.server";
import { logger } from "../../../utils/logger";
import * as NotificationRepository from "../NotificationRepository.server";
import type { Notification } from "../notifications-types";
import {
mapMetaForTranslation,
notificationLink,
} from "../notifications-utils";
import { notificationLink } from "../notifications-utils";
import webPush, { webPushEnabled } from "./webPush.server";
const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
@ -168,10 +165,12 @@ function pushNotificationOptions(
): Parameters<ServiceWorkerRegistration["showNotification"]>[1] & {
title: string;
} {
const meta = mapMetaForTranslation(notification, "en-US");
return {
title: t(`common:notifications.title.${notification.type}`),
body: t(`common:notifications.text.${notification.type}`, meta),
body: t(
`common:notifications.text.${notification.type}`,
notification.meta,
),
icon: notification.pictureUrl ?? "/static-assets/img/app-icon.png",
data: { url: notificationLink(notification) },
};

View File

@ -62,9 +62,15 @@ export type Notification =
>
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>
| NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }>
| NotificationItem<"SCRIM_SCHEDULED", { id: number; at: number }>
| NotificationItem<"SCRIM_CANCELED", { id: number; at: number }>
| NotificationItem<"SCRIM_STARTING_SOON", { id: number; at: number }>
| NotificationItem<
"SCRIM_SCHEDULED",
{ id: number; opponentTeamName: string }
>
| NotificationItem<"SCRIM_CANCELED", { id: number; opponentTeamName: string }>
| NotificationItem<
"SCRIM_STARTING_SOON",
{ id: number; opponentTeamName: string }
>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>
| NotificationItem<"FRIEND_REQUEST_RECEIVED", { senderUsername: string }>
| NotificationItem<

View File

@ -107,27 +107,3 @@ export const notificationLink = (notification: Notification) => {
assertUnreachable(notification);
}
};
/** Takes the `meta` object of a notification and transforms it (if needed) to show the translated string to user */
export const mapMetaForTranslation = (
notification: Notification,
language: string,
) => {
if (
notification.type === "SCRIM_SCHEDULED" ||
notification.type === "SCRIM_CANCELED" ||
notification.type === "SCRIM_STARTING_SOON"
) {
return {
...notification.meta,
timeString: new Date(notification.meta.at).toLocaleString(language, {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
}),
};
}
return notification.meta;
};

View File

@ -8,10 +8,7 @@ import {
parseRequestPayload,
} from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import {
databaseTimestampToDate,
databaseTimestampToJavascriptTimestamp,
} from "../../../utils/dates";
import { databaseTimestampToDate } from "../../../utils/dates";
import { errorToast } from "../../../utils/remix.server";
import { requireUser } from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
@ -42,17 +39,29 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
reason: data.reason,
});
notify({
userIds: Scrim.participantIdsListFromAccepted(post),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: {
id: post.id,
at: databaseTimestampToJavascriptTimestamp(Scrim.getStartTime(post)),
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
},
});
});
notify({
userIds: acceptedRequest.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
}
return null;
};

View File

@ -11,7 +11,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
databaseTimestampToDate,
databaseTimestampToJavascriptTimestamp,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { ConcurrentModificationError } from "~/utils/errors";
@ -157,18 +156,24 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
}
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(request);
notify({
userIds: [
...post.users.map((m) => m.id),
...request.users.map((m) => m.id),
],
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_SCHEDULED",
meta: {
id: post.id,
at: databaseTimestampToJavascriptTimestamp(request.at ?? post.at),
},
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
notify({
userIds: request.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});

View File

@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
import { applyFilters, participantIdsListFromAccepted } from "./Scrim";
import {
applyFilters,
participantIdsListFromAccepted,
sideDisplayName,
} from "./Scrim";
type MockUser = { id: number };
type MockRequest = { isAccepted: boolean; users: MockUser[] };
@ -78,6 +82,27 @@ describe("participantIdsListFromAccepted", () => {
});
});
describe("sideDisplayName", () => {
it("returns the team name when team is set", () => {
const result = sideDisplayName({
team: { name: "Team Olive" },
users: [{ username: "sendou", isOwner: true }],
});
expect(result).toBe("Team Olive");
});
it("falls back to {owner}'s pickup when team is null", () => {
const result = sideDisplayName({
team: null,
users: [
{ username: "alice", isOwner: false },
{ username: "sendou", isOwner: true },
],
});
expect(result).toBe("sendou's pickup");
});
});
describe("applyFilters", () => {
function createPostForFilters(
at: Date,

View File

@ -50,6 +50,19 @@ export function getStartTime(post: ScrimPost): number {
return acceptedRequest?.at ?? post.at;
}
/**
* Returns a display name for a scrim side: the team name when set,
* otherwise "{ownerUsername}'s pickup".
*/
export function sideDisplayName(side: {
team: { name: string } | null;
users: Array<{ username: string; isOwner: boolean }>;
}): string {
if (side.team) return side.team.name;
const owner = side.users.find((u) => u.isOwner) ?? side.users[0];
return `${owner.username}'s pickup`;
}
export function applyFilters(post: ScrimPost, filters: ScrimFilters): boolean {
const hasMinFilter = filters.divs?.min !== null;
const hasMaxFilter = filters.divs?.max !== null;

View File

@ -447,4 +447,36 @@ describe("cancelMatch", () => {
expect(result.status).toBe("CANT_CANCEL");
expect(result.shouldRefreshCaches).toBe(false);
});
test("admin cancel locks match without applying SP changes", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
const adminUserId = 1;
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: adminUserId,
isAdminReport: true,
});
expect(result.status).toBe("CANCEL_CONFIRMED");
expect(result.shouldRefreshCaches).toBe(true);
const alphaGroup = await fetchGroup(alphaGroupId);
const bravoGroup = await fetchGroup(bravoGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
expect(bravoGroup?.status).toBe("INACTIVE");
const skills = await fetchSkills(match.id);
const realSkills = skills.filter((s) => s.season !== -1);
expect(realSkills).toHaveLength(0);
expect(skills).toHaveLength(1);
expect(skills[0].season).toBe(-1);
const maps = await fetchMapResults(match.id);
for (const map of maps) {
expect(map.winnerGroupId).toBeNull();
}
});
});

View File

@ -16,8 +16,10 @@ import {
concatUserSubmittedImagePrefix,
tournamentLogoWithDefault,
} from "~/utils/kysely.server";
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import type { Unpacked } from "~/utils/types";
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
import { SendouQError } from "../sendouq/q-utils.server";
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
import { compareMatchToReportedScores } from "./core/match.server";
@ -438,7 +440,15 @@ export function create({
memento: JSON.stringify(memento),
})
.returningAll()
.executeTakeFirstOrThrow();
.executeTakeFirstOrThrow()
.catch((error) => {
// race: another manager matched one of the two groups first, tripping the
// unique constraint on GroupMatch.alphaGroupId / bravoGroupId
if (errorIsSqliteUniqueConstraintFailure(error)) {
throw new SendouQError("Group is already in a match");
}
throw error;
});
await trx
.insertInto("GroupMatchMap")
@ -790,13 +800,25 @@ export async function reportScore({
export async function cancelMatch({
matchId,
reportedByUserId,
isAdminReport,
}: {
matchId: number;
reportedByUserId: number;
isAdminReport?: boolean;
}): Promise<CancelMatchResult> {
const match = await findById(matchId);
invariant(match, "Match not found");
if (isAdminReport) {
await db.transaction().execute(async (trx) => {
await updateScore({ matchId, reportedByUserId, winners: [] }, trx);
await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx);
await SQGroupRepository.setAsInactive(match.groupBravo.id, trx);
await lockMatchWithoutSkillChange(match.id, trx);
});
return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true };
}
const members = buildMembers(match);
const reporterGroupId = members.find(
(m) => m.id === reportedByUserId,

View File

@ -1,5 +1,4 @@
import type { Rating } from "node_modules/openskill/dist/types";
import { ordinal } from "openskill";
import { ordinal, type Rating } from "openskill";
import type {
GroupSkillDifference,
Tables,

View File

@ -28,7 +28,9 @@
.tierName {
color: var(--color-text-inverse);
line-height: 1.2;
white-space: nowrap;
max-width: 90px;
overflow-wrap: break-word;
word-wrap: break-word;
}
.popupContent {

View File

@ -70,7 +70,6 @@
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: lowercase;
white-space: nowrap;
}
.authorInfo {
@ -83,5 +82,4 @@
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text);
white-space: nowrap;
}

View File

@ -1,5 +1,6 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
@ -278,22 +279,8 @@ export class RoundRobinBracket extends Bracket {
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return this.standingsWithoutNonParticipants(
sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
stats: team.stats,
};
}),
Standings.reNumberPlacements(sorted),
);
}

View File

@ -1,5 +1,6 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import invariant from "~/utils/invariant";
@ -443,22 +444,8 @@ export class SwissBracket extends Bracket {
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return this.standingsWithoutNonParticipants(
sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
stats: team.stats,
};
}),
Standings.reNumberPlacements(sorted),
);
}

View File

@ -274,22 +274,36 @@ const getValidatedBrackets = (
);
describe("validatedSources - other rules", () => {
it("handles NOT_RESOLVING_WINNER (only round robin)", () => {
const error = getValidatedBrackets([
it("accepts a single round robin with no follow-ups", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
]) as Progression.ValidationError;
]);
expect(error.type).toBe("NOT_RESOLVING_WINNER");
expect(Array.isArray(result)).toBe(true);
});
it("handles NOT_RESOLVING_WINNER (ends in round robin)", () => {
const error = getValidatedBrackets([
it("accepts a single A/B round robin with no follow-ups", () => {
const result = getValidatedBrackets([
{
settings: {
hasAbDivisions: true,
teamsPerGroup: 6,
},
type: "round_robin",
},
]);
expect(Array.isArray(result)).toBe(true);
});
it("accepts a swiss to round robin progression", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "single_elimination",
type: "swiss",
},
{
settings: {},
@ -301,9 +315,30 @@ describe("validatedSources - other rules", () => {
},
],
},
]) as Progression.ValidationError;
]);
expect(error.type).toBe("NOT_RESOLVING_WINNER");
expect(Array.isArray(result)).toBe(true);
});
it("accepts a round robin to round robin progression", () => {
const result = getValidatedBrackets([
{
settings: {},
type: "round_robin",
},
{
settings: {},
type: "round_robin",
sources: [
{
bracketId: "0",
placements: "1,2",
},
],
},
]);
expect(Array.isArray(result)).toBe(true);
});
it("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => {

View File

@ -405,7 +405,6 @@ function resolvesWinner(brackets: ParsedBracket[]) {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
if (finals?.type === "round_robin") return false;
if (
finals.type === "swiss" &&
(finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1
@ -668,6 +667,16 @@ export function isFinals(idx: number, brackets: ParsedBracket[]) {
return resolveMainBracketProgression(brackets).at(-1) === idx;
}
/** Returns true if the finals bracket of the tournament is an A/B divisions round robin. */
export function hasAbDivisionsFinals(brackets: ParsedBracket[]): boolean {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
return (
finals.type === "round_robin" && finals.settings?.hasAbDivisions === true
);
}
/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket".
* Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers
*/

View File

@ -1,7 +1,7 @@
// this file offers database functions specifically for the crud.server.ts file
import { sql } from "~/db/sql";
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import type { Tables } from "~/db/tables";
import type {
Group as GroupType,
Match as MatchType,
@ -243,20 +243,17 @@ export class Round {
stageId: Tables["TournamentRound"]["stageId"];
groupId: Tables["TournamentRound"]["groupId"];
number: Tables["TournamentRound"]["number"];
maps: Pick<TournamentRoundMaps, "count" | "type">;
constructor(
id: Tables["TournamentRound"]["id"] | undefined,
stageId: Tables["TournamentRound"]["stageId"],
groupId: Tables["TournamentRound"]["groupId"],
number: Tables["TournamentRound"]["number"],
maps: Pick<TournamentRoundMaps, "count" | "type">,
) {
this.id = id;
this.stageId = stageId;
this.groupId = groupId;
this.number = number;
this.maps = maps;
}
insert() {
@ -389,9 +386,6 @@ export class Match {
groupId: Tables["TournamentMatch"]["groupId"],
roundId: Tables["TournamentMatch"]["roundId"],
number: Tables["TournamentMatch"]["number"],
_unknown1: null,
_unknown2: null,
_unknown3: null,
opponentOne: string,
opponentTwo: string,
) {

View File

@ -1,210 +1,195 @@
// @ts-nocheck TODO
import type {
CrudInterface,
DataTypes,
OmitId,
Table,
} from "~/modules/brackets-manager/types";
import { Group, Match, Round, Stage } from "./crud-db.server";
export class SqlDatabase {
insert(table, arg) {
export class SqlDatabase implements CrudInterface {
insert<T extends Table>(table: T, value: OmitId<DataTypes[T]>): number;
insert<T extends Table>(table: T, values: OmitId<DataTypes[T]>[]): boolean;
insert<T extends Table>(
table: T,
arg: OmitId<DataTypes[T]> | OmitId<DataTypes[T]>[],
): number | boolean {
switch (table) {
case "participant":
throw new Error("not implemented");
// return Team.insertMissing(arg);
case "stage": {
const value = arg as OmitId<DataTypes["stage"]>;
const stage = new Stage(
undefined,
arg.tournament_id,
arg.number,
arg.name,
arg.type,
JSON.stringify(arg.settings),
value.tournament_id,
value.number,
value.name,
value.type,
JSON.stringify(value.settings),
);
return stage.insert() && stage.id;
stage.insert();
return stage.id!;
}
case "group": {
const group = new Group(undefined, arg.stage_id, arg.number);
return group.insert() && group.id;
const value = arg as OmitId<DataTypes["group"]>;
const group = new Group(undefined, value.stage_id, value.number);
group.insert();
return group.id!;
}
case "round": {
const value = arg as OmitId<DataTypes["round"]>;
const round = new Round(
undefined,
arg.stage_id,
arg.group_id,
arg.number,
value.stage_id,
value.group_id,
value.number,
);
return round.insert() && round.id;
round.insert();
return round.id!;
}
case "match": {
const value = arg as OmitId<DataTypes["match"]>;
const match = new Match(
undefined,
arg.status,
arg.stage_id,
arg.group_id,
arg.round_id,
arg.number,
null,
null,
null,
JSON.stringify(arg.opponent1),
JSON.stringify(arg.opponent2),
value.status,
value.stage_id,
value.group_id,
value.round_id,
value.number,
JSON.stringify(value.opponent1),
JSON.stringify(value.opponent2),
);
return match.insert() && match.id;
match.insert();
return match.id!;
}
}
}
select(table, arg) {
select<T extends Table>(table: T): Array<DataTypes[T]> | null;
select<T extends Table>(table: T, id: number): DataTypes[T] | null;
select<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>,
): Array<DataTypes[T]> | null;
select<T extends Table>(
table: T,
arg?: number | Partial<DataTypes[T]>,
): DataTypes[T] | Array<DataTypes[T]> | null {
switch (table) {
case "participant":
case "stage": {
if (typeof arg === "number") {
throw new Error("not implemented");
// const team = Team.getById(arg);
// return team && convertTeam(team);
return Stage.getById(arg) as DataTypes[T];
}
if (arg.tournament_id) {
return Team.getByTournamentId(arg.tournament_id);
const filter = arg as Partial<DataTypes["stage"]> | undefined;
if (filter?.tournament_id) {
return Stage.getByTournamentId(filter.tournament_id) as Array<
DataTypes[T]
>;
}
break;
}
case "stage":
case "group": {
if (typeof arg === "number") {
return Stage.getById(arg);
return Group.getById(arg) as DataTypes[T];
}
if (arg.tournament_id && arg.number) {
throw new Error("not implemented");
// const stage = Stage.getByTournamentAndNumber(
// arg.tournament_id,
// arg.number,
// );
// return stage && [convertStage(stage)];
const filter = arg as Partial<DataTypes["group"]> | undefined;
if (filter?.stage_id && filter.number) {
const group = Group.getByStageAndNumber(
filter.stage_id,
filter.number,
);
return group ? ([group] as Array<DataTypes[T]>) : null;
}
if (arg.tournament_id) {
return Stage.getByTournamentId(arg.tournament_id);
if (filter?.stage_id) {
return Group.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
break;
}
case "group":
if (!arg) {
throw new Error("not implemented");
// const groups = Group.getAll();
// return groups?.map(convertGroup);
}
case "round": {
if (typeof arg === "number") {
return Group.getById(arg);
return Round.getById(arg) as DataTypes[T];
}
if (arg.stage_id && arg.number) {
const group = Group.getByStageAndNumber(arg.stage_id, arg.number);
return group && [group];
const filter = arg as Partial<DataTypes["round"]> | undefined;
if (filter?.group_id && filter.number) {
const round = Round.getByGroupAndNumber(
filter.group_id,
filter.number,
);
return round ? ([round] as Array<DataTypes[T]>) : null;
}
if (arg.stage_id) {
return Group.getByStageId(arg.stage_id);
if (filter?.group_id) {
return Round.getByGroupId(filter.group_id) as Array<DataTypes[T]>;
}
if (filter?.stage_id) {
return Round.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
break;
}
case "round":
if (!arg) {
throw new Error("not implemented");
// const rounds = Round.getAll();
// return rounds?.map(convertRound);
}
case "match": {
if (typeof arg === "number") {
return Round.getById(arg);
return Match.getById(arg) as DataTypes[T];
}
if (arg.group_id && arg.number) {
const round = Round.getByGroupAndNumber(arg.group_id, arg.number);
return round && [round];
const filter = arg as Partial<DataTypes["match"]> | undefined;
if (filter?.round_id && filter.number) {
const match = Match.getByRoundAndNumber(
filter.round_id,
filter.number,
);
return match ? ([match] as Array<DataTypes[T]>) : null;
}
if (arg.group_id) {
return Round.getByGroupId(arg.group_id);
if (filter?.stage_id) {
return Match.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
if (arg.stage_id) {
return Round.getByStageId(arg.stage_id);
if (filter?.round_id) {
return Match.getByRoundId(filter.round_id) as Array<DataTypes[T]>;
}
break;
case "match":
if (!arg) {
throw new Error("not implemented");
// const matches = Match.getAll();
// return matches?.map(convertMatch);
}
if (typeof arg === "number") {
return Match.getById(arg);
}
if (arg.round_id && arg.number) {
const match = Match.getByRoundAndNumber(arg.round_id, arg.number);
return match && [match];
}
if (arg.stage_id) {
return Match.getByStageId(arg.stage_id);
}
if (arg.group_id) {
throw new Error("not implemented");
// const matches = Match.getByGroupId(arg.group_id);
// return matches?.map(convertMatch);
}
if (arg.round_id) {
return Match.getByRoundId(arg.round_id);
}
break;
// throw new Error("not implemented");
// if (typeof arg === "number") {
// const game = MatchGame.getById(arg);
// return game && convertMatchGame(game);
// }
// if (arg.parent_id && arg.number) {
// const game = MatchGame.getByParentAndNumber(
// arg.parent_id,
// arg.number,
// );
// return game && [convertMatchGame(game)];
// }
// if (arg.parent_id) {
// const games = MatchGame.getByParentId(arg.parent_id);
// return games?.map(convertMatchGame);
// }
// break;
}
}
return null;
}
update(table, query, update) {
update<T extends Table>(table: T, id: number, value: DataTypes[T]): boolean;
update<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>,
value: Partial<DataTypes[T]>,
): boolean;
update<T extends Table>(
table: T,
query: number | Partial<DataTypes[T]>,
value: DataTypes[T] | Partial<DataTypes[T]>,
): boolean {
switch (table) {
case "stage":
case "stage": {
if (typeof query === "number") {
const update = value as Partial<DataTypes["stage"]>;
return Stage.updateSettings(query, JSON.stringify(update.settings));
}
break;
}
case "match":
case "match": {
if (typeof query === "number") {
const update = value as DataTypes["match"];
const match = new Match(
query,
update.status,
@ -212,95 +197,22 @@ export class SqlDatabase {
update.group_id,
update.round_id,
update.number,
null,
null,
null,
JSON.stringify(update.opponent1),
JSON.stringify(update.opponent2),
);
return match.update();
}
break;
// throw new Error("not implemented");
// if (typeof query === "number") {
// const game = new MatchGame(
// query,
// update.stage_id,
// update.parent_id,
// update.status,
// update.number,
// null,
// null,
// null,
// JSON.stringify(update.opponent1),
// JSON.stringify(update.opponent2),
// );
// return game.update();
// }
// if (query.parent_id) {
// const game = new MatchGame(
// undefined,
// update.stage_id,
// query.parent_id,
// update.status,
// update.number,
// null,
// null,
// null,
// JSON.stringify(update.opponent1),
// JSON.stringify(update.opponent2),
// );
// return game.updateByParentId();
// }
// break;
}
}
return false;
}
delete(_table, _filter) {
delete<T extends Table>(table: T): boolean;
delete<T extends Table>(table: T, filter: Partial<DataTypes[T]>): boolean;
delete(): boolean {
throw new Error("not implemented");
// switch (table) {
// case "stage":
// return Number.isInteger(filter.id) && Stage.deleteById(filter.id);
// case "group":
// return (
// Number.isInteger(filter.stage_id) &&
// Group.deleteByStageId(filter.stage_id)
// );
// case "round":
// return (
// Number.isInteger(filter.stage_id) &&
// Round.deleteByStageId(filter.stage_id)
// );
// case "match":
// return (
// Number.isInteger(filter.stage_id) &&
// Match.deleteByStageId(filter.stage_id)
// );
// if (Number.isInteger(filter.stage_id))
// return MatchGame.deleteByStageId(filter.stage_id);
// if (
// Number.isInteger(filter.parent_id) &&
// Number.isInteger(filter.number)
// )
// return MatchGame.deleteByParentAndNumber(
// filter.parent_id,
// filter.number,
// );
// return false;
// default:
// return false;
// }
}
}

View File

@ -3,8 +3,6 @@ import { SqlDatabase } from "./crud.server";
export function getServerTournamentManager() {
const storage = new SqlDatabase();
// TODO: fix this ts-expect-error comment
// @ts-expect-error interface mismatch
const manager = new BracketsManager(storage);
return manager;

View File

@ -16,6 +16,7 @@ import type { Tables, WinLossParticipationArray } from "../../../db/tables";
import { ensureOneStandingPerUser } from "../tournament-bracket-utils";
import type { Standing } from "./Bracket";
import type { ParsedBracket } from "./Progression";
import * as Progression from "./Progression";
export interface TournamentSummary {
skills: Omit<
@ -39,6 +40,7 @@ type TeamsArg = Array<{
id: number;
members: Array<{ userId: number }>;
startingBracketIdx?: number | null;
abDivision?: number | null;
}>;
type Rating = Pick<Tables["Skill"], "mu" | "sigma">;
@ -572,25 +574,31 @@ function tournamentResults({
}) {
const result: TournamentSummary["tournamentResults"] = [];
const firstPlaceFinishesCount = finalStandings.filter(
(s) => s.placement === 1,
).length;
const isMultiStartingBracket = firstPlaceFinishesCount > 1;
const isMultiStartingBracket =
Progression.startingBrackets(progression).length > 1;
const isAbDivisionsFinals = Progression.hasAbDivisionsFinals(progression);
for (const standing of finalStandings) {
const team = teams.find((t) => t.id === standing.team.id);
invariant(team);
const div =
// second check should be redundant, but just here in case
typeof team.startingBracketIdx === "number" && isMultiStartingBracket
? getBracketProgressionLabel(team.startingBracketIdx, progression)
: null;
const divisionParticipantCount =
div !== null
? teams.filter((t) => t.startingBracketIdx === team.startingBracketIdx)
.length
: participantCount;
let div: string | null = null;
let divisionParticipantCount = participantCount;
if (isAbDivisionsFinals && typeof team.abDivision === "number") {
div = team.abDivision === 0 ? "A" : "B";
divisionParticipantCount = teams.filter(
(t) => t.abDivision === team.abDivision,
).length;
} else if (
isMultiStartingBracket &&
typeof team.startingBracketIdx === "number"
) {
div = getBracketProgressionLabel(team.startingBracketIdx, progression);
divisionParticipantCount = teams.filter(
(t) => t.startingBracketIdx === team.startingBracketIdx,
).length;
}
for (const player of standing.team.members) {
result.push({

View File

@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AllMatchResult } from "~/features/tournament-match/queries/allMatchResultsByTournamentId.server";
import invariant from "~/utils/invariant";
import type { Tables } from "../../../db/tables";
import type * as Progression from "./Progression";
import { tournamentSummary } from "./summarizer.server";
import type { TournamentDataTeam } from "./Tournament.server";
@ -65,6 +66,7 @@ describe("tournamentSummary()", () => {
seedingSkillCountsFor,
withMemberInTwoTeams = false,
teamsWithStartingBrackets,
teamsWithAbDivisions,
progression,
finalStandings,
}: {
@ -75,13 +77,11 @@ describe("tournamentSummary()", () => {
id: number;
startingBracketIdx: number | null;
}>;
progression?: Array<{
name: string;
type: "single_elimination";
settings: Record<string, never>;
requiresCheckIn: boolean;
sources?: Array<{ bracketIdx: number; placements: number[] }>;
teamsWithAbDivisions?: Array<{
id: number;
abDivision: 0 | 1;
}>;
progression?: Progression.ParsedBracket[];
finalStandings?: Array<{
placement: number;
team: TournamentDataTeam;
@ -122,17 +122,19 @@ describe("tournamentSummary()", () => {
},
];
const teams = teamsWithStartingBrackets
? defaultTeams.map((team) => {
const startingBracket = teamsWithStartingBrackets.find(
(t) => t.id === team.id,
);
return {
...team,
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
};
})
: defaultTeams;
const teams = defaultTeams.map((team) => {
const startingBracket = teamsWithStartingBrackets?.find(
(t) => t.id === team.id,
);
const abDivisionEntry = teamsWithAbDivisions?.find(
(t) => t.id === team.id,
);
return {
...team,
startingBracketIdx: startingBracket?.startingBracketIdx ?? null,
abDivision: abDivisionEntry?.abDivision ?? null,
};
});
return tournamentSummary({
finalStandings: finalStandings ?? [
@ -779,6 +781,102 @@ describe("tournamentSummary()", () => {
expect(team4Results.every((r) => r.participantCount === 2)).toBeTruthy();
});
test("div is set from abDivision when finals is an A/B divisions round robin", () => {
const summary = summarize({
teamsWithAbDivisions: [
{ id: 1, abDivision: 0 },
{ id: 2, abDivision: 1 },
{ id: 3, abDivision: 0 },
{ id: 4, abDivision: 1 },
],
progression: [
{
name: "Groups stage",
type: "round_robin",
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
requiresCheckIn: false,
},
],
finalStandings: [
{
placement: 1,
team: createTeam(1, [1, 2, 3, 4]),
},
{
placement: 1,
team: createTeam(2, [5, 6, 7, 8]),
},
{
placement: 2,
team: createTeam(3, [9, 10, 11, 12]),
},
{
placement: 2,
team: createTeam(4, [13, 14, 15, 16]),
},
],
});
const team1Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 1,
);
const team2Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 2,
);
const team3Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 3,
);
const team4Results = summary.tournamentResults.filter(
(r) => r.tournamentTeamId === 4,
);
expect(team1Results.every((r) => r.div === "A")).toBeTruthy();
expect(team2Results.every((r) => r.div === "B")).toBeTruthy();
expect(team3Results.every((r) => r.div === "A")).toBeTruthy();
expect(team4Results.every((r) => r.div === "B")).toBeTruthy();
});
test("participantCount counts teams per abDivision for A/B finals", () => {
const summary = summarize({
teamsWithAbDivisions: [
{ id: 1, abDivision: 0 },
{ id: 2, abDivision: 1 },
{ id: 3, abDivision: 0 },
{ id: 4, abDivision: 1 },
],
progression: [
{
name: "Groups stage",
type: "round_robin",
settings: { hasAbDivisions: true, teamsPerGroup: 4 },
requiresCheckIn: false,
},
],
finalStandings: [
{
placement: 1,
team: createTeam(1, [1, 2, 3, 4]),
},
{
placement: 1,
team: createTeam(2, [5, 6, 7, 8]),
},
{
placement: 2,
team: createTeam(3, [9, 10, 11, 12]),
},
{
placement: 2,
team: createTeam(4, [13, 14, 15, 16]),
},
],
});
for (const result of summary.tournamentResults) {
expect(result.participantCount).toBe(2);
}
});
test("excludes matches ended early by organizer from calculations", () => {
const summary = summarize({
results: [

View File

@ -11,6 +11,7 @@ import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tourn
import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { Status } from "~/modules/brackets-model";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
@ -148,7 +149,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
match.chatCode &&
!matchIsOver &&
match.opponentOne?.id &&
match.opponentTwo?.id
match.opponentTwo?.id &&
match.status > Status.Locked
) {
// only add global chat for active roster (or all if not yet set i.e. first match)
// if roster changed mid-set the subs can still see the chat on the match page
@ -180,7 +182,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
}
const shouldSeeChat =
const hasPermsToSeeChat =
tournament.isOrganizerOrStreamer(user) ||
match.players.some((p) => p.id === user?.id);
@ -195,7 +197,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
const visibleChatCode =
shouldSeeChat && !chatCodeExpired ? match.chatCode : undefined;
hasPermsToSeeChat && !chatCodeExpired ? match.chatCode : undefined;
// xxx: optimization, can be skipped if user can't join anyway
const [roomLinks, anyUserPrefersNoSplatnet] = matchIsOver
@ -209,7 +211,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
]);
return {
match: shouldSeeChat ? match : { ...match, chatCode: undefined },
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
mapList,
matchIsOver,

View File

@ -0,0 +1,253 @@
import { describe, expect, it } from "vitest";
import {
progressions,
testTournament,
tournamentCtxTeam,
} from "~/features/tournament-bracket/core/tests/test-utils";
import { BracketsManager } from "~/modules/brackets-manager";
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
import invariant from "~/utils/invariant";
import { reNumberPlacements, tournamentStandings } from "./Standings";
describe("tournamentStandings", () => {
it("returns single-division standings for a tournament with one starting bracket", () => {
const tournament = singleEliminationTournament();
const result = tournamentStandings(tournament);
expect(result.type).toBe("single");
invariant(result.type === "single");
expect(result.standings.length).toBeGreaterThan(0);
expect(result.standings[0].placement).toBe(1);
expect(result.standings[0].team.id).toBe(1);
});
it("returns one div per starting bracket for a tournament with multiple starting brackets", () => {
const tournament = testTournament({
ctx: {
settings: { bracketProgression: progressions.manyStartBrackets },
teams: [
tournamentCtxTeam(1, { startingBracketIdx: 0, seed: 1 }),
tournamentCtxTeam(2, { startingBracketIdx: 0, seed: 2 }),
tournamentCtxTeam(3, { startingBracketIdx: 1, seed: 3 }),
tournamentCtxTeam(4, { startingBracketIdx: 1, seed: 4 }),
],
},
});
const result = tournamentStandings(tournament);
expect(result.type).toBe("multi");
invariant(result.type === "multi");
expect(result.standings).toHaveLength(2);
for (const { div } of result.standings) {
expect(typeof div).toBe("string");
expect(div.length).toBeGreaterThan(0);
}
const divs = result.standings.map((s) => s.div);
expect(new Set(divs).size).toBe(2);
});
it("splits A/B divisions finals into 'A' and 'B' divs with teams partitioned by abDivision", () => {
const tournament = abDivisionsTournament();
const result = tournamentStandings(tournament);
expect(result.type).toBe("multi");
invariant(result.type === "multi");
expect(result.standings.map((s) => s.div)).toEqual(["A", "B"]);
const [a, b] = result.standings;
expect(a.standings.map((s) => s.team.id)).toEqual([1, 3]);
expect(b.standings.map((s) => s.team.id)).toEqual([2, 4]);
expect(a.standings.every((s) => s.team.abDivision === 0)).toBe(true);
expect(b.standings.every((s) => s.team.abDivision === 1)).toBe(true);
});
it("re-numbers placements within each A/B division starting from 1", () => {
const tournament = abDivisionsTournament();
const result = tournamentStandings(tournament);
invariant(result.type === "multi");
const [a, b] = result.standings;
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
});
});
describe("reNumberPlacements", () => {
it("keeps already contiguous placements unchanged", () => {
const result = reNumberPlacements([
{ placement: 1 },
{ placement: 2 },
{ placement: 3 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 2, 3]);
});
it("groups tied placements and skips numbers to match team count", () => {
const result = reNumberPlacements([
{ placement: 1 },
{ placement: 1 },
{ placement: 3 },
{ placement: 3 },
{ placement: 5 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 3, 5]);
});
it("re-numbers from 1 when the input has been filtered (e.g. top finishers removed)", () => {
const result = reNumberPlacements([
{ placement: 3 },
{ placement: 3 },
{ placement: 5 },
{ placement: 7 },
]);
expect(result.map((s) => s.placement)).toEqual([1, 1, 3, 4]);
});
it("adds the offset to every placement", () => {
const result = reNumberPlacements(
[{ placement: 1 }, { placement: 1 }, { placement: 3 }],
10,
);
expect(result.map((s) => s.placement)).toEqual([11, 11, 13]);
});
it("preserves non-placement fields on each standing", () => {
const result = reNumberPlacements([
{ placement: 1, team: { id: 7 }, note: "a" },
{ placement: 2, team: { id: 8 }, note: "b" },
]);
expect(result).toEqual([
{ placement: 1, team: { id: 7 }, note: "a" },
{ placement: 2, team: { id: 8 }, note: "b" },
]);
});
it("returns an empty array when given an empty array", () => {
expect(reNumberPlacements([])).toEqual([]);
expect(reNumberPlacements([], 5)).toEqual([]);
});
});
function singleEliminationTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "Main Bracket",
tournamentId: 1,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: { seedOrdering: ["natural"] },
});
while (true) {
const pending = storage
.select<any>("match")!
.find(
(m) =>
typeof m.opponent1?.id === "number" &&
typeof m.opponent2?.id === "number" &&
m.opponent1.result !== "win" &&
m.opponent2.result !== "win",
);
if (!pending) break;
const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id;
manager.update.match({
id: pending.id,
opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 },
opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" },
});
}
return testTournament({
ctx: {
settings: {
bracketProgression: progressions.singleElimination,
},
teams: [
tournamentCtxTeam(1, { seed: 1 }),
tournamentCtxTeam(2, { seed: 2 }),
tournamentCtxTeam(3, { seed: 3 }),
tournamentCtxTeam(4, { seed: 4 }),
],
},
data: manager.get.tournamentData(1),
});
}
function abDivisionsTournament() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "AB RR",
tournamentId: 1,
type: "round_robin",
seeding: [1, 2, 3, 4],
abDivisions: [0, 1, 0, 1],
settings: {
groupCount: 1,
hasAbDivisions: true,
seedOrdering: ["groups.seed_optimized"],
},
});
const winnerByMatchup: Record<string, number> = {
"1-2": 1,
"1-4": 1,
"2-3": 2,
"3-4": 3,
};
for (const match of storage.select<any>("match")!) {
const a = match.opponent1.id as number;
const b = match.opponent2.id as number;
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
const winnerId = winnerByMatchup[key];
invariant(winnerId, `unexpected matchup ${key}`);
const loserScore = key === "2-3" || key === "3-4" ? 1 : 0;
const winnerIsOpp1 = match.opponent1.id === winnerId;
manager.update.match({
id: match.id,
opponent1: winnerIsOpp1
? { score: 2, result: "win" }
: { score: loserScore },
opponent2: winnerIsOpp1
? { score: loserScore }
: { score: 2, result: "win" },
});
}
const data = manager.get.tournamentData(1);
return testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "round_robin",
name: "AB RR",
requiresCheckIn: false,
settings: { hasAbDivisions: true },
},
],
},
teams: [
tournamentCtxTeam(1, { abDivision: 0, seed: 1 }),
tournamentCtxTeam(2, { abDivision: 1, seed: 2 }),
tournamentCtxTeam(3, { abDivision: 0, seed: 3 }),
tournamentCtxTeam(4, { abDivision: 1, seed: 4 }),
],
},
data,
});
}

View File

@ -24,6 +24,34 @@ export function flattenStandings(
: standingsResult.standings.flatMap((div) => div.standings);
}
/**
* Re-numbers placements in a sorted standings array so that tied placements stay
* grouped (e.g. `[1, 1, 3, 3, 5]`) while non-tied positions reflect the true
* number of teams above them. Useful after filtering or merging standings where
* the original placement numbers no longer match the team count.
*
* Pass `offset` to shift every placement downwards used when the returned
* standings will be appended below standings from another bracket.
*/
export function reNumberPlacements<T extends { placement: number }>(
standings: T[],
offset = 0,
): T[] {
let lastOriginalPlacement = 0;
let currentPlacement = 0;
return standings.map((standing, index) => {
if (standing.placement !== lastOriginalPlacement) {
lastOriginalPlacement = standing.placement;
currentPlacement = index + 1;
}
return {
...standing,
placement: currentPlacement + offset,
};
});
}
/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */
export function calculateSPR({
standings,
@ -143,24 +171,42 @@ export function matchesPlayed({
export function tournamentStandings(
tournament: Tournament,
): TournamentStandingsResult {
const startingBracketIdxs = Progression.startingBrackets(
tournament.ctx.settings.bracketProgression,
);
const progression = tournament.ctx.settings.bracketProgression;
const startingBracketIdxs = Progression.startingBrackets(progression);
if (startingBracketIdxs.length <= 1) {
const standings = tournamentStandingsForBracket(tournament, undefined);
if (Progression.hasAbDivisionsFinals(progression)) {
return {
type: "multi",
standings: [
{
div: "A",
standings: reNumberPlacements(
standings.filter((s) => s.team.abDivision === 0),
),
},
{
div: "B",
standings: reNumberPlacements(
standings.filter((s) => s.team.abDivision === 1),
),
},
],
};
}
return {
type: "single",
standings: tournamentStandingsForBracket(tournament, undefined),
standings,
};
}
return {
type: "multi",
standings: startingBracketIdxs.map((bracketIdx) => ({
div: getBracketProgressionLabel(
bracketIdx,
tournament.ctx.settings.bracketProgression,
),
div: getBracketProgressionLabel(bracketIdx, progression),
standings: tournamentStandingsForBracket(tournament, bracketIdx),
})),
};
@ -241,8 +287,6 @@ function standingsToMergeable<
standings: T[];
teamsAboveFromAnotherBracketsCount: number;
}) {
const result: T[] = [];
const filtered = standings.filter(
(standing) => !alreadyIncludedTeamIds.has(standing.team.id),
);
@ -250,22 +294,8 @@ function standingsToMergeable<
// e.g. if standings start at 3rd place, this must mean there is 2 teams left to finish _this_ bracket
const unfinishedTeamsCount = (standings.at(0)?.placement ?? 1) - 1;
let placement = 1;
for (const [i, standing] of filtered.entries()) {
const placementChanged =
i !== 0 && standing.placement !== filtered[i - 1].placement;
if (placementChanged) {
placement = i + 1;
}
result.push({
...standing,
placement:
placement + teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
});
}
return result;
return reNumberPlacements(
filtered,
teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
);
}

View File

@ -589,6 +589,10 @@ function DownloadParticipants() {
}
function simpleListInSeededOrder() {
const hasCheckedInTeams = tournament.ctx.teams.some(
(team) => team.checkIns.length > 0,
);
return tournament.ctx.teams
.slice()
.sort(
@ -596,7 +600,7 @@ function DownloadParticipants() {
(a.seed ?? Number.POSITIVE_INFINITY) -
(b.seed ?? Number.POSITIVE_INFINITY),
)
.filter((team) => team.checkIns.length > 0)
.filter((team) => !hasCheckedInTeams || team.checkIns.length > 0)
.map((team) => team.name)
.join("\n");
}

View File

@ -89,11 +89,11 @@ export function UserResultsTable({
<td className="whitespace-nowrap">
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "short",
year: "numeric",
month: "numeric",
year: "2-digit",
})}
</td>
<td id={nameCellId}>
<td id={nameCellId} className="whitespace-nowrap">
<div className="stack horizontal xs items-center">
{result.eventId ? (
<Link to={calendarEventPage(result.eventId)}>
@ -106,11 +106,12 @@ export function UserResultsTable({
<img
src={result.logoUrl}
alt=""
width={18}
height={18}
width={24}
height={24}
className="rounded-full"
/>
) : null}
{result.tier ? <TierPill tier={result.tier} /> : null}
<Link
to={tournamentBracketsPage({
tournamentId: result.tournamentId,
@ -119,7 +120,6 @@ export function UserResultsTable({
>
{result.eventName}
</Link>
{result.tier ? <TierPill tier={result.tier} /> : null}
{result.div ? (
<span className="text-lighter">({result.div})</span>
) : null}
@ -130,7 +130,7 @@ export function UserResultsTable({
<td>
<ParticipationPill setResults={result.setResults} />
</td>
<td>
<td className="whitespace-nowrap">
<div className="stack horizontal md items-center">
<SendouPopover
trigger={

View File

@ -2,7 +2,6 @@ import { add, sub } from "date-fns";
import { notify } from "../features/notifications/core/notify.server";
import * as Scrim from "../features/scrims/core/Scrim";
import * as ScrimPostRepository from "../features/scrims/ScrimPostRepository.server";
import { databaseTimestampToJavascriptTimestamp } from "../utils/dates";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
@ -19,23 +18,30 @@ export const NotifyScrimStartingSoonRoutine = new Routine({
});
for (const scrim of scrims) {
const participantIds = Scrim.participantIdsListFromAccepted(scrim);
const acceptedRequest = scrim.requests.find((r) => r.isAccepted);
if (!acceptedRequest) continue;
const postTeamName = Scrim.sideDisplayName(scrim);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
logger.info(
`Notifying scrim starting soon for scrim ${scrim.id} with ${participantIds.length} participants`,
`Notifying scrim starting soon for scrim ${scrim.id} with ${scrim.users.length + acceptedRequest.users.length} participants`,
);
await notify({
notification: {
type: "SCRIM_STARTING_SOON",
meta: {
id: scrim.id,
at: databaseTimestampToJavascriptTimestamp(
Scrim.getStartTime(scrim),
),
},
meta: { id: scrim.id, opponentTeamName: requestTeamName },
},
userIds: participantIds,
userIds: scrim.users.map((u) => u.id),
});
await notify({
notification: {
type: "SCRIM_STARTING_SOON",
meta: { id: scrim.id, opponentTeamName: postTeamName },
},
userIds: acceptedRequest.users.map((u) => u.id),
});
}
},

View File

@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest";
import { pathnameFromPotentialURL, truncateBySentence } from "./strings";
import {
pathnameFromPotentialURL,
removeMarkdown,
truncateBySentence,
} from "./strings";
describe("pathnameFromPotentialURL()", () => {
test("Resolves path name from valid URL", () => {
@ -44,3 +48,30 @@ describe("truncateBySentence()", () => {
expect(truncateBySentence(text, 20)).toBe("First line");
});
});
describe("removeMarkdown()", () => {
test("Decodes &nbsp; entities and collapses runs", () => {
const text = "&nbsp;&nbsp;&nbsp;&nbsp; Global Gauntlet is an event";
expect(removeMarkdown(text)).toBe("Global Gauntlet is an event");
});
test("Decodes common named HTML entities", () => {
expect(removeMarkdown("Tom &amp; Jerry &lt;3 &quot;hi&quot;")).toBe(
'Tom & Jerry <3 "hi"',
);
});
test("Decodes numeric HTML entities", () => {
expect(removeMarkdown("caf&#233; &#x26; tea")).toBe("café & tea");
});
test("Leaves unknown named entities untouched", () => {
expect(removeMarkdown("AT&amp;T &fakeentity; rules")).toBe(
"AT&T &fakeentity; rules",
);
});
test("Strips HTML tags and markdown emphasis", () => {
expect(removeMarkdown("<p>Hello **world**!</p>")).toBe("Hello world!");
});
});

View File

@ -76,12 +76,35 @@ export function truncateBySentence(value: string, max: number) {
}
// based on https://github.com/zuchka/remove-markdown
const NAMED_HTML_ENTITIES: Record<string, string> = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
};
export function removeMarkdown(value: string) {
const htmlReplaceRegex = /<[^>]*>/g;
return (
value
// Remove HTML tags
.replace(htmlReplaceRegex, "")
// Decode named HTML entities (e.g. &nbsp;, &amp;)
.replace(/&([a-zA-Z]+);/g, (match, name: string) => {
const replacement = NAMED_HTML_ENTITIES[name.toLowerCase()];
return replacement ?? match;
})
// Decode numeric HTML entities (e.g. &#160; or &#xA0;)
.replace(/&#(x?[0-9a-fA-F]+);/g, (_, code: string) => {
const codePoint = code.startsWith("x")
? Number.parseInt(code.slice(1), 16)
: Number.parseInt(code, 10);
return Number.isFinite(codePoint)
? String.fromCodePoint(codePoint)
: "";
})
// Remove setext-style headers
.replace(/^[=-]{2,}\s*$/g, "")
// Remove footnotes?
@ -113,5 +136,8 @@ export function removeMarkdown(value: string) {
// .replace(/(\S+)\n\s*(\S+)/g, '$1 $2')
// Replace strike through
.replace(/~(.*?)~/g, "$1")
// Collapse runs of whitespace (e.g. from decoded &nbsp; or stripped tags)
.replace(/[ \t ]{2,}/g, " ")
.trim()
);
}

View File

@ -0,0 +1,150 @@
---
title: "NA League 2026 Event #3 (Regular Season Week 1) "
date: 2026-04-21
author:
- name: YELLOW
link: https://sendou.ink/u/great-hero-yellow
---
#### *The first week of the Regular Season was so intense that by the end of it, you forget that its not the Playoffs yet.*
<img width="1200" height="675" alt="NAL2026-r1e3" src="https://github.com/user-attachments/assets/1de746a3-4f7e-4046-887e-fce7493bce3c" />
On Saturday, April 18, 2026, the Splatoon 3 North American League began its Regular Season. The training wheels were gone, and now wins started counting, earning points to qualify for the Playoffs in June. Teams only need to play in four weeks to be eligible for the Playoffs, but needed to ensure a high amount of points to make the top eight.
Nine, Falco, and Nox led the commentator panel, and Power was operating the spectator camera.
Despite the day being filled with competing events like the Global Gauntlet Qualifier 2, LUTI Playoffs, and two LANs in Sunset Surge and Dont Flounder at the Function, big names in the North American scene showed up to throw down:
1. Milky Way
2. SIZA
3. Content Cat
4. usagi fanclub
5. NME
6. Moonlight
7. Hypernova
8. lfn
The Splattercolor Screen was used in Losers Finals, and its effects were briefly on screen. As we dive into the details, if you plan on rewatching, take caution if you are sensitive to its effects.
## **Winners Quarterfinals: Content Cat vs. Moonlight (13)**
While Content Cat is a new team to the NA League, its players arent, with most having appeared in the Top Cut stream in last years Regular Season. And Moonlight needs no introduction by now; you know them, you love them\!
Game one, Tower Control at MakoMart; it took nearly half of the game before anyone cleared a single checkpoint. Once Moonlight started rolling, they didnt stop, giving Content Cat no points and taking the knockout.
Game two, Clam Blitz at Urchin Underpass, notorious for its difficulty to score, saw Moonlight opening the basket within 30 seconds. Held up in mid, Content Cat had to backtrack once Moonlight snuck in and scored again. Content Cat got points on the board, but after overtime, lost 5286.
Turf War at Scorch Gorge was next, and it only saw three splats in the first 60 seconds. It took until the latter half for things to pick up, with Content Cat losing players and seeing the Danger\! warning. At the one minute pan out, the map looked favorable to Content Cat, and true to appearances, they won, 53.8% 44.2%.
<img width="1000" height="562" alt="WQF_TWscorch" src="https://github.com/user-attachments/assets/5ae3c9bf-4551-4f3a-b04f-2c232700a0b8" />
*An even 98.0% total ink coverage—can you spot the 2% left uninked?*
Already, the 2026 NA League was setting new records, as the total ink coverage percent from this game, 98%, beat the 2025 NA Leagues record of 97.3%, set by Gourmet Race vs. BEt in last years Event \#10\!
Splat Zones at Mahi-Mahi Resort saw Content Cat take the first lead when both teams went down two players. Moonlight had the lead when the first minute ended, never giving it up and stopping Content Cat when they were one point from taking it. Moonlight knocked out at the halfway point, sending Content Cat to the Losers Bracket.
## **Winners Semifinals: Milky Way vs. NME (31)**
Advancing to Winners Semifinals were Moonlight, Hypernova, Milky Way, and NME, which is a team three parts fofofo (last NA Leagues \#3 team). Content Cat, SIZA, usagi fanclub, and lfn were in the Losers Bracket.
The set began on Undertow Spillway for Splat Zones. NME struggled to keep players up, almost always down at least one. They still gave Milky Way a tough fight, keeping the zone neutral to stall the points gain. In overtime, NME was ticking close to Milky Ways lead, but was wiped out, losing 9497.
Tower Control at Robo ROM-en was second, another difficult game for NME. Within the first two minutes, Milky Ways push ended at just 4 points to knockout. NME was able to get some points to their name, but Milky Ways lead persevered, 967.
Rainmaker at Mahi-Mahi Resort would become NMEs bread and butter—Milky Way cleared their first checkpoint in 30 seconds, but NME retaliated by taking them down three players, dunking the checkpoint, taking Milky Way down in a delayed wipe, and knocking out just after a minute into the game\!
<img width="1000" height="562" alt="WSF_RMmahi-mahi" src="https://github.com/user-attachments/assets/2bf59d85-e284-4e57-8ce4-e498633c8437" />
*What could have been a sub-minute knockout, if only the pedestal had been inked\!*
Game four went to Turf War at Crableg Capital. At the start, Milky Way didnt paint their base much, but it was NME who had the Danger\! indicator sooner. Milky Way had their turn with it as Now or Never started to play, and were even down three players in the last 20 seconds, but still walked out 55.3% 40.8%, headed to Winners Finals.
## **Winners Finals: Milky Way vs. Moonlight (32)**
Those who paid attention to last years NA League Playoffs will recognize Milky Way vs. Moonlight as the Grand Finals set. The two teams have historically clashed multiple times, and the hosts were stoked to commentate it so early in the Regular Season.
Splat Zones at Mahi-Mahi Resort saw Moonlight take a strong offense, getting the zone in 12 seconds and Milky Way down two players. They were stopped at 37, and Milky Way inched ahead, but Moonlight stopped them at 26\. Painting through Triple Inkstrikes and a Booyah Bomb, Moonlight took the lead and knocked out at 2:24.
Turf War at MakoMart didnt bring out anything unexpected from these teams; both had a REEF-LUX 450, both had a Snipewriter 5H. Danger\! popped up in Moonlights overhead a few times, critically in the last minute, and they lost 39.4% 56.6%.
Game three was Tower Control at Inkblot Art Academy. Moonlight moved the tower one point, then Milky Way, and they were pushed back to 100\. Moonlight cleared the first checkpoint before Milky Way, but 20 seconds later, Milky Way had the lead; soon after, more checkpoints, and before three minutes had passed, the knockout.
Clam Blitz at Crableg Capital saw no points gained until the last 40 seconds, where Milky Way scored just one Power Clam. Moonlight ran in and scored in the final six seconds, took the lead, and in overtime, a jump in sealed the basket and the win, 4420.
<img width="1000" height="562" alt="WF_CBcrableg" src="https://github.com/user-attachments/assets/9fb34fae-75eb-4aa4-8192-999abca97cde" />
*SSNolan with the game-winning play, getting the wipeout, providing jump-ins, and grabbing the last clam needed to close the basket.*
Game five went to Rainmaker at Museum dAlfonsino. Moonlight entered with a double Ink Vac comp, and it truly felt like there was one online at all times. At the halfway point, with both teams down two, Moonlight took the lead, but needed another 30 seconds to clear the first checkpoint.
Milky Way only needed 25 seconds to get their first checkpoint from there, and shortly after, took the lead to 18\. Approaching overtime, things looked good for Moonlight with Milky Way down 3, but trapped between an enemy and a Suction Bomb, they fell short of the lead, 6282, and were sent to the Losers Bracket.
## **Losers Semifinals: Hypernova vs. NME (23)**
After both Hypernova and NME went 31 over their opponents in Losers Quarterfinals, they squared up to decide who advanced to fight Moonlight in Losers Finals.
Game one was Tower Control at Museum dAlfonsino. NME went all the way to 16 in a minute and a half. It took Hypernova two minutes, but they got the lead and third checkpoint, then jumped out to play defense for the last minute. In the final 40 seconds, they ended up wiped out twice, and NME beat the clock for a knockout.
For Rainmaker at Crableg Capital, NME brought the uncommon Ballpoint Splatling Nouveau for Ink Vac coverage. NME was at a numbers disadvantage frequently, but Hypernova played cautiously and hesitated to move forward. They held the lead at 9, and NME crept closer, to 14, but two wipeouts in the last 20 seconds kept them at bay, and Hypernova kept the win, 9186.
<img width="1000" height="562" alt="LSF_RMcrableg" src="https://github.com/user-attachments/assets/a0c8ff0d-60f8-4675-9b20-f446354822f7" />
*In the background, Synapse trades splats with DATKID to get a second wipeout over NME, six seconds after the previous one.*
Game three was Clam Blitz at Scorch Gorge. Across two pushes in the first half of the game, NME brought their score down to 27\. Hypernovas first push got them to 54, but they were caught in a delayed wipeout. At the one minute mark, NME scored again, wiped out Hypernova, and knocked out.
Shipshape Cargo Co. was chosen for Turf War—the Splattercolor Screen was used in this game, so be careful watching replays. The game was solidly in Hypernovas hands for a majority of the three minutes, taking NME down two, three players multiple times. By the final minute, they had set up a lockout, keeping NME contained in 1/3rd of the map.
Hypernova won 63.6% 33.7%, tying with the 2025 NA Leagues total ink coverage percentage record, 97.3%\!
A game five Splat Zones is rare, and unexpected at Undertow Spillway. Early in the game, NME set the goal to reach at 34\. The teams traded wipeouts as Hypernova fought to keep up with NMEs score. In the last five seconds, Hypernova wiped out NME, and in overtime, kept them down three. Just one tick to tie, the zone was neutralized. Hypernova was wiped out, and NME took the win, 8887.
## **Losers Finals: Moonlight vs. NME (31)**
A brief respite from game five sets, Moonlight and NME would face off to determine who got their runback against Milky Way at Grand Finals.
The set started with Tower Control at MakoMart, and already 40 seconds in, Moonlight suffered a delayed wipeout and NME was down two players. It took until the halfway point for a checkpoint to be cleared, which was Moonlights, but they didnt get much further after half of their team was splatted. NME took the game into overtime, but lost with a wipeout, and their score was 755.
The most popular Turf War stage from 2025s NA League, Urchin Underpass, was back, and one minute in, Moonlight was the team with Danger\! in their overhead. NME went down two, but Moonlight was still behind in score. In the last ten seconds, NME was again down two, then three, and Moonlight earned their comeback, 53.8% 39.8%.
Next, NME brought Moonlight to their turf, Rainmaker at Mahi-Mahi Resort. Running a double-Ultra Stamp comp, NME replayed the same story as before: their opponent got the first checkpoint, but it was NME going directly from their first checkpoint right into the knockout, leaving two minutes and 19 seconds to spare.
<img width="1000" height="562" alt="LF_RMmahi-mahi" src="https://github.com/user-attachments/assets/50dc9db8-1c2e-49d4-9171-f26d19dfd7fd" />
*Pedestal painted this time, 3z is able to squid roll to the win, protected by an Ultra Stamp.*
Game four was Splat Zones at Crableg Capital. The opening favored NME, overtaking Moonlights score of 34, and Moonlight had to use an Ink Vac and Big Bubbler to stop NME at 4, before the knockout. Moonlight rebounded, wiping out NME a minute later, and in the last seconds, Omega got a triple, securing their knockout and set win.
## **Grand Finals: Milky Way vs. Moonlight (32)**
Back to knocking on Milky Ways door, Moonlight was ready to take on their biggest opponent and deepen the storyline between the \#1 and \#2 teams from the 2025 NA League.
Repeating game one from Winners Finals, Splat Zones at Mahi-Mahi Resort, the game could be neatly divided into one-minute segments: at 4:11, Milky Way took the lead. At 3:11, Moonlight took the lead. At 2:11, Milky Way was back in the lead. Fast forward to 0:39, Moonlight was ahead once again, and the game went into overtime. Just as Milky Way was about to tie, Moonlights Ink Vac protected the zone, ending the game 9291.
Repeating game two, Turf War at MakoMart, the first minute was advantageous for Moonlight, with only one player being splatted on their side. Overall, splats were few in the game until the end. Milky Way threw out Triple Inkstrikes at the last second, which was the most crucial play, giving them just enough ink to eke ahead, 49.4% 47.4%.
Game three was Tower Control again, but at Crableg Capital rather than Inkblot Art Academy. The result was still the same, as Milky Way rode the tower through checkpoints one, two, and three with speed, knocking out by the three minute mark.
Interestingly, game four was Clam Blitz… at Shipshape Cargo Co. But it worked out for Moonlight, scoring in the first 40 seconds. At the halfway point, as Milky Way was throwing clams to open Moonlights basket, Moonlight beat them to it, forcing Milky Way to turn around. Moonlight was wiped out for this, but they had the lead at 58\.
<img width="1000" height="562" alt="GF_CBshipshape" src="https://github.com/user-attachments/assets/e4702243-e359-40ff-88dd-5b309d41a376" />
*Milky Way nowhere to be seen near their basket, instead set up by Moonlights basket in anticipation of scoring first.*
Once the basket closed, Milky Way was already set up and immediately scored. However, they only scored to 60\. Moonlight scored again as overtime began, ending it as soon as it started, and won the game 4240, and tied the set again.
Game five again. Rainmaker at Museum dAlfonsino again. Moonlight had a double Ink Vac comp again. Moonlight cleared their checkpoint, and went to extend their lead, but the Rainmaker reset faster. Milky Way baited out an Ink Vac; taking Moonlight down two, they claimed the lead at 47\. Moonlight lost the Rainmaker in the final seconds and couldnt pop the shield in time, and without overtime, their run ended short, 4257.
Moonlight sitting at second place to Milky Way after two down-to-the-wire game five sets is building a strong storyline so early in the Regular Season, and as more teams end up in Top Cut, who else will enrich the narrative? When will the newly-revitalized FTWin make their entrance?
With no 30 sets on stream, in a strangely Hagglefish Market-less event, the first week of the Regular Season has set a high standard for successive weeks to live up to.
Make sure youre there to see it\!
<img width="1919" height="1079" alt="R1E3_finalbracket" src="https://github.com/user-attachments/assets/8569f9a2-b4f7-4ed3-be7d-c4e910ae0c85" />
Original Posting Date: April 21, 2026 at [Splatoon Stronghold](https://www.splatoonstronghold.com/news/na-league-2026-e3-rs1).
Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social).

View File

@ -0,0 +1,187 @@
---
title: "SWS26 Recap: Global Gauntlet 1, Qualifier 2"
date: 2026-04-23
author:
- name: YELLOW
link: https://sendou.ink/u/great-hero-yellow
---
#### *To no surprise, PxG qualifies for the Global Gauntlet Finals—more interestingly, whos going with them?*
<img width="1200" height="675" alt="SWS26Recap_GG1Q2" src="https://github.com/user-attachments/assets/ccce8fcd-0d46-4e9d-840d-1206964d3ab5" />
On Saturday, April 18, 2026, the second qualifier for IPL and AREA CUPs global tournament series, the Global Gauntlet, took place to decide the final three Western teams who will compete at the First Edition Global Gauntlet Finals.
Last week, the first three teams—FTWin, ezmd, and Azure—earned their seats, and all six teams from Japan have been revealed by this point. Youll find all of the teams participating at the end of the article\!
The double-elimination, Splat Zones-only tournament saw 15 teams step into the ring, including heavy-hitters like PxG, Sun-Eater, healthy diet food groups, and nm. Popgun and Sasu commentated the action, and Ely was on the spectator camera.
After nearly three and a half hours of action, what went down?
## **Winners Round 1: healthy diet food groups vs. Sooo nights at a lan 😂 (12)**
Kicking off Qualifier 2 was healthy diet food groups, returning from Qualifier 1, and Sooo nights at a lan, a pickup with players who were on team hi last qualifier. The first two rounds of the event were Best of 3 sets.
Game one, the random map selection, went to Hammerhead Bridge, after healthy diet food groups banned Flounder Heights and Crableg Capital, and Sooo nights at a lan banned Humpback Pump Track and Manta Maria.
Healthy diet food groups was the first team to take the zone, after 35 seconds of fighting over it in neutral. Midway through the game, Sooo nights at a lan got close to taking the lead, but needed a second push to get it, and began to lock out once there. Healthy diet food groups prevented the knockout, but still lost the game, 7688.
Game two went to Museum dAlfonsino, after Sooo nights at a lan banned Sturgeon Shipyard and Shipshape Cargo Co.
Once more, healthy diet good groups started with the zone. They didnt get too far before their opponent took the zone, then the lead. After going down two players each, healthy diet food groups ended up back in the lead, and this would flip-flop a few times before a tense clash in the final seconds where healthy diet food groups stopped Sooo nights at a lan and took the win, 8581.
<img width="1000" height="562" alt="WR1_museum" src="https://github.com/user-attachments/assets/eecd54d2-1d8e-49a1-b663-9bed15a90afb" />
*Just five ticks from losing the lead, healthy diet food groups neutralized the zone and would quickly cap it, just before the game ended, securing their win.*
Game three went to Brinewater Springs; Mincemeat Metalworks and Eeltail Alley were healthy diet food groups map strikes.
Sooo nights at a lan, who selected this map, chose well; they kept the game in their hands for pretty much the whole, short match. After wiping healthy diet food groups out after two minutes had passed, the game ended in a knockout victory for them, and they advanced to the next round.
## **Winners Round 2 \- Black Bull ♣️🐂 vs. NEVER BACK DOWN NEVER WHAT (20)**
The second round in Winners Bracket was between Black Bull, a pickup of Black Lotus players, and NEVER BACK DOWN NEVER WHAT, another returning face from Qualifier 1\. This set was still a Best of 3, but the next would be a Best of 5\.
Game one, Brinewater Springs, was randomly chosen after Black Bull banned Eeltail Alley and Crableg Capital, and NEVER BACK DOWN NEVER WHAT banned Urchin Underpass and Humpback Pump Track.
The game began with NEVER BACK DOWN NEVER WHAT taking the zone, but within the first 30 seconds, Black Bull took possession and the lead. Black Bulls run was stopped at 49, but was just a small bump in the road, as they ended up knocking out just past the three-minute mark.
Game two, Mahi-Mahi Resort, was NEVER BACK DOWN NEVER WHATs selection after Black Bull banned Museum dAlfonsino and Mincemeat Metalworks.
Once more, NEVER BACK DOWN NEVER WHAT led the game from the start, taking Black Bull down three players, getting all the way to 11 before Black Bull set up a Big Bubbler in the corner to safely break their way into the zone. Just when 11 sounded like a safe lead, Black Bull got the 100-to-0, taking the game just one second slower than the previous one.
<img width="1000" height="562" alt="WR2_mahi-mahi" src="https://github.com/user-attachments/assets/c0296f46-4ae7-448b-a2e6-2300acd06156" />
*SOUL WORLD rallies their Black Bull teammates to the Big Bubbler, initiating the play that would lead the team to a 100-to-0 victory.*
## **Winners Semifinals Part 1 \- PxG vs. French \+ Kenshin (31)**
The bracket skipped over Winners Quarterfinals and went right to Winners Semifinals, transitioning from a Best of 3 to Best of 5\. PxG was set up to face French \+ Kenshin, for the first of Qualifier 2s three tickets to the First Edition Global Gauntlet Finals.
Game one was at Hagglefish Market, chosen after PxG banned Urchin Underpass and Barnacle & Dime, and French \+ Kenshin banned Crableg Capital and Hammerhead Bridge.
French \+ Kenshin claimed the zone first and was able to get to a respectable score of 61 before PxG locked in. Two minutes into the game, PxG took the lead, being delayed at 34 before knocking out with just over two minutes to spare.
Game two was at Mahi-Mahi Resort; PxG banned Umami Ruins and Undertow Spillway.
A disconnect (which would be some foreshadowing for later) caused the game to reset, and once it started in full, PxGs opening saw them hold the zone to 34, where French \+ Kenshin finally capped the zone after several attempts of only neutralizing it. French \+ Kenshin was wiped out by the two-minute mark, and seconds later, PxG had another knockout.
French \+ Kenshins game three selection was Inkblot Art Academy, after PxG banned Shipshape Cargo Co. and Wahoo World.
French \+ Kenshins opening push got them incredibly far, to 25, before PxG could take the zone. It took PxG about a minute to take the lead, and they were so close to knocking out, but were stopped at 4\. With one minute remaining, French \+ Kenshin at 9 and PxG at 4, Gos disconnected, and PxG was not able to maintain their lead, losing it in the final 20 seconds, and French \+ Kenshin had their own knockout in the books.
<img width="1000" height="562" alt="WSF_inkblot" src="https://github.com/user-attachments/assets/4801d7a9-80ca-4bb5-840d-a05a3d52279a" />
*PxG held the lead in the 3v4 for an impressive amount of time, but one E-liter 4K cant keep the zone against three opponents.*
PxG counterpicked to MakoMart after French \+ Kenshin banned Museum dAlfonsino and Mincemeat Metalworks.
PxG came back with a vengeance in this game, wiping out their opponent twice in the first minute. PxG made it to 21 before French \+ Kenshin took the zone from them, but only for four points before they cycled being down two players, and ultimately, PxG took the final knockout, securing their \#4 spot at the Global Gauntlet Finals\!
## **Winners Semifinals Part 2: Sooo nights at a lan 😂 vs. Black Bull ♣️🐂 (23)**
On the other side of the bracket, for the second ticket to the GG Finals, was Sooo nights at a lan and Black Bull, for their second time each on stream. Sooo nights at a lan (seed 10\) has upset seeds 7 (healthy diet food groups) and 2 (nm) to get this far, and was looking to upset another in Black Bull, who was seed 3\.
Urchin Underpass was game ones map; Black Bull banned Inkblot Art Academy and Museum dAlfonsino, and Sooo nights at a lan banned Wahoo World and Manta Maria.
Sooo nights at a lan started the match with the zone, but by the end of the first minute, Black Bull had the zone and lead, ticking down points, and almost knocked out, finally losing the zone at 2\. With 70 ticks and 24 penalty points, Sooo nights at a lan went the distance in one sweep, knocking out as the clock read 2:10.
<img width="1000" height="562" alt="WSF2_urchin" src="https://github.com/user-attachments/assets/9d5efaed-6595-40b9-8dcd-0a86d662dd5f" />
Game two went to Umami Ruins by Black Bulls choice, seeing strikes on Hammerhead Bridge and Mincemeat Metalworks.
The first major lead in the game occurred after Sooo nights at a lan, who only had a lead of 1 point, won a 20-second firefight over the neutral zones, and was able to get to 55 before Black Bull had their turn. Black Bull locked out their opponent, taking them down three players to get the lead, and knocked out for the win.
Game three went to Scorch Gorge as Sooo nights at a lans counterpick after Black Bull banned Humpback Pump Track and Shipshape Cargo Co.
It took 40 seconds for the zone to be capped; Sooo nights at a lan had it up until they reached 42\. After having the zone for about 30 seconds, Black Bull was in the lead and extended it to 19\. Both teams started to trade possession, and Sooo nights at a lan tied with Black Bull, unfortunately ending up one point behind after they lost the zone. However, they came back and wiped out Black Bull, getting the win with time to spare.
Game four, Black Bulls counterpick, went to Robo ROM-en. Sooo nights at a lan banned Bluefin Depot and Crableg Capital.
Sooo nights at a lan had a strong lead, keeping ahead of Black Bull even after they took the zone. Black Bull eventually got their lead after wiping out Sooo nights at a lan, and their opposition couldnt regroup and retake, resulting in another knockout for Black Bull.
After trading wins, the set was tied 22, going to a game five, where Black Bull banned MakoMart and Undertow Spillway. Sooo nights at a lan selected Hagglefish Market.
First Black Bull had the lead, then Sooo nights at a lan had the lead, both in the same minute. The zone flipped back and forth, finally settling with Black Bull, who sealed the game with a Triple Splashdown for the knockout, set win, and \#5 spot at the GG Finals\!
## **Losers Round 4: Sun-Eater vs. Sooo nights at a lan 😂 (12)**
Once the Winners Bracket concluded, the stream would see two sets in the Losers Bracket to determine the final Western team for the GG Finals. Since this was not Quarterfinals, it was another Best of 3 set.
As some fun trivia provided by Ely, we learned that Sooo nights at a lan was named that because one of their players, Nightmare, couldnt play in the tournament due to playing at the Sunset Surge LAN.
Game one, Mahi-Mahi Resort, was selected from the map pool after Sun-Eater banned Shipshape Cargo Co. and Marlin Airport and Sooo nights at a lan banned Crableg Capital and Hammerhead Bridge.
Although Sun-Eater began with the zone, they lost it quickly. The water on the map was already dropping before the first minute was up, as Sooo nights at a lan extended their lead. One minute and a half into the game, Sooo nights at a lan knocked Sun-Eater out.
Much to the dismay of the commentators, Sun-Eater selected Wahoo World for game two. Bluefin Depot and Museum dAlfonsino were banned.
Sooo nights at a lans recurring tactic was to use their Crab Tank to paint the zone offensively, and again and again it worked against Sun-Eater. Each time, Sun-Eater bounced back stronger, ending up back in the lead. In the final seconds, they secured their win by taking the zone back, and the final score was 7763.
<img width="1000" height="562" alt="LR4_wahooworld" src="https://github.com/user-attachments/assets/487c8624-82a2-4f9d-81f2-a3d6094796d4" />
*The Crab Tank that caused Sun-Eater so much trouble, just one of the many instances where it both protected and painted the zone.*
Game three, determining who went to Losers Semifinals, went to MakoMart after Sun-Eater banned Humpback Pump Track and Flounder Heights.
Sun-Eater started strong, holding the zone for most of the first minute. While their respawns kept staggering, Sooo nights at a lan capitalized to take the lead. Both teams kept one-upping the other with the lead. The only game to go into overtime, and Sun-Eater was just one point away from winning before Sooo nights at a lan overpowered them, winning 9190 and advancing to face nm in Losers Semifinals.
## **Losers Semifinals: Sooo nights at a lan 😂 vs. nm (03)**
The final ticket to the GG Finals on April 24th\! This was not the first time Sooo nights at a lan met nm in this tournament—in fact, Sooo nights at a lan was the team who sent nm to the Losers Bracket 2-1 in Winners Round 2\. While Sooo nights at a lan had taken every set thus far to the last possible game, nm meanwhile had torn through the Losers Bracket 20 in every set.
While waiting for the set to start, nm was in the Twitch chat talking with Popgun and Sasu, and revealed that the name “nm” stands for “No Mercy”, which was exactly what they were about to put on display.
Game one went to Sturgeon Shipyard. Nm banned Humpback Pump Track and Undertow Spillway. Sooo nights at a lan banned Hagglefish Market and Manta Maria.
Early on, nm claimed the zone, taking down two players on the opposing team. Sooo nights at a lan tried to paint with their Crab Tank, but it was shot down before it could even neutralize the zone. Going 100-to-0, nm ended the first game in a knockout. This was the first game one in the entire tournament that Sooo nights at a lan didnt win.
MakoMart was selected for game two after nm banned Flounder Heights and Umami Ruins.
Nm guarded the zone so well that Sooo nights at a lan could only take shots at the objective from afar. Sooo nights at a lan was able to take the zone a few times this game, but never held it for long. After a wipeout, nm earned another knockout victory.
<img width="1000" height="562" alt="LSF_makomart" src="https://github.com/user-attachments/assets/fcb71cd9-9926-4d26-b7e9-3f3e6de36a8c" />
*Sooo nights at a lan, using their Crab Tank to try breaking into the zone, only for it to be taken out momentarily by an Ultra Stamp.*
Game three, match point for nm, went to Brinewater Springs after Urchin Underpass and Inkblot Art Academy were banned.
Leading the charge with an Ink Vac, nm took the zone first. After going down two players, Sooo nights at a lan had a chance to take the zone and keep their opponent at 40 points. They took their turn with the lead, defended it against nm a few times, but nm had the last say, wresting control and getting one final, decisive knockout to end the set 30 and take the \#6 spot on the Wests GG Finals roster\!
### **The First Edition Global Gauntlet Finals: Whos Going?**
The First Edition Global Gauntlet Finals takes place during the International Timeslot, which is 8 PM PT / 11 PM ET on Friday, April 24th for North America and 5 AM CET / 12 PM JT on Saturday, April 25th for the EU and Japan.
From the West, the six teams have been decided:
1. FTWin
2. ezmd
3. Azure
4. PxG
5. Black Lotus (Black Bull)
6. No Mercy (nm)
From Japan, AREA CUP has revealed all six teams attending:
1. Zest
2. Utopia
3. 07 Quartet
4. EmpEror
5. swing
6. ISM rhythm
EmpEror, a newly-announced addition to Japans GG Finals team, is composed of Lobster and Naegora, two champions from Splat World Series 2025s \#1 team The Invincible Fleet Rei Maru, along with Neespa and Chiaki.
Speaking of The Invincible Fleet Rei Maru, their player Reimaru is competing as part of swing, a group made up of Momo and Norishio from last SWSs team DragonReX, Reimaru, and Streamer Sena. Momo recently played in the SendouQ Season 10 Finale last month on Flow Dragons, with Black Lotuss captain, Noctis, and took the second place silver, next to PxGs first place gold.
The last-announced team, revealed one day before the Finals take place, ISM rhythm, has the final player from The Invincible Fleet Rei Maru, Grandroll, playing with new teammates Livia, Yocchan Ika, and Goyame.
Following the Splat World Series last year, the West has made a strong showing of taking part in more Japanese events, such as AREA CUP, or just meeting together for scrims. The Global Gauntlets, created as a warm up to prepare both sides for the Splat World Series, will give players much to strategize over after the premiere Finals event.
Last time teams of this caliber met, only PxG walked away with a win over a Japanese team. How has the West used their time to close the gap?
Be there on April 24th to find out\!
Original Posting Date: April 23, 2026 at [Splatoon Stronghold](https://www.splatoonstronghold.com/news/sws26-recap-gg1-q2).
Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social).

View File

@ -9,7 +9,6 @@
"filteringByTag": "Viser resultater filtreret efter #{{tag}}",
"commissionsOpen": "Åben for bestillinger",
"commissionsClosed": "Lukket for bestillinger",
"openCommissionsOnly": "Vis kunstnere med åbne bestillinger",
"gainPerms": "Lav venligt et opslad til vores helpdesk på vores Discord-server for at få tilladelse til at uploade kunst. Bemærk venligt, at du skal være kunstneren af det kunst, som du uploader, og kun Splatoon-relateret kunst tillades.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink er et projekt af <2>Sendou</2> med hjælp fra bidragsydere:",
"project": "Sendou.ink er et projekt af Sendou med hjælp fra bidragsydere:",
"code": "Se alle der har hjulpet med kodningen",
"lean": "Hjalp med at fremvise Splatoons 'indre' og skabte Lanista-botten",
"borzoic": "Lavede mærker, ikoner og kunst til forsiden",

View File

@ -9,7 +9,6 @@
"filteringByTag": "",
"commissionsOpen": "",
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink ist ein Projekt von <2>Sendou</2> - mit der Hilfe von Mitwirkenden:",
"project": "Sendou.ink ist ein Projekt von Sendou - mit der Hilfe von Mitwirkenden:",
"code": "Alle am Code Mitwirkenden ansehen",
"lean": "Hilft mit der Analyse von Splatoon-Internals und hat den Lanista-Bot erstellt",
"borzoic": "Erstellte Abzeichen, Icons und Grafiken auf der Homepage",

View File

@ -9,7 +9,6 @@
"filteringByTag": "Showing results filtered by #{{tag}}",
"commissionsOpen": "Commissions are open",
"commissionsClosed": "Commissions are closed",
"openCommissionsOnly": "Show artists with open commissions",
"gainPerms": "Please post on the helpdesk of our Discord to gain permissions to upload art. Note that you must be the artist of the art you are uploading and only Splatoon related art is allowed.",
"tabs.recentlyUploaded": "Recently Uploaded",
"tabs.showcase": "Showcase",

View File

@ -79,11 +79,11 @@
"notifications.title.SCRIM_NEW_REQUEST": "New Scrim Request",
"notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} requested a scrim",
"notifications.title.SCRIM_SCHEDULED": "Scrim Scheduled",
"notifications.text.SCRIM_SCHEDULED": "New scrim scheduled at {{timeString}}",
"notifications.text.SCRIM_SCHEDULED": "New scrim scheduled vs. {{opponentTeamName}}",
"notifications.title.SCRIM_CANCELED": "Scrim Canceled",
"notifications.text.SCRIM_CANCELED": "The scrim at {{timeString}} was canceled",
"notifications.text.SCRIM_CANCELED": "The scrim vs. {{opponentTeamName}} was canceled",
"notifications.title.SCRIM_STARTING_SOON": "Scrim Starting Soon",
"notifications.text.SCRIM_STARTING_SOON": "Your scrim at {{timeString}} is starting soon",
"notifications.text.SCRIM_STARTING_SOON": "Your scrim vs. {{opponentTeamName}} is starting soon",
"notifications.title.COMMISSIONS_CLOSED": "Commissions Closed",
"notifications.text.COMMISSIONS_CLOSED": "If your commissions are still open, please re-enable them",
"notifications.title.FRIEND_REQUEST_RECEIVED": "Friend Request",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink is a project by <2>Sendou</2> with help from contributors:",
"project": "Sendou.ink is a project by Sendou with help from contributors:",
"code": "See all code contributors",
"lean": "Helped with uncovering Splatoon internals and created the Lanista bot",
"borzoic": "Made badges, icons and front page art",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Mostrando resultados filtrados por #{{tag}}",
"commissionsOpen": "Comisiones abiertas",
"commissionsClosed": "Comisiones cerradas",
"openCommissionsOnly": "Mostrar artistas con comisiones abiertas",
"gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionado con Splatoon.",
"tabs.recentlyUploaded": "Subidas recientemente",
"tabs.showcase": "Destacadas",

View File

@ -79,11 +79,11 @@
"notifications.title.SCRIM_NEW_REQUEST": "Nueva Solicitud de Scrim",
"notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} ha solicitado un scrim",
"notifications.title.SCRIM_SCHEDULED": "Scrim Programado",
"notifications.text.SCRIM_SCHEDULED": "Nuevo scrim programado para las {{timeString}}",
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "Scrim Cancelado",
"notifications.text.SCRIM_CANCELED": "El scrim de las {{timeString}} fue cancelado",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "El scrim empieza pronto",
"notifications.text.SCRIM_STARTING_SOON": "Tu scrim de las {{timeString}} empieza pronto",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas",
"notifications.text.COMMISSIONS_CLOSED": "Si tus comisiones siguen abiertas, por favor vuelve a activarlas",
"notifications.title.FRIEND_REQUEST_RECEIVED": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink es un proyecto hecho por <2>Sendou</2> con ayuda de contribuidores:",
"project": "Sendou.ink es un proyecto hecho por Sendou con ayuda de contribuidores:",
"code": "Ver todos los contribuidores",
"lean": "Ayudó a descubrir partes internas de Splatoon y creó el bot Lanista",
"borzoic": "Creó insignias, íconos y el arte de la página principal",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Mostrando resultados filtrados por #{{tag}}",
"commissionsOpen": "Comisiones abiertas",
"commissionsClosed": "Comisiones cerradas",
"openCommissionsOnly": "Mostrar artistas con comisiones abiertas",
"gainPerms": "Por favor manda mensaje en el 'helpdesk' de nuestro Discord para obtener permiso para subir arte. Debes ser el artista que creó el arte que subas, y solo se permite arte relacionada con Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink es un proyecto hecho por <2>Sendou</2> con ayuda de contribuidores:",
"project": "Sendou.ink es un proyecto hecho por Sendou con ayuda de contribuidores:",
"code": "Ver todos los contribuidores",
"lean": "Ayudó a descubrir partes internas de Splatoon y creó el bot Lanista",
"borzoic": "Creó insignias, íconos y el arte de la página principal",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Affichage des résultats filtrés par #{{tag}}",
"commissionsOpen": "Accepte les commissions",
"commissionsClosed": "N'accepte pas les commissions",
"openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink est un projet créé par <2>Sendou</2> avec l'aide de contributeurs :",
"project": "Sendou.ink est un projet créé par Sendou avec l'aide de contributeurs :",
"code": "Voir tous les contributeurs au code",
"lean": "A aidé à découvrir les fonctionnements internes de Splatoon et a créé le bot Lanista",
"borzoic": "A créé les badges, les icônes et l'illustration de la page d'accueil",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Affichage des résultats filtrés par #{{tag}}",
"commissionsOpen": "Accepte les commissions",
"commissionsClosed": "N'accepte pas les commissions",
"openCommissionsOnly": "Ne montrer que les artistes qui acceptent les commissions",
"gainPerms": "Vous pouvez demander dans le salon ''helpdesk'' sur notre discord pour avoir cette permission. Note: vous devez êtres l'artist pour publier votre création, celle-ci doit être seulement en rapport avec Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -79,7 +79,7 @@
"notifications.title.SCRIM_NEW_REQUEST": "Nouvelle Demande De Scrim",
"notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} vous demande de scrim",
"notifications.title.SCRIM_SCHEDULED": "Scrim Programmé",
"notifications.text.SCRIM_SCHEDULED": "Nouveau scrim programmé à {{timeString}}",
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink est un projet créé par <2>Sendou</2> avec l'aide de contributeurs :",
"project": "Sendou.ink est un projet créé par Sendou avec l'aide de contributeurs :",
"code": "Voir tous les contributeurs au code",
"lean": "A aidé à découvrir les fonctionnements internes de Splatoon et a créé le bot Lanista",
"borzoic": "A créé les badges, les icônes et l'illustration de la page d'accueil",

View File

@ -10,7 +10,6 @@
"filteringByTag": "מראה תוצאות לפי סינון של #{{tag}}",
"commissionsOpen": "בקשות פתוחות",
"commissionsClosed": "בקשות סגורות",
"openCommissionsOnly": "הראה אומנים עם בקשות פתוחות",
"gainPerms": "נא לכתוב בערוץ helpdesk בדיסקורד כדי לקבל הרשאות להעלות ציורים. שימו לב שאתם חייבים להיות יוצר הציור ורק אמנות הקשורה ל-Splatoon מותרת..",
"tabs.recentlyUploaded": "הועלה לאחרונה",
"tabs.showcase": "תצוגה",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink הוא פרויקט של <2>Sendou</2> עם עזרה מהתורמים:",
"project": "Sendou.ink הוא פרויקט של Sendou עם עזרה מהתורמים:",
"code": "ראה את כל תורמי הקוד",
"lean": "עזר בחשיפת מידע פנימי של Splatoon ויצירת הבוט Lanista",
"borzoic": "הכין תגים, אייקונים וציור לעמוד הראשון",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Mostrando risultati filtrati da #{{tag}}",
"commissionsOpen": "Commissioni aperte",
"commissionsClosed": "Commissioni chiuse",
"openCommissionsOnly": "Mostra artisti con commissioni aperte",
"gainPerms": "Si prega di postare sull'helpdesk del nostro Discord per ottenere i permessi per caricare art. Nota che devi essere tu l'artista dell'art che stai caricando, e solo art relative a Splatoon sono ammesse.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink è un proggeto creato da <2>Sendou</2> con aiuto dai contributori:",
"project": "Sendou.ink è un proggeto creato da Sendou con aiuto dai contributori:",
"code": "Vedi tutti i contributori al codice sorgente",
"lean": "Ha aiutato scoprendo le mecchaniche interne di Splatoon e creando il bot Lanista",
"borzoic": "Ha creato le medaglie, le icone e l'arte sulla pagina principale",

View File

@ -7,7 +7,6 @@
"filteringByTag": "#{{tag}} で絞り込まれた結果を表示中",
"commissionsOpen": "依頼を受付中",
"commissionsClosed": "依頼の受付なし",
"openCommissionsOnly": "依頼を受付中のアーティストを表示",
"gainPerms": "作品をアップロードしたい場合は私たちのディスコードサーバーのヘルプデスクで許可を得てください。アップロードするには作品の作者でないといけません。また、スプラトゥーン関連の作品のみアップロードできます。",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink は <2>Sendou</2> によるプロジェクトで、以下のコントリビューターに支えられています:",
"project": "Sendou.ink は Sendou によるプロジェクトで、以下のコントリビューターに支えられています:",
"code": "すべてのコードコントリビューターを見る",
"lean": "Splatoon 内部理解のサポート、Lanista bot の作成",
"borzoic": "バッジ、アイコン、トップページアートの作成",

View File

@ -7,7 +7,6 @@
"filteringByTag": "",
"commissionsOpen": "",
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink는 <2>Sendou</2>가 기여자들의 도움을 받아 만든 프로젝트입니다:",
"project": "Sendou.ink는 Sendou가 기여자들의 도움을 받아 만든 프로젝트입니다:",
"code": "전체 코드 기여자 확인",
"lean": "스플래툰의 내부를 파헤치는 데에 도움을 주고 Lanista bot을 만들었습니다",
"borzoic": "배지, 아이콘과 표지 그림을 만들었습니다",

View File

@ -9,7 +9,6 @@
"filteringByTag": "",
"commissionsOpen": "",
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink is een project door <2>Sendou</2> met behulp van de volgende bijdragers:",
"project": "Sendou.ink is een project door Sendou met behulp van de volgende bijdragers:",
"code": "",
"lean": "Heeft geholpen met het onthullingen van de interne werkingen van Splatoon en heeft ook de Lanista bot ontwikkeld.",
"borzoic": "Heeft badges, iconen en de voorpagina art gemaakt.",

View File

@ -11,7 +11,6 @@
"filteringByTag": "",
"commissionsOpen": "",
"commissionsClosed": "",
"openCommissionsOnly": "",
"gainPerms": "",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink jest projektem zrobionym przez <2>Sendou</2> wraz z pomocą innych współtwórców:",
"project": "Sendou.ink jest projektem zrobionym przez Sendou wraz z pomocą innych współtwórców:",
"code": "Zobacz wszystkich współtwórców kodu",
"lean": "Pomógł z odkrywaniem wnętrz Splatoona oraz stworzył Lanista bot",
"borzoic": "Stworzyła odznaki, ikony i ilustracje na głównej stronie",

View File

@ -10,7 +10,6 @@
"filteringByTag": "Mostrando resultados filtrados por/pela #{{tag}}",
"commissionsOpen": "Comissões estão abertas",
"commissionsClosed": "Comissões estão fechadas",
"openCommissionsOnly": "Mostrar somente artistas com comissões abertas",
"gainPerms": "Por favor, poste na central de ajuda (helpdesk em Inglês) do nosso Discord para ganhar permissões para fazer o upload de arte. Lembre-se que você precisa ser o artista da arte da qual você está fazendo o upload e que apenas arte relacionada com Splatoon é permitida.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink é um projeto por <2>Sendou</2> com ajuda dos contribuidores(as):",
"project": "Sendou.ink é um projeto por Sendou com ajuda dos contribuidores(as):",
"code": "Ver todos os contribuidores(as) de código",
"lean": "Ajudou com as descobertas do sistema interno do Splatoon e criou o bot Lanista",
"borzoic": "Fez as insígnias, ícones e arte da página inicial",

View File

@ -11,7 +11,6 @@
"filteringByTag": "Результаты по фильтру #{{tag}}",
"commissionsOpen": "Заказы открыты",
"commissionsClosed": "Заказы закрыты",
"openCommissionsOnly": "Показать художников с открытыми заказами",
"gainPerms": "Пожалуйста, напишите в helpdesk на нашем Discord сервере, чтобы получить доступ к загрузке артов. Учтите, что вы должны быть автором артов, которые вы загружаете. Арты должны быть строго по тематике Splatoon.",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -79,7 +79,7 @@
"notifications.title.SCRIM_NEW_REQUEST": "Новый Скрим Запрос",
"notifications.text.SCRIM_NEW_REQUEST": "{{fromUsername}} запросил скрим",
"notifications.title.SCRIM_SCHEDULED": "Скрим Запланирован",
"notifications.text.SCRIM_SCHEDULED": "Новый скрим запланирован на {{timeString}}",
"notifications.text.SCRIM_SCHEDULED": "",
"notifications.title.SCRIM_CANCELED": "",
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink это проект, созданный <2>Sendou</2> с поддержкой помощников:",
"project": "Sendou.ink это проект, созданный Sendou с поддержкой помощников:",
"code": "Посмотреть всех, кто внёс вклад в код",
"lean": "Помощь с исследованием внутренностей Splatoon и создатель бота Lanista",
"borzoic": "Создатель рисунка на главной странице, а также значков и иконок",

View File

@ -7,7 +7,6 @@
"filteringByTag": "正在显示包含 #{{tag}} 的结果",
"commissionsOpen": "委托开放中",
"commissionsClosed": "委托未开放",
"openCommissionsOnly": "显示开放委托的创作者",
"gainPerms": "请在我们Discord中的helpdesk申请上传作品的权限。请注意您必须是作品的原作者并且只能上传斯普拉遁相关作品。",
"tabs.recentlyUploaded": "",
"tabs.showcase": "",

View File

@ -1,5 +1,5 @@
{
"project": "Sendou.ink是<2>Sendou</2>在以下贡献者的帮助下创建的:",
"project": "Sendou.ink是Sendou在以下贡献者的帮助下创建的:",
"code": "查看所有代码贡献者",
"lean": "帮助破解斯普拉遁并创建了Lanista bot",
"borzoic": "制作徽章、图标,设计首页",

View File

@ -22,6 +22,7 @@
"biome:fix": "biome check --error-on-warnings --write .",
"biome:fix:unsafe": "biome check --error-on-warnings --write --unsafe .",
"typecheck": "react-router typegen && tsc --noEmit",
"typecheck:scripts": "tsc --noEmit -p scripts",
"test:unit:browser": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 BROWSER_HEADLESS=true vitest --silent=passed-only run",
"test:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only --project browser",
"test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only",
@ -46,12 +47,12 @@
"@epic-web/cachified": "5.6.2",
"@faker-js/faker": "10.4.0",
"@formatjs/intl-durationformat": "0.10.4",
"@internationalized/date": "3.12.0",
"@internationalized/date": "3.12.1",
"@react-router/node": "7.14.1",
"@react-router/serve": "7.14.1",
"@remix-run/form-data-parser": "0.16.0",
"@tldraw/tldraw": "3.12.1",
"@zumer/snapdom": "2.8.0",
"@zumer/snapdom": "2.9.0",
"aws-sdk": "2.1693.0",
"better-sqlite3": "12.9.0",
"clsx": "2.1.1",
@ -61,7 +62,7 @@
"gray-matter": "4.0.3",
"i18next": "25.10.10",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "3.0.4",
"i18next-http-backend": "3.0.5",
"ics": "3.11.0",
"isbot": "5.1.38",
"jsoncrush": "1.1.8",
@ -69,7 +70,7 @@
"lru-cache": "11.3.5",
"lucide-react": "1.8.0",
"markdown-to-jsx": "9.7.15",
"nanoid": "5.1.7",
"nanoid": "5.1.9",
"neverthrow": "8.2.0",
"node-cron": "4.2.1",
"nprogress": "0.2.0",
@ -78,7 +79,7 @@
"partysocket": "1.1.16",
"qrcode.react": "4.2.0",
"react": "19.2.5",
"react-aria-components": "1.16.0",
"react-aria-components": "1.17.0",
"react-charts": "3.0.0-beta.57",
"react-dom": "19.2.5",
"react-error-boundary": "6.1.1",
@ -98,7 +99,7 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.11",
"@biomejs/biome": "2.4.13",
"@playwright/test": "1.59.1",
"@react-router/dev": "7.14.1",
"@types/better-sqlite3": "7.6.13",
@ -118,7 +119,7 @@
"ley": "0.8.1",
"sql-formatter": "15.7.3",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript": "6.0.3",
"vite": "8.0.8",
"vite-node": "6.0.0",
"vite-plugin-babel": "1.6.0",

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

View File

@ -1,5 +1,3 @@
// @ts-nocheck
// To run this script you need from https://github.com/Leanny/leanny.github.io
// 1) WeaponInfoMain.json inside dicts
// 2) WeaponInfoSub.json inside dicts
@ -18,10 +16,13 @@ import type {
SubWeaponParams,
WeaponKit,
} from "~/features/build-analyzer/analyzer-types";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
type SpecialWeaponId,
SQUID_BEAKON_ID,
type SubWeaponId,
subWeaponIds,
weaponIdToBaseWeaponId,
} from "~/modules/in-game-lists/weapon-ids";
@ -109,7 +110,8 @@ async function main() {
if (specialWeaponShouldBeSkipped(specialWeapon)) continue;
const rawParams = loadWeaponParamsObject(specialWeapon);
const params = parametersToSpecialWeaponResult(rawParams);
const params: Record<string, any> =
parametersToSpecialWeaponResult(rawParams);
// Super Chumps has two distinct splash damage values (near/far)
// that should be labeled separately in the analyzer
@ -179,7 +181,7 @@ function splitIntoBaseStatsAndKits(
> = {};
for (const [idStr, params] of Object.entries(allParams)) {
const id = Number(idStr);
const id = Number(idStr) as MainWeaponId;
const baseId = weaponIdToBaseWeaponId(id);
if (!weaponGroups[baseId]) weaponGroups[baseId] = [];
weaponGroups[baseId].push({ id, params });
@ -497,11 +499,11 @@ function parametersToMainWeaponResult(
const resolveMin = (
valueOne: number | null | undefined,
valueTwo: number | null | undefined,
) => {
): number | undefined => {
if (typeof valueOne !== "number" && typeof valueTwo !== "number")
return undefined;
if (typeof valueOne !== "number") return valueTwo;
if (typeof valueOne !== "number") return valueTwo as number;
if (typeof valueTwo !== "number") return valueOne;
return Math.min(valueOne, valueTwo);
@ -510,11 +512,11 @@ function parametersToMainWeaponResult(
const resolveMax = (
valueOne: number | null | undefined,
valueTwo: number | null | undefined,
) => {
): number | undefined => {
if (typeof valueOne !== "number" && typeof valueTwo !== "number")
return undefined;
if (typeof valueOne !== "number") return valueTwo;
if (typeof valueOne !== "number") return valueTwo as number;
if (typeof valueTwo !== "number") return valueOne;
return Math.max(valueOne, valueTwo);

View File

@ -1,5 +1,3 @@
// @ts-nocheck
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -57,7 +55,13 @@ async function main() {
internalName,
brand: gear.Brand,
translations: langDicts.map(([langCode, translations]) => {
const name = translations[categoryKey]?.[internalName];
const category = (
translations as unknown as Record<
string,
Record<string, string> | undefined
>
)[categoryKey];
const name = category?.[internalName];
invariant(name, `Missing translation for ${internalName}`);
return {

View File

@ -1,5 +1,3 @@
// @ts-nocheck
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -12,6 +10,9 @@ import {
translationJsonFolderName,
} from "./utils";
type LangDicts = Awaited<ReturnType<typeof loadLangDicts>>;
type LangDict = LangDicts[number][1];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -176,7 +177,9 @@ async function main() {
invariant(internalName, `Missing internal name for ${ability}`);
const translation = decodeURIComponent(
langDict["CommonMsg/Gear/GearPowerName"][internalName],
(langDict["CommonMsg/Gear/GearPowerName"] as Record<string, string>)[
internalName
],
);
translationsMap[`ABILITY_${ability}`] = translation;
@ -216,7 +219,7 @@ async function main() {
generateBadgeData(langDicts);
}
function generateBadgeData(langDicts) {
function generateBadgeData(langDicts: LangDicts) {
const badgeFiles = fs
.readdirSync(BADGE_DIR)
.filter((f) => f.endsWith(".png"));
@ -271,9 +274,12 @@ function generateBadgeData(langDicts) {
fs.writeFileSync(path.join(OUTPUT_DIR, "game-badge-ids.ts"), tsContent);
}
function buildBadgeTranslations(badgeIds, langDict) {
const badgeMsg = langDict["CommonMsg/Badge/BadgeMsg"];
const translationsMap = {};
function buildBadgeTranslations(badgeIds: string[], langDict: LangDict) {
const badgeMsg = langDict["CommonMsg/Badge/BadgeMsg"] as Record<
string,
string
>;
const translationsMap: Record<string, string> = {};
for (const id of badgeIds) {
if (badgeMsg[id] && !badgeMsg[id].includes("[group=")) {
@ -306,7 +312,9 @@ function buildBadgeTranslations(badgeIds, langDict) {
continue;
}
const lookupValue = langDict[rule.lookupDict]?.[variantName];
const lookupValue = (
langDict as unknown as Record<string, Record<string, string> | undefined>
)[rule.lookupDict]?.[variantName];
if (!lookupValue) {
translationsMap[id] = id;
continue;
@ -321,7 +329,10 @@ function buildBadgeTranslations(badgeIds, langDict) {
return translationsMap;
}
function writeBadgeJson(folder, translationsMap) {
function writeBadgeJson(
folder: string,
translationsMap: Record<string, string>,
) {
fs.writeFileSync(
path.join(__dirname, "..", "locales", folder, "game-badges.json"),
`${JSON.stringify(translationsMap, null, 2)}\n`,

View File

@ -1,9 +1,12 @@
// @ts-nocheck
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants";
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
specialWeaponIds,
@ -23,6 +26,21 @@ const __dirname = path.dirname(__filename);
const OUTPUT_DIR_PATH = path.join(__dirname, "output");
type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number];
type ResultEntry = {
mainWeaponIds: MainWeaponId[];
subWeaponIds: SubWeaponId[];
specialWeaponIds: SpecialWeaponId[];
rates: { target: string; rate: number }[];
};
type DamageRateCell = {
ColumnKey: string;
RowKey: string;
DamageRate?: number;
};
const weaponParamsToWeaponIds = (
params: typeof weapons | typeof subWeapons | typeof specialWeapons,
key: string,
@ -39,39 +57,48 @@ const weaponParamsToWeaponIds = (
.map((weapon) => weapon.Id);
};
const result = {};
for (const cell of Object.values(params.CellList)) {
if (!DAMAGE_RECEIVERS.includes(cell.ColumnKey)) continue;
const isDamageReceiver = (key: string): key is DamageReceiver =>
(DAMAGE_RECEIVERS as readonly string[]).includes(key);
const result: Record<string, ResultEntry | undefined> = {};
for (const cell of Object.values(params.CellList) as DamageRateCell[]) {
if (!isDamageReceiver(cell.ColumnKey)) continue;
if (!cell.DamageRate) continue;
if (!result[cell.RowKey]) {
result[cell.RowKey] = {
mainWeaponIds: weaponParamsToWeaponIds(weapons, cell.RowKey).filter(
(id) => mainWeaponIds.includes(id),
(id): id is MainWeaponId =>
(mainWeaponIds as readonly number[]).includes(id),
),
subWeaponIds: weaponParamsToWeaponIds(subWeapons, cell.RowKey).filter(
(id) => subWeaponIds.includes(id),
(id): id is SubWeaponId =>
(subWeaponIds as readonly number[]).includes(id),
),
specialWeaponIds: weaponParamsToWeaponIds(
specialWeapons,
cell.RowKey,
).filter((id) => specialWeaponIds.includes(id)),
).filter((id): id is SpecialWeaponId =>
(specialWeaponIds as readonly number[]).includes(id),
),
rates: [],
};
}
const entry = result[cell.RowKey]!;
// if it applies to no PvP weapons, we don't care about it
if (
result[cell.RowKey].mainWeaponIds.length === 0 &&
result[cell.RowKey].subWeaponIds.length === 0 &&
result[cell.RowKey].specialWeaponIds.length === 0 &&
entry.mainWeaponIds.length === 0 &&
entry.subWeaponIds.length === 0 &&
entry.specialWeaponIds.length === 0 &&
cell.RowKey !== "ObjectEffect_Up"
) {
result[cell.RowKey] = undefined;
continue;
}
result[cell.RowKey].rates.push({
entry.rates.push({
target: cell.ColumnKey,
rate: cell.DamageRate,
});
@ -81,7 +108,7 @@ for (const cell of Object.values(params.CellList)) {
cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") ||
cell.ColumnKey.includes("BulletUmbrellaCanopyWide")
) {
result[cell.RowKey].rates.push({
entry.rates.push({
target: `${cell.ColumnKey}_Launched`,
rate: cell.DamageRate,
});
@ -89,12 +116,12 @@ for (const cell of Object.values(params.CellList)) {
// if it has special damage rates for Splat Brella, add the same value for Recycled Brella
if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") {
result[cell.RowKey].rates.push({
entry.rates.push({
target: "BulletShelterCanopyFocus",
rate: cell.DamageRate,
});
result[cell.RowKey].rates.push({
entry.rates.push({
target: "BulletShelterCanopyFocus_Launched",
rate: cell.DamageRate,
});

View File

@ -1,5 +1,3 @@
// @ts-nocheck
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -63,8 +61,8 @@ async function main() {
continue;
}
const weapon: any = weapons.find(
(weapon: any) =>
const weapon = (weapons as Array<{ __RowId: string; Id: number }>).find(
(weapon) =>
file.includes(`${weapon.__RowId}.`) ||
file.includes(`${weapon.__RowId}_`),
);

View File

@ -1,4 +1,3 @@
// @ts-nocheck
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

5
scripts/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.ts", "../types/**/*.d.ts"],
"exclude": []
}

Some files were not shown because too many files have changed in this diff Show More