This commit is contained in:
Kalle 2026-05-22 21:31:15 +03:00
parent fe9fc8c9d8
commit eb8d98dbb7
42 changed files with 824 additions and 389 deletions

View File

@ -23,7 +23,7 @@ interface MatchBannerProps {
screenLegal?: boolean;
joinPool?: string | null;
joinViaQr?: boolean;
children: React.ReactNode;
children?: React.ReactNode;
}
export function MatchBanner({

View File

@ -90,6 +90,8 @@ import {
} from "./constants";
import placements from "./placements.json";
// xxx: lets clear room metadata in skalop and make sure every chatCode setting route (scrim) sets metadata
const SENDOUQ_DEFAULT_MAPS: Record<
ModeShort,
[StageId, StageId, StageId, StageId, StageId, StageId, StageId]
@ -3188,7 +3190,34 @@ async function scrimPosts() {
return result;
};
for (let i = 0; i < 20; i++) {
// Deterministic post 1: admin (Sendou) vs N-ZAP. The e2e map-by-map test
// navigates straight to /scrims/1 and relies on this being an accepted
// scrim with admin on the ALPHA side and N-ZAP on the BRAVO side.
const adminVsNzapAt = date(true);
const adminVsNzapPostId = await ScrimPostRepository.insert({
at: adminVsNzapAt,
rangeEnd: null,
isScheduledForFuture: true,
teamId: null,
text: null,
visibility: null,
users: users()
.map((u) => ({ ...u, isOwner: 0 }))
.concat({ userId: ADMIN_ID, isOwner: 1 }),
managedByAnyone: true,
maps: null,
mapsTournamentId: null,
});
await ScrimPostRepository.insertRequest({
scrimPostId: adminVsNzapPostId,
users: users()
.map((u) => ({ ...u, isOwner: 0 }))
.concat({ userId: NZAP_TEST_ID, isOwner: 1 }),
message: null,
});
await ScrimPostRepository.acceptRequest(1);
for (let i = 0; i < 19; i++) {
const divs = divRange();
const atTime = date();
const hasRangeEnd = Math.random() > 0.5;
@ -3258,7 +3287,9 @@ async function scrimPostRequests() {
.where("TeamMember.teamId", "=", 1)
.execute();
for (const id of [1, 5, 12, 14, 19]) {
// Post 1 is already accepted (admin-vs-nzap, seeded in scrimPosts()), so it
// is excluded here.
for (const id of [5, 12, 14, 19]) {
await ScrimPostRepository.insertRequest({
scrimPostId: id,
users: allianceRogueMembers.map((member) => ({
@ -3272,8 +3303,6 @@ async function scrimPostRequests() {
: null,
});
}
await ScrimPostRepository.acceptRequest(3);
}
async function associations() {

View File

@ -1278,8 +1278,6 @@ export interface ScrimMap {
winnerSide: "ALPHA" | "BRAVO" | null;
reportedAt: number | null;
reportedByUserId: number | null;
// xxx: replayOfIndex can be inferred?
replayOfIndex: number | null;
}
export interface ScrimPostUser {

View File

@ -599,3 +599,129 @@ describe("MapList.generate() with initialWeights", () => {
expect(maps[0].stageId).toBe(1);
});
});
describe("MapList.resume()", () => {
const POOL = new MapPool({
TW: [],
SZ: [1, 2, 3],
TC: [4, 5, 6],
RM: [7, 8, 9],
CB: [10, 11, 12],
});
function nextMap(
history: Array<{ mode: "SZ" | "TC" | "RM" | "CB"; stageId: StageId }>,
) {
const gen = MapList.resume({ mapPool: POOL, history });
gen.next();
const result = gen.next({ amount: 1 }).value;
return result![0];
}
it("starts with the pool's first mode when history is empty", () => {
for (let i = 0; i < 20; i++) {
expect(nextMap([]).mode).toBe("SZ");
}
});
it("rotates through modes in pool order across history length", () => {
expect(nextMap([{ mode: "SZ", stageId: 1 }]).mode).toBe("TC");
expect(
nextMap([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
]).mode,
).toBe("RM");
expect(
nextMap([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
]).mode,
).toBe("CB");
});
it("wraps the mode order back to the start after a full rotation", () => {
const history = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
{ mode: "CB", stageId: 10 },
] as const;
expect(nextMap([...history]).mode).toBe("SZ");
});
it("avoids already-played (mode, stage) pairs", () => {
const history = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
{ mode: "CB", stageId: 10 },
] as const;
for (let i = 0; i < 30; i++) {
const next = nextMap([...history]);
expect(next.mode).toBe("SZ");
expect(next.stageId).not.toBe(1);
}
});
it("rotates only through modes present in the pool", () => {
const threeModePool = new MapPool({
TW: [],
SZ: [1, 2, 3],
TC: [4, 5, 6],
RM: [7, 8, 9],
CB: [],
});
const pickMode = (
history: Array<{ mode: "SZ" | "TC" | "RM"; stageId: StageId }>,
) => {
const gen = MapList.resume({ mapPool: threeModePool, history });
gen.next();
return gen.next({ amount: 1 }).value![0].mode;
};
expect(pickMode([])).toBe("SZ");
expect(pickMode([{ mode: "SZ", stageId: 1 }])).toBe("TC");
expect(
pickMode([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
]),
).toBe("RM");
expect(
pickMode([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
]),
).toBe("SZ");
});
it("exclusion is keyed on (mode, stage), not stage alone", () => {
const sharedPool = new MapPool({
TW: [],
SZ: [1, 2],
TC: [1, 2],
RM: [],
CB: [],
});
const seenForTC = new Set<StageId>();
for (let i = 0; i < 50; i++) {
const gen = MapList.resume({
mapPool: sharedPool,
history: [{ mode: "SZ", stageId: 1 }],
});
gen.next();
const next = gen.next({ amount: 1 }).value![0];
expect(next.mode).toBe("TC");
seenForTC.add(next.stageId);
}
expect(seenForTC.has(1)).toBe(true);
expect(seenForTC.has(2)).toBe(true);
});
});

View File

@ -52,6 +52,8 @@ export function* generate(args: {
initialWeights?: Map<string, number>;
/** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */
skipEnsureMinimumCandidates?: boolean;
/** Fixed mode order — when set, skips the random `modeOrders` shuffle and uses only this order. Intended for `resume`. */
modeOrder?: ModeShort[];
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
if (args.mapPool.isEmpty()) {
while (true) yield [];
@ -64,7 +66,7 @@ export function* generate(args: {
args.mapPool.parsed,
args.initialWeights,
);
const orderedModes = modeOrders(modes);
const orderedModes = args.modeOrder ? [args.modeOrder] : modeOrders(modes);
let currentOrderIndex = 0;
const firstArgs = yield [];
@ -135,6 +137,44 @@ export function* generate(args: {
}
}
/**
* Returns a generator primed to continue map selection after the given history.
*
* Keeps the pool's mode order stable (rotated so the next-to-play mode is first)
* and biases against already-played `(mode, stage)` pairs so they are not picked
* again unless every option in that mode has already been played.
*
* @example
* const generator = resume({ mapPool, history });
* generator.next();
* const { mode, stageId } = generator.next({ amount: 1 }).value![0];
*/
export function resume(args: {
mapPool: MapPool;
history: Array<{ mode: ModeShort; stageId: StageId }>;
}) {
const modes = args.mapPool.modes;
const lastMode = args.history.at(-1)?.mode;
const lastIdx = lastMode ? modes.indexOf(lastMode) : -1;
const offset = modes.length > 0 ? (lastIdx + 1) % modes.length : 0;
const modeOrder = [...modes.slice(offset), ...modes.slice(0, offset)];
const initialWeights = new Map<string, number>();
for (const pair of args.mapPool.stageModePairs) {
initialWeights.set(modeStageKey(pair.mode, pair.stageId), 0);
}
for (const { mode, stageId } of args.history) {
initialWeights.set(modeStageKey(mode, stageId), -1000);
}
return generate({
mapPool: args.mapPool,
modeOrder,
initialWeights: initialWeights.size > 0 ? initialWeights : undefined,
skipEnsureMinimumCandidates: true,
});
}
function initializeWeights(
modes: ModeShort[],
mapPool: ReadonlyMapPoolObject,

View File

@ -0,0 +1,55 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import type { ScrimSide } from "./scrims-types";
type UpsertMapListArgs = Omit<TablesInsertable["ScrimMapList"], "updatedAt">;
/**
* 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) {
return db
.selectFrom("ScrimMapList")
.select(["side", "source", "tournamentId", "serializedPool", "updatedAt"])
.where("scrimPostId", "=", scrimPostId)
.execute();
}

View File

@ -1,77 +1,13 @@
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { TablesInsertable } from "~/db/tables";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { databaseTimestampNow } from "~/utils/dates";
import type { ScrimSide } from "./scrims-types";
// xxx: rename to ScrimMapRepository?
// xxx: TablesInsertable
interface UpsertMapListArgs {
scrimPostId: number;
side: ScrimSide;
source: "TOURNAMENT" | "POOL";
tournamentId?: number | null;
serializedPool?: string | null;
}
/**
* Inserts a map list row for the given side, replacing any existing row for
* the same `(scrimPostId, side)` pair.
*/
export async function upsertMapList(args: UpsertMapListArgs): Promise<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;
}
/**
@ -86,7 +22,6 @@ export async function insertMap(args: InsertMapArgs): Promise<number> {
index: args.index,
mode: args.mode,
stageId: args.stageId,
replayOfIndex: args.replayOfIndex ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@ -94,11 +29,12 @@ export async function insertMap(args: InsertMapArgs): Promise<number> {
return inserted.id;
}
// xxx: TablesInsertable
interface ReportMapWinnerArgs {
mapId: number;
winnerSide: ScrimSide;
reportedByUserId: number;
winnerSide: NonNullable<TablesInsertable["ScrimMap"]["winnerSide"]>;
reportedByUserId: NonNullable<
TablesInsertable["ScrimMap"]["reportedByUserId"]
>;
}
/** Marks an existing map as reported with the given winner side. */
@ -157,13 +93,11 @@ interface ReplaceCurrentMapAsReplayArgs {
scrimPostId: number;
mode: ModeShort;
stageId: StageId;
replayOfIndex: number;
}
/**
* Replaces the currently unreported map for the scrim with a replay of the
* given source map (same mode/stage, marked as `replayOfIndex`). The current
* map's index is preserved.
* given source map (same mode/stage). The current map's index is preserved.
*/
export async function replaceCurrentMapAsReplay(
args: ReplaceCurrentMapAsReplayArgs,
@ -173,7 +107,6 @@ export async function replaceCurrentMapAsReplay(
.set({
mode: args.mode,
stageId: args.stageId,
replayOfIndex: args.replayOfIndex,
})
.where("scrimPostId", "=", args.scrimPostId)
.where("reportedAt", "is", null)
@ -181,12 +114,10 @@ export async function replaceCurrentMapAsReplay(
}
/** Returns the scrim's maps ordered by index ascending. */
export function findMapsByScrimPostId(
scrimPostId: number,
): Promise<Tables["ScrimMap"][]> {
export function findMapsByScrimPostId(scrimPostId: number) {
return db
.selectFrom("ScrimMap")
.selectAll()
.select(["id", "index", "mode", "stageId", "winnerSide", "reportedAt"])
.where("scrimPostId", "=", scrimPostId)
.orderBy("index", "asc")
.execute();

View File

@ -307,6 +307,11 @@ const mapDBRowToScrimPost = (
MANAGE_REQUESTS: managerIds,
DELETE_POST: managerIds,
CANCEL: managerIds.concat(requests.at(0)?.users.map((u) => u.id) ?? []),
MANAGE_TRACKING: someRequestIsAccepted
? users
.map((u) => u.id)
.concat(requests[0]?.users.map((u) => u.id) ?? [])
: [],
},
managedByAnyone: Boolean(row.managedByAnyone),
canceled,

View File

@ -10,116 +10,77 @@ import {
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import { databaseTimestampToDate } from "../../../utils/dates";
import { requireUser } from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import * as ScrimMapByMapRepository from "../ScrimMapByMapRepository.server";
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
import * as ScrimMapRepository from "../ScrimMapRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import {
cancelScrimSchema,
scrimMapByMapActionSchema,
} from "../scrims-schemas";
import { scrimIdActionSchema } from "../scrims-schemas";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { id } = parseParams({ params, schema: idObject });
const post = notFoundIfFalsy(await ScrimPostRepository.findById(id));
const user = requireUser();
const formData = await request.clone().formData();
// xxx: this is weird, convert to switch...case at action function elvel following similar pattern to elsewhere on the codebase
if (formData.get("_action")) {
return handleMapByMapAction({ post, user, request });
}
const data = await parseRequestPayload({
request,
schema: cancelScrimSchema,
schema: scrimIdActionSchema,
});
requirePermission(post, "CANCEL");
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
await ScrimPostRepository.cancelScrim(id, {
userId: user.id,
reason: data.reason,
});
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
notify({
userIds: acceptedRequest.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
}
return null;
};
async function handleMapByMapAction({
post,
user,
request,
}: {
post: NonNullable<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,
});
};
const [maps, mapLists] = await Promise.all([
ScrimMapByMapRepository.findMapsByScrimPostId(post.id),
ScrimMapByMapRepository.findMapListsByScrimPostId(post.id),
]);
if (Scrim.isTrackingLocked(maps, mapLists)) {
errorToast("Tracking is locked");
}
requirePermission(post, "MANAGE_TRACKING");
switch (data._action) {
case "CANCEL_SCRIM": {
requirePermission(post, "CANCEL");
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
await ScrimPostRepository.cancelScrim(id, {
userId: user.id,
reason: data.reason,
});
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
notify({
userIds: acceptedRequest.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
}
break;
}
case "SUBMIT_MAP_LIST": {
await ScrimMapByMapRepository.upsertMapList({
const { viewerSide, maps } = await loadMapByMapContext({ post, user });
await ScrimMapListRepository.upsertMapList({
scrimPostId: post.id,
side: viewerSide!,
side: viewerSide,
source: data.source,
tournamentId: data.tournamentId ?? null,
serializedPool: data.serializedPool ?? null,
@ -128,18 +89,26 @@ async function handleMapByMapAction({
if (maps.length === 0) {
await tryGenerateAndInsertNextMap({ post, user, maps });
}
broadcastRevalidate({ post, user });
break;
}
case "REMOVE_MAP_LIST": {
await ScrimMapByMapRepository.deleteMapList(post.id, viewerSide!);
const { viewerSide } = await loadMapByMapContext({ post, user });
await ScrimMapListRepository.deleteMapList(post.id, viewerSide);
broadcastRevalidate({ post, user });
break;
}
case "REPORT_MAP": {
const { maps } = await loadMapByMapContext({ post, user });
const target = maps.find((m) => m.id === data.mapId);
errorToastIfFalsy(target, "Map not found");
errorToastIfFalsy(target!.reportedAt === null, "Map already reported");
await ScrimMapByMapRepository.reportMapWinner({
await ScrimMapRepository.reportMapWinner({
mapId: data.mapId,
winnerSide: data.winnerSide,
reportedByUserId: user.id,
@ -151,34 +120,81 @@ async function handleMapByMapAction({
: m,
);
await tryGenerateAndInsertNextMap({ post, user, maps: reportedMaps });
broadcastRevalidate({ post, user });
break;
}
case "UNDO_MAP": {
const { maps } = await loadMapByMapContext({ post, user });
const latest = Scrim.lastReportedMap(maps);
errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo");
await ScrimMapByMapRepository.undoMostRecentMap(post.id);
await ScrimMapRepository.undoMostRecentMap(post.id);
broadcastRevalidate({ post, user });
break;
}
case "REPLAY_MAP": {
const { maps } = await loadMapByMapContext({ post, user });
const latest = Scrim.lastReportedMap(maps);
errorToastIfFalsy(latest, "No map to replay");
const currentMap = maps.find((m) => m.reportedAt === null);
errorToastIfFalsy(currentMap, "No current map to replace");
await ScrimMapByMapRepository.replaceCurrentMapAsReplay({
await ScrimMapRepository.replaceCurrentMapAsReplay({
scrimPostId: post.id,
mode: latest!.mode,
stageId: latest!.stageId,
replayOfIndex: latest!.index,
});
broadcastRevalidate({ post, user });
break;
}
default: {
assertUnreachable(data);
}
}
broadcastRevalidate();
return null;
};
async function loadMapByMapContext({
post,
user,
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: ReturnType<typeof requireUser>;
}) {
const viewerSide = Scrim.sideOfUser(post, user.id);
const [maps, mapLists] = await Promise.all([
ScrimMapRepository.findMapsByScrimPostId(post.id),
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
]);
if (Scrim.isTrackingLocked(maps, mapLists)) {
errorToast("Tracking is locked");
}
return { viewerSide: viewerSide!, maps, mapLists };
}
function broadcastRevalidate({
post,
user,
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: ReturnType<typeof requireUser>;
}) {
if (!post.chatCode) return;
ChatSystemMessage.send({
room: post.chatCode,
revalidateOnly: true,
authorUserId: user.id,
});
}
async function resolveTournamentPool(
@ -189,7 +205,7 @@ async function resolveTournamentPool(
return data.ctx.toSetMapPool;
}
// xxx: should this be in inside repository?
// xxx: should this be in inside repository? and inside trx
async function tryGenerateAndInsertNextMap({
post,
user,
@ -197,11 +213,9 @@ async function tryGenerateAndInsertNextMap({
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: ReturnType<typeof requireUser>;
maps: Awaited<
ReturnType<typeof ScrimMapByMapRepository.findMapsByScrimPostId>
>;
maps: Awaited<ReturnType<typeof ScrimMapRepository.findMapsByScrimPostId>>;
}) {
const mapLists = await ScrimMapByMapRepository.findMapListsByScrimPostId(
const mapLists = await ScrimMapListRepository.findMapListsByScrimPostId(
post.id,
);
if (mapLists.length === 0) return;
@ -227,7 +241,7 @@ async function tryGenerateAndInsertNextMap({
history: maps.map((m) => ({ mode: m.mode, stageId: m.stageId })),
});
await ScrimMapByMapRepository.insertMap({
await ScrimMapRepository.insertMap({
scrimPostId: post.id,
index: Scrim.nextMapIndex(maps),
mode: next.mode,

View File

@ -1,11 +1,10 @@
// xxx: use * as React syntax
import { useState } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { ScrimSide } from "../scrims-types";
import { parseMapPoolInput } from "../scrims-utils";
import styles from "./ScrimMapListForm.module.css";
interface Props {
@ -15,10 +14,10 @@ interface Props {
export function ScrimMapListForm(_: Props) {
const { t } = useTranslation(["scrims", "common"]);
const fetcher = useFetcher();
const [source, setSource] = useState<"TOURNAMENT" | "POOL">("POOL");
const [tournamentId, setTournamentId] = useState<number | null>(null);
const [poolInput, setPoolInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [source, setSource] = React.useState<"TOURNAMENT" | "POOL">("POOL");
const [tournamentId, setTournamentId] = React.useState<number | null>(null);
const [poolInput, setPoolInput] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const onSubmit = () => {
setError(null);
@ -39,19 +38,8 @@ export function ScrimMapListForm(_: Props) {
return;
}
const serialized = extractSerializedPool(poolInput);
if (!serialized) {
setError(t("scrims:mapByMap.form.poolInvalid"));
return;
}
try {
const pool = new MapPool(serialized);
if (pool.isEmpty()) {
setError(t("scrims:mapByMap.form.poolInvalid"));
return;
}
} catch {
const pool = parseMapPoolInput(poolInput);
if (!pool) {
setError(t("scrims:mapByMap.form.poolInvalid"));
return;
}
@ -60,7 +48,7 @@ export function ScrimMapListForm(_: Props) {
{
_action: "SUBMIT_MAP_LIST",
source: "POOL",
serializedPool: serialized,
serializedPool: pool.serialized,
},
{ method: "post" },
);
@ -125,26 +113,3 @@ export function ScrimMapListForm(_: Props) {
</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

@ -5,6 +5,7 @@ import { Link, useLoaderData } from "react-router";
import { Image } from "~/components/Image";
import {
IconBanner,
MatchBanner,
MatchBannerContainer,
} from "~/components/match-page/MatchBanner";
import { useUser } from "~/features/auth/core/user";
@ -54,6 +55,23 @@ export function ScrimMatchBanner() {
members: [...data.post.users, ...acceptedRequest.users],
});
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
const joinPool = Scrim.resolvePoolCode(data.post.id);
const currentMap = data.mapByMap.currentMap;
if (currentMap) {
return (
<MatchBannerContainer>
<MatchBanner
stageId={currentMap.stageId}
mode={currentMap.mode}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
/>
</MatchBannerContainer>
);
}
return (
<MatchBannerContainer>
@ -62,7 +80,7 @@ export function ScrimMatchBanner() {
header={t("scrims:banner.freeForm.header")}
subtitle={t("scrims:banner.freeForm.subtitle")}
screenLegal={screenLegal}
joinPool={Scrim.resolvePoolCode(data.post.id)}
joinPool={joinPool}
joinViaQr={joinViaQr}
topRight={
hasMaps ? (

View File

@ -8,7 +8,7 @@ import { SendouForm } from "~/form/SendouForm";
import { useHasPermission } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader } from "../loaders/scrims.$id.server";
import { cancelScrimSchema } from "../scrims-schemas";
import { cancelScrimFormSchema } from "../scrims-schemas";
export function ScrimMatchHeader() {
const { t } = useTranslation(["common", "scrims"]);
@ -61,7 +61,7 @@ export function ScrimMatchHeader() {
function CancelScrimForm() {
return (
<SendouForm
schema={cancelScrimSchema}
schema={cancelScrimFormSchema}
submitButtonTestId="cancel-scrim-submit"
>
{({ FormField }) => <FormField name="reason" />}

View File

@ -4,15 +4,18 @@
gap: var(--s-4);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-3);
}
.toggleRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.sectionTitle {
font-weight: var(--bold);
margin-bottom: var(--s-2);
font-size: var(--font-sm);
}
.table {
@ -27,17 +30,37 @@
}
.table th {
color: var(--text-lighter);
font-weight: var(--bold);
font-size: var(--fonts-sm);
border-bottom: 1px solid var(--border);
color: var(--color-text-high);
font-weight: var(--weight-bold);
font-size: var(--font-sm);
border-bottom: var(--border-style);
}
.cellRight {
.table tbody tr {
border-bottom: var(--border-style);
&:last-child {
border-bottom: none;
}
}
.cellNum {
width: 4rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.empty {
color: var(--text-lighter);
color: var(--color-text-high);
font-style: italic;
}
.stageModeLabel {
display: inline-flex;
align-items: center;
gap: var(--s-2);
}
.stageImage {
border-radius: var(--radius-field);
}

View File

@ -1,22 +1,36 @@
// xxx: import * as React
import { useState } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouSwitch } from "~/components/elements/Switch";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { ModeImage, StageImage } from "~/components/Image";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import type { loader } from "../loaders/scrims.$id.server";
import styles from "./ScrimMatchStatsTab.module.css";
type View = "MODE" | "STAGE" | "BOTH";
const VIEW_OPTIONS: View[] = ["MODE", "STAGE", "BOTH"];
export function ScrimMatchStatsTab() {
const { t } = useTranslation(["scrims", "game-misc"]);
const data = useLoaderData<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);
const ownPool = data.mapByMap?.ownPool
? new MapPool(data.mapByMap.ownPool)
: null;
const [view, setView] = React.useState<View>("BOTH");
const [restrictToPool, setRestrictToPool] = React.useState(Boolean(ownPool));
if (!viewerSide || maps.length === 0) {
return (
@ -26,10 +40,7 @@ export function ScrimMatchStatsTab() {
);
}
const restrictPool =
restrictToPool && ownList?.serializedPool
? new MapPool(ownList.serializedPool)
: undefined;
const restrictPool = restrictToPool && ownPool ? ownPool : undefined;
const stats = ScrimMapByMap.stats(maps, viewerSide, {
restrictToPool: restrictPool,
@ -38,53 +49,75 @@ export function ScrimMatchStatsTab() {
return (
<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}
<div className={styles.controls}>
<SendouChipRadioGroup>
{VIEW_OPTIONS.map((option) => (
<SendouChipRadio
key={option}
name="scrim-stats-view"
value={option}
checked={view === option}
onChange={(value) => setView(value as View)}
>
{t(`scrims:mapByMap.stats.view.${option}` as const)}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
{ownPool ? (
<label className={styles.toggleRow}>
<SendouSwitch
isSelected={restrictToPool}
onChange={setRestrictToPool}
/>
{t("scrims:mapByMap.stats.restrictToPool")}
</label>
) : null}
</div>
<section data-testid="stats-section-byMode">
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byMode")}
</h3>
{view === "MODE" ? (
<StatsTable
rows={stats.byMode.map((r) => ({
key: r.key,
label: t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, {
defaultValue: r.key,
}),
label: (
<span className={styles.stageModeLabel}>
<ModeImage mode={r.key as ModeShort} size={20} />
{t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, {
defaultValue: r.key,
})}
</span>
),
wins: r.wins,
losses: r.losses,
}))}
/>
</section>
) : null}
<section>
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byStage")}
</h3>
{view === "STAGE" ? (
<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,
}))}
rows={stats.byStage.map((r) => {
const stageId = Number(r.key);
return {
key: r.key,
label: (
<span className={styles.stageModeLabel}>
<StageImage
stageId={stageId as StageId}
width={36}
className={styles.stageImage}
/>
{t(`game-misc:STAGE_${stageId}` as const, {
defaultValue: r.key,
})}
</span>
),
wins: r.wins,
losses: r.losses,
};
})}
/>
</section>
) : null}
<section>
<h3 className={styles.sectionTitle}>
{t("scrims:mapByMap.stats.byStageMode")}
</h3>
{view === "BOTH" ? (
<StatsTable
rows={stats.byStageMode.map((r) => {
const [stageId, mode] = r.key.split("-");
@ -92,19 +125,25 @@ export function ScrimMatchStatsTab() {
`game-misc:STAGE_${Number(stageId)}` as const,
{ defaultValue: stageId },
);
const modeLabel = t(
`game-misc:MODE_LONG_${mode as "SZ"}` as const,
{ defaultValue: mode },
);
return {
key: r.key,
label: `${stageLabel}${modeLabel}`,
label: (
<span className={styles.stageModeLabel}>
<ModeImage mode={mode as ModeShort} size={20} />
<StageImage
stageId={Number(stageId) as StageId}
width={36}
className={styles.stageImage}
/>
{stageLabel}
</span>
),
wins: r.wins,
losses: r.losses,
};
})}
/>
</section>
) : null}
</div>
</SendouTabPanel>
);
@ -113,7 +152,12 @@ export function ScrimMatchStatsTab() {
function StatsTable({
rows,
}: {
rows: Array<{ key: string; label: string; wins: number; losses: number }>;
rows: Array<{
key: string;
label: React.ReactNode;
wins: number;
losses: number;
}>;
}) {
const { t } = useTranslation(["scrims"]);
@ -123,25 +167,36 @@ function StatsTable({
);
}
const sortedRows = [...rows]
.map((row) => ({ ...row, winRate: row.wins / (row.wins + row.losses) }))
.sort((a, b) => {
if (b.winRate !== a.winRate) return b.winRate - a.winRate;
return b.wins + b.losses - (a.wins + a.losses);
});
return (
<table className={styles.table}>
<thead>
<tr>
<th>{t("scrims:mapByMap.stats.col.label")}</th>
<th className={styles.cellRight}>
<th className={styles.cellNum}>
{t("scrims:mapByMap.stats.col.wins")}
</th>
<th className={styles.cellRight}>
<th className={styles.cellNum}>
{t("scrims:mapByMap.stats.col.losses")}
</th>
<th className={styles.cellNum}>
{t("scrims:mapByMap.stats.col.winPct")}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
{sortedRows.map((row) => (
<tr key={row.key}>
<td>{row.label}</td>
<td className={styles.cellRight}>{row.wins}</td>
<td className={styles.cellRight}>{row.losses}</td>
<td className={styles.cellNum}>{row.wins}</td>
<td className={styles.cellNum}>{row.losses}</td>
<td className={styles.cellNum}>{Math.round(row.winRate * 100)}%</td>
</tr>
))}
</tbody>

View File

@ -128,7 +128,12 @@ describe("applyFilters", () => {
isScheduledForFuture: false,
managedByAnyone: false,
mapsTournament: null,
permissions: { MANAGE_REQUESTS: [], CANCEL: [], DELETE_POST: [] },
permissions: {
MANAGE_REQUESTS: [],
CANCEL: [],
DELETE_POST: [],
MANAGE_TRACKING: [],
},
team: null,
};
}

View File

@ -107,6 +107,26 @@ describe("ScrimMapByMap.generateNextMap", () => {
}
});
it("advances from the last played mode when a mode was replayed", () => {
const pool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART],
TC: [stagesObj.HAMMERHEAD_BRIDGE],
RM: [stagesObj.WAHOO_WORLD],
CB: [stagesObj.EELTAIL_ALLEY],
TW: [],
});
const next = generateNextMap({
pool,
history: [
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE },
{ mode: "SZ", stageId: stagesObj.MAKOMART },
],
});
expect(next.mode).toBe("TC");
});
it("can still generate when only one stage is available", () => {
const pool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE],

View File

@ -20,6 +20,25 @@ type ScrimMapRow = Pick<
"index" | "mode" | "stageId" | "winnerSide" | "reportedAt"
>;
/**
* Resolves a single map list row to a concrete MapPool. Returns null when the
* row references a tournament that wasn't pre-resolved, or a POOL row with no
* serialized pool.
*/
// xxx: data model should be simpler
export function resolveList(
list: ScrimMapListRow,
tournamentPools: Map<number, DbMapPoolList> = new Map(),
): MapPool | null {
if (list.source === "TOURNAMENT") {
if (!list.tournamentId) return null;
const tournamentList = tournamentPools.get(list.tournamentId);
return tournamentList ? new MapPool(tournamentList) : null;
}
if (!list.serializedPool) return null;
return new MapPool(list.serializedPool);
}
/**
* Merges the submitted map lists into a single deduplicated MapPool. Tournament
* pools are resolved by the caller and passed in via `tournamentPools` keyed by
@ -63,9 +82,8 @@ export function unionPool(
}
/**
* Generates the next single map for the scrim by replaying the past map history
* through the underlying generator so the resulting map respects its
* anti-repeat and mode-ordering behavior.
* Generates the next single map for the scrim, keeping the pool's mode order
* stable across calls and avoiding already-played `(mode, stage)` pairs.
*/
export function generateNextMap(args: {
pool: MapPool;
@ -75,23 +93,9 @@ export function generateNextMap(args: {
throw new Error("Cannot generate map from empty pool");
}
const initialWeights = new Map<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({
const generator = MapList.resume({
mapPool: args.pool,
initialWeights: initialWeights.size > 0 ? initialWeights : undefined,
skipEnsureMinimumCandidates: true,
history: args.history,
});
generator.next();

View File

@ -11,7 +11,8 @@ import {
} from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import * as ScrimMapByMapRepository from "../ScrimMapByMapRepository.server";
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
import * as ScrimMapRepository from "../ScrimMapRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
@ -77,10 +78,9 @@ async function resolveMapByMap({
post: NonNullable<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),
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
ScrimMapRepository.findMapsByScrimPostId(post.id),
]);
const tournamentPools = new Map<
@ -102,6 +102,13 @@ async function resolveMapByMap({
const viewerSide = Scrim.sideOfUser(post, user.id);
const locked = Scrim.isTrackingLocked(maps, mapLists);
const ownList = viewerSide
? mapLists.find((l) => l.side === viewerSide)
: undefined;
const ownPool = ownList
? ScrimMapByMap.resolveList(ownList, tournamentPools)
: null;
return {
mapLists,
maps,
@ -109,5 +116,6 @@ async function resolveMapByMap({
viewerSide,
locked,
pool: pool ? pool.serialized : null,
ownPool: ownPool ? ownPool.serialized : null,
};
}

View File

@ -1,7 +1,5 @@
import { useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { MatchPage } from "~/components/match-page/MatchPage";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, scrimsPage } from "../../../utils/urls";
import { action } from "../actions/scrims.$id.server";
@ -22,15 +20,8 @@ export const handle: SendouRouteHandle = {
};
// xxx: Top right maps link icon maybe out of place now?
// xxx: action tab weird, can only report score once submitted map. instead make map in the "optional" section as we have weapon report in?
export default function ScrimPage() {
const data = useLoaderData<typeof loader>();
// xxx: maybe not needed since we have the chat?
useWebsocketRevalidation(
data.post.chatCode ?? "",
Boolean(data.post.chatCode),
);
return (
<Main>
<MatchPage>

View File

@ -71,7 +71,8 @@ const cancelRequestSchema = z.object({
scrimPostRequestId: id,
});
export const cancelScrimSchema = z.object({
export const cancelScrimFormSchema = z.object({
_action: stringConstant("CANCEL_SCRIM"),
reason: textAreaRequired({
label: "labels.scrimCancelReason",
bottomText: "bottomTexts.scrimCancelReasonHelp",
@ -209,7 +210,8 @@ const replayMapSchema = z.object({
_action: _action("REPLAY_MAP"),
});
export const scrimMapByMapActionSchema = z.union([
export const scrimIdActionSchema = z.union([
cancelScrimFormSchema,
submitMapListSchema,
removeMapListSchema,
reportMapSchema,

View File

@ -35,6 +35,7 @@ export interface ScrimPost {
MANAGE_REQUESTS: number[];
DELETE_POST: number[];
CANCEL: number[];
MANAGE_TRACKING: number[];
};
managedByAnyone: boolean;
/** When the post was made was it scheduled for a future time slot (as opposed to looking now) */

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatFlexTimeDisplay, generateTimeOptions } from "./scrims-utils";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import {
formatFlexTimeDisplay,
generateTimeOptions,
parseMapPoolInput,
} from "./scrims-utils";
describe("generateTimeOptions", () => {
it("includes both start and end times", () => {
@ -233,3 +238,74 @@ describe("formatFlexTimeDisplay", () => {
expect(result).toBe("+1h 1m");
});
});
describe("parseMapPoolInput", () => {
const VALID_POOL = "tw:3330000;sz:3a14000;tc:2c98000;rm:2bc0000;cb:39c0000";
it("returns null for empty string", () => {
expect(parseMapPoolInput("")).toBeNull();
});
it("returns null for whitespace-only string", () => {
expect(parseMapPoolInput(" \t\n ")).toBeNull();
});
it("returns null when the parsed pool is empty", () => {
expect(parseMapPoolInput("not-a-valid-pool")).toBeNull();
});
it("returns a MapPool for a bare serialized pool", () => {
const result = parseMapPoolInput(VALID_POOL);
expect(result).toBeInstanceOf(MapPool);
expect(result?.serialized).toBe(VALID_POOL);
});
it("trims whitespace around a bare serialized pool", () => {
const result = parseMapPoolInput(` ${VALID_POOL} `);
expect(result?.serialized).toBe(VALID_POOL);
});
it("extracts the pool param from a full URL", () => {
const result = parseMapPoolInput(
`https://sendou.ink/maps?pool=${VALID_POOL}`,
);
expect(result?.serialized).toBe(VALID_POOL);
});
it("returns null for a URL without a pool param", () => {
expect(parseMapPoolInput("https://sendou.ink/maps?other=1")).toBeNull();
});
it("ignores other URL params when extracting pool", () => {
const result = parseMapPoolInput(
`https://sendou.ink/maps?foo=bar&pool=${VALID_POOL}&baz=qux`,
);
expect(result?.serialized).toBe(VALID_POOL);
});
it("returns null for a malformed URL with ://", () => {
expect(parseMapPoolInput("not a url://")).toBeNull();
});
it("parses the pool value from a query-string fragment", () => {
expect(parseMapPoolInput(`pool=${VALID_POOL}`)?.serialized).toBe(
VALID_POOL,
);
});
it("stops at the next & in a query-string fragment", () => {
expect(parseMapPoolInput(`pool=${VALID_POOL}&other=1`)?.serialized).toBe(
VALID_POOL,
);
});
it("preserves leading params before pool= in a query-string fragment", () => {
expect(parseMapPoolInput(`foo=bar&pool=${VALID_POOL}`)?.serialized).toBe(
VALID_POOL,
);
});
});

View File

@ -1,5 +1,6 @@
import { differenceInMinutes } from "date-fns";
import * as R from "remeda";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { databaseTimestampToDate } from "~/utils/dates";
import * as Scrim from "./core/Scrim";
import type { LutiDiv, ScrimPost } from "./scrims-types";
@ -87,6 +88,19 @@ export function generateTimeOptions(startDate: Date, endDate: Date): number[] {
return Array.from(timestamps).sort((a, b) => a - b);
}
export function parseMapPoolInput(input: string): MapPool | null {
const serialized = extractSerializedPool(input);
if (!serialized) return null;
try {
const pool = new MapPool(serialized);
if (pool.isEmpty()) return null;
return pool;
} catch {
return null;
}
}
export function formatFlexTimeDisplay(
startTimestamp: number,
endTimestamp: number,
@ -110,3 +124,25 @@ export function formatFlexTimeDisplay(
return null;
}
function extractSerializedPool(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (trimmed.includes("://")) {
try {
const url = new URL(trimmed);
return url.searchParams.get("pool");
} catch {
return null;
}
}
if (trimmed.includes("pool=")) {
const start = trimmed.indexOf("pool=") + "pool=".length;
const next = trimmed.indexOf("&", start);
return trimmed.slice(start, next === -1 ? undefined : next);
}
return trimmed;
}

Binary file not shown.

View File

@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas";
import { newScrimPostPage, scrimsPage } from "~/utils/urls";
import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls";
import {
expect,
impersonate,
@ -247,43 +247,13 @@ test.describe("Scrims", () => {
test("map-by-map: lists, report, undo, replay, change list, stats", async ({
page,
}) => {
await seed(page, "NO_SCRIMS");
await seed(page);
// ADMIN creates a scrim scheduled in the future
await impersonate(page, ADMIN_ID);
// xxx: just navigate to /scrims/1 straight
await navigate({ page, url: newScrimPostPage() });
const form = createFormHelpers(page, scrimsNewFormSchema);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(18, 0, 0, 0);
await form.setDateTime("at", tomorrow);
await submit(page);
// NZAP requests the scrim
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: scrimsPage() });
await page.getByTestId("available-scrims-tab").click();
await page.getByTestId("request-scrim-button").first().click();
await selectUser({ labelName: "User 2", page, userName: "5" });
await selectUser({ labelName: "User 3", page, userName: "6" });
await selectUser({ labelName: "User 4", page, userName: "7" });
await submit(page);
// ADMIN accepts and opens the scrim page
await impersonate(page, ADMIN_ID);
await navigate({ page, url: scrimsPage() });
await page.getByTestId("confirm-modal-trigger-button").click();
await submit(page, "confirm-button");
await page.getByTestId("booked-scrims-tab").click();
await page.getByRole("link", { name: "Contact" }).click();
await page.waitForURL(/\/scrims\/\d+/);
const scrimUrl = page.url();
const scrimUrl = scrimPage(1);
// ADMIN opens the Action tab — the map list form is shown immediately
await impersonate(page, ADMIN_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "By mode",
"mapByMap.stats.byStage": "By stage",
"mapByMap.stats.byStageMode": "By stage & mode",
"mapByMap.stats.view.MODE": "Mode",
"mapByMap.stats.view.STAGE": "Stage",
"mapByMap.stats.view.BOTH": "Stage & Mode",
"mapByMap.stats.col.label": "Map",
"mapByMap.stats.col.wins": "Wins",
"mapByMap.stats.col.losses": "Losses"
"mapByMap.stats.col.losses": "Losses",
"mapByMap.stats.col.winPct": "Win %"
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -113,7 +113,11 @@
"mapByMap.stats.byMode": "",
"mapByMap.stats.byStage": "",
"mapByMap.stats.byStageMode": "",
"mapByMap.stats.view.MODE": "",
"mapByMap.stats.view.STAGE": "",
"mapByMap.stats.view.BOTH": "",
"mapByMap.stats.col.label": "",
"mapByMap.stats.col.wins": "",
"mapByMap.stats.col.losses": ""
"mapByMap.stats.col.losses": "",
"mapByMap.stats.col.winPct": ""
}

View File

@ -28,7 +28,6 @@ export function up(db) {
"winnerSide" text check ("winnerSide" in ('ALPHA','BRAVO')),
"reportedAt" integer,
"reportedByUserId" integer,
"replayOfIndex" integer,
foreign key ("scrimPostId") references "ScrimPost"("id") on delete cascade,
foreign key ("reportedByUserId") references "User"("id"),
unique("scrimPostId", "index") on conflict rollback