This commit is contained in:
Kalle 2026-05-20 20:59:29 +03:00
parent beaf7bb2e4
commit 487545f648
69 changed files with 2663 additions and 22 deletions

View File

@ -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<MatchTabsKey, React.ReactNode> = {
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
stats: <BarChart3 />,
admin: <Key />,
};
@ -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;

View File

@ -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<number>;
updatedAt: Generated<number>;
}
export interface ScrimMapList {
id: GeneratedAlways<number>;
scrimPostId: number;
side: "ALPHA" | "BRAVO";
source: "TOURNAMENT" | "POOL";
tournamentId: number | null;
serializedPool: string | null;
updatedAt: number;
}
export interface ScrimMap {
id: GeneratedAlways<number>;
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;

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<Tables["ScrimMapList"][]> {
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<number> {
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<void> {
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<void> {
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<Tables["ScrimMap"][]> {
return db
.selectFrom("ScrimMap")
.selectAll()
.where("scrimPostId", "=", scrimPostId)
.orderBy("index", "asc")
.execute();
}

View File

@ -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)) {

View File

@ -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<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: ReturnType<typeof requireUser>;
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<ReturnType<typeof resolveTournamentPool>>
>();
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<typeof requireUser>,
) {
const data = await tournamentDataCached({ tournamentId, user });
return data.ctx.toSetMapPool;
}

View File

@ -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);
}

View File

@ -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<number | null>(null);
const [poolInput, setPoolInput] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className={styles.root} data-testid="scrim-map-list-form">
<div className={styles.sourceRow}>
<label className={styles.sourceOption}>
<input
type="radio"
name="source"
value="POOL"
data-testid="source-radio-pool"
checked={source === "POOL"}
onChange={() => setSource("POOL")}
/>
{t("scrims:mapByMap.form.sourcePool")}
</label>
<label className={styles.sourceOption}>
<input
type="radio"
name="source"
value="TOURNAMENT"
data-testid="source-radio-tournament"
checked={source === "TOURNAMENT"}
onChange={() => setSource("TOURNAMENT")}
/>
{t("scrims:mapByMap.form.sourceTournament")}
</label>
</div>
{source === "TOURNAMENT" ? (
<TournamentSearch
label={t("scrims:mapByMap.form.tournamentLabel")}
initialTournamentId={tournamentId ?? undefined}
onChange={(tournament) => setTournamentId(tournament?.id ?? null)}
/>
) : (
<input
type="text"
data-testid="pool-input"
placeholder={t("scrims:mapByMap.form.poolPlaceholder")}
className={styles.input}
value={poolInput}
onChange={(e) => setPoolInput(e.target.value)}
/>
)}
{error ? <div className={styles.error}>{error}</div> : null}
<div className={styles.actions}>
<SendouButton
testId="submit-map-list-button"
isDisabled={fetcher.state !== "idle"}
onPress={onSubmit}
>
{t("common:actions.submit")}
</SendouButton>
</div>
</div>
);
}
// 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;
}

View File

@ -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);
}

View File

@ -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<typeof loader>();
const user = useUser();
const viewerSide = user ? Scrim.sideOfUser(data.post, user.id) : null;
if (!data.mapByMap) return <EnableTrackingSection />;
if (data.mapByMap.locked) return <LockedSection />;
if (!viewerSide) return <NotParticipantSection />;
const ownList = data.mapByMap.mapLists.find((l) => l.side === viewerSide);
if (!ownList) {
return <SubmitFirstListSection viewerSide={viewerSide} />;
}
if (!data.mapByMap.currentMap) {
return <GenerateNextMapSection viewerSide={viewerSide} />;
}
return <ReportMapSection viewerSide={viewerSide} />;
}
function EnableTrackingSection() {
const { t } = useTranslation(["scrims"]);
const fetcher = useFetcher();
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<p className={styles.intro}>
{t("scrims:mapByMap.enable.explanation")}
</p>
<SendouButton
testId="enable-tracking-button"
isDisabled={fetcher.state !== "idle"}
onPress={() => {
fetcher.submit({ _action: "ENABLE_TRACKING" }, { method: "post" });
}}
>
{t("scrims:mapByMap.enable.button")}
</SendouButton>
</div>
</SendouTabPanel>
);
}
function LockedSection() {
const { t } = useTranslation(["scrims"]);
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.locked}>{t("scrims:mapByMap.locked")}</div>
</SendouTabPanel>
);
}
function NotParticipantSection() {
const { t } = useTranslation(["scrims"]);
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.locked}>
{t("scrims:mapByMap.nonParticipantNotice")}
</div>
</SendouTabPanel>
);
}
function SubmitFirstListSection({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["scrims"]);
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<p className={styles.intro}>{t("scrims:mapByMap.submitList.intro")}</p>
<ScrimMapListForm viewerSide={viewerSide} />
<MapListsSummary viewerSide={viewerSide} />
</div>
</SendouTabPanel>
);
}
function GenerateNextMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["scrims"]);
const fetcher = useFetcher();
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<SendouButton
testId="reveal-next-map-button"
isDisabled={fetcher.state !== "idle"}
onPress={() => {
fetcher.submit(
{ _action: "GENERATE_NEXT_MAP" },
{ method: "post" },
);
}}
>
{t("scrims:mapByMap.revealNextMap")}
</SendouButton>
<ReplayAndUndoButtons />
<MapListsSummary viewerSide={viewerSide} />
</div>
</SendouTabPanel>
);
}
function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
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 (
<MatchActionTab
teams={[
{ id: ALPHA_TEAM_ID, name: alphaName },
{ id: BRAVO_TEAM_ID, name: bravoName },
]}
ownTeamId={ownTeamId}
stageId={map.stageId}
mode={map.mode}
withPoints={false}
isSubmitting={fetcher.state !== "idle"}
onSubmit={({ winnerId }) => {
fetcher.submit(
{
_action: "REPORT_MAP",
mapId: String(map.id),
winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
},
{ method: "post" },
);
}}
actionButtons={<ReplayAndUndoButtons />}
/>
);
}
function ReplayAndUndoButtons() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
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 (
<div className={styles.aux}>
{undoAllowed ? (
<SendouButton
testId="undo-map-button"
variant="outlined"
size="small"
isDisabled={fetcher.state !== "idle"}
onPress={() => {
fetcher.submit({ _action: "UNDO_MAP" }, { method: "post" });
}}
>
{t("scrims:mapByMap.undo")}
</SendouButton>
) : null}
{replayAllowed ? (
<SendouButton
testId="replay-map-button"
variant="outlined"
size="small"
isDisabled={fetcher.state !== "idle"}
onPress={() => {
fetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" });
}}
>
{t("scrims:mapByMap.replay")}
</SendouButton>
) : null}
</div>
);
}
function MapListsSummary({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["scrims", "q"]);
const data = useLoaderData<typeof loader>();
const lists = data.mapByMap?.mapLists ?? [];
const sides: ScrimSide[] = ["ALPHA", "BRAVO"];
return (
<div className={styles.mapListsSummary}>
{sides.map((side) => {
const list = lists.find((l) => l.side === side);
const isOwn = side === viewerSide;
return (
<div
key={side}
className={styles.mapListRow}
data-testid={`map-list-row-${side}`}
>
<div className={styles.mapListRowHeader}>
<span>
{side === "ALPHA"
? t("q:match.sides.alpha")
: t("q:match.sides.bravo")}
</span>
{isOwn && list ? <ReplaceOwnListLink /> : null}
</div>
{list ? (
<MapListDisplay
source={list.source}
tournamentId={list.tournamentId}
serializedPool={list.serializedPool}
/>
) : (
<span className={styles.mapListRowMissing}>
{t("scrims:mapByMap.noListYet")}
</span>
)}
</div>
);
})}
</div>
);
}
function ReplaceOwnListLink() {
const { t } = useTranslation(["scrims"]);
const fetcher = useFetcher();
return (
<SendouButton
testId="remove-list-button"
variant="minimal-destructive"
size="miniscule"
isDisabled={fetcher.state !== "idle"}
onPress={() => {
fetcher.submit({ _action: "REMOVE_MAP_LIST" }, { method: "post" });
}}
>
{t("scrims:mapByMap.removeList")}
</SendouButton>
);
}
function MapListDisplay({
source,
tournamentId,
serializedPool,
}: {
source: "TOURNAMENT" | "POOL";
tournamentId: number | null;
serializedPool: string | null;
}) {
const { t } = useTranslation(["scrims"]);
if (source === "TOURNAMENT") {
return (
<span>
{t("scrims:mapByMap.tournamentList", {
id: tournamentId ?? "?",
})}
</span>
);
}
return (
<span>
{t("scrims:mapByMap.poolList", {
pool: serializedPool?.slice(0, 32) ?? "",
})}
</span>
);
}

View File

@ -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;
}

View File

@ -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<typeof loader>();
const maps = data.mapByMap?.maps ?? [];
if (maps.length === 0) {
return (
<SendouTabPanel id={TAB_KEYS.RESULT}>
<div>{t("scrims:mapByMap.result.empty")}</div>
</SendouTabPanel>
);
}
return (
<SendouTabPanel id={TAB_KEYS.RESULT}>
<div className={styles.root} data-testid="scrim-result-timeline">
{maps.map((map) => (
<div
key={map.id}
className={styles.row}
data-testid={`result-row-${map.index}`}
>
<span className={styles.idx}>{map.index + 1}</span>
<StageImage stageId={map.stageId} width={72} />
<div className={styles.modeStage}>
<div className={styles.modeStageHeader}>
<ModeImage mode={map.mode} width={20} />
<span>{t(`game-misc:STAGE_${map.stageId}` as const)}</span>
</div>
{map.replayOfIndex !== null ? (
<span className={styles.replayTag}>
{t("scrims:mapByMap.result.replayTag", {
index: map.replayOfIndex + 1,
})}
</span>
) : null}
</div>
{map.winnerSide ? (
<span
className={clsx(styles.winner, {
[styles.winnerAlpha]: map.winnerSide === "ALPHA",
[styles.winnerBravo]: map.winnerSide === "BRAVO",
})}
>
{map.winnerSide === "ALPHA"
? t("q:match.sides.alpha")
: t("q:match.sides.bravo")}
</span>
) : null}
</div>
))}
</div>
</SendouTabPanel>
);
}

View File

@ -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;
}

View File

@ -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<typeof loader>();
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 (
<SendouTabPanel id={TAB_KEYS.STATS}>
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
</SendouTabPanel>
);
}
const restrictPool =
restrictToPool && ownList?.serializedPool
? new MapPool(ownList.serializedPool)
: undefined;
const stats = ScrimMapByMap.stats(maps, viewerSide, {
restrictToPool: restrictPool,
});
return (
<SendouTabPanel id={TAB_KEYS.STATS}>
<div className={styles.root} data-testid="scrim-stats-root">
{ownList?.source === "POOL" && ownList.serializedPool ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={restrictToPool}
onChange={(e) => setRestrictToPool(e.target.checked)}
/>
{t("scrims:mapByMap.stats.restrictToPool")}
</label>
) : null}
<section data-testid="stats-section-byMode">
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byMode")}
</h3>
<StatsTable
rows={stats.byMode.map((r) => ({
key: r.key,
label: t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, {
defaultValue: r.key,
}),
wins: r.wins,
losses: r.losses,
}))}
/>
</section>
<section>
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byStage")}
</h3>
<StatsTable
rows={stats.byStage.map((r) => ({
key: r.key,
label: t(`game-misc:STAGE_${Number(r.key)}` as const, {
defaultValue: r.key,
}),
wins: r.wins,
losses: r.losses,
}))}
/>
</section>
<section>
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byStageMode")}
</h3>
<StatsTable
rows={stats.byStageMode.map((r) => {
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,
};
})}
/>
</section>
</div>
</SendouTabPanel>
);
}
function StatsTable({
rows,
}: {
rows: Array<{ key: string; label: string; wins: number; losses: number }>;
}) {
const { t } = useTranslation(["scrims"]);
if (rows.length === 0) {
return (
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
);
}
return (
<table className={styles.table}>
<thead>
<tr>
<th>{t("scrims:mapByMap.stats.col.label")}</th>
<th className={styles.cellRight}>
{t("scrims:mapByMap.stats.col.wins")}
</th>
<th className={styles.cellRight}>
{t("scrims:mapByMap.stats.col.losses")}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td>{row.label}</td>
<td className={styles.cellRight}>{row.wins}</td>
<td className={styles.cellRight}>{row.losses}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@ -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 (
<MatchTabs tabs={[TAB_KEYS.ROSTERS, TAB_KEYS.JOIN]}>
<MatchTabs tabs={tabs}>
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
@ -60,10 +65,34 @@ export function ScrimMatchTabs() {
},
]}
/>
<ScrimMatchActionTab />
<ScrimMatchResultTab />
<ScrimMatchStatsTab />
</MatchTabs>
);
}
function resolveTabs(data: ReturnType<typeof useLoaderData<typeof loader>>) {
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 {

View File

@ -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);
});
});

View File

@ -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<Tables["ScrimMap"], "reportedAt">[] = [],
now: number = Date.now(),
): boolean {
if (post.trackingLockedAt !== null) return true;
if (post.trackingEnabledAt === null) return false;
const lastReportedAt = maps.reduce<number | null>((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<Tables["ScrimMap"], "index">[],
): 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<Tables["ScrimMap"], "index" | "reportedAt">,
>(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;
}

View File

@ -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<MapRow> & { 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<number, DbMapPoolList>();
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 }]);
});
});

View File

@ -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<number, DbMapPoolList> = 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<Tables["ScrimMap"], "mode" | "stageId">[];
}): { mode: ModeShort; stageId: StageId } {
if (args.pool.isEmpty()) {
throw new Error("Cannot generate map from empty pool");
}
const initialWeights = new Map<string, number>();
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<ModeShort, StatsRow>();
const byStage = new Map<StageId, StatsRow>();
const byStageMode = new Map<string, StatsRow>();
const stageModeKey = (mode: ModeShort, stageId: StageId) =>
`${stageId}-${mode}`;
const bump = <K>(
bucket: Map<K, StatsRow>,
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()]),
};
}

View File

@ -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<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
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<ReturnType<typeof resolveTournamentMapPool>>
>();
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,
};
}

View File

@ -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<typeof loader>();
// xxx: maybe not needed since we have the chat?
useWebsocketRevalidation(
data.post.chatCode ?? "",
Boolean(data.post.chatCode),
);
return (
<Main>
<MatchPage>

View File

@ -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;

View File

@ -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 = [

View File

@ -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 {

Binary file not shown.

View File

@ -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");
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -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.",

View File

@ -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"
}

View File

@ -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.",

View File

@ -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": ""
}

View File

@ -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.",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -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.",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -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.",

View File

@ -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": ""
}

View File

@ -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%",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -227,6 +227,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "",
"tiers.currentCriteria": "",
"tiers.info.p1": "",

View File

@ -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": ""
}

View File

@ -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.",

View File

@ -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": ""
}

View File

@ -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 процентиль и т.д.",

View File

@ -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": ""
}

View File

@ -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%的玩家。",

View File

@ -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": ""
}

View File

@ -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");
})();
}