From eb8d98dbb762889f60dd272e1ea516bcaa98daf0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 22 May 2026 21:31:15 +0300 Subject: [PATCH] Progress --- app/components/match-page/MatchBanner.tsx | 2 +- app/db/seed/index.ts | 37 ++- app/db/tables.ts | 2 - .../map-list-generator/core/MapList.test.ts | 126 ++++++++++ .../map-list-generator/core/MapList.ts | 42 +++- .../scrims/ScrimMapListRepository.server.ts | 55 +++++ ...server.ts => ScrimMapRepository.server.ts} | 85 +------ .../scrims/ScrimPostRepository.server.ts | 5 + .../scrims/actions/scrims.$id.server.ts | 218 ++++++++++-------- .../scrims/components/ScrimMapListForm.tsx | 53 +---- .../scrims/components/ScrimMatchBanner.tsx | 20 +- .../scrims/components/ScrimMatchHeader.tsx | 4 +- .../components/ScrimMatchStatsTab.module.css | 45 +++- .../scrims/components/ScrimMatchStatsTab.tsx | 165 ++++++++----- app/features/scrims/core/Scrim.test.ts | 7 +- .../scrims/core/ScrimMapByMap.test.ts | 20 ++ app/features/scrims/core/ScrimMapByMap.ts | 42 ++-- .../scrims/loaders/scrims.$id.server.ts | 16 +- app/features/scrims/routes/scrims.$id.tsx | 11 +- app/features/scrims/scrims-schemas.ts | 6 +- app/features/scrims/scrims-types.ts | 1 + app/features/scrims/scrims-utils.test.ts | 78 ++++++- app/features/scrims/scrims-utils.ts | 36 +++ db-test.sqlite3 | Bin 1318912 -> 1318912 bytes e2e/scrims.spec.ts | 40 +--- locales/da/scrims.json | 6 +- locales/de/scrims.json | 6 +- locales/en/scrims.json | 6 +- locales/es-ES/scrims.json | 6 +- locales/es-US/scrims.json | 6 +- locales/fr-CA/scrims.json | 6 +- locales/fr-EU/scrims.json | 6 +- locales/he/scrims.json | 6 +- locales/it/scrims.json | 6 +- locales/ja/scrims.json | 6 +- locales/ko/scrims.json | 6 +- locales/nl/scrims.json | 6 +- locales/pl/scrims.json | 6 +- locales/pt-BR/scrims.json | 6 +- locales/ru/scrims.json | 6 +- locales/zh/scrims.json | 6 +- migrations/143-scrim-map-by-map.js | 1 - 42 files changed, 824 insertions(+), 389 deletions(-) create mode 100644 app/features/scrims/ScrimMapListRepository.server.ts rename app/features/scrims/{ScrimMapByMapRepository.server.ts => ScrimMapRepository.server.ts} (57%) diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx index 5fae48a75..02944d369 100644 --- a/app/components/match-page/MatchBanner.tsx +++ b/app/components/match-page/MatchBanner.tsx @@ -23,7 +23,7 @@ interface MatchBannerProps { screenLegal?: boolean; joinPool?: string | null; joinViaQr?: boolean; - children: React.ReactNode; + children?: React.ReactNode; } export function MatchBanner({ diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 1b805ef42..3cb45eda5 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -90,6 +90,8 @@ import { } from "./constants"; import placements from "./placements.json"; +// xxx: lets clear room metadata in skalop and make sure every chatCode setting route (scrim) sets metadata + const SENDOUQ_DEFAULT_MAPS: Record< ModeShort, [StageId, StageId, StageId, StageId, StageId, StageId, StageId] @@ -3188,7 +3190,34 @@ async function scrimPosts() { return result; }; - for (let i = 0; i < 20; i++) { + // Deterministic post 1: admin (Sendou) vs N-ZAP. The e2e map-by-map test + // navigates straight to /scrims/1 and relies on this being an accepted + // scrim with admin on the ALPHA side and N-ZAP on the BRAVO side. + const adminVsNzapAt = date(true); + const adminVsNzapPostId = await ScrimPostRepository.insert({ + at: adminVsNzapAt, + rangeEnd: null, + isScheduledForFuture: true, + teamId: null, + text: null, + visibility: null, + users: users() + .map((u) => ({ ...u, isOwner: 0 })) + .concat({ userId: ADMIN_ID, isOwner: 1 }), + managedByAnyone: true, + maps: null, + mapsTournamentId: null, + }); + await ScrimPostRepository.insertRequest({ + scrimPostId: adminVsNzapPostId, + users: users() + .map((u) => ({ ...u, isOwner: 0 })) + .concat({ userId: NZAP_TEST_ID, isOwner: 1 }), + message: null, + }); + await ScrimPostRepository.acceptRequest(1); + + for (let i = 0; i < 19; i++) { const divs = divRange(); const atTime = date(); const hasRangeEnd = Math.random() > 0.5; @@ -3258,7 +3287,9 @@ async function scrimPostRequests() { .where("TeamMember.teamId", "=", 1) .execute(); - for (const id of [1, 5, 12, 14, 19]) { + // Post 1 is already accepted (admin-vs-nzap, seeded in scrimPosts()), so it + // is excluded here. + for (const id of [5, 12, 14, 19]) { await ScrimPostRepository.insertRequest({ scrimPostId: id, users: allianceRogueMembers.map((member) => ({ @@ -3272,8 +3303,6 @@ async function scrimPostRequests() { : null, }); } - - await ScrimPostRepository.acceptRequest(3); } async function associations() { diff --git a/app/db/tables.ts b/app/db/tables.ts index 4702760bf..9cd23840d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1278,8 +1278,6 @@ export interface ScrimMap { winnerSide: "ALPHA" | "BRAVO" | null; reportedAt: number | null; reportedByUserId: number | null; - // xxx: replayOfIndex can be inferred? - replayOfIndex: number | null; } export interface ScrimPostUser { diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index ecd647426..ef819e728 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -599,3 +599,129 @@ describe("MapList.generate() with initialWeights", () => { expect(maps[0].stageId).toBe(1); }); }); + +describe("MapList.resume()", () => { + const POOL = new MapPool({ + TW: [], + SZ: [1, 2, 3], + TC: [4, 5, 6], + RM: [7, 8, 9], + CB: [10, 11, 12], + }); + + function nextMap( + history: Array<{ mode: "SZ" | "TC" | "RM" | "CB"; stageId: StageId }>, + ) { + const gen = MapList.resume({ mapPool: POOL, history }); + gen.next(); + const result = gen.next({ amount: 1 }).value; + return result![0]; + } + + it("starts with the pool's first mode when history is empty", () => { + for (let i = 0; i < 20; i++) { + expect(nextMap([]).mode).toBe("SZ"); + } + }); + + it("rotates through modes in pool order across history length", () => { + expect(nextMap([{ mode: "SZ", stageId: 1 }]).mode).toBe("TC"); + expect( + nextMap([ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + ]).mode, + ).toBe("RM"); + expect( + nextMap([ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + { mode: "RM", stageId: 7 }, + ]).mode, + ).toBe("CB"); + }); + + it("wraps the mode order back to the start after a full rotation", () => { + const history = [ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + { mode: "RM", stageId: 7 }, + { mode: "CB", stageId: 10 }, + ] as const; + expect(nextMap([...history]).mode).toBe("SZ"); + }); + + it("avoids already-played (mode, stage) pairs", () => { + const history = [ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + { mode: "RM", stageId: 7 }, + { mode: "CB", stageId: 10 }, + ] as const; + + for (let i = 0; i < 30; i++) { + const next = nextMap([...history]); + expect(next.mode).toBe("SZ"); + expect(next.stageId).not.toBe(1); + } + }); + + it("rotates only through modes present in the pool", () => { + const threeModePool = new MapPool({ + TW: [], + SZ: [1, 2, 3], + TC: [4, 5, 6], + RM: [7, 8, 9], + CB: [], + }); + + const pickMode = ( + history: Array<{ mode: "SZ" | "TC" | "RM"; stageId: StageId }>, + ) => { + const gen = MapList.resume({ mapPool: threeModePool, history }); + gen.next(); + return gen.next({ amount: 1 }).value![0].mode; + }; + + expect(pickMode([])).toBe("SZ"); + expect(pickMode([{ mode: "SZ", stageId: 1 }])).toBe("TC"); + expect( + pickMode([ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + ]), + ).toBe("RM"); + expect( + pickMode([ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 4 }, + { mode: "RM", stageId: 7 }, + ]), + ).toBe("SZ"); + }); + + it("exclusion is keyed on (mode, stage), not stage alone", () => { + const sharedPool = new MapPool({ + TW: [], + SZ: [1, 2], + TC: [1, 2], + RM: [], + CB: [], + }); + + const seenForTC = new Set(); + for (let i = 0; i < 50; i++) { + const gen = MapList.resume({ + mapPool: sharedPool, + history: [{ mode: "SZ", stageId: 1 }], + }); + gen.next(); + const next = gen.next({ amount: 1 }).value![0]; + expect(next.mode).toBe("TC"); + seenForTC.add(next.stageId); + } + + expect(seenForTC.has(1)).toBe(true); + expect(seenForTC.has(2)).toBe(true); + }); +}); diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index 2df7c589b..6ffb18695 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -52,6 +52,8 @@ export function* generate(args: { initialWeights?: Map; /** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */ skipEnsureMinimumCandidates?: boolean; + /** Fixed mode order — when set, skips the random `modeOrders` shuffle and uses only this order. Intended for `resume`. */ + modeOrder?: ModeShort[]; }): Generator, Array, GenerateNext> { if (args.mapPool.isEmpty()) { while (true) yield []; @@ -64,7 +66,7 @@ export function* generate(args: { args.mapPool.parsed, args.initialWeights, ); - const orderedModes = modeOrders(modes); + const orderedModes = args.modeOrder ? [args.modeOrder] : modeOrders(modes); let currentOrderIndex = 0; const firstArgs = yield []; @@ -135,6 +137,44 @@ export function* generate(args: { } } +/** + * Returns a generator primed to continue map selection after the given history. + * + * Keeps the pool's mode order stable (rotated so the next-to-play mode is first) + * and biases against already-played `(mode, stage)` pairs so they are not picked + * again unless every option in that mode has already been played. + * + * @example + * const generator = resume({ mapPool, history }); + * generator.next(); + * const { mode, stageId } = generator.next({ amount: 1 }).value![0]; + */ +export function resume(args: { + mapPool: MapPool; + history: Array<{ mode: ModeShort; stageId: StageId }>; +}) { + const modes = args.mapPool.modes; + const lastMode = args.history.at(-1)?.mode; + const lastIdx = lastMode ? modes.indexOf(lastMode) : -1; + const offset = modes.length > 0 ? (lastIdx + 1) % modes.length : 0; + const modeOrder = [...modes.slice(offset), ...modes.slice(0, offset)]; + + const initialWeights = new Map(); + for (const pair of args.mapPool.stageModePairs) { + initialWeights.set(modeStageKey(pair.mode, pair.stageId), 0); + } + for (const { mode, stageId } of args.history) { + initialWeights.set(modeStageKey(mode, stageId), -1000); + } + + return generate({ + mapPool: args.mapPool, + modeOrder, + initialWeights: initialWeights.size > 0 ? initialWeights : undefined, + skipEnsureMinimumCandidates: true, + }); +} + function initializeWeights( modes: ModeShort[], mapPool: ReadonlyMapPoolObject, diff --git a/app/features/scrims/ScrimMapListRepository.server.ts b/app/features/scrims/ScrimMapListRepository.server.ts new file mode 100644 index 000000000..b923e2c34 --- /dev/null +++ b/app/features/scrims/ScrimMapListRepository.server.ts @@ -0,0 +1,55 @@ +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; +import { databaseTimestampNow } from "~/utils/dates"; +import type { ScrimSide } from "./scrims-types"; + +type UpsertMapListArgs = Omit; + +/** + * Inserts a map list row for the given side, replacing any existing row for + * the same `(scrimPostId, side)` pair. + */ +export async function upsertMapList(args: UpsertMapListArgs): Promise { + const now = databaseTimestampNow(); + + await db + .insertInto("ScrimMapList") + .values({ + scrimPostId: args.scrimPostId, + side: args.side, + source: args.source, + tournamentId: args.tournamentId ?? null, + serializedPool: args.serializedPool ?? null, + updatedAt: now, + }) + .onConflict((oc) => + oc.columns(["scrimPostId", "side"]).doUpdateSet({ + source: args.source, + tournamentId: args.tournamentId ?? null, + serializedPool: args.serializedPool ?? null, + updatedAt: now, + }), + ) + .execute(); +} + +/** Deletes a side's map list, if one exists. */ +export async function deleteMapList( + scrimPostId: number, + side: ScrimSide, +): Promise { + await db + .deleteFrom("ScrimMapList") + .where("scrimPostId", "=", scrimPostId) + .where("side", "=", side) + .execute(); +} + +/** Returns all submitted map lists for the scrim. */ +export function findMapListsByScrimPostId(scrimPostId: number) { + return db + .selectFrom("ScrimMapList") + .select(["side", "source", "tournamentId", "serializedPool", "updatedAt"]) + .where("scrimPostId", "=", scrimPostId) + .execute(); +} diff --git a/app/features/scrims/ScrimMapByMapRepository.server.ts b/app/features/scrims/ScrimMapRepository.server.ts similarity index 57% rename from app/features/scrims/ScrimMapByMapRepository.server.ts rename to app/features/scrims/ScrimMapRepository.server.ts index 684b31781..91c84ef82 100644 --- a/app/features/scrims/ScrimMapByMapRepository.server.ts +++ b/app/features/scrims/ScrimMapRepository.server.ts @@ -1,77 +1,13 @@ import { db } from "~/db/sql"; -import type { Tables } from "~/db/tables"; +import type { TablesInsertable } from "~/db/tables"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { databaseTimestampNow } from "~/utils/dates"; -import type { ScrimSide } from "./scrims-types"; - -// xxx: rename to ScrimMapRepository? - -// xxx: TablesInsertable -interface UpsertMapListArgs { - scrimPostId: number; - side: ScrimSide; - source: "TOURNAMENT" | "POOL"; - tournamentId?: number | null; - serializedPool?: string | null; -} - -/** - * Inserts a map list row for the given side, replacing any existing row for - * the same `(scrimPostId, side)` pair. - */ -export async function upsertMapList(args: UpsertMapListArgs): Promise { - const now = databaseTimestampNow(); - - await db - .insertInto("ScrimMapList") - .values({ - scrimPostId: args.scrimPostId, - side: args.side, - source: args.source, - tournamentId: args.tournamentId ?? null, - serializedPool: args.serializedPool ?? null, - updatedAt: now, - }) - .onConflict((oc) => - oc.columns(["scrimPostId", "side"]).doUpdateSet({ - source: args.source, - tournamentId: args.tournamentId ?? null, - serializedPool: args.serializedPool ?? null, - updatedAt: now, - }), - ) - .execute(); -} - -/** Deletes a side's map list, if one exists. */ -export async function deleteMapList( - scrimPostId: number, - side: ScrimSide, -): Promise { - await db - .deleteFrom("ScrimMapList") - .where("scrimPostId", "=", scrimPostId) - .where("side", "=", side) - .execute(); -} - -/** Returns all submitted map lists for the scrim. */ -export function findMapListsByScrimPostId( - scrimPostId: number, -): Promise { - return db - .selectFrom("ScrimMapList") - .selectAll() - .where("scrimPostId", "=", scrimPostId) - .execute(); -} interface InsertMapArgs { scrimPostId: number; index: number; mode: ModeShort; stageId: StageId; - replayOfIndex?: number | null; } /** @@ -86,7 +22,6 @@ export async function insertMap(args: InsertMapArgs): Promise { index: args.index, mode: args.mode, stageId: args.stageId, - replayOfIndex: args.replayOfIndex ?? null, }) .returning("id") .executeTakeFirstOrThrow(); @@ -94,11 +29,12 @@ export async function insertMap(args: InsertMapArgs): Promise { return inserted.id; } -// xxx: TablesInsertable interface ReportMapWinnerArgs { mapId: number; - winnerSide: ScrimSide; - reportedByUserId: number; + winnerSide: NonNullable; + reportedByUserId: NonNullable< + TablesInsertable["ScrimMap"]["reportedByUserId"] + >; } /** Marks an existing map as reported with the given winner side. */ @@ -157,13 +93,11 @@ interface ReplaceCurrentMapAsReplayArgs { scrimPostId: number; mode: ModeShort; stageId: StageId; - replayOfIndex: number; } /** * Replaces the currently unreported map for the scrim with a replay of the - * given source map (same mode/stage, marked as `replayOfIndex`). The current - * map's index is preserved. + * given source map (same mode/stage). The current map's index is preserved. */ export async function replaceCurrentMapAsReplay( args: ReplaceCurrentMapAsReplayArgs, @@ -173,7 +107,6 @@ export async function replaceCurrentMapAsReplay( .set({ mode: args.mode, stageId: args.stageId, - replayOfIndex: args.replayOfIndex, }) .where("scrimPostId", "=", args.scrimPostId) .where("reportedAt", "is", null) @@ -181,12 +114,10 @@ export async function replaceCurrentMapAsReplay( } /** Returns the scrim's maps ordered by index ascending. */ -export function findMapsByScrimPostId( - scrimPostId: number, -): Promise { +export function findMapsByScrimPostId(scrimPostId: number) { return db .selectFrom("ScrimMap") - .selectAll() + .select(["id", "index", "mode", "stageId", "winnerSide", "reportedAt"]) .where("scrimPostId", "=", scrimPostId) .orderBy("index", "asc") .execute(); diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index be1c79f6b..6873b5a60 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -307,6 +307,11 @@ const mapDBRowToScrimPost = ( MANAGE_REQUESTS: managerIds, DELETE_POST: managerIds, CANCEL: managerIds.concat(requests.at(0)?.users.map((u) => u.id) ?? []), + MANAGE_TRACKING: someRequestIsAccepted + ? users + .map((u) => u.id) + .concat(requests[0]?.users.map((u) => u.id) ?? []) + : [], }, managedByAnyone: Boolean(row.managedByAnyone), canceled, diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index 252cfc080..576b94542 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -10,116 +10,77 @@ import { parseParams, parseRequestPayload, } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; import { idObject } from "~/utils/zod"; import { databaseTimestampToDate } from "../../../utils/dates"; import { requireUser } from "../../auth/core/user.server"; import * as Scrim from "../core/Scrim"; import * as ScrimMapByMap from "../core/ScrimMapByMap"; -import * as ScrimMapByMapRepository from "../ScrimMapByMapRepository.server"; +import * as ScrimMapListRepository from "../ScrimMapListRepository.server"; +import * as ScrimMapRepository from "../ScrimMapRepository.server"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; -import { - cancelScrimSchema, - scrimMapByMapActionSchema, -} from "../scrims-schemas"; +import { scrimIdActionSchema } from "../scrims-schemas"; export const action = async ({ request, params }: ActionFunctionArgs) => { const { id } = parseParams({ params, schema: idObject }); const post = notFoundIfFalsy(await ScrimPostRepository.findById(id)); - const user = requireUser(); - const formData = await request.clone().formData(); - // xxx: this is weird, convert to switch...case at action function elvel following similar pattern to elsewhere on the codebase - if (formData.get("_action")) { - return handleMapByMapAction({ post, user, request }); - } - const data = await parseRequestPayload({ request, - schema: cancelScrimSchema, + schema: scrimIdActionSchema, }); - requirePermission(post, "CANCEL"); - - errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted"); - errorToastIfFalsy(!post.canceled, "Scrim is already canceled"); - - if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) { - errorToast("Cannot cancel a scrim that was already scheduled to start"); - } - - await ScrimPostRepository.cancelScrim(id, { - userId: user.id, - reason: data.reason, - }); - - 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; -}; - -async function handleMapByMapAction({ - post, - user, - request, -}: { - post: NonNullable>>; - user: ReturnType; - request: Request; -}) { - const data = await parseRequestPayload({ - request, - schema: scrimMapByMapActionSchema, - }); - - const viewerSide = Scrim.sideOfUser(post, user.id); - errorToastIfFalsy(viewerSide, "You are not a participant in this scrim"); - - const broadcastRevalidate = () => { - if (!post.chatCode) return; - ChatSystemMessage.send({ - room: post.chatCode, - revalidateOnly: true, - authorUserId: user.id, - }); - }; - - const [maps, mapLists] = await Promise.all([ - ScrimMapByMapRepository.findMapsByScrimPostId(post.id), - ScrimMapByMapRepository.findMapListsByScrimPostId(post.id), - ]); - - if (Scrim.isTrackingLocked(maps, mapLists)) { - errorToast("Tracking is locked"); - } + requirePermission(post, "MANAGE_TRACKING"); switch (data._action) { + case "CANCEL_SCRIM": { + requirePermission(post, "CANCEL"); + + errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted"); + errorToastIfFalsy(!post.canceled, "Scrim is already canceled"); + + if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) { + errorToast("Cannot cancel a scrim that was already scheduled to start"); + } + + await ScrimPostRepository.cancelScrim(id, { + userId: user.id, + reason: data.reason, + }); + + 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 }, + }, + }); + } + + break; + } case "SUBMIT_MAP_LIST": { - await ScrimMapByMapRepository.upsertMapList({ + const { viewerSide, maps } = await loadMapByMapContext({ post, user }); + + await ScrimMapListRepository.upsertMapList({ scrimPostId: post.id, - side: viewerSide!, + side: viewerSide, source: data.source, tournamentId: data.tournamentId ?? null, serializedPool: data.serializedPool ?? null, @@ -128,18 +89,26 @@ async function handleMapByMapAction({ if (maps.length === 0) { await tryGenerateAndInsertNextMap({ post, user, maps }); } + + broadcastRevalidate({ post, user }); break; } case "REMOVE_MAP_LIST": { - await ScrimMapByMapRepository.deleteMapList(post.id, viewerSide!); + const { viewerSide } = await loadMapByMapContext({ post, user }); + + await ScrimMapListRepository.deleteMapList(post.id, viewerSide); + + broadcastRevalidate({ post, user }); break; } case "REPORT_MAP": { + const { maps } = await loadMapByMapContext({ post, user }); + const target = maps.find((m) => m.id === data.mapId); errorToastIfFalsy(target, "Map not found"); errorToastIfFalsy(target!.reportedAt === null, "Map already reported"); - await ScrimMapByMapRepository.reportMapWinner({ + await ScrimMapRepository.reportMapWinner({ mapId: data.mapId, winnerSide: data.winnerSide, reportedByUserId: user.id, @@ -151,34 +120,81 @@ async function handleMapByMapAction({ : m, ); await tryGenerateAndInsertNextMap({ post, user, maps: reportedMaps }); + + broadcastRevalidate({ post, user }); break; } case "UNDO_MAP": { + const { maps } = await loadMapByMapContext({ post, user }); + const latest = Scrim.lastReportedMap(maps); errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo"); - await ScrimMapByMapRepository.undoMostRecentMap(post.id); + await ScrimMapRepository.undoMostRecentMap(post.id); + + broadcastRevalidate({ post, user }); break; } case "REPLAY_MAP": { + const { maps } = await loadMapByMapContext({ post, user }); + const latest = Scrim.lastReportedMap(maps); errorToastIfFalsy(latest, "No map to replay"); const currentMap = maps.find((m) => m.reportedAt === null); errorToastIfFalsy(currentMap, "No current map to replace"); - await ScrimMapByMapRepository.replaceCurrentMapAsReplay({ + await ScrimMapRepository.replaceCurrentMapAsReplay({ scrimPostId: post.id, mode: latest!.mode, stageId: latest!.stageId, - replayOfIndex: latest!.index, }); + + broadcastRevalidate({ post, user }); break; } + default: { + assertUnreachable(data); + } } - broadcastRevalidate(); return null; +}; + +async function loadMapByMapContext({ + post, + user, +}: { + post: NonNullable>>; + user: ReturnType; +}) { + const viewerSide = Scrim.sideOfUser(post, user.id); + + const [maps, mapLists] = await Promise.all([ + ScrimMapRepository.findMapsByScrimPostId(post.id), + ScrimMapListRepository.findMapListsByScrimPostId(post.id), + ]); + + if (Scrim.isTrackingLocked(maps, mapLists)) { + errorToast("Tracking is locked"); + } + + return { viewerSide: viewerSide!, maps, mapLists }; +} + +function broadcastRevalidate({ + post, + user, +}: { + post: NonNullable>>; + user: ReturnType; +}) { + if (!post.chatCode) return; + ChatSystemMessage.send({ + room: post.chatCode, + revalidateOnly: true, + authorUserId: user.id, + }); } async function resolveTournamentPool( @@ -189,7 +205,7 @@ async function resolveTournamentPool( return data.ctx.toSetMapPool; } -// xxx: should this be in inside repository? +// xxx: should this be in inside repository? and inside trx async function tryGenerateAndInsertNextMap({ post, user, @@ -197,11 +213,9 @@ async function tryGenerateAndInsertNextMap({ }: { post: NonNullable>>; user: ReturnType; - maps: Awaited< - ReturnType - >; + maps: Awaited>; }) { - const mapLists = await ScrimMapByMapRepository.findMapListsByScrimPostId( + const mapLists = await ScrimMapListRepository.findMapListsByScrimPostId( post.id, ); if (mapLists.length === 0) return; @@ -227,7 +241,7 @@ async function tryGenerateAndInsertNextMap({ history: maps.map((m) => ({ mode: m.mode, stageId: m.stageId })), }); - await ScrimMapByMapRepository.insertMap({ + await ScrimMapRepository.insertMap({ scrimPostId: post.id, index: Scrim.nextMapIndex(maps), mode: next.mode, diff --git a/app/features/scrims/components/ScrimMapListForm.tsx b/app/features/scrims/components/ScrimMapListForm.tsx index 6d6d16888..6e08f2779 100644 --- a/app/features/scrims/components/ScrimMapListForm.tsx +++ b/app/features/scrims/components/ScrimMapListForm.tsx @@ -1,11 +1,10 @@ -// xxx: use * as React syntax -import { useState } from "react"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { TournamentSearch } from "~/components/elements/TournamentSearch"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; import type { ScrimSide } from "../scrims-types"; +import { parseMapPoolInput } from "../scrims-utils"; import styles from "./ScrimMapListForm.module.css"; interface Props { @@ -15,10 +14,10 @@ interface Props { export function ScrimMapListForm(_: Props) { const { t } = useTranslation(["scrims", "common"]); const fetcher = useFetcher(); - const [source, setSource] = useState<"TOURNAMENT" | "POOL">("POOL"); - const [tournamentId, setTournamentId] = useState(null); - const [poolInput, setPoolInput] = useState(""); - const [error, setError] = useState(null); + const [source, setSource] = React.useState<"TOURNAMENT" | "POOL">("POOL"); + const [tournamentId, setTournamentId] = React.useState(null); + const [poolInput, setPoolInput] = React.useState(""); + const [error, setError] = React.useState(null); const onSubmit = () => { setError(null); @@ -39,19 +38,8 @@ export function ScrimMapListForm(_: Props) { return; } - const serialized = extractSerializedPool(poolInput); - if (!serialized) { - setError(t("scrims:mapByMap.form.poolInvalid")); - return; - } - - try { - const pool = new MapPool(serialized); - if (pool.isEmpty()) { - setError(t("scrims:mapByMap.form.poolInvalid")); - return; - } - } catch { + const pool = parseMapPoolInput(poolInput); + if (!pool) { setError(t("scrims:mapByMap.form.poolInvalid")); return; } @@ -60,7 +48,7 @@ export function ScrimMapListForm(_: Props) { { _action: "SUBMIT_MAP_LIST", source: "POOL", - serializedPool: serialized, + serializedPool: pool.serialized, }, { method: "post" }, ); @@ -125,26 +113,3 @@ export function ScrimMapListForm(_: Props) { ); } - -// xxx: unit tested function, extract -function extractSerializedPool(input: string): string | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - if (trimmed.includes("://")) { - try { - const url = new URL(trimmed); - return url.searchParams.get("pool"); - } catch { - return null; - } - } - - if (trimmed.includes("pool=")) { - const start = trimmed.indexOf("pool=") + "pool=".length; - const next = trimmed.indexOf("&", start); - return trimmed.slice(start, next === -1 ? undefined : next); - } - - return trimmed; -} diff --git a/app/features/scrims/components/ScrimMatchBanner.tsx b/app/features/scrims/components/ScrimMatchBanner.tsx index dd1558f54..103bbb0af 100644 --- a/app/features/scrims/components/ScrimMatchBanner.tsx +++ b/app/features/scrims/components/ScrimMatchBanner.tsx @@ -5,6 +5,7 @@ import { Link, useLoaderData } from "react-router"; import { Image } from "~/components/Image"; import { IconBanner, + MatchBanner, MatchBannerContainer, } from "~/components/match-page/MatchBanner"; import { useUser } from "~/features/auth/core/user"; @@ -54,6 +55,23 @@ export function ScrimMatchBanner() { members: [...data.post.users, ...acceptedRequest.users], }); const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale; + const joinPool = Scrim.resolvePoolCode(data.post.id); + + const currentMap = data.mapByMap.currentMap; + + if (currentMap) { + return ( + + + + ); + } return ( @@ -62,7 +80,7 @@ export function ScrimMatchBanner() { header={t("scrims:banner.freeForm.header")} subtitle={t("scrims:banner.freeForm.subtitle")} screenLegal={screenLegal} - joinPool={Scrim.resolvePoolCode(data.post.id)} + joinPool={joinPool} joinViaQr={joinViaQr} topRight={ hasMaps ? ( diff --git a/app/features/scrims/components/ScrimMatchHeader.tsx b/app/features/scrims/components/ScrimMatchHeader.tsx index de6546bc5..9a4ad3d05 100644 --- a/app/features/scrims/components/ScrimMatchHeader.tsx +++ b/app/features/scrims/components/ScrimMatchHeader.tsx @@ -8,7 +8,7 @@ import { SendouForm } from "~/form/SendouForm"; import { useHasPermission } from "~/modules/permissions/hooks"; import { databaseTimestampToDate } from "~/utils/dates"; import type { loader } from "../loaders/scrims.$id.server"; -import { cancelScrimSchema } from "../scrims-schemas"; +import { cancelScrimFormSchema } from "../scrims-schemas"; export function ScrimMatchHeader() { const { t } = useTranslation(["common", "scrims"]); @@ -61,7 +61,7 @@ export function ScrimMatchHeader() { function CancelScrimForm() { return ( {({ FormField }) => } diff --git a/app/features/scrims/components/ScrimMatchStatsTab.module.css b/app/features/scrims/components/ScrimMatchStatsTab.module.css index b34cc3b2a..64cea5d2a 100644 --- a/app/features/scrims/components/ScrimMatchStatsTab.module.css +++ b/app/features/scrims/components/ScrimMatchStatsTab.module.css @@ -4,15 +4,18 @@ gap: var(--s-4); } +.controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-3); +} + .toggleRow { display: flex; align-items: center; gap: var(--s-2); -} - -.sectionTitle { - font-weight: var(--bold); - margin-bottom: var(--s-2); + font-size: var(--font-sm); } .table { @@ -27,17 +30,37 @@ } .table th { - color: var(--text-lighter); - font-weight: var(--bold); - font-size: var(--fonts-sm); - border-bottom: 1px solid var(--border); + color: var(--color-text-high); + font-weight: var(--weight-bold); + font-size: var(--font-sm); + border-bottom: var(--border-style); } -.cellRight { +.table tbody tr { + border-bottom: var(--border-style); + + &:last-child { + border-bottom: none; + } +} + +.cellNum { + width: 4rem; text-align: right; + font-variant-numeric: tabular-nums; } .empty { - color: var(--text-lighter); + color: var(--color-text-high); font-style: italic; } + +.stageModeLabel { + display: inline-flex; + align-items: center; + gap: var(--s-2); +} + +.stageImage { + border-radius: var(--radius-field); +} diff --git a/app/features/scrims/components/ScrimMatchStatsTab.tsx b/app/features/scrims/components/ScrimMatchStatsTab.tsx index 681af7ac3..2258f5b01 100644 --- a/app/features/scrims/components/ScrimMatchStatsTab.tsx +++ b/app/features/scrims/components/ScrimMatchStatsTab.tsx @@ -1,22 +1,36 @@ -// xxx: import * as React -import { useState } from "react"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import { useLoaderData } from "react-router"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSwitch } from "~/components/elements/Switch"; import { SendouTabPanel } from "~/components/elements/Tabs"; +import { ModeImage, StageImage } from "~/components/Image"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import * as ScrimMapByMap from "../core/ScrimMapByMap"; import type { loader } from "../loaders/scrims.$id.server"; import styles from "./ScrimMatchStatsTab.module.css"; +type View = "MODE" | "STAGE" | "BOTH"; + +const VIEW_OPTIONS: View[] = ["MODE", "STAGE", "BOTH"]; + export function ScrimMatchStatsTab() { const { t } = useTranslation(["scrims", "game-misc"]); const data = useLoaderData(); - const [restrictToPool, setRestrictToPool] = useState(false); const viewerSide = data.mapByMap?.viewerSide; const maps = data.mapByMap?.maps ?? []; - const ownList = data.mapByMap?.mapLists.find((l) => l.side === viewerSide); + const ownPool = data.mapByMap?.ownPool + ? new MapPool(data.mapByMap.ownPool) + : null; + + const [view, setView] = React.useState("BOTH"); + const [restrictToPool, setRestrictToPool] = React.useState(Boolean(ownPool)); if (!viewerSide || maps.length === 0) { return ( @@ -26,10 +40,7 @@ export function ScrimMatchStatsTab() { ); } - const restrictPool = - restrictToPool && ownList?.serializedPool - ? new MapPool(ownList.serializedPool) - : undefined; + const restrictPool = restrictToPool && ownPool ? ownPool : undefined; const stats = ScrimMapByMap.stats(maps, viewerSide, { restrictToPool: restrictPool, @@ -38,53 +49,75 @@ export function ScrimMatchStatsTab() { return (
- {ownList?.source === "POOL" && ownList.serializedPool ? ( - - ) : null} +
+ + {VIEW_OPTIONS.map((option) => ( + setView(value as View)} + > + {t(`scrims:mapByMap.stats.view.${option}` as const)} + + ))} + + {ownPool ? ( + + ) : null} +
-
-

- {t("scrims:mapByMap.stats.byMode")} -

+ {view === "MODE" ? ( ({ key: r.key, - label: t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, { - defaultValue: r.key, - }), + label: ( + + + {t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, { + defaultValue: r.key, + })} + + ), wins: r.wins, losses: r.losses, }))} /> -
+ ) : null} -
-

- {t("scrims:mapByMap.stats.byStage")} -

+ {view === "STAGE" ? ( ({ - key: r.key, - label: t(`game-misc:STAGE_${Number(r.key)}` as const, { - defaultValue: r.key, - }), - wins: r.wins, - losses: r.losses, - }))} + rows={stats.byStage.map((r) => { + const stageId = Number(r.key); + return { + key: r.key, + label: ( + + + {t(`game-misc:STAGE_${stageId}` as const, { + defaultValue: r.key, + })} + + ), + wins: r.wins, + losses: r.losses, + }; + })} /> -
+ ) : null} -
-

- {t("scrims:mapByMap.stats.byStageMode")} -

+ {view === "BOTH" ? ( { const [stageId, mode] = r.key.split("-"); @@ -92,19 +125,25 @@ export function ScrimMatchStatsTab() { `game-misc:STAGE_${Number(stageId)}` as const, { defaultValue: stageId }, ); - const modeLabel = t( - `game-misc:MODE_LONG_${mode as "SZ"}` as const, - { defaultValue: mode }, - ); return { key: r.key, - label: `${stageLabel} — ${modeLabel}`, + label: ( + + + + {stageLabel} + + ), wins: r.wins, losses: r.losses, }; })} /> -
+ ) : null}
); @@ -113,7 +152,12 @@ export function ScrimMatchStatsTab() { function StatsTable({ rows, }: { - rows: Array<{ key: string; label: string; wins: number; losses: number }>; + rows: Array<{ + key: string; + label: React.ReactNode; + wins: number; + losses: number; + }>; }) { const { t } = useTranslation(["scrims"]); @@ -123,25 +167,36 @@ function StatsTable({ ); } + const sortedRows = [...rows] + .map((row) => ({ ...row, winRate: row.wins / (row.wins + row.losses) })) + .sort((a, b) => { + if (b.winRate !== a.winRate) return b.winRate - a.winRate; + return b.wins + b.losses - (a.wins + a.losses); + }); + return ( - - + - {rows.map((row) => ( + {sortedRows.map((row) => ( - - + + + ))} diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 8c1ccd187..5a250fda8 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -128,7 +128,12 @@ describe("applyFilters", () => { isScheduledForFuture: false, managedByAnyone: false, mapsTournament: null, - permissions: { MANAGE_REQUESTS: [], CANCEL: [], DELETE_POST: [] }, + permissions: { + MANAGE_REQUESTS: [], + CANCEL: [], + DELETE_POST: [], + MANAGE_TRACKING: [], + }, team: null, }; } diff --git a/app/features/scrims/core/ScrimMapByMap.test.ts b/app/features/scrims/core/ScrimMapByMap.test.ts index d428f3041..6a40bde97 100644 --- a/app/features/scrims/core/ScrimMapByMap.test.ts +++ b/app/features/scrims/core/ScrimMapByMap.test.ts @@ -107,6 +107,26 @@ describe("ScrimMapByMap.generateNextMap", () => { } }); + it("advances from the last played mode when a mode was replayed", () => { + const pool = new MapPool({ + SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART], + TC: [stagesObj.HAMMERHEAD_BRIDGE], + RM: [stagesObj.WAHOO_WORLD], + CB: [stagesObj.EELTAIL_ALLEY], + TW: [], + }); + + const next = generateNextMap({ + pool, + history: [ + { mode: "SZ", stageId: stagesObj.SCORCH_GORGE }, + { mode: "SZ", stageId: stagesObj.MAKOMART }, + ], + }); + + expect(next.mode).toBe("TC"); + }); + it("can still generate when only one stage is available", () => { const pool = new MapPool({ SZ: [stagesObj.SCORCH_GORGE], diff --git a/app/features/scrims/core/ScrimMapByMap.ts b/app/features/scrims/core/ScrimMapByMap.ts index f5115b7d2..067c8a2f2 100644 --- a/app/features/scrims/core/ScrimMapByMap.ts +++ b/app/features/scrims/core/ScrimMapByMap.ts @@ -20,6 +20,25 @@ type ScrimMapRow = Pick< "index" | "mode" | "stageId" | "winnerSide" | "reportedAt" >; +/** + * Resolves a single map list row to a concrete MapPool. Returns null when the + * row references a tournament that wasn't pre-resolved, or a POOL row with no + * serialized pool. + */ +// xxx: data model should be simpler +export function resolveList( + list: ScrimMapListRow, + tournamentPools: Map = new Map(), +): MapPool | null { + if (list.source === "TOURNAMENT") { + if (!list.tournamentId) return null; + const tournamentList = tournamentPools.get(list.tournamentId); + return tournamentList ? new MapPool(tournamentList) : null; + } + if (!list.serializedPool) return null; + return new MapPool(list.serializedPool); +} + /** * Merges the submitted map lists into a single deduplicated MapPool. Tournament * pools are resolved by the caller and passed in via `tournamentPools` keyed by @@ -63,9 +82,8 @@ export function unionPool( } /** - * Generates the next single map for the scrim by replaying the past map history - * through the underlying generator so the resulting map respects its - * anti-repeat and mode-ordering behavior. + * Generates the next single map for the scrim, keeping the pool's mode order + * stable across calls and avoiding already-played `(mode, stage)` pairs. */ export function generateNextMap(args: { pool: MapPool; @@ -75,23 +93,9 @@ export function generateNextMap(args: { throw new Error("Cannot generate map from empty pool"); } - const initialWeights = new Map(); - if (args.history.length > 0) { - for (const pair of args.pool.stageModePairs) { - initialWeights.set(MapList.modeStageKey(pair.mode, pair.stageId), 0); - } - for (const played of args.history) { - initialWeights.set( - MapList.modeStageKey(played.mode, played.stageId), - -1000, // xxx: why -1000? - ); - } - } - - const generator = MapList.generate({ + const generator = MapList.resume({ mapPool: args.pool, - initialWeights: initialWeights.size > 0 ? initialWeights : undefined, - skipEnsureMinimumCandidates: true, + history: args.history, }); generator.next(); diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 1588d0e7f..c7a85c9a5 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -11,7 +11,8 @@ import { } from "../../auth/core/user.server"; import * as Scrim from "../core/Scrim"; import * as ScrimMapByMap from "../core/ScrimMapByMap"; -import * as ScrimMapByMapRepository from "../ScrimMapByMapRepository.server"; +import * as ScrimMapListRepository from "../ScrimMapListRepository.server"; +import * as ScrimMapRepository from "../ScrimMapRepository.server"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { @@ -77,10 +78,9 @@ async function resolveMapByMap({ post: NonNullable>>; user: AuthenticatedUser; }) { - // xxx: can we collapse to one query? const [mapLists, maps] = await Promise.all([ - ScrimMapByMapRepository.findMapListsByScrimPostId(post.id), - ScrimMapByMapRepository.findMapsByScrimPostId(post.id), + ScrimMapListRepository.findMapListsByScrimPostId(post.id), + ScrimMapRepository.findMapsByScrimPostId(post.id), ]); const tournamentPools = new Map< @@ -102,6 +102,13 @@ async function resolveMapByMap({ const viewerSide = Scrim.sideOfUser(post, user.id); const locked = Scrim.isTrackingLocked(maps, mapLists); + const ownList = viewerSide + ? mapLists.find((l) => l.side === viewerSide) + : undefined; + const ownPool = ownList + ? ScrimMapByMap.resolveList(ownList, tournamentPools) + : null; + return { mapLists, maps, @@ -109,5 +116,6 @@ async function resolveMapByMap({ viewerSide, locked, pool: pool ? pool.serialized : null, + ownPool: ownPool ? ownPool.serialized : null, }; } diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx index ff1b7477b..9b4bc40a6 100644 --- a/app/features/scrims/routes/scrims.$id.tsx +++ b/app/features/scrims/routes/scrims.$id.tsx @@ -1,7 +1,5 @@ -import { useLoaderData } from "react-router"; import { Main } from "~/components/Main"; import { MatchPage } from "~/components/match-page/MatchPage"; -import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, scrimsPage } from "../../../utils/urls"; import { action } from "../actions/scrims.$id.server"; @@ -22,15 +20,8 @@ export const handle: SendouRouteHandle = { }; // xxx: Top right maps link icon maybe out of place now? +// xxx: action tab weird, can only report score once submitted map. instead make map in the "optional" section as we have weapon report in? export default function ScrimPage() { - const data = useLoaderData(); - - // xxx: maybe not needed since we have the chat? - useWebsocketRevalidation( - data.post.chatCode ?? "", - Boolean(data.post.chatCode), - ); - return (
diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index ea091bab2..0cb494cfc 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -71,7 +71,8 @@ const cancelRequestSchema = z.object({ scrimPostRequestId: id, }); -export const cancelScrimSchema = z.object({ +export const cancelScrimFormSchema = z.object({ + _action: stringConstant("CANCEL_SCRIM"), reason: textAreaRequired({ label: "labels.scrimCancelReason", bottomText: "bottomTexts.scrimCancelReasonHelp", @@ -209,7 +210,8 @@ const replayMapSchema = z.object({ _action: _action("REPLAY_MAP"), }); -export const scrimMapByMapActionSchema = z.union([ +export const scrimIdActionSchema = z.union([ + cancelScrimFormSchema, submitMapListSchema, removeMapListSchema, reportMapSchema, diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts index ecf03f382..36a5fbcb1 100644 --- a/app/features/scrims/scrims-types.ts +++ b/app/features/scrims/scrims-types.ts @@ -35,6 +35,7 @@ export interface ScrimPost { MANAGE_REQUESTS: number[]; DELETE_POST: number[]; CANCEL: number[]; + MANAGE_TRACKING: number[]; }; managedByAnyone: boolean; /** When the post was made was it scheduled for a future time slot (as opposed to looking now) */ diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 18d6e306c..605c23233 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatFlexTimeDisplay, generateTimeOptions } from "./scrims-utils"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { + formatFlexTimeDisplay, + generateTimeOptions, + parseMapPoolInput, +} from "./scrims-utils"; describe("generateTimeOptions", () => { it("includes both start and end times", () => { @@ -233,3 +238,74 @@ describe("formatFlexTimeDisplay", () => { expect(result).toBe("+1h 1m"); }); }); + +describe("parseMapPoolInput", () => { + const VALID_POOL = "tw:3330000;sz:3a14000;tc:2c98000;rm:2bc0000;cb:39c0000"; + + it("returns null for empty string", () => { + expect(parseMapPoolInput("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(parseMapPoolInput(" \t\n ")).toBeNull(); + }); + + it("returns null when the parsed pool is empty", () => { + expect(parseMapPoolInput("not-a-valid-pool")).toBeNull(); + }); + + it("returns a MapPool for a bare serialized pool", () => { + const result = parseMapPoolInput(VALID_POOL); + + expect(result).toBeInstanceOf(MapPool); + expect(result?.serialized).toBe(VALID_POOL); + }); + + it("trims whitespace around a bare serialized pool", () => { + const result = parseMapPoolInput(` ${VALID_POOL} `); + + expect(result?.serialized).toBe(VALID_POOL); + }); + + it("extracts the pool param from a full URL", () => { + const result = parseMapPoolInput( + `https://sendou.ink/maps?pool=${VALID_POOL}`, + ); + + expect(result?.serialized).toBe(VALID_POOL); + }); + + it("returns null for a URL without a pool param", () => { + expect(parseMapPoolInput("https://sendou.ink/maps?other=1")).toBeNull(); + }); + + it("ignores other URL params when extracting pool", () => { + const result = parseMapPoolInput( + `https://sendou.ink/maps?foo=bar&pool=${VALID_POOL}&baz=qux`, + ); + + expect(result?.serialized).toBe(VALID_POOL); + }); + + it("returns null for a malformed URL with ://", () => { + expect(parseMapPoolInput("not a url://")).toBeNull(); + }); + + it("parses the pool value from a query-string fragment", () => { + expect(parseMapPoolInput(`pool=${VALID_POOL}`)?.serialized).toBe( + VALID_POOL, + ); + }); + + it("stops at the next & in a query-string fragment", () => { + expect(parseMapPoolInput(`pool=${VALID_POOL}&other=1`)?.serialized).toBe( + VALID_POOL, + ); + }); + + it("preserves leading params before pool= in a query-string fragment", () => { + expect(parseMapPoolInput(`foo=bar&pool=${VALID_POOL}`)?.serialized).toBe( + VALID_POOL, + ); + }); +}); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 469678237..e1d7d8a60 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -1,5 +1,6 @@ import { differenceInMinutes } from "date-fns"; import * as R from "remeda"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { databaseTimestampToDate } from "~/utils/dates"; import * as Scrim from "./core/Scrim"; import type { LutiDiv, ScrimPost } from "./scrims-types"; @@ -87,6 +88,19 @@ export function generateTimeOptions(startDate: Date, endDate: Date): number[] { return Array.from(timestamps).sort((a, b) => a - b); } +export function parseMapPoolInput(input: string): MapPool | null { + const serialized = extractSerializedPool(input); + if (!serialized) return null; + + try { + const pool = new MapPool(serialized); + if (pool.isEmpty()) return null; + return pool; + } catch { + return null; + } +} + export function formatFlexTimeDisplay( startTimestamp: number, endTimestamp: number, @@ -110,3 +124,25 @@ export function formatFlexTimeDisplay( return null; } + +function extractSerializedPool(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + if (trimmed.includes("://")) { + try { + const url = new URL(trimmed); + return url.searchParams.get("pool"); + } catch { + return null; + } + } + + if (trimmed.includes("pool=")) { + const start = trimmed.indexOf("pool=") + "pool=".length; + const next = trimmed.indexOf("&", start); + return trimmed.slice(start, next === -1 ? undefined : next); + } + + return trimmed; +} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 7e942bfc4cf6279ee72187341920e4e049ee7ca6..35a588a5d0518d7ae7acbcc27dbae6c152154fc6 100644 GIT binary patch delta 179 zcmZoT5YTWSV1l&Z2L=X4Ck8lR{6A60nDqmLUhj{_l-3l+))c1J6z0|xmev&3))cnZ z6!z8>j@A^;))cPR6z@1vq4AZCQeG;5Jeg6vKeirU~40014rMO$Ho0-_fWn~#VJhw-#6i#5AuCZE} bZ+g5KzuojNp9O`dpAO^H+McjlctHsO%D6i9 delta 195 zcmZoT5YTWSV1l&ZdjBlc diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 2f93eb48d..0bfb0f569 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -2,7 +2,7 @@ import type { Page } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas"; -import { newScrimPostPage, scrimsPage } from "~/utils/urls"; +import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls"; import { expect, impersonate, @@ -247,43 +247,13 @@ test.describe("Scrims", () => { test("map-by-map: lists, report, undo, replay, change list, stats", async ({ page, }) => { - await seed(page, "NO_SCRIMS"); + await seed(page); - // ADMIN creates a scrim scheduled in the future - await impersonate(page, ADMIN_ID); - - // xxx: just navigate to /scrims/1 straight - await navigate({ page, url: newScrimPostPage() }); - - const form = createFormHelpers(page, scrimsNewFormSchema); - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(18, 0, 0, 0); - await form.setDateTime("at", tomorrow); - await submit(page); - - // NZAP requests the scrim - await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: scrimsPage() }); - await page.getByTestId("available-scrims-tab").click(); - await page.getByTestId("request-scrim-button").first().click(); - await selectUser({ labelName: "User 2", page, userName: "5" }); - await selectUser({ labelName: "User 3", page, userName: "6" }); - await selectUser({ labelName: "User 4", page, userName: "7" }); - await submit(page); - - // ADMIN accepts and opens the scrim page - await impersonate(page, ADMIN_ID); - await navigate({ page, url: scrimsPage() }); - await page.getByTestId("confirm-modal-trigger-button").click(); - await submit(page, "confirm-button"); - await page.getByTestId("booked-scrims-tab").click(); - await page.getByRole("link", { name: "Contact" }).click(); - await page.waitForURL(/\/scrims\/\d+/); - - const scrimUrl = page.url(); + const scrimUrl = scrimPage(1); // ADMIN opens the Action tab — the map list form is shown immediately + await impersonate(page, ADMIN_ID); + await navigate({ page, url: scrimUrl }); await page.getByRole("tab", { name: "Action" }).click(); await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); diff --git a/locales/da/scrims.json b/locales/da/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/de/scrims.json b/locales/de/scrims.json index d58e4f7dd..8d9e57c87 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/en/scrims.json b/locales/en/scrims.json index 3c6cbc1ab..c17a12dc6 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "By mode", "mapByMap.stats.byStage": "By stage", "mapByMap.stats.byStageMode": "By stage & mode", + "mapByMap.stats.view.MODE": "Mode", + "mapByMap.stats.view.STAGE": "Stage", + "mapByMap.stats.view.BOTH": "Stage & Mode", "mapByMap.stats.col.label": "Map", "mapByMap.stats.col.wins": "Wins", - "mapByMap.stats.col.losses": "Losses" + "mapByMap.stats.col.losses": "Losses", + "mapByMap.stats.col.winPct": "Win %" } diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index 24d1db7fb..d199a073f 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index b498ef4b9..aed868b39 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/he/scrims.json b/locales/he/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/it/scrims.json b/locales/it/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index aa99a8a63..948741d96 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -113,7 +113,11 @@ "mapByMap.stats.byMode": "", "mapByMap.stats.byStage": "", "mapByMap.stats.byStageMode": "", + "mapByMap.stats.view.MODE": "", + "mapByMap.stats.view.STAGE": "", + "mapByMap.stats.view.BOTH": "", "mapByMap.stats.col.label": "", "mapByMap.stats.col.wins": "", - "mapByMap.stats.col.losses": "" + "mapByMap.stats.col.losses": "", + "mapByMap.stats.col.winPct": "" } diff --git a/migrations/143-scrim-map-by-map.js b/migrations/143-scrim-map-by-map.js index 19d325544..19982beca 100644 --- a/migrations/143-scrim-map-by-map.js +++ b/migrations/143-scrim-map-by-map.js @@ -28,7 +28,6 @@ export function up(db) { "winnerSide" text check ("winnerSide" in ('ALPHA','BRAVO')), "reportedAt" integer, "reportedByUserId" integer, - "replayOfIndex" integer, foreign key ("scrimPostId") references "ScrimPost"("id") on delete cascade, foreign key ("reportedByUserId") references "User"("id"), unique("scrimPostId", "index") on conflict rollback
{t("scrims:mapByMap.stats.col.label")} + {t("scrims:mapByMap.stats.col.wins")} + {t("scrims:mapByMap.stats.col.losses")} + {t("scrims:mapByMap.stats.col.winPct")} +
{row.label}{row.wins}{row.losses}{row.wins}{row.losses}{Math.round(row.winRate * 100)}%