diff --git a/app/components/match-page/MatchTabs.tsx b/app/components/match-page/MatchTabs.tsx index e6f745a1a..d042e4ab0 100644 --- a/app/components/match-page/MatchTabs.tsx +++ b/app/components/match-page/MatchTabs.tsx @@ -1,4 +1,11 @@ -import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react"; +import { + BarChart3, + DoorOpen, + Key, + ScrollText, + Tally5, + Users, +} from "lucide-react"; import type * as React from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router"; @@ -19,6 +26,7 @@ export const TAB_KEYS = { ACTION: "action", JOIN: "join", RESULT: "result", + STATS: "stats", ADMIN: "admin", } as const; @@ -27,6 +35,7 @@ const TAB_ICONS: Record = { action: , join: , result: , + stats: , admin: , }; @@ -35,6 +44,7 @@ const TAB_TRANSLATION_KEYS = { action: "q:match.tabs.action", join: "common:actions.join", result: "q:match.tabs.result", + stats: "q:match.tabs.stats", admin: "common:pages.admin", } as const; diff --git a/app/db/tables.ts b/app/db/tables.ts index 6f8e45bc1..632ffe3b9 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1255,10 +1255,38 @@ export interface ScrimPost { maps: "SZ" | "ALL" | "RANKED" | null; /** If set, specifies the maps of a tournament to play */ mapsTournamentId: number | null; + // xxx: I don't think these two are needed. trackingEnabledAt is inferred when ScrimMapList is set. trackingLockedAt is inferred from last map reportedAt + /** When map-by-map tracking was enabled on this scrim post (null when not enabled) */ + trackingEnabledAt: number | null; + /** When map-by-map tracking was locked (no further mutations allowed) */ + trackingLockedAt: number | null; createdAt: GeneratedAlways; updatedAt: Generated; } +export interface ScrimMapList { + id: GeneratedAlways; + scrimPostId: number; + side: "ALPHA" | "BRAVO"; + source: "TOURNAMENT" | "POOL"; + tournamentId: number | null; + serializedPool: string | null; + updatedAt: number; +} + +export interface ScrimMap { + id: GeneratedAlways; + scrimPostId: number; + index: number; + mode: ModeShort; + stageId: StageId; + winnerSide: "ALPHA" | "BRAVO" | null; + reportedAt: number | null; + reportedByUserId: number | null; + // xxx: replayOfIndex can be inferred? + replayOfIndex: number | null; +} + export interface ScrimPostUser { scrimPostId: number; userId: number; @@ -1442,6 +1470,8 @@ export interface DB { ScrimPostUser: ScrimPostUser; ScrimPostRequest: ScrimPostRequest; ScrimPostRequestUser: ScrimPostRequestUser; + ScrimMapList: ScrimMapList; + ScrimMap: ScrimMap; Association: Association; AssociationMember: AssociationMember; Notification: Notification; diff --git a/app/features/scrims/ScrimMapByMapRepository.server.ts b/app/features/scrims/ScrimMapByMapRepository.server.ts new file mode 100644 index 000000000..e07d60ab1 --- /dev/null +++ b/app/features/scrims/ScrimMapByMapRepository.server.ts @@ -0,0 +1,177 @@ +import { db } from "~/db/sql"; +import type { Tables } 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? + +/** + * Enables map-by-map tracking on a scrim post. Idempotent: existing + * `trackingEnabledAt` is preserved when called multiple times. + */ +export async function enableTracking(scrimPostId: number): Promise { + await db + .updateTable("ScrimPost") + .set({ trackingEnabledAt: databaseTimestampNow() }) + .where("id", "=", scrimPostId) + .where("trackingEnabledAt", "is", null) + .execute(); +} + +// xxx: this functionality not needed +/** Marks tracking as explicitly locked. Sets `trackingLockedAt` if null. */ +export async function lockTracking(scrimPostId: number): Promise { + await db + .updateTable("ScrimPost") + .set({ trackingLockedAt: databaseTimestampNow() }) + .where("id", "=", scrimPostId) + .where("trackingLockedAt", "is", null) + .execute(); +} + +// 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; +} + +/** + * Inserts a new map row representing the next unreported slot. Returns the + * inserted map's id. + */ +export async function insertMap(args: InsertMapArgs): Promise { + const inserted = await db + .insertInto("ScrimMap") + .values({ + scrimPostId: args.scrimPostId, + index: args.index, + mode: args.mode, + stageId: args.stageId, + replayOfIndex: args.replayOfIndex ?? null, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return inserted.id; +} + +// xxx: TablesInsertable +interface ReportMapWinnerArgs { + mapId: number; + winnerSide: ScrimSide; + reportedByUserId: number; +} + +/** Marks an existing map as reported with the given winner side. */ +export async function reportMapWinner( + args: ReportMapWinnerArgs, +): Promise { + await db + .updateTable("ScrimMap") + .set({ + winnerSide: args.winnerSide, + reportedAt: databaseTimestampNow(), + reportedByUserId: args.reportedByUserId, + }) + .where("id", "=", args.mapId) + .where("reportedAt", "is", null) + .execute(); +} + +// xxx: to avoid double undo, should also have index submitted +/** + * Deletes the most recently reported map row for the scrim. Caller is + * responsible for verifying the `canUndo` invariant; this method only enforces + * "highest index that has been reported". + */ +export async function undoMostRecentMap(scrimPostId: number): Promise { + await db.transaction().execute(async (trx) => { + const latest = await trx + .selectFrom("ScrimMap") + .select("id") + .where("scrimPostId", "=", scrimPostId) + .where("reportedAt", "is not", null) + .orderBy("index", "desc") + .limit(1) + .executeTakeFirst(); + + if (!latest) return; + + await trx.deleteFrom("ScrimMap").where("id", "=", latest.id).execute(); + }); +} + +/** Returns the scrim's maps ordered by index ascending. */ +export function findMapsByScrimPostId( + scrimPostId: number, +): Promise { + return db + .selectFrom("ScrimMap") + .selectAll() + .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..82bb5d580 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -136,6 +136,8 @@ const baseFindQuery = db "ScrimPost.canceledByUserId", "ScrimPost.cancelReason", "ScrimPost.isScheduledForFuture", + "ScrimPost.trackingEnabledAt", + "ScrimPost.trackingLockedAt", jsonBuildObject({ name: eb.ref("Team.name"), customUrl: eb.ref("Team.customUrl"), @@ -310,6 +312,8 @@ const mapDBRowToScrimPost = ( }, managedByAnyone: Boolean(row.managedByAnyone), canceled, + trackingEnabledAt: row.trackingEnabledAt, + trackingLockedAt: row.trackingLockedAt, }; if (!Scrim.isAccepted(result)) { diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index d78e2c1c2..ee534c1d3 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -1,7 +1,10 @@ import type { ActionFunctionArgs } from "react-router"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; +import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { + errorToast, errorToastIfFalsy, notFoundIfFalsy, parseParams, @@ -9,17 +12,28 @@ import { } from "~/utils/remix.server"; import { idObject } from "~/utils/zod"; import { databaseTimestampToDate } from "../../../utils/dates"; -import { errorToast } from "../../../utils/remix.server"; 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 ScrimPostRepository from "../ScrimPostRepository.server"; -import { cancelScrimSchema } from "../scrims-schemas"; +import { + cancelScrimSchema, + scrimMapByMapActionSchema, +} 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, @@ -65,3 +79,148 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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, + }); + }; + + if (data._action === "ENABLE_TRACKING") { + await ScrimMapByMapRepository.enableTracking(post.id); + broadcastRevalidate(); + return null; + } + + errorToastIfFalsy(Scrim.isTrackingEnabled(post), "Tracking is not enabled"); + + const maps = await ScrimMapByMapRepository.findMapsByScrimPostId(post.id); + + if (Scrim.isTrackingLocked(post, maps)) { + await ScrimMapByMapRepository.lockTracking(post.id); + errorToast("Tracking is locked"); + } + + switch (data._action) { + case "SUBMIT_MAP_LIST": { + await ScrimMapByMapRepository.upsertMapList({ + scrimPostId: post.id, + side: viewerSide!, + source: data.source, + tournamentId: data.tournamentId ?? null, + serializedPool: data.serializedPool ?? null, + }); + break; + } + case "REMOVE_MAP_LIST": { + await ScrimMapByMapRepository.deleteMapList(post.id, viewerSide!); + break; + } + // xxx: maybe extract this? + case "GENERATE_NEXT_MAP": { + const currentMap = maps.find((m) => m.reportedAt === null); + errorToastIfFalsy(!currentMap, "A map is already being played"); + + const mapLists = await ScrimMapByMapRepository.findMapListsByScrimPostId( + post.id, + ); + errorToastIfFalsy(mapLists.length > 0, "No map list submitted yet"); + + const tournamentPools = new Map< + number, + Awaited> + >(); + for (const list of mapLists) { + if (list.source !== "TOURNAMENT" || !list.tournamentId) continue; + if (tournamentPools.has(list.tournamentId)) continue; + tournamentPools.set( + list.tournamentId, + await resolveTournamentPool(list.tournamentId, user), + ); + } + + const pool = ScrimMapByMap.unionPool(mapLists, tournamentPools); + errorToastIfFalsy(!pool.isEmpty(), "Map pool is empty"); + + const next = ScrimMapByMap.generateNextMap({ + pool, + history: maps.map((m) => ({ mode: m.mode, stageId: m.stageId })), + }); + + await ScrimMapByMapRepository.insertMap({ + scrimPostId: post.id, + // xxx: instead of passing as arg, generate inline in DB code + index: Scrim.nextMapIndex(maps), + mode: next.mode, + stageId: next.stageId, + }); + break; + } + case "REPORT_MAP": { + const target = maps.find((m) => m.id === data.mapId); + errorToastIfFalsy(target, "Map not found"); + errorToastIfFalsy(target!.reportedAt === null, "Map already reported"); + + await ScrimMapByMapRepository.reportMapWinner({ + mapId: data.mapId, + winnerSide: data.winnerSide, + reportedByUserId: user.id, + }); + break; + } + case "UNDO_MAP": { + const latest = Scrim.lastReportedMap(maps); + errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo"); + + await ScrimMapByMapRepository.undoMostRecentMap(post.id); + break; + } + case "REPLAY_MAP": { + const latest = Scrim.lastReportedMap(maps); + errorToastIfFalsy(latest, "No map to replay"); + + const currentMap = maps.find((m) => m.reportedAt === null); + errorToastIfFalsy(!currentMap, "A map is already being played"); + + await ScrimMapByMapRepository.insertMap({ + scrimPostId: post.id, + index: Scrim.nextMapIndex(maps), + mode: latest!.mode, + stageId: latest!.stageId, + replayOfIndex: latest!.index, + }); + break; + } + } + + broadcastRevalidate(); + return null; +} + +async function resolveTournamentPool( + tournamentId: number, + user: ReturnType, +) { + const data = await tournamentDataCached({ tournamentId, user }); + return data.ctx.toSetMapPool; +} diff --git a/app/features/scrims/components/ScrimMapListForm.module.css b/app/features/scrims/components/ScrimMapListForm.module.css new file mode 100644 index 000000000..0b1b3d751 --- /dev/null +++ b/app/features/scrims/components/ScrimMapListForm.module.css @@ -0,0 +1,39 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-3); + border-radius: var(--rounded); + background: var(--bg-lighter); +} + +.sourceRow { + display: flex; + gap: var(--s-3); +} + +.sourceOption { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.input { + width: 100%; + padding: var(--s-2); + border-radius: var(--rounded); + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text); +} + +.actions { + display: flex; + gap: var(--s-2); + justify-content: flex-end; +} + +.error { + color: var(--theme-error); + font-size: var(--fonts-sm); +} diff --git a/app/features/scrims/components/ScrimMapListForm.tsx b/app/features/scrims/components/ScrimMapListForm.tsx new file mode 100644 index 000000000..6d6d16888 --- /dev/null +++ b/app/features/scrims/components/ScrimMapListForm.tsx @@ -0,0 +1,150 @@ +// xxx: use * as React syntax +import { useState } 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 styles from "./ScrimMapListForm.module.css"; + +interface Props { + viewerSide: ScrimSide; +} + +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 onSubmit = () => { + setError(null); + + if (source === "TOURNAMENT") { + if (!tournamentId) { + setError(t("scrims:mapByMap.form.tournamentRequired")); + return; + } + fetcher.submit( + { + _action: "SUBMIT_MAP_LIST", + source: "TOURNAMENT", + tournamentId: String(tournamentId), + }, + { method: "post" }, + ); + 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 { + setError(t("scrims:mapByMap.form.poolInvalid")); + return; + } + + fetcher.submit( + { + _action: "SUBMIT_MAP_LIST", + source: "POOL", + serializedPool: serialized, + }, + { method: "post" }, + ); + }; + + // xxx: migrate to SendouForm + return ( +
+
+ + +
+ + {source === "TOURNAMENT" ? ( + setTournamentId(tournament?.id ?? null)} + /> + ) : ( + setPoolInput(e.target.value)} + /> + )} + + {error ?
{error}
: null} + +
+ + {t("common:actions.submit")} + +
+
+ ); +} + +// 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/ScrimMatchActionTab.module.css b/app/features/scrims/components/ScrimMatchActionTab.module.css new file mode 100644 index 000000000..65c0cb5f8 --- /dev/null +++ b/app/features/scrims/components/ScrimMatchActionTab.module.css @@ -0,0 +1,55 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-4); + align-items: stretch; +} + +.intro { + text-align: center; + color: var(--text-lighter); + max-width: 36rem; + margin-inline: auto; +} + +.locked { + padding: var(--s-3); + border-radius: var(--rounded); + background: var(--bg-lighter); + color: var(--text-lighter); + text-align: center; +} + +.aux { + display: flex; + gap: var(--s-2); + justify-content: center; + flex-wrap: wrap; +} + +.mapListsSummary { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.mapListRow { + display: flex; + flex-direction: column; + gap: var(--s-1); + padding: var(--s-2); + border-radius: var(--rounded); + background: var(--bg-lighter); +} + +.mapListRowHeader { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: var(--bold); +} + +.mapListRowMissing { + font-style: italic; + color: var(--text-lighter); +} diff --git a/app/features/scrims/components/ScrimMatchActionTab.tsx b/app/features/scrims/components/ScrimMatchActionTab.tsx new file mode 100644 index 000000000..20cb14e0a --- /dev/null +++ b/app/features/scrims/components/ScrimMatchActionTab.tsx @@ -0,0 +1,295 @@ +import { useTranslation } from "react-i18next"; +import { useFetcher, useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { MatchActionTab } from "~/components/match-page/MatchActionTab"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { useUser } from "~/features/auth/core/user"; +import * as Scrim from "../core/Scrim"; +import * as ScrimMapByMap from "../core/ScrimMapByMap"; +import type { loader } from "../loaders/scrims.$id.server"; +import type { ScrimSide } from "../scrims-types"; +import { ScrimMapListForm } from "./ScrimMapListForm"; +import styles from "./ScrimMatchActionTab.module.css"; + +const ALPHA_TEAM_ID = 1; +const BRAVO_TEAM_ID = 2; + +export function ScrimMatchActionTab() { + const data = useLoaderData(); + const user = useUser(); + + const viewerSide = user ? Scrim.sideOfUser(data.post, user.id) : null; + + if (!data.mapByMap) return ; + if (data.mapByMap.locked) return ; + if (!viewerSide) return ; + + const ownList = data.mapByMap.mapLists.find((l) => l.side === viewerSide); + if (!ownList) { + return ; + } + + if (!data.mapByMap.currentMap) { + return ; + } + + return ; +} + +function EnableTrackingSection() { + const { t } = useTranslation(["scrims"]); + const fetcher = useFetcher(); + + return ( + +
+

+ {t("scrims:mapByMap.enable.explanation")} +

+ { + fetcher.submit({ _action: "ENABLE_TRACKING" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.enable.button")} + +
+
+ ); +} + +function LockedSection() { + const { t } = useTranslation(["scrims"]); + return ( + +
{t("scrims:mapByMap.locked")}
+
+ ); +} + +function NotParticipantSection() { + const { t } = useTranslation(["scrims"]); + return ( + +
+ {t("scrims:mapByMap.nonParticipantNotice")} +
+
+ ); +} + +function SubmitFirstListSection({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["scrims"]); + return ( + +
+

{t("scrims:mapByMap.submitList.intro")}

+ + +
+
+ ); +} + +function GenerateNextMapSection({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["scrims"]); + const fetcher = useFetcher(); + return ( + +
+ { + fetcher.submit( + { _action: "GENERATE_NEXT_MAP" }, + { method: "post" }, + ); + }} + > + {t("scrims:mapByMap.revealNextMap")} + + + +
+
+ ); +} + +function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["q"]); + const data = useLoaderData(); + const fetcher = useFetcher(); + const map = data.mapByMap!.currentMap!; + const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!; + + const alphaName = data.post.team + ? Scrim.sideDisplayName(data.post) + : t("q:match.groupAlpha"); + const bravoName = acceptedRequest.team + ? Scrim.sideDisplayName(acceptedRequest) + : t("q:match.groupBravo"); + + const ownTeamId = viewerSide === "ALPHA" ? ALPHA_TEAM_ID : BRAVO_TEAM_ID; + + return ( + { + fetcher.submit( + { + _action: "REPORT_MAP", + mapId: String(map.id), + winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO", + }, + { method: "post" }, + ); + }} + actionButtons={} + /> + ); +} + +function ReplayAndUndoButtons() { + const { t } = useTranslation(["scrims"]); + const data = useLoaderData(); + const fetcher = useFetcher(); + + const latest = Scrim.lastReportedMap(data.mapByMap?.maps ?? []); + const undoAllowed = ScrimMapByMap.canUndo(latest, data.mapByMap?.maps ?? []); + const replayAllowed = latest && !data.mapByMap?.currentMap; + + if (!undoAllowed && !replayAllowed) return null; + + return ( +
+ {undoAllowed ? ( + { + fetcher.submit({ _action: "UNDO_MAP" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.undo")} + + ) : null} + {replayAllowed ? ( + { + fetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.replay")} + + ) : null} +
+ ); +} + +function MapListsSummary({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["scrims", "q"]); + const data = useLoaderData(); + const lists = data.mapByMap?.mapLists ?? []; + + const sides: ScrimSide[] = ["ALPHA", "BRAVO"]; + + return ( +
+ {sides.map((side) => { + const list = lists.find((l) => l.side === side); + const isOwn = side === viewerSide; + return ( +
+
+ + {side === "ALPHA" + ? t("q:match.sides.alpha") + : t("q:match.sides.bravo")} + + {isOwn && list ? : null} +
+ {list ? ( + + ) : ( + + {t("scrims:mapByMap.noListYet")} + + )} +
+ ); + })} +
+ ); +} + +function ReplaceOwnListLink() { + const { t } = useTranslation(["scrims"]); + const fetcher = useFetcher(); + return ( + { + fetcher.submit({ _action: "REMOVE_MAP_LIST" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.removeList")} + + ); +} + +function MapListDisplay({ + source, + tournamentId, + serializedPool, +}: { + source: "TOURNAMENT" | "POOL"; + tournamentId: number | null; + serializedPool: string | null; +}) { + const { t } = useTranslation(["scrims"]); + if (source === "TOURNAMENT") { + return ( + + {t("scrims:mapByMap.tournamentList", { + id: tournamentId ?? "?", + })} + + ); + } + return ( + + {t("scrims:mapByMap.poolList", { + pool: serializedPool?.slice(0, 32) ?? "", + })} + + ); +} diff --git a/app/features/scrims/components/ScrimMatchResultTab.module.css b/app/features/scrims/components/ScrimMatchResultTab.module.css new file mode 100644 index 000000000..27081bacf --- /dev/null +++ b/app/features/scrims/components/ScrimMatchResultTab.module.css @@ -0,0 +1,55 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.row { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-2); + border-radius: var(--rounded); + background: var(--bg-lighter); +} + +.idx { + font-weight: var(--bold); + min-width: 2rem; + text-align: right; +} + +.modeStage { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.modeStageHeader { + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--bold); +} + +.winner { + margin-left: auto; + padding: var(--s-1) var(--s-2); + border-radius: var(--rounded); + font-size: var(--fonts-sm); + font-weight: var(--bold); +} + +.winnerAlpha { + background: var(--theme-alpha); +} + +.winnerBravo { + background: var(--theme-bravo); +} + +.replayTag { + font-size: var(--fonts-xs); + color: var(--text-lighter); + font-style: italic; +} diff --git a/app/features/scrims/components/ScrimMatchResultTab.tsx b/app/features/scrims/components/ScrimMatchResultTab.tsx new file mode 100644 index 000000000..ff93b32ef --- /dev/null +++ b/app/features/scrims/components/ScrimMatchResultTab.tsx @@ -0,0 +1,66 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { ModeImage, StageImage } from "~/components/Image"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import type { loader } from "../loaders/scrims.$id.server"; +import styles from "./ScrimMatchResultTab.module.css"; + +// xxx: why not using Timeline? + +export function ScrimMatchResultTab() { + const { t } = useTranslation(["scrims", "game-misc", "q"]); + const data = useLoaderData(); + const maps = data.mapByMap?.maps ?? []; + + if (maps.length === 0) { + return ( + +
{t("scrims:mapByMap.result.empty")}
+
+ ); + } + + return ( + +
+ {maps.map((map) => ( +
+ {map.index + 1} + +
+
+ + {t(`game-misc:STAGE_${map.stageId}` as const)} +
+ {map.replayOfIndex !== null ? ( + + {t("scrims:mapByMap.result.replayTag", { + index: map.replayOfIndex + 1, + })} + + ) : null} +
+ {map.winnerSide ? ( + + {map.winnerSide === "ALPHA" + ? t("q:match.sides.alpha") + : t("q:match.sides.bravo")} + + ) : null} +
+ ))} +
+
+ ); +} diff --git a/app/features/scrims/components/ScrimMatchStatsTab.module.css b/app/features/scrims/components/ScrimMatchStatsTab.module.css new file mode 100644 index 000000000..b34cc3b2a --- /dev/null +++ b/app/features/scrims/components/ScrimMatchStatsTab.module.css @@ -0,0 +1,43 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-4); +} + +.toggleRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.sectionTitle { + font-weight: var(--bold); + margin-bottom: var(--s-2); +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: var(--s-1) var(--s-2); + text-align: left; +} + +.table th { + color: var(--text-lighter); + font-weight: var(--bold); + font-size: var(--fonts-sm); + border-bottom: 1px solid var(--border); +} + +.cellRight { + text-align: right; +} + +.empty { + color: var(--text-lighter); + font-style: italic; +} diff --git a/app/features/scrims/components/ScrimMatchStatsTab.tsx b/app/features/scrims/components/ScrimMatchStatsTab.tsx new file mode 100644 index 000000000..681af7ac3 --- /dev/null +++ b/app/features/scrims/components/ScrimMatchStatsTab.tsx @@ -0,0 +1,150 @@ +// xxx: import * as React +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as ScrimMapByMap from "../core/ScrimMapByMap"; +import type { loader } from "../loaders/scrims.$id.server"; +import styles from "./ScrimMatchStatsTab.module.css"; + +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); + + if (!viewerSide || maps.length === 0) { + return ( + +
{t("scrims:mapByMap.stats.empty")}
+
+ ); + } + + const restrictPool = + restrictToPool && ownList?.serializedPool + ? new MapPool(ownList.serializedPool) + : undefined; + + const stats = ScrimMapByMap.stats(maps, viewerSide, { + restrictToPool: restrictPool, + }); + + return ( + +
+ {ownList?.source === "POOL" && ownList.serializedPool ? ( + + ) : null} + +
+

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

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

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

+ ({ + key: r.key, + label: t(`game-misc:STAGE_${Number(r.key)}` as const, { + defaultValue: r.key, + }), + wins: r.wins, + losses: r.losses, + }))} + /> +
+ +
+

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

+ { + const [stageId, mode] = r.key.split("-"); + const stageLabel = t( + `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}`, + wins: r.wins, + losses: r.losses, + }; + })} + /> +
+
+
+ ); +} + +function StatsTable({ + rows, +}: { + rows: Array<{ key: string; label: string; wins: number; losses: number }>; +}) { + const { t } = useTranslation(["scrims"]); + + if (rows.length === 0) { + return ( +
{t("scrims:mapByMap.stats.empty")}
+ ); + } + + return ( + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
{t("scrims:mapByMap.stats.col.label")} + {t("scrims:mapByMap.stats.col.wins")} + + {t("scrims:mapByMap.stats.col.losses")} +
{row.label}{row.wins}{row.losses}
+ ); +} diff --git a/app/features/scrims/components/ScrimMatchTabs.tsx b/app/features/scrims/components/ScrimMatchTabs.tsx index f036eaf76..64a971907 100644 --- a/app/features/scrims/components/ScrimMatchTabs.tsx +++ b/app/features/scrims/components/ScrimMatchTabs.tsx @@ -16,6 +16,9 @@ import * as Scrim from "../core/Scrim"; import type { loader } from "../loaders/scrims.$id.server"; import { SCRIM } from "../scrims-constants"; import type { ScrimPost } from "../scrims-types"; +import { ScrimMatchActionTab } from "./ScrimMatchActionTab"; +import { ScrimMatchResultTab } from "./ScrimMatchResultTab"; +import { ScrimMatchStatsTab } from "./ScrimMatchStatsTab"; export function ScrimMatchTabs() { const { t } = useTranslation(["q"]); @@ -35,8 +38,10 @@ export function ScrimMatchTabs() { members: allMembers, }); + const tabs = resolveTabs(data); + return ( - + + + + ); } +function resolveTabs(data: ReturnType>) { + const tabs: Array<(typeof TAB_KEYS)[keyof typeof TAB_KEYS]> = [ + TAB_KEYS.ROSTERS, + TAB_KEYS.JOIN, + TAB_KEYS.ACTION, + ]; + + if (data.mapByMap && data.mapByMap.maps.length > 0) { + tabs.push(TAB_KEYS.RESULT); + } + + if ( + data.mapByMap?.maps.some((m) => m.reportedAt !== null) && + data.mapByMap.viewerSide !== null + ) { + tabs.push(TAB_KEYS.STATS); + } + + return tabs; +} + function mapTeam(team: ScrimPost["team"]) { if (!team) return undefined; return { diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 602a96b48..a292e26de 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import { SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; import type { ScrimFilters, ScrimPost } from "../scrims-types"; import { applyFilters, + isTrackingLocked, participantIdsListFromAccepted, sideDisplayName, + sideOfUser, } from "./Scrim"; type MockUser = { id: number }; @@ -127,6 +130,8 @@ describe("applyFilters", () => { mapsTournament: null, permissions: { MANAGE_REQUESTS: [], CANCEL: [], DELETE_POST: [] }, team: null, + trackingEnabledAt: null, + trackingLockedAt: null, }; } @@ -461,3 +466,85 @@ describe("applyFilters", () => { }); }); }); + +describe("sideOfUser", () => { + it("returns ALPHA for users in the post's users list", () => { + const post = createPost( + [{ id: 1 }], + [{ isAccepted: true, users: [{ id: 2 }] }], + ); + expect(sideOfUser(post, 1)).toBe("ALPHA"); + }); + + it("returns BRAVO for users in the accepted request's users list", () => { + const post = createPost( + [{ id: 1 }], + [{ isAccepted: true, users: [{ id: 2 }] }], + ); + expect(sideOfUser(post, 2)).toBe("BRAVO"); + }); + + it("returns null for non-participants", () => { + const post = createPost( + [{ id: 1 }], + [{ isAccepted: true, users: [{ id: 2 }] }], + ); + expect(sideOfUser(post, 99)).toBeNull(); + }); + + it("ignores users only in non-accepted requests", () => { + const post = createPost( + [{ id: 1 }], + [{ isAccepted: false, users: [{ id: 2 }] }], + ); + expect(sideOfUser(post, 2)).toBeNull(); + }); +}); + +describe("isTrackingLocked", () => { + const ONE_HOUR_MS = 60 * 60 * 1000; + const lockWindowMs = SCRIM_TRACKING_AUTO_LOCK_HOURS * ONE_HOUR_MS; + + function postWith( + trackingEnabledAt: number | null, + trackingLockedAt: number | null = null, + ): ScrimPost { + return { + trackingEnabledAt, + trackingLockedAt, + } as unknown as ScrimPost; + } + + it("returns true when trackingLockedAt is set", () => { + expect(isTrackingLocked(postWith(100, 200), [], Date.now())).toBe(true); + }); + + it("returns false when tracking not enabled", () => { + expect(isTrackingLocked(postWith(null), [], Date.now())).toBe(false); + }); + + it("returns false just inside the auto-lock window", () => { + const now = 1_000_000_000; + const enabledSeconds = (now - (lockWindowMs - ONE_HOUR_MS)) / 1000; + expect(isTrackingLocked(postWith(enabledSeconds), [], now)).toBe(false); + }); + + it("returns true just past the auto-lock window", () => { + const now = 1_000_000_000; + const enabledSeconds = (now - (lockWindowMs + ONE_HOUR_MS)) / 1000; + expect(isTrackingLocked(postWith(enabledSeconds), [], now)).toBe(true); + }); + + it("uses the most recent reported map as the reference point", () => { + const now = 1_000_000_000; + const enabledSeconds = (now - lockWindowMs * 2) / 1000; + const recentMapSeconds = (now - ONE_HOUR_MS) / 1000; + expect( + isTrackingLocked( + postWith(enabledSeconds), + [{ reportedAt: recentMapSeconds }], + now, + ), + ).toBe(false); + }); +}); diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index 1a45a425d..2eb670986 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -1,9 +1,10 @@ import { format, isWeekend } from "date-fns"; import * as R from "remeda"; +import type { Tables } from "~/db/tables"; import { databaseTimestampToDate } from "~/utils/dates"; import { logger } from "~/utils/logger"; -import { LUTI_DIVS } from "../scrims-constants"; -import type { ScrimFilters, ScrimPost } from "../scrims-types"; +import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; +import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types"; /** Returns true if the original poster has accepted any of the requests. */ export function isAccepted(post: ScrimPost) { @@ -125,3 +126,76 @@ export function defaultFilters(): ScrimFilters { export function filtersAreDefault(filters: ScrimFilters): boolean { return R.isShallowEqual(filters, defaultFilters()); } + +/** + * Returns the side ("ALPHA" or "BRAVO") the user belongs to in the scrim, or + * null when the user is not part of the accepted pairing. + * + * The post's own users list is treated as the ALPHA side; the accepted + * request's users list is treated as the BRAVO side. + */ +export function sideOfUser(post: ScrimPost, userId: number): ScrimSide | null { + if (post.users.some((u) => u.id === userId)) return "ALPHA"; + + const acceptedRequest = post.requests.find((r) => r.isAccepted); + if (acceptedRequest?.users.some((u) => u.id === userId)) return "BRAVO"; + + return null; +} + +// xxx: useless helper +/** Returns true when map-by-map tracking has been enabled on the post. */ +export function isTrackingEnabled(post: ScrimPost): boolean { + return post.trackingEnabledAt !== null; +} + +/** + * Returns true when map-by-map tracking is locked, either because it was + * explicitly locked or because the auto-lock window has elapsed since the last + * activity (`trackingEnabledAt` or the most recent reported map). + */ +export function isTrackingLocked( + post: ScrimPost, + maps: Pick[] = [], + now: number = Date.now(), +): boolean { + if (post.trackingLockedAt !== null) return true; + if (post.trackingEnabledAt === null) return false; + + const lastReportedAt = maps.reduce((acc, m) => { + if (m.reportedAt === null) return acc; + return acc === null || m.reportedAt > acc ? m.reportedAt : acc; + }, null); + + // xxx: use date-fns + const referenceSeconds = lastReportedAt ?? post.trackingEnabledAt; + const elapsedHours = (now - referenceSeconds * 1000) / (60 * 60 * 1000); + + return elapsedHours > SCRIM_TRACKING_AUTO_LOCK_HOURS; +} + +/** + * Returns the next 0-based map index to be inserted given a list of existing + * maps. Existing maps need not be in any particular order. + */ +export function nextMapIndex( + maps: Pick[], +): number { + if (maps.length === 0) return 0; + return Math.max(...maps.map((m) => m.index)) + 1; +} + +/** + * Returns the most recently reported map (by `index`), or undefined if no map + * has been reported yet. + */ +export function lastReportedMap< + T extends Pick, +>(maps: T[]): T | undefined { + let latest: T | undefined; + for (const map of maps) { + if (map.reportedAt === null) continue; + if (!latest || map.index > latest.index) latest = map; + } + return latest; +} diff --git a/app/features/scrims/core/ScrimMapByMap.test.ts b/app/features/scrims/core/ScrimMapByMap.test.ts new file mode 100644 index 000000000..78b0a63f1 --- /dev/null +++ b/app/features/scrims/core/ScrimMapByMap.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import type { Tables } from "~/db/tables"; +import { + type DbMapPoolList, + MapPool, +} from "~/features/map-list-generator/core/map-pool"; +import { stagesObj } from "~/modules/in-game-lists/stage-ids"; +import type { StageId } from "~/modules/in-game-lists/types"; +import { canUndo, generateNextMap, stats, unionPool } from "./ScrimMapByMap"; + +type MapRow = Pick< + Tables["ScrimMap"], + "index" | "mode" | "stageId" | "winnerSide" | "reportedAt" +>; + +function makeMap(overrides: Partial & { index: number }): MapRow { + return { + index: overrides.index, + mode: overrides.mode ?? "SZ", + stageId: overrides.stageId ?? stagesObj.SCORCH_GORGE, + winnerSide: overrides.winnerSide ?? null, + reportedAt: overrides.reportedAt ?? null, + }; +} + +describe("ScrimMapByMap.unionPool", () => { + it("deduplicates stage-mode pairs across multiple POOL lists", () => { + const aPool = new MapPool({ + SZ: [stagesObj.SCORCH_GORGE, stagesObj.EELTAIL_ALLEY], + TC: [], + CB: [], + RM: [], + TW: [], + }).serialized; + const bPool = new MapPool({ + SZ: [stagesObj.EELTAIL_ALLEY, stagesObj.MAKOMART], + TC: [], + CB: [], + RM: [], + TW: [], + }).serialized; + + const pool = unionPool([ + { + side: "ALPHA", + source: "POOL", + tournamentId: null, + serializedPool: aPool, + }, + { + side: "BRAVO", + source: "POOL", + tournamentId: null, + serializedPool: bPool, + }, + ]); + + expect([...pool.parsed.SZ].sort((a, b) => a - b)).toEqual( + [ + stagesObj.SCORCH_GORGE, + stagesObj.EELTAIL_ALLEY, + stagesObj.MAKOMART, + ].sort((a, b) => a - b), + ); + }); + + it("resolves tournament pools via the provided map", () => { + const tournamentPools = new Map(); + tournamentPools.set(7, [ + { mode: "SZ", stageId: stagesObj.HAMMERHEAD_BRIDGE as StageId }, + { mode: "TC", stageId: stagesObj.MAKOMART as StageId }, + ]); + + const pool = unionPool( + [ + { + side: "ALPHA", + source: "TOURNAMENT", + tournamentId: 7, + serializedPool: null, + }, + ], + tournamentPools, + ); + + expect(pool.parsed.SZ).toEqual([stagesObj.HAMMERHEAD_BRIDGE]); + expect(pool.parsed.TC).toEqual([stagesObj.MAKOMART]); + }); +}); + +describe("ScrimMapByMap.generateNextMap", () => { + it("avoids the just-played stage when alternatives exist", () => { + const pool = new MapPool({ + SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART, stagesObj.WAHOO_WORLD], + TC: [], + CB: [], + RM: [], + TW: [], + }); + + for (let i = 0; i < 25; i++) { + const next = generateNextMap({ + pool, + history: [{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE }], + }); + expect(next.stageId).not.toBe(stagesObj.SCORCH_GORGE); + } + }); + + it("can still generate when only one stage is available", () => { + const pool = new MapPool({ + SZ: [stagesObj.SCORCH_GORGE], + TC: [], + CB: [], + RM: [], + TW: [], + }); + + const next = generateNextMap({ pool, history: [] }); + expect(next).toEqual({ mode: "SZ", stageId: stagesObj.SCORCH_GORGE }); + }); +}); + +describe("ScrimMapByMap.canUndo", () => { + it("returns true for the most recent reported map", () => { + const history = [ + makeMap({ index: 0, reportedAt: 100 }), + makeMap({ index: 1, reportedAt: 200 }), + ]; + + expect(canUndo(history[1], history)).toBe(true); + }); + + it("returns false for unreported maps", () => { + const history = [makeMap({ index: 0, reportedAt: null })]; + expect(canUndo(history[0], history)).toBe(false); + }); + + it("returns false for a non-latest reported map", () => { + const history = [ + makeMap({ index: 0, reportedAt: 100 }), + makeMap({ index: 1, reportedAt: 200 }), + ]; + expect(canUndo(history[0], history)).toBe(false); + }); + + it("returns false when given undefined", () => { + expect(canUndo(undefined, [])).toBe(false); + }); +}); + +describe("ScrimMapByMap.stats", () => { + const history: MapRow[] = [ + makeMap({ + index: 0, + mode: "SZ", + stageId: stagesObj.SCORCH_GORGE, + winnerSide: "ALPHA", + reportedAt: 100, + }), + makeMap({ + index: 1, + mode: "SZ", + stageId: stagesObj.MAKOMART, + winnerSide: "BRAVO", + reportedAt: 200, + }), + makeMap({ + index: 2, + mode: "TC", + stageId: stagesObj.MAKOMART, + winnerSide: "ALPHA", + reportedAt: 300, + }), + makeMap({ + index: 3, + mode: "SZ", + stageId: stagesObj.SCORCH_GORGE, + winnerSide: null, + reportedAt: null, + }), + ]; + + it("aggregates wins/losses from the viewer's perspective", () => { + const result = stats(history, "ALPHA"); + + const szMode = result.byMode.find((r) => r.key === "SZ"); + expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 }); + + const tcMode = result.byMode.find((r) => r.key === "TC"); + expect(tcMode).toEqual({ key: "TC", wins: 1, losses: 0 }); + }); + + it("flips wins/losses when viewing as BRAVO", () => { + const result = stats(history, "BRAVO"); + + const szMode = result.byMode.find((r) => r.key === "SZ"); + expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 }); + + const tcMode = result.byMode.find((r) => r.key === "TC"); + expect(tcMode).toEqual({ key: "TC", wins: 0, losses: 1 }); + }); + + it("filters out empty rows", () => { + const result = stats(history, "ALPHA"); + for (const row of result.byMode) { + expect(row.wins + row.losses).toBeGreaterThan(0); + } + }); + + it("respects restrictToPool", () => { + const restrictToPool = new MapPool({ + SZ: [stagesObj.SCORCH_GORGE], + TC: [], + CB: [], + RM: [], + TW: [], + }); + + const result = stats(history, "ALPHA", { restrictToPool }); + expect(result.byMode).toEqual([{ key: "SZ", wins: 1, losses: 0 }]); + }); +}); diff --git a/app/features/scrims/core/ScrimMapByMap.ts b/app/features/scrims/core/ScrimMapByMap.ts new file mode 100644 index 000000000..f5115b7d2 --- /dev/null +++ b/app/features/scrims/core/ScrimMapByMap.ts @@ -0,0 +1,208 @@ +// xxx: probably just merge with Scrim + +import type { Tables } from "~/db/tables"; +import * as MapList from "~/features/map-list-generator/core/MapList"; +import { + type DbMapPoolList, + MapPool, +} from "~/features/map-list-generator/core/map-pool"; +import type { MapPoolObject } from "~/features/map-list-generator/core/map-pool-serializer/types"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; + +type ScrimMapListRow = Pick< + Tables["ScrimMapList"], + "side" | "source" | "tournamentId" | "serializedPool" +>; + +type ScrimMapRow = Pick< + Tables["ScrimMap"], + "index" | "mode" | "stageId" | "winnerSide" | "reportedAt" +>; + +/** + * Merges the submitted map lists into a single deduplicated MapPool. Tournament + * pools are resolved by the caller and passed in via `tournamentPools` keyed by + * the tournament id. + */ +export function unionPool( + lists: ScrimMapListRow[], + tournamentPools: Map = new Map(), +): MapPool { + const merged: MapPoolObject = { + TW: [], + SZ: [], + TC: [], + RM: [], + CB: [], + }; + + const addPair = (mode: ModeShort, stageId: StageId) => { + if (!merged[mode].includes(stageId)) merged[mode].push(stageId); + }; + + for (const list of lists) { + if (list.source === "TOURNAMENT") { + const tournamentList = list.tournamentId + ? tournamentPools.get(list.tournamentId) + : undefined; + if (!tournamentList) continue; + for (const { mode, stageId } of tournamentList) { + addPair(mode, stageId); + } + } else { + if (!list.serializedPool) continue; + const pool = new MapPool(list.serializedPool); + for (const { mode, stageId } of pool.stageModePairs) { + addPair(mode, stageId); + } + } + } + + return new MapPool(merged); +} + +/** + * 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. + */ +export function generateNextMap(args: { + pool: MapPool; + history: Pick[]; +}): { mode: ModeShort; stageId: StageId } { + if (args.pool.isEmpty()) { + 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({ + mapPool: args.pool, + initialWeights: initialWeights.size > 0 ? initialWeights : undefined, + skipEnsureMinimumCandidates: true, + }); + + generator.next(); + const result = generator.next({ amount: 1 }).value; + + if (!result || result.length === 0) { + throw new Error("Failed to generate map"); + } + + return { mode: result[0].mode, stageId: result[0].stageId }; +} + +/** + * Returns true when the given map is the most recently reported one and is + * therefore eligible to be undone. + */ +export function canUndo( + map: ScrimMapRow | undefined, + history: ScrimMapRow[], +): boolean { + if (!map || map.reportedAt === null) return false; + + for (const other of history) { + if (other.reportedAt === null) continue; + if (other.index > map.index) return false; + } + + return true; +} + +export type StatsRow = { + key: string; + wins: number; + losses: number; +}; + +export type Stats = { + byMode: StatsRow[]; + byStage: StatsRow[]; + byStageMode: StatsRow[]; +}; + +/** + * Aggregates per-mode, per-stage, and per-(stage, mode) win/loss counts from + * the viewer's perspective. Maps outside `restrictToPool` (when provided) are + * skipped, as are unreported maps. Empty rows are filtered out. + */ +export function stats( + maps: ScrimMapRow[], + viewerSide: "ALPHA" | "BRAVO", + opts: { restrictToPool?: MapPool } = {}, +): Stats { + const byMode = new Map(); + const byStage = new Map(); + const byStageMode = new Map(); + + const stageModeKey = (mode: ModeShort, stageId: StageId) => + `${stageId}-${mode}`; + + const bump = ( + bucket: Map, + key: K, + display: string, + isWin: boolean, + ) => { + const existing = bucket.get(key); + if (existing) { + if (isWin) existing.wins += 1; + else existing.losses += 1; + return; + } + bucket.set(key, { + key: display, + wins: isWin ? 1 : 0, + losses: isWin ? 0 : 1, + }); + }; + + for (const map of maps) { + if (map.reportedAt === null || map.winnerSide === null) continue; + if ( + opts.restrictToPool && + !opts.restrictToPool.has({ mode: map.mode, stageId: map.stageId }) + ) { + continue; + } + + const isWin = map.winnerSide === viewerSide; + + bump(byMode, map.mode, map.mode, isWin); + bump(byStage, map.stageId, String(map.stageId), isWin); + bump( + byStageMode, + stageModeKey(map.mode, map.stageId), + stageModeKey(map.mode, map.stageId), + isWin, + ); + } + + const filterEmpty = (rows: StatsRow[]) => + rows.filter((r) => r.wins + r.losses > 0); + + const orderedByMode: StatsRow[] = []; + for (const mode of modesShort) { + const row = byMode.get(mode); + if (row) orderedByMode.push(row); + } + + return { + byMode: filterEmpty(orderedByMode), + byStage: filterEmpty([...byStage.values()]), + byStageMode: filterEmpty([...byStageMode.values()]), + }; +} diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 127684040..be37b63f0 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -10,6 +10,8 @@ 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 ScrimPostRepository from "../ScrimPostRepository.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { @@ -36,6 +38,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { RoomLinkRepository.findByUserIds(participantIds, 3), ]); + const mapByMap = Scrim.isTrackingEnabled(post) + ? await resolveMapByMap({ post, user }) + : null; + return { post, chatCode: @@ -53,6 +59,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { tournamentMapPool: post.mapsTournament ? await resolveTournamentMapPool(post.mapsTournament.id, user) : null, + mapByMap, }; }; @@ -64,3 +71,45 @@ async function resolveTournamentMapPool( return data.ctx.toSetMapPool; } + +async function resolveMapByMap({ + post, + user, +}: { + 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), + ]); + + const tournamentPools = new Map< + number, + Awaited> + >(); + for (const list of mapLists) { + if (list.source !== "TOURNAMENT" || !list.tournamentId) continue; + if (tournamentPools.has(list.tournamentId)) continue; + const pool = await resolveTournamentMapPool(list.tournamentId, user); + tournamentPools.set(list.tournamentId, pool); + } + + const pool = + mapLists.length > 0 + ? ScrimMapByMap.unionPool(mapLists, tournamentPools) + : null; + const currentMap = maps.find((m) => m.reportedAt === null) ?? null; + const viewerSide = Scrim.sideOfUser(post, user.id); + const locked = Scrim.isTrackingLocked(post, maps); + + return { + mapLists, + maps, + currentMap, + viewerSide, + locked, + pool: pool ? pool.serialized : null, + }; +} diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx index d962ee2a7..ff1b7477b 100644 --- a/app/features/scrims/routes/scrims.$id.tsx +++ b/app/features/scrims/routes/scrims.$id.tsx @@ -1,5 +1,7 @@ +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"; @@ -19,7 +21,16 @@ export const handle: SendouRouteHandle = { }), }; +// xxx: Top right maps link icon maybe out of place now? 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-constants.ts b/app/features/scrims/scrims-constants.ts index c182b0bc9..0bef4aa9b 100644 --- a/app/features/scrims/scrims-constants.ts +++ b/app/features/scrims/scrims-constants.ts @@ -21,3 +21,5 @@ export const SCRIM = { MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours ROOM_LINK_FRESHNESS_MINUTES: 30, }; + +export const SCRIM_TRACKING_AUTO_LOCK_HOURS = 4; diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index d1f5bbc98..910db3092 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -174,6 +174,60 @@ export const scrimsActionSchema = z.union([ persistScrimFiltersSchema, ]); +const enableTrackingSchema = z.object({ + _action: _action("ENABLE_TRACKING"), +}); + +// xxx: can we use discriminated union? +const submitMapListSchema = z + .object({ + _action: _action("SUBMIT_MAP_LIST"), + source: z.enum(["TOURNAMENT", "POOL"]), + tournamentId: z.preprocess(falsyToNull, id.nullable()).optional(), + serializedPool: z.preprocess(falsyToNull, z.string().nullable()).optional(), + }) + .refine( + (value) => + (value.source === "TOURNAMENT" && + value.tournamentId && + !value.serializedPool) || + (value.source === "POOL" && value.serializedPool && !value.tournamentId), + { message: "exactly one of tournamentId / serializedPool required" }, + ); + +const removeMapListSchema = z.object({ + _action: _action("REMOVE_MAP_LIST"), +}); + +// xxx: should not be needed to +const generateNextMapSchema = z.object({ + _action: _action("GENERATE_NEXT_MAP"), +}); + +const reportMapSchema = z.object({ + _action: _action("REPORT_MAP"), + mapId: id, + winnerSide: z.enum(["ALPHA", "BRAVO"]), +}); + +const undoMapSchema = z.object({ + _action: _action("UNDO_MAP"), +}); + +const replayMapSchema = z.object({ + _action: _action("REPLAY_MAP"), +}); + +export const scrimMapByMapActionSchema = z.union([ + enableTrackingSchema, + submitMapListSchema, + removeMapListSchema, + generateNextMapSchema, + reportMapSchema, + undoMapSchema, + replayMapSchema, +]); + const MAX_SCRIM_POST_TEXT_LENGTH = 500; export const RANGE_END_OPTIONS = [ diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts index 7781f8d98..a5e53c637 100644 --- a/app/features/scrims/scrims-types.ts +++ b/app/features/scrims/scrims-types.ts @@ -4,6 +4,8 @@ import type { LUTI_DIVS } from "./scrims-constants"; export type LutiDiv = (typeof LUTI_DIVS)[number]; +export type ScrimSide = "ALPHA" | "BRAVO"; + export interface ScrimPost { id: number; at: number; @@ -42,6 +44,11 @@ export interface ScrimPost { byUser: ScrimPostUser; reason: string; } | null; + // xxx: if inferred, do they need to be here? + /** When map-by-map tracking was enabled on this scrim. Null when not enabled. */ + trackingEnabledAt: number | null; + /** When map-by-map tracking was locked. Null when not locked. */ + trackingLockedAt: number | null; } export interface ScrimPostRequest { diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 71c1c353f..7e942bfc4 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index fc6d640db..a3082e177 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,3 +1,4 @@ +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"; @@ -10,9 +11,12 @@ import { selectUser, submit, test, + waitForPOSTResponse, } from "./helpers/playwright"; import { createFormHelpers } from "./helpers/playwright-form"; +const TEST_POOL_SERIALIZED = "sz:3a14000;tc:2c98000"; + test.describe("Scrims", () => { test("creates a new scrim & deletes it", async ({ page }) => { await seed(page); @@ -239,4 +243,147 @@ test.describe("Scrims", () => { // on /maps page await expect(page.getByText("Create map list")).toBeVisible(); }); + + test("map-by-map: lists, report, undo, replay, change list, stats", async ({ + page, + }) => { + await seed(page, "NO_SCRIMS"); + + // 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(); + + // ADMIN enables tracking from the Action tab + await page.getByRole("tab", { name: "Action" }).click(); + await submit(page, "enable-tracking-button"); + await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); + + // ADMIN submits a tournament-based map list (Swim or Sink) + await page.getByTestId("source-radio-tournament").click(); + await page.getByRole("button", { name: /Tournament search/i }).click(); + await page.getByTestId("tournament-search-input").fill("Swim or Sink"); + await expect( + page.getByTestId("tournament-search-item").first(), + ).toBeVisible(); + await page.getByTestId("tournament-search-item").first().click(); + await submit(page, "submit-map-list-button"); + await expect(page.getByTestId("map-list-row-ALPHA")).toContainText( + "Tournament", + ); + + // NZAP submits a pool-URL-based map list + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: scrimUrl }); + await page.getByRole("tab", { name: "Action" }).click(); + await page.getByTestId("source-radio-pool").click(); + await page.getByTestId("pool-input").fill(TEST_POOL_SERIALIZED); + await submit(page, "submit-map-list-button"); + await expect(page.getByTestId("reveal-next-map-button")).toBeVisible(); + await expect(page.getByTestId("map-list-row-BRAVO")).toContainText("Pool"); + + // Map 1: reveal → ALPHA wins + await submit(page, "reveal-next-map-button"); + await reportScrimMapWinner(page, "ALPHA"); + await expect(page.getByTestId("reveal-next-map-button")).toBeVisible(); + + // Map 2: reveal → BRAVO wins + await submit(page, "reveal-next-map-button"); + await reportScrimMapWinner(page, "BRAVO"); + await expect(page.getByTestId("reveal-next-map-button")).toBeVisible(); + + // Map 3: reveal → ALPHA wins → undo + await submit(page, "reveal-next-map-button"); + await reportScrimMapWinner(page, "ALPHA"); + await expect(page.getByTestId("undo-map-button")).toBeVisible(); + await submit(page, "undo-map-button"); + // After undo, the third map row is gone — only two reported maps remain + await expect(page.getByTestId("result-row-2")).toHaveCount(0); + + // Reveal a fresh map 3 and report BRAVO wins + await submit(page, "reveal-next-map-button"); + await reportScrimMapWinner(page, "BRAVO"); + + // Replay last map: inserts an unreported duplicate, then report ALPHA wins + await expect(page.getByTestId("replay-map-button")).toBeVisible(); + await submit(page, "replay-map-button"); + await reportScrimMapWinner(page, "ALPHA"); + + // Switch back to ADMIN to change their list + await impersonate(page, ADMIN_ID); + await navigate({ page, url: scrimUrl }); + await page.getByRole("tab", { name: "Action" }).click(); + + // Remove ALPHA's tournament list + await waitForPOSTResponse(page, async () => { + await page + .getByTestId("map-list-row-ALPHA") + .getByTestId("remove-list-button") + .click(); + }); + await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); + + // Re-submit ALPHA's list, this time as a pool URL + await page.getByTestId("source-radio-pool").click(); + await page.getByTestId("pool-input").fill(TEST_POOL_SERIALIZED); + await submit(page, "submit-map-list-button"); + await expect(page.getByTestId("map-list-row-ALPHA")).toContainText("Pool"); + + // Verify stats tab reflects the played maps + await page.getByRole("tab", { name: "Stats" }).click(); + await expect(page.getByTestId("scrim-stats-root")).toBeVisible(); + + // Four reported maps total (Alpha 2 / Bravo 2 from ADMIN's POV). + // Sum of wins+losses across byMode rows should equal 4. + const winCells = await page + .getByTestId("stats-section-byMode") + .locator("tbody tr td:nth-child(2)") + .allInnerTexts(); + const lossCells = await page + .getByTestId("stats-section-byMode") + .locator("tbody tr td:nth-child(3)") + .allInnerTexts(); + const total = + winCells.reduce((acc, v) => acc + Number(v), 0) + + lossCells.reduce((acc, v) => acc + Number(v), 0); + expect(total).toBe(4); + }); }); + +async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") { + const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2"; + await expect( + page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), + ).toHaveCount(0); + await page.getByTestId(testId).click(); + await submit(page, "report-score-button"); +} diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 28c98f243..e8081c836 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 51bb2da10..f7c0f2f8c 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index b2ccf4cda..83f92de53 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index b1b4756c0..fedf0f237 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 5240dd83f..efe6f5c93 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 5c6587355..9d769fee8 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 7295ec087..df5c111d8 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index cd17d3164..0c8085ecd 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index f5d35af8d..e01932de3 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 8bc4ffdff..7772c2682 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 6e1bb68d0..6c2747d91 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/locales/da/q.json b/locales/da/q.json index 09c57a825..f85c0f50b 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/de/q.json b/locales/de/q.json index 75631dd93..dfe5ae424 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 4524dfcaa..74d6088e0 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/en/q.json b/locales/en/q.json index 96682db6a..d54c0ec71 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "Rosters", "match.tabs.action": "Action", "match.tabs.result": "Result", + "match.tabs.stats": "Stats", "preparing.joinQ": "Join the queue", "tiers.currentCriteria": "Current criteria", "tiers.info.p1": "For example, Leviathan is the top 5% of players. Diamond is the 85th percentile etc.", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index c69545d48..5dee25cc0 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "Canceled by {{user}}", "banner.canceled.subtitle": "Reason: {{reason}}", "banner.freeForm.header": "Free form practice", - "banner.freeForm.subtitle": "Communicate the maplist with the opponents" + "banner.freeForm.subtitle": "Communicate the maplist with the opponents", + "mapByMap.enable.button": "Enable map tracking", + "mapByMap.enable.explanation": "Enable map-by-map tracking to draw maps from a shared pool and report results. Once enabled, tracking cannot be turned off.", + "mapByMap.locked": "Map tracking is locked. No further changes can be made.", + "mapByMap.nonParticipantNotice": "Only participants can manage map tracking.", + "mapByMap.submitList.intro": "Submit a map list to begin generating maps.", + "mapByMap.revealNextMap": "Reveal next map", + "mapByMap.undo": "Undo last map", + "mapByMap.replay": "Replay last map", + "mapByMap.removeList": "Remove list", + "mapByMap.noListYet": "Not submitted yet", + "mapByMap.tournamentList": "Tournament map pool (id: {{id}})", + "mapByMap.poolList": "Pool: {{pool}}", + "mapByMap.form.sourceTournament": "Tournament", + "mapByMap.form.sourcePool": "Pool URL", + "mapByMap.form.tournamentLabel": "Tournament", + "mapByMap.form.tournamentRequired": "Please select a tournament", + "mapByMap.form.poolPlaceholder": "sendou.ink/maps?pool=... or raw pool value", + "mapByMap.form.poolInvalid": "Invalid map pool", + "mapByMap.result.empty": "No maps played yet", + "mapByMap.result.replayTag": "Replay of map {{index}}", + "mapByMap.stats.empty": "No reported maps yet", + "mapByMap.stats.restrictToPool": "Restrict to my submitted pool", + "mapByMap.stats.byMode": "By mode", + "mapByMap.stats.byStage": "By stage", + "mapByMap.stats.byStageMode": "By stage & mode", + "mapByMap.stats.col.label": "Map", + "mapByMap.stats.col.wins": "Wins", + "mapByMap.stats.col.losses": "Losses" } diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 25e4b178c..fcedbe9c5 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Unirte a la fila", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index ee145a923..2743fbf15 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/es-US/q.json b/locales/es-US/q.json index d7a98cfd9..f9cf7359d 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Unirte a la fila", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 440d316ba..9cc51ecc6 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 7dcfdc846..f97f9f928 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Rejoindre la queue", "tiers.currentCriteria": "Critères actuels", "tiers.info.p1": "Par exemple, Les Léviathans font partie des 5 % des meilleurs joueurs. Le diamant est le top 15%, etc.", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 92d515ac0..37d37d633 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/he/q.json b/locales/he/q.json index 3faca72b3..55406c2db 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/it/q.json b/locales/it/q.json index 501946cb4..4abbecbc8 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Unisciti alla coda", "tiers.currentCriteria": "Criterio corrente", "tiers.info.p1": "Per esempio Leviathan è la top 5% dei giocatori. Diamante è l' 85esimo percentile etc.", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/ja/q.json b/locales/ja/q.json index ef6fcd2be..9feb19e8c 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "列に入る", "tiers.currentCriteria": "現在の基準", "tiers.info.p1": "例として、Leviathanはプレイヤーの上位5%、Diamondは上位15%", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/ko/q.json b/locales/ko/q.json index 75631dd93..dfe5ae424 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/nl/q.json b/locales/nl/q.json index 75631dd93..dfe5ae424 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/pl/q.json b/locales/pl/q.json index 75631dd93..dfe5ae424 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index cbea4df20..4ce6555d4 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Entrar na fila", "tiers.currentCriteria": "Critérios atuais", "tiers.info.p1": "Por exemplo, Leviathan é o top 5% dos jogadores. Diamond é top 15% e etc.", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/ru/q.json b/locales/ru/q.json index 7bfc27c49..25d079d42 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Присоединиться к очереди", "tiers.currentCriteria": "Текущие критерии", "tiers.info.p1": "Например, Leviathan - топ 5% игроков, Diamond - 85 процентиль и т.д.", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/locales/zh/q.json b/locales/zh/q.json index 5e66fff87..e2072fd6a 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -227,6 +227,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "开始匹配", "tiers.currentCriteria": "当前规则", "tiers.info.p1": "比如说,Leviathan是前5%的玩家,Diamond是前15%的玩家。", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 76c07c585..acc51e7fc 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -89,5 +89,33 @@ "banner.canceled.header": "", "banner.canceled.subtitle": "", "banner.freeForm.header": "", - "banner.freeForm.subtitle": "" + "banner.freeForm.subtitle": "", + "mapByMap.enable.button": "", + "mapByMap.enable.explanation": "", + "mapByMap.locked": "", + "mapByMap.nonParticipantNotice": "", + "mapByMap.submitList.intro": "", + "mapByMap.revealNextMap": "", + "mapByMap.undo": "", + "mapByMap.replay": "", + "mapByMap.removeList": "", + "mapByMap.noListYet": "", + "mapByMap.tournamentList": "", + "mapByMap.poolList": "", + "mapByMap.form.sourceTournament": "", + "mapByMap.form.sourcePool": "", + "mapByMap.form.tournamentLabel": "", + "mapByMap.form.tournamentRequired": "", + "mapByMap.form.poolPlaceholder": "", + "mapByMap.form.poolInvalid": "", + "mapByMap.result.empty": "", + "mapByMap.result.replayTag": "", + "mapByMap.stats.empty": "", + "mapByMap.stats.restrictToPool": "", + "mapByMap.stats.byMode": "", + "mapByMap.stats.byStage": "", + "mapByMap.stats.byStageMode": "", + "mapByMap.stats.col.label": "", + "mapByMap.stats.col.wins": "", + "mapByMap.stats.col.losses": "" } diff --git a/migrations/143-scrim-map-by-map.js b/migrations/143-scrim-map-by-map.js new file mode 100644 index 000000000..bee14f422 --- /dev/null +++ b/migrations/143-scrim-map-by-map.js @@ -0,0 +1,53 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "ScrimPost" add column "trackingEnabledAt" integer`, + ).run(); + + db.prepare( + /* sql */ `alter table "ScrimPost" add column "trackingLockedAt" integer`, + ).run(); + + db.prepare( + /* sql */ ` + create table "ScrimMapList" ( + "id" integer primary key autoincrement, + "scrimPostId" integer not null, + "side" text not null check ("side" in ('ALPHA','BRAVO')), + "source" text not null check ("source" in ('TOURNAMENT','POOL')), + "tournamentId" integer, + "serializedPool" text, + "updatedAt" integer not null, + foreign key ("scrimPostId") references "ScrimPost"("id") on delete cascade, + foreign key ("tournamentId") references "Tournament"("id"), + unique("scrimPostId", "side") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + create table "ScrimMap" ( + "id" integer primary key autoincrement, + "scrimPostId" integer not null, + "index" integer not null, + "mode" text not null, + "stageId" integer not null, + "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 + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `create index scrim_map_scrim_post_id_index_idx on "ScrimMap"("scrimPostId", "index")`, + ).run(); + + db.pragma("foreign_key_check"); + })(); +}