mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-23 20:07:35 -05:00
Progress
This commit is contained in:
parent
fe9fc8c9d8
commit
eb8d98dbb7
|
|
@ -23,7 +23,7 @@ interface MatchBannerProps {
|
|||
screenLegal?: boolean;
|
||||
joinPool?: string | null;
|
||||
joinViaQr?: boolean;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MatchBanner({
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
55
app/features/scrims/ScrimMapListRepository.server.ts
Normal file
55
app/features/scrims/ScrimMapListRepository.server.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user