Scrim map by map mode (#3104)

This commit is contained in:
Kalle 2026-05-25 17:35:44 +03:00 committed by GitHub
parent 72a3d81640
commit a3d7cb2fcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 3623 additions and 348 deletions

View File

@ -5,7 +5,8 @@
}
.table {
width: 100%;
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-xs);

View File

@ -1,6 +1,7 @@
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin-block-end: var(--label-margin);
margin: 0;
display: block;
text-box: trim-start cap alphabetic;
}

View File

@ -146,13 +146,17 @@
.select {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin-block-end: var(--label-margin);
margin: 0;
display: block;
text-box: trim-start cap alphabetic;
}
.clearButton {

View File

@ -91,6 +91,7 @@ export const TournamentSearch = React.forwardRef(function TournamentSearch<
placeholder=""
selectedKey={selectedKey}
onSelectionChange={onSelectionChange as (key: Key | null) => void}
className={selectStyles.select}
aria-label="Tournament search"
{...rest}
>

View File

@ -1,5 +1,6 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
@ -11,7 +12,6 @@ import { SendouTabPanel } from "../elements/Tabs";
import { ModeImage } from "../Image";
import styles from "./MatchActionPickBanTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
export interface PickBanMapOption {
stageId?: StageId;
@ -30,7 +30,7 @@ interface MatchActionPickBanTabProps {
type: "PICK" | "BAN";
onSubmit?: (data: PickBanSubmission) => void;
isSubmitting?: boolean;
weaponReport?: WeaponReporterProps;
secondaryAction?: React.ReactNode;
waitingFor?: string;
}
@ -39,7 +39,7 @@ export function MatchActionPickBanTab({
type,
onSubmit,
isSubmitting,
weaponReport,
secondaryAction,
waitingFor,
}: MatchActionPickBanTabProps) {
const { t } = useTranslation(["q", "common", "game-misc"]);
@ -155,7 +155,7 @@ export function MatchActionPickBanTab({
</>
)}
</div>
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
{secondaryAction}
</SendouTabPanel>
);
}

View File

@ -19,7 +19,6 @@ import {
type MatchTimelineProps,
type TimelineMap,
} from "./MatchTimeline";
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
const LONG_TEAM_NAME_THRESHOLD = 16;
@ -30,6 +29,7 @@ interface ActionTabTeam {
}
interface SetEndingData extends MatchTimelineProps {
score: { alpha: number; bravo: number };
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
setEndingTeamIds: number[];
}
@ -44,7 +44,7 @@ interface MatchActionTabProps {
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
weaponReport?: WeaponReporterProps;
secondaryAction?: React.ReactNode;
}
export function MatchActionTab({
@ -57,7 +57,7 @@ export function MatchActionTab({
isSubmitting,
setEnding,
actionButtons,
weaponReport,
secondaryAction,
}: MatchActionTabProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const [winnerId, setWinnerId] = useState<number | null>(null);
@ -188,7 +188,7 @@ export function MatchActionTab({
</SendouButton>
</div>
)}
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
{secondaryAction}
</SendouTabPanel>
);
}

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

@ -0,0 +1,24 @@
import TimePopover from "~/components/TimePopover";
interface MatchBannerScheduledTimeProps {
time: Date;
}
export function MatchBannerScheduledTime({
time,
}: MatchBannerScheduledTimeProps) {
return (
<TimePopover
time={time}
options={{
weekday: "short",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="font-semi-bold"
/>
);
}

View File

@ -0,0 +1,22 @@
import { LocaleTime } from "~/components/LocaleTime";
interface MatchBannerStartedAtProps {
time: Date;
}
export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) {
return (
<LocaleTime
date={time}
options={{
month: "numeric",
year: "2-digit",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-lighter font-semi-bold"
inline
/>
);
}

View File

@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./MatchBannerTopRow.module.css";
const MAX_MINUTES = 60;
interface MatchBannerTimerProps {
time: {
currentMinutes: number;
totalMinutes: number;
};
}
export function MatchBannerTimer({ time }: MatchBannerTimerProps) {
const isHydrated = useHydrated();
const { i18n } = useTranslation();
if (!isHydrated) return null;
const minuteFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "minute",
unitDisplay: "short",
});
const hourFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "hour",
unitDisplay: "short",
});
const dateTime = (minutes: number) => `PT0H${minutes}M`;
const displayValue = (minutes: number) =>
minutes >= MAX_MINUTES
? `${hourFormatter.format(1)}+`
: minuteFormatter.format(minutes);
return (
<div className={styles.values} data-testid="match-timer">
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
{displayValue(time.currentMinutes)}
</time>
<time dateTime={dateTime(time.totalMinutes)}>
{displayValue(time.totalMinutes)}
</time>
</div>
);
}

View File

@ -1,31 +1,31 @@
import { useTranslation } from "react-i18next";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./MatchBannerTopRow.module.css";
interface MatchBannerTopRowProps {
score: {
score?: {
alpha: number;
bravo: number;
isFinal: boolean;
count: number;
bestOf: boolean;
};
time?: {
currentMinutes: number;
totalMinutes: number;
count?: number;
bestOf?: boolean;
};
children?: React.ReactNode;
}
export function MatchBannerTopRow({ score, time }: MatchBannerTopRowProps) {
export function MatchBannerTopRow({ score, children }: MatchBannerTopRowProps) {
return (
<div className={styles.root}>
<Score score={score} />
{time ? <Timer time={time} /> : null}
{score ? <Score score={score} /> : <div />}
{children}
</div>
);
}
function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
function Score({
score,
}: {
score: NonNullable<MatchBannerTopRowProps["score"]>;
}) {
const { t } = useTranslation(["q"]);
return (
@ -39,50 +39,12 @@ function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
>
{score.isFinal
? t("q:match.banner.final")
: score.bestOf
? t("q:match.banner.bestOf", { count: score.count })
: t("q:match.banner.playAll", { count: score.count })}
: score.count !== undefined
? score.bestOf
? t("q:match.banner.bestOf", { count: score.count })
: t("q:match.banner.playAll", { count: score.count })
: null}
</div>
</div>
);
}
function Timer({
time,
}: {
time: NonNullable<MatchBannerTopRowProps["time"]>;
}) {
const isHydrated = useHydrated();
const { i18n } = useTranslation();
if (!isHydrated) return null;
const minuteFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "minute",
unitDisplay: "short",
});
const hourFormatter = new Intl.NumberFormat(i18n.language, {
style: "unit",
unit: "hour",
unitDisplay: "short",
});
const MAX_MINUTES = 60;
const dateTime = (minutes: number) => `PT0H${minutes}M`;
const displayValue = (minutes: number) =>
minutes >= MAX_MINUTES
? `${hourFormatter.format(1)}+`
: minuteFormatter.format(minutes);
return (
<div className={styles.values} data-testid="match-timer">
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
{displayValue(time.currentMinutes)}
</time>
<time dateTime={dateTime(time.totalMinutes)}>
{displayValue(time.totalMinutes)}
</time>
</div>
);
}

View File

@ -1,4 +1,11 @@
import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react";
import {
BarChart3,
DoorOpen,
Key,
ScrollText,
Tally5,
Users,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
@ -19,6 +26,7 @@ export const TAB_KEYS = {
ACTION: "action",
JOIN: "join",
RESULT: "result",
STATS: "stats",
ADMIN: "admin",
} as const;
@ -27,6 +35,7 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
stats: <BarChart3 />,
admin: <Key />,
};
@ -35,6 +44,7 @@ const TAB_TRANSLATION_KEYS = {
action: "q:match.tabs.action",
join: "common:actions.join",
result: "q:match.tabs.result",
stats: "q:match.tabs.stats",
admin: "common:pages.admin",
} as const;

View File

@ -80,7 +80,7 @@ export interface TimelinePickBanEvent {
export interface MatchTimelineProps {
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
score: { alpha: number; bravo: number };
score?: { alpha: number; bravo: number };
maps: TimelineMap[];
spChanges?: TimelineSpChanges;
/** When true, render only the team + score header (no per-map rows or SP section). */
@ -175,9 +175,11 @@ function TimelineHeader({
) : null}
</div>
<div className={styles.headerScore}>
<span className={styles.headerScoreValue}>
{score.alpha}-{score.bravo}
</span>
{score ? (
<span className={styles.headerScoreValue}>
{score.alpha}-{score.bravo}
</span>
) : null}
{isOngoing ? (
<span className={styles.headerScoreLive}>
{t("q:match.timeline.live")}

View File

@ -0,0 +1,38 @@
.rootCollapsed {
display: flex;
justify-content: center;
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-2);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
}
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-4);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
container-type: inline-size;
position: relative;
&.standalone {
margin-block-start: calc(-1 * var(--s-6));
min-height: 200px;
justify-content: center;
}
}
.collapseButton {
position: absolute;
inset-block-start: var(--s-2);
inset-inline-end: var(--s-3);
& svg {
min-width: 22px;
max-width: 22px;
}
}

View File

@ -0,0 +1,62 @@
import clsx from "clsx";
import { ChevronUp } from "lucide-react";
import type * as React from "react";
import { SendouButton } from "../elements/Button";
import styles from "./SecondaryAction.module.css";
interface SecondaryActionProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
collapsedLabel: string;
collapsedIcon?: JSX.Element;
expandedAriaLabel?: string;
standalone?: boolean;
children: React.ReactNode;
}
/**
* Generic collapsible panel rendered below the primary match action.
* Hosts optional follow-up actions (e.g. weapon reporting, scrim map list
* management) and switches to a full-tab standalone variant when there is
* no primary action to sit underneath.
*/
export function SecondaryAction({
isOpen,
onOpenChange,
collapsedLabel,
collapsedIcon,
expandedAriaLabel,
standalone,
children,
}: SecondaryActionProps) {
if (!isOpen && !standalone) {
return (
<div className={styles.rootCollapsed}>
<SendouButton
variant="minimal"
size="small"
icon={collapsedIcon}
onPress={() => onOpenChange(true)}
>
{collapsedLabel}
</SendouButton>
</div>
);
}
return (
<div className={clsx(styles.root, { [styles.standalone]: standalone })}>
{standalone ? null : (
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => onOpenChange(false)}
className={styles.collapseButton}
aria-label={expandedAriaLabel ?? collapsedLabel}
/>
)}
{children}
</div>
);
}

View File

@ -1,15 +1,3 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-4);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
container-type: inline-size;
}
.pastRow {
display: flex;
align-items: center;
@ -76,33 +64,3 @@
display: flex;
gap: var(--s-1);
}
.rootCollapsed {
display: flex;
justify-content: center;
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-2);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
}
.rootExpanded {
position: relative;
}
.rootStandalone {
margin-block-start: calc(-1 * var(--s-6));
min-height: 200px;
justify-content: center;
}
.collapseButton {
position: absolute;
top: var(--s-2);
right: var(--s-3);
& svg {
min-width: 22px;
max-width: 22px;
}
}

View File

@ -1,5 +1,4 @@
import clsx from "clsx";
import { ChevronUp, Crosshair } from "lucide-react";
import { Crosshair } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
@ -13,6 +12,7 @@ import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls";
import { SendouButton } from "../elements/Button";
import { Image, StageImage, WeaponImage } from "../Image";
import { WeaponSelect } from "../WeaponSelect";
import { SecondaryAction } from "./SecondaryAction";
import styles from "./WeaponReporter.module.css";
interface WeaponReporterMap {
@ -64,37 +64,14 @@ export function WeaponReporter({
);
};
if (!isOpen && !standalone) {
return (
<div className={styles.rootCollapsed}>
<SendouButton
variant="minimal"
size="small"
icon={<Crosshair size={16} />}
onPress={() => handleToggle(true)}
>
{t("q:match.actions.reportWeapons")}
</SendouButton>
</div>
);
}
return (
<div
className={clsx(styles.root, styles.rootExpanded, {
[styles.rootStandalone]: standalone,
})}
<SecondaryAction
isOpen={isOpen}
onOpenChange={handleToggle}
collapsedLabel={t("q:match.actions.reportWeapons")}
collapsedIcon={<Crosshair size={16} />}
standalone={standalone}
>
{standalone ? null : (
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => handleToggle(false)}
className={styles.collapseButton}
aria-label={t("q:match.actions.reportWeapons")}
/>
)}
{inputTargetMap ? (
<div className={styles.mapRow}>
<MapInfo map={inputTargetMap} />
@ -153,7 +130,7 @@ export function WeaponReporter({
))}
</div>
) : null}
</div>
</SecondaryAction>
);
}

View File

@ -3188,7 +3188,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: 4,
});
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 +3285,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 +3301,6 @@ async function scrimPostRequests() {
: null,
});
}
await ScrimPostRepository.acceptRequest(3);
}
async function associations() {

View File

@ -1259,6 +1259,27 @@ export interface ScrimPost {
updatedAt: Generated<number>;
}
export interface ScrimMapList {
id: GeneratedAlways<number>;
scrimPostId: number;
side: "ALPHA" | "BRAVO";
source: "TOURNAMENT" | "POOL";
tournamentId: number | null;
serializedPool: string | null;
updatedAt: number;
}
export interface ScrimMap {
id: GeneratedAlways<number>;
scrimPostId: number;
index: number;
mode: ModeShort;
stageId: StageId;
winnerSide: "ALPHA" | "BRAVO" | null;
reportedAt: number | null;
reportedByUserId: number | null;
}
export interface ScrimPostUser {
scrimPostId: number;
userId: number;
@ -1442,6 +1463,8 @@ export interface DB {
ScrimPostUser: ScrimPostUser;
ScrimPostRequest: ScrimPostRequest;
ScrimPostRequestUser: ScrimPostRequestUser;
ScrimMapList: ScrimMapList;
ScrimMap: ScrimMap;
Association: Association;
AssociationMember: AssociationMember;
Notification: Notification;

View File

@ -11,7 +11,9 @@ export type SystemMessageType =
| "CANCEL_CONFIRMED"
| "CANCEL_REFUSED"
| "TOURNAMENT_UPDATED"
| "TOURNAMENT_MATCH_UPDATED";
| "TOURNAMENT_MATCH_UPDATED"
| "MAP_REPLAYED"
| "MAP_PICKED";
export type SystemMessageContext = {
name: string;

View File

@ -101,6 +101,12 @@ export function Chat({
case "USER_LEFT": {
return t("common:chat.systemMsg.userLeft", { name: name() });
}
case "MAP_REPLAYED": {
return t("common:chat.systemMsg.mapReplayed", { name: name() });
}
case "MAP_PICKED": {
return t("common:chat.systemMsg.mapPicked", { name: name() });
}
default: {
return null;
}

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

@ -15,6 +15,7 @@ import {
MatchBannerContainer,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchPage } from "~/components/match-page/MatchPage";
@ -80,11 +81,14 @@ export default function MatchPageTestRoute() {
count: 5,
bestOf: true,
}}
time={{
currentMinutes: 3,
totalMinutes: 1,
}}
/>
>
<MatchBannerTimer
time={{
currentMinutes: 3,
totalMinutes: 1,
}}
/>
</MatchBannerTopRow>
<IconBanner
icon={<Ban size={32} />}
header={t("q:match.cancelRequested")}

View File

@ -0,0 +1,123 @@
import type { Transaction } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { databaseTimestampNow } from "~/utils/dates";
import * as ScrimMapRepository from "./ScrimMapRepository.server";
import type { ScrimSide } from "./scrims-types";
type SubmitMapListArgs = Omit<TablesInsertable["ScrimMapList"], "updatedAt">;
/**
* Inserts a map list row for the given side, replacing any existing row for
* the same `(scrimPostId, side)` pair, and (atomically) generates and inserts
* the next map for the scrim if no unreported map is currently waiting.
*/
export async function submitMapListAndGenerateIfNeeded(
args: SubmitMapListArgs,
): Promise<void> {
const now = databaseTimestampNow();
await db.transaction().execute(async (trx) => {
await trx
.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();
await ScrimMapRepository.tryGenerateAndInsertNextMapInTrx(
trx,
args.scrimPostId,
);
});
}
/** 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();
}
export type ResolvedScrimMapList = {
side: ScrimSide;
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
tournament?: { id: number; name: string };
updatedAt: number;
};
/**
* Returns all submitted map lists for the scrim with the pool resolved into
* concrete `(mode, stageId)` pairs. Tournament-sourced rows additionally carry
* the tournament's id and name for display. Pass a transaction as `executor`
* to read within an existing transaction.
*/
export async function findMapListsByScrimPostId(
scrimPostId: number,
executor: typeof db | Transaction<DB> = db,
): Promise<ResolvedScrimMapList[]> {
const rows = await executor
.selectFrom("ScrimMapList")
.leftJoin(
"CalendarEvent",
"ScrimMapList.tournamentId",
"CalendarEvent.tournamentId",
)
.select((eb) => [
"ScrimMapList.side",
"ScrimMapList.source",
"ScrimMapList.tournamentId",
"ScrimMapList.serializedPool",
"ScrimMapList.updatedAt",
eb.ref("CalendarEvent.name").as("tournamentName"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.mode", "MapPoolMap.stageId"])
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
).as("tournamentMapPool"),
])
.where("ScrimMapList.scrimPostId", "=", scrimPostId)
.execute();
return rows.map((row) => ({
side: row.side,
mapList: resolveMapList(row),
tournament:
row.source === "TOURNAMENT" && row.tournamentId !== null
? { id: row.tournamentId, name: row.tournamentName ?? "" }
: undefined,
updatedAt: row.updatedAt,
}));
}
function resolveMapList(row: {
source: "TOURNAMENT" | "POOL";
serializedPool: string | null;
tournamentMapPool: Array<{ mode: ModeShort; stageId: StageId }>;
}): Array<{ mode: ModeShort; stageId: StageId }> {
if (row.source === "TOURNAMENT") return row.tournamentMapPool;
if (!row.serializedPool) return [];
return new MapPool(row.serializedPool).stageModePairs;
}

View File

@ -0,0 +1,156 @@
import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { databaseTimestampNow } from "~/utils/dates";
import * as Scrim from "./core/Scrim";
import * as ScrimMapByMap from "./core/ScrimMapByMap";
import * as ScrimMapListRepository from "./ScrimMapListRepository.server";
interface ReportMapArgs {
scrimPostId: number;
mapId: number;
winnerSide: NonNullable<TablesInsertable["ScrimMap"]["winnerSide"]>;
reportedByUserId: NonNullable<
TablesInsertable["ScrimMap"]["reportedByUserId"]
>;
}
/**
* Marks an existing map as reported with the given winner side, and
* (atomically) generates and inserts the next map for the scrim if no
* unreported map is currently waiting.
*/
export async function reportMapAndGenerateNext(
args: ReportMapArgs,
): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx
.updateTable("ScrimMap")
.set({
winnerSide: args.winnerSide,
reportedAt: databaseTimestampNow(),
reportedByUserId: args.reportedByUserId,
})
.where("id", "=", args.mapId)
.where("reportedAt", "is", null)
.execute();
await tryGenerateAndInsertNextMapInTrx(trx, args.scrimPostId);
});
}
/**
* Reverses the most recent report: deletes the currently unreported map (the
* auto-generated next slot, if any) and clears the winner/reportedAt fields on
* the most recently reported map so it can be played again.
*/
export async function undoMostRecentMap(scrimPostId: number): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx
.deleteFrom("ScrimMap")
.where("scrimPostId", "=", scrimPostId)
.where("reportedAt", "is", null)
.execute();
const latestReported = await trx
.selectFrom("ScrimMap")
.select("id")
.where("scrimPostId", "=", scrimPostId)
.where("reportedAt", "is not", null)
.orderBy("index", "desc")
.limit(1)
.executeTakeFirst();
if (!latestReported) return;
await trx
.updateTable("ScrimMap")
.set({
reportedAt: null,
winnerSide: null,
reportedByUserId: null,
})
.where("id", "=", latestReported.id)
.execute();
});
}
interface ReplaceCurrentMapArgs {
scrimPostId: number;
mode: ModeShort;
stageId: StageId;
}
/**
* Replaces the currently unreported map for the scrim with the given
* mode/stage. Used by both the "replay previous map" and "pick a map" actions.
* The current map's index is preserved.
*/
export async function replaceCurrentMap(
args: ReplaceCurrentMapArgs,
): Promise<void> {
await db
.updateTable("ScrimMap")
.set({
mode: args.mode,
stageId: args.stageId,
})
.where("scrimPostId", "=", args.scrimPostId)
.where("reportedAt", "is", null)
.execute();
}
/** Returns the scrim's maps ordered by index ascending. */
export function findMapsByScrimPostId(scrimPostId: number) {
return db
.selectFrom("ScrimMap")
.select(["id", "index", "mode", "stageId", "winnerSide", "reportedAt"])
.where("scrimPostId", "=", scrimPostId)
.orderBy("index", "asc")
.execute();
}
/**
* If a pool can be derived from the submitted map lists and no unreported map
* is currently waiting, generates and inserts the next map. Runs entirely
* within the caller's transaction so the read of the existing maps and the
* insert see a consistent snapshot and no two concurrent report/submit actions
* can insert a "next" map at the same index.
*/
export async function tryGenerateAndInsertNextMapInTrx(
trx: Transaction<DB>,
scrimPostId: number,
): Promise<void> {
const mapLists = await ScrimMapListRepository.findMapListsByScrimPostId(
scrimPostId,
trx,
);
if (mapLists.length === 0) return;
const pool = ScrimMapByMap.unionPool(mapLists);
if (pool.isEmpty()) return;
const maps = await trx
.selectFrom("ScrimMap")
.select(["index", "mode", "stageId", "reportedAt"])
.where("scrimPostId", "=", scrimPostId)
.execute();
if (maps.some((m) => m.reportedAt === null)) return;
const next = ScrimMapByMap.generateNextMap({
pool,
history: maps.map((m) => ({ mode: m.mode, stageId: m.stageId })),
});
await trx
.insertInto("ScrimMap")
.values({
scrimPostId,
index: Scrim.nextMapIndex(maps),
mode: next.mode,
stageId: next.stageId,
})
.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

@ -1,67 +1,240 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
errorToast,
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import { databaseTimestampToDate } from "../../../utils/dates";
import { errorToast } from "../../../utils/remix.server";
import { requireUser } from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
import * as ScrimMapRepository from "../ScrimMapRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { cancelScrimSchema } from "../scrims-schemas";
import { scrimIdActionSchema } from "../scrims-schemas";
import { parseMapPoolInput } from "../scrims-utils";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { id } = parseParams({ params, schema: idObject });
const post = notFoundIfFalsy(await ScrimPostRepository.findById(id));
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: cancelScrimSchema,
schema: scrimIdActionSchema,
});
requirePermission(post, "CANCEL");
requirePermission(post, "MANAGE_TRACKING");
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
switch (data._action) {
case "CANCEL_SCRIM": {
requirePermission(post, "CANCEL");
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
await ScrimPostRepository.cancelScrim(id, {
userId: user.id,
reason: data.reason,
});
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
await ScrimPostRepository.cancelScrim(id, {
userId: user.id,
reason: data.reason,
});
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
notify({
userIds: acceptedRequest.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
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": {
const { viewerSide } = await loadMapByMapContext({ post, user });
if (data.source === "FROM_POST") {
errorToastIfFalsy(post.mapsTournament, "Post has no tournament to use");
}
const serializedPool =
data.source === "POOL"
? (parseMapPoolInput(data.serializedPool!)?.serialized ?? null)
: null;
errorToastIfFalsy(
data.source !== "POOL" || serializedPool,
"Invalid map pool",
);
const resolvedSource: "POOL" | "TOURNAMENT" =
data.source === "POOL" ? "POOL" : "TOURNAMENT";
await ScrimMapListRepository.submitMapListAndGenerateIfNeeded({
scrimPostId: post.id,
side: viewerSide,
source: resolvedSource,
tournamentId:
data.source === "FROM_POST"
? post.mapsTournament!.id
: (data.tournamentId ?? null),
serializedPool,
});
broadcastRevalidate({ post, user });
break;
}
case "REMOVE_MAP_LIST": {
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 ScrimMapRepository.reportMapAndGenerateNext({
scrimPostId: post.id,
mapId: data.mapId,
winnerSide: data.winnerSide,
reportedByUserId: user.id,
});
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 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 ScrimMapRepository.replaceCurrentMap({
scrimPostId: post.id,
mode: latest!.mode,
stageId: latest!.stageId,
});
broadcastMapChange({ post, type: "MAP_REPLAYED", user });
break;
}
case "PICK_MAP": {
const { maps } = await loadMapByMapContext({ post, user });
const currentMap = maps.find((m) => m.reportedAt === null);
errorToastIfFalsy(currentMap, "No current map to replace");
await ScrimMapRepository.replaceCurrentMap({
scrimPostId: post.id,
mode: data.mode,
stageId: data.stageId,
});
broadcastMapChange({ post, type: "MAP_PICKED", user });
break;
}
default: {
assertUnreachable(data);
}
}
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,
});
}
function broadcastMapChange({
post,
type,
user,
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
type: "MAP_REPLAYED" | "MAP_PICKED";
user: ReturnType<typeof requireUser>;
}) {
if (!post.chatCode) return;
ChatSystemMessage.send({
room: post.chatCode,
type,
context: { name: user.username },
});
}

View File

@ -0,0 +1,24 @@
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouForm } from "~/form/SendouForm";
import { pickMapFormSchema } from "../scrims-schemas";
export function PickMapDialog({
trigger,
heading,
}: {
trigger: React.ReactNode;
heading: string;
}) {
return (
<SendouDialog heading={heading} trigger={trigger}>
<SendouForm schema={pickMapFormSchema} defaultValues={{ mode: "SZ" }}>
{({ FormField }) => (
<>
<FormField name="mode" />
<FormField name="stageId" />
</>
)}
</SendouForm>
</SendouDialog>
);
}

View File

@ -78,6 +78,10 @@
font-size: var(--font-xs);
}
.tournamentPopoverTrigger {
height: auto;
}
.textContent {
padding-inline: var(--s-4);
padding-bottom: var(--s-3);

View File

@ -202,6 +202,7 @@ function ScrimTournamentPopover({
trigger={
<SendouButton
variant="minimal"
className={styles.tournamentPopoverTrigger}
data-testid="tournament-popover-trigger"
>
<Avatar

View File

@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import type { loader } from "../loaders/scrims.$id.server";
import { submitMapListFormSchema } from "../scrims-schemas";
type SourceValue = "POOL" | "TOURNAMENT" | "FROM_POST";
export function ScrimMapListForm() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const postTournament = data.post.mapsTournament;
const isPostAuthorSide = data.mapByMap.viewerSide === "ALPHA";
const useFromPost = postTournament != null && isPostAuthorSide;
const defaultSource: SourceValue = useFromPost ? "FROM_POST" : "TOURNAMENT";
return (
<div data-testid="scrim-map-list-form">
<SendouForm
title={t("scrims:mapByMap.submitListHeading")}
schema={submitMapListFormSchema}
submitButtonTestId="submit-map-list-button"
fullWidth
defaultValues={{ source: defaultSource }}
>
{() => (
<>
<SourceField
postTournamentName={
useFromPost ? (postTournament?.name ?? null) : null
}
/>
<SourceDependentFields />
</>
)}
</SendouForm>
</div>
);
}
function SourceField({
postTournamentName,
}: {
postTournamentName: string | null;
}) {
const { t } = useTranslation(["forms"]);
const items = postTournamentName
? [
{ value: "FROM_POST", label: () => postTournamentName },
{
value: "POOL",
label: () => t("forms:options.scrimMapSource.POOL"),
},
]
: [
{
value: "TOURNAMENT",
label: () => t("forms:options.scrimMapSource.TOURNAMENT"),
},
{
value: "POOL",
label: () => t("forms:options.scrimMapSource.POOL"),
},
];
return <FormField name="source" options={items} />;
}
function SourceDependentFields() {
const { values } = useFormFieldContext();
const source = values.source as SourceValue;
if (source === "POOL") return <FormField name="serializedPool" />;
if (source === "TOURNAMENT") return <FormField name="tournamentId" />;
return null;
}

View File

@ -0,0 +1,48 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-4);
align-items: stretch;
width: 100%;
container-type: inline-size;
}
.intro {
text-align: center;
color: var(--color-text-high);
max-width: 32rem;
font-size: var(--font-xs);
margin-inline: auto;
@container (max-width: 599px) {
max-width: 18rem;
}
}
.mapListsSummary {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.mapListRow {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-2);
}
.mapListRowHeader {
font-weight: var(--weight-bold);
}
.mapListBody {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mapListRowMissing {
font-style: italic;
color: var(--color-text-high);
}

View File

@ -0,0 +1,116 @@
import { Map as MapIcon, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { SecondaryAction } from "~/components/match-page/SecondaryAction";
import type { loader } from "../loaders/scrims.$id.server";
import type { ScrimSide } from "../scrims-types";
import { ScrimMapListForm } from "./ScrimMapListForm";
import styles from "./ScrimMapListManager.module.css";
interface Props {
viewerSide: ScrimSide;
standalone?: boolean;
}
export function ScrimMapListManager({ viewerSide, standalone }: Props) {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const ownList = data.mapByMap.mapLists.find((l) => l.side === viewerSide);
const [isOpen, setIsOpen] = useState(() => !ownList);
return (
<SecondaryAction
isOpen={isOpen}
onOpenChange={setIsOpen}
collapsedLabel={t("scrims:mapByMap.manageMapLists")}
collapsedIcon={<MapIcon size={16} />}
standalone={standalone}
>
<div className={styles.root}>
{ownList ? null : <ScrimMapListForm />}
<MapListsSummary viewerSide={viewerSide} />
</div>
</SecondaryAction>
);
}
function MapListsSummary({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["scrims", "q"]);
const data = useLoaderData<typeof loader>();
const lists = data.mapByMap.mapLists;
const sides: ScrimSide[] = ["ALPHA", "BRAVO"];
return (
<div className={styles.mapListsSummary}>
{sides.map((side) => {
const list = lists.find((l) => l.side === side);
const isOwn = side === viewerSide;
return (
<div
key={side}
className={styles.mapListRow}
data-testid={`map-list-row-${side}`}
>
<div className={styles.mapListRowHeader}>
{side === "ALPHA"
? t("q:match.sides.alpha")
: t("q:match.sides.bravo")}
</div>
<div className={styles.mapListBody}>
{list ? (
<>
<MapListDisplay
tournament={list.tournament}
mapCount={list.mapList.length}
/>
{isOwn ? <RemoveOwnListButton /> : null}
</>
) : (
<span className={styles.mapListRowMissing}>
{t("scrims:mapByMap.noListYet")}
</span>
)}
</div>
</div>
);
})}
</div>
);
}
function RemoveOwnListButton() {
const { t } = useTranslation(["scrims", "common"]);
return (
<FormWithConfirm
fields={[["_action", "REMOVE_MAP_LIST"]]}
dialogHeading={t("scrims:mapByMap.removeListConfirm")}
submitButtonText={t("common:actions.remove")}
submitButtonTestId="remove-list-button"
>
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Trash2 size={16} />}
aria-label={t("scrims:mapByMap.removeList")}
/>
</FormWithConfirm>
);
}
function MapListDisplay({
tournament,
mapCount,
}: {
tournament: { id: number; name: string } | undefined;
mapCount: number;
}) {
const { t } = useTranslation(["scrims"]);
if (tournament) {
return <span>{tournament.name}</span>;
}
return <span>{t("scrims:mapByMap.poolList", { count: mapCount })}</span>;
}

View File

@ -0,0 +1,7 @@
.locked {
padding: var(--s-3);
border-radius: var(--radius-box);
background: var(--color-bg-high);
color: var(--color-text-high);
text-align: center;
}

View File

@ -0,0 +1,155 @@
import { MapPin, Repeat, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { useUser } from "~/features/auth/core/user";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import type { loader } from "../loaders/scrims.$id.server";
import type { ScrimSide } from "../scrims-types";
import { PickMapDialog } from "./PickMapDialog";
import { ScrimMapListManager } from "./ScrimMapListManager";
import styles from "./ScrimMatchActionTab.module.css";
const ALPHA_TEAM_ID = 1;
const BRAVO_TEAM_ID = 2;
export function ScrimMatchActionTab() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const viewerSide = user ? Scrim.sideOfUser(data.post, user.id) : null;
if (data.mapByMap.locked) return null;
if (!viewerSide) return <NotParticipantSection />;
if (!data.mapByMap.currentMap) {
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<ScrimMapListManager viewerSide={viewerSide} standalone />
</SendouTabPanel>
);
}
return <ReportMapSection viewerSide={viewerSide} />;
}
function NotParticipantSection() {
const { t } = useTranslation(["scrims"]);
return (
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.locked}>
{t("scrims:mapByMap.nonParticipantNotice")}
</div>
</SendouTabPanel>
);
}
function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const map = data.mapByMap!.currentMap!;
const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!;
const alphaName = data.post.team
? Scrim.sideDisplayName(data.post)
: t("q:match.groupAlpha");
const bravoName = acceptedRequest.team
? Scrim.sideDisplayName(acceptedRequest)
: t("q:match.groupBravo");
const ownTeamId = viewerSide === "ALPHA" ? ALPHA_TEAM_ID : BRAVO_TEAM_ID;
return (
<MatchActionTab
key={map.id}
teams={[
{ id: ALPHA_TEAM_ID, name: alphaName },
{ id: BRAVO_TEAM_ID, name: bravoName },
]}
ownTeamId={ownTeamId}
stageId={map.stageId}
mode={map.mode}
withPoints={false}
isSubmitting={fetcher.state !== "idle"}
onSubmit={({ winnerId }) => {
fetcher.submit(
{
_action: "REPORT_MAP",
mapId: String(map.id),
winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
},
{ method: "post" },
);
}}
actionButtons={<MapActionButtons />}
secondaryAction={<ScrimMapListManager viewerSide={viewerSide} />}
/>
);
}
function MapActionButtons() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const undoFetcher = useFetcher();
const replayFetcher = useFetcher();
const maps = data.mapByMap?.maps ?? [];
const currentMap = data.mapByMap?.currentMap;
const latest = Scrim.lastReportedMap(maps);
const undoAllowed = ScrimMapByMap.canUndo(latest, maps);
const replayAllowed = Boolean(latest && currentMap);
return (
<>
<SendouButton
testId="undo-map-button"
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
isPending={undoFetcher.state !== "idle"}
isDisabled={!undoAllowed}
onPress={() => {
undoFetcher.submit({ _action: "UNDO_MAP" }, { method: "post" });
}}
>
{t("scrims:mapByMap.undo")}
</SendouButton>
<SendouButton
testId="replay-map-button"
variant="minimal"
size="miniscule"
icon={<Repeat size={16} />}
isPending={replayFetcher.state !== "idle"}
isDisabled={!replayAllowed}
onPress={() => {
replayFetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" });
}}
>
{t("scrims:mapByMap.replay")}
</SendouButton>
<PickMapDialog
key={
currentMap
? `${currentMap.id}-${currentMap.mode}-${currentMap.stageId}`
: "no-map"
}
heading={t("scrims:mapByMap.pickDialog.heading")}
trigger={
<SendouButton
testId="pick-map-button"
variant="minimal"
size="miniscule"
icon={<MapPin size={16} />}
>
{t("scrims:mapByMap.pick")}
</SendouButton>
}
/>
</>
);
}

View File

@ -1,23 +1,23 @@
import { sub } from "date-fns";
import { Ban, Swords } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { Image } from "~/components/Image";
import { useLoaderData } from "react-router";
import {
IconBanner,
MatchBanner,
MatchBannerContainer,
} from "~/components/match-page/MatchBanner";
import { MatchBannerScheduledTime } from "~/components/match-page/MatchBannerScheduledTime";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { logger } from "~/utils/logger";
import type { SerializeFrom } from "~/utils/remix";
import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import * as Scrim from "../core/Scrim";
import type { loader } from "../loaders/scrims.$id.server";
import { SCRIM } from "../scrims-constants";
import type { ScrimPost } from "../scrims-types";
export function ScrimMatchBanner() {
const { t } = useTranslation(["scrims"]);
@ -26,9 +26,12 @@ export function ScrimMatchBanner() {
const screenLegal = !data.anyUserPrefersNoScreen;
const topRow = <ScrimMatchBannerTopRow />;
if (data.post.canceled) {
return (
<MatchBannerContainer>
{topRow}
<IconBanner
icon={<Ban size={32} />}
header={t("scrims:banner.canceled.header", {
@ -42,8 +45,6 @@ export function ScrimMatchBanner() {
);
}
const hasMaps = data.post.maps || data.tournamentMapPool;
const acceptedRequest = data.post.requests[0];
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
@ -54,53 +55,51 @@ 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>
{topRow}
<MatchBanner
stageId={currentMap.stageId}
mode={currentMap.mode}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
/>
</MatchBannerContainer>
);
}
return (
<MatchBannerContainer>
{topRow}
<IconBanner
icon={<Swords size={32} />}
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 ? (
<MapsLink
maps={data.post.maps}
tournamentMapPool={data.tournamentMapPool}
/>
) : undefined
}
/>
</MatchBannerContainer>
);
}
function MapsLink({
maps,
tournamentMapPool,
}: Pick<ScrimPost, "maps"> &
Pick<SerializeFrom<typeof loader>, "tournamentMapPool">) {
const mapPool = () => {
if (tournamentMapPool) return new MapPool(tournamentMapPool);
function ScrimMatchBannerTopRow() {
const data = useLoaderData<typeof loader>();
if (maps === "SZ") return MapPool.SZ;
if (maps === "RANKED") return MapPool.ANARCHY;
if (maps === "ALL") return MapPool.ALL;
logger.info(`Unknown scrim maps value: ${maps}`);
return MapPool.ALL;
};
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
const scheduledAt = databaseTimestampToDate(
acceptedRequest?.at ?? data.post.at,
);
return (
<Link to={mapsPageWithMapPool(mapPool())}>
<Image
path={navIconUrl("maps")}
width={32}
height={32}
alt="Generate maplist"
/>
</Link>
<MatchBannerTopRow>
<MatchBannerScheduledTime time={scheduledAt} />
</MatchBannerTopRow>
);
}

View File

@ -3,12 +3,12 @@ import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { MatchPageHeader } from "~/components/match-page/MatchPageHeader";
import TimePopover from "~/components/TimePopover";
import { SendouForm } from "~/form/SendouForm";
import { useHasPermission } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import * as Scrim from "../core/Scrim";
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"]);
@ -16,13 +16,20 @@ export function ScrimMatchHeader() {
const allowedToCancel = useHasPermission(data.post, "CANCEL");
const isCanceled = Boolean(data.post.canceled);
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
const scrimTime = acceptedRequest?.at ?? data.post.at;
const canCancel =
allowedToCancel &&
!isCanceled &&
databaseTimestampToDate(data.post.at) > new Date();
const acceptedRequest = data.post.requests.find((r) => r.isAccepted);
const viewerSide = data.mapByMap.viewerSide;
const opponentSide =
viewerSide === "ALPHA"
? acceptedRequest
: viewerSide === "BRAVO"
? data.post
: acceptedRequest;
return (
<MatchPageHeader
subtitle={t("scrims:page.scheduledScrim")}
@ -42,18 +49,11 @@ export function ScrimMatchHeader() {
) : undefined
}
>
<TimePopover
time={databaseTimestampToDate(scrimTime)}
options={{
weekday: "short",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-left"
/>
{opponentSide
? t("scrims:page.vs", {
opponent: Scrim.sideDisplayName(opponentSide),
})
: null}
</MatchPageHeader>
);
}
@ -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

@ -0,0 +1,49 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
}
.toggleRow {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
}
.labelCell {
white-space: nowrap;
}
.cellNum {
width: 4rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.empty {
color: var(--color-text-high);
font-style: italic;
}
.stageModeLabel {
display: inline-flex;
align-items: center;
gap: var(--s-2);
& > * {
flex-shrink: 0;
}
}
.stageImage {
border-radius: var(--radius-field);
}

View File

@ -0,0 +1,206 @@
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 { Table } from "~/components/Table";
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 viewerSide = data.mapByMap?.viewerSide;
const maps = data.mapByMap?.maps ?? [];
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 (
<SendouTabPanel id={TAB_KEYS.STATS}>
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
</SendouTabPanel>
);
}
const restrictPool = restrictToPool && ownPool ? ownPool : undefined;
const stats = ScrimMapByMap.stats(maps, viewerSide, {
restrictToPool: restrictPool,
});
return (
<SendouTabPanel id={TAB_KEYS.STATS}>
<div className={styles.root} data-testid="scrim-stats-root">
<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>
{view === "MODE" ? (
<StatsTable
rows={stats.byMode.map((r) => ({
key: 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,
}))}
/>
) : null}
{view === "STAGE" ? (
<StatsTable
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,
};
})}
/>
) : null}
{view === "BOTH" ? (
<StatsTable
rows={stats.byStageMode.map((r) => {
const [stageId, mode] = r.key.split("-");
const stageLabel = t(
`game-misc:STAGE_${Number(stageId)}` as const,
{ defaultValue: stageId },
);
return {
key: r.key,
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,
};
})}
/>
) : null}
</div>
</SendouTabPanel>
);
}
function StatsTable({
rows,
}: {
rows: Array<{
key: string;
label: React.ReactNode;
wins: number;
losses: number;
}>;
}) {
const { t } = useTranslation(["scrims"]);
if (rows.length === 0) {
return (
<div className={styles.empty}>{t("scrims:mapByMap.stats.empty")}</div>
);
}
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>
<thead>
<tr>
<th>{t("scrims:mapByMap.stats.col.label")}</th>
<th className={styles.cellNum}>
{t("scrims:mapByMap.stats.col.wins")}
</th>
<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>
{sortedRows.map((row) => (
<tr key={row.key}>
<td className={styles.labelCell}>{row.label}</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>
</Table>
);
}

View File

@ -2,20 +2,27 @@ import { sub } from "date-fns";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs";
import type { TimelineMap } from "~/components/match-page/MatchTimeline";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import {
resolveActiveRoomLink,
useConfirmRoom,
} from "~/features/chat/room-link-utils";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampToJavascriptTimestamp,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { teamPage } from "~/utils/urls";
import * as Scrim from "../core/Scrim";
import type { loader } from "../loaders/scrims.$id.server";
import { SCRIM } from "../scrims-constants";
import type { ScrimPost } from "../scrims-types";
import { ScrimMatchActionTab } from "./ScrimMatchActionTab";
import { ScrimMatchStatsTab } from "./ScrimMatchStatsTab";
export function ScrimMatchTabs() {
const { t } = useTranslation(["q"]);
@ -35,8 +42,10 @@ export function ScrimMatchTabs() {
members: allMembers,
});
const tabs = resolveTabs(data);
return (
<MatchTabs tabs={[TAB_KEYS.ROSTERS, TAB_KEYS.JOIN]}>
<MatchTabs tabs={tabs}>
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
@ -60,10 +69,54 @@ export function ScrimMatchTabs() {
},
]}
/>
<ScrimMatchActionTab />
<MatchResultTab
teams={{
alpha: {
name: data.post.team
? Scrim.sideDisplayName(data.post)
: t("q:match.groupAlpha"),
avatar: data.post.team?.avatarUrl ?? undefined,
},
bravo: {
name: acceptedRequest.team
? Scrim.sideDisplayName(acceptedRequest)
: t("q:match.groupBravo"),
avatar: acceptedRequest.team?.avatarUrl ?? undefined,
},
}}
maps={resolveTimelineMaps(data, acceptedRequest)}
isOngoing={!data.mapByMap?.locked && data.mapByMap?.currentMap !== null}
/>
<ScrimMatchStatsTab />
</MatchTabs>
);
}
function resolveTabs(data: ReturnType<typeof useLoaderData<typeof loader>>) {
const tabs: Array<(typeof TAB_KEYS)[keyof typeof TAB_KEYS]> = [
TAB_KEYS.ROSTERS,
TAB_KEYS.JOIN,
];
if (!data.mapByMap?.locked) {
tabs.push(TAB_KEYS.ACTION);
}
if (data.mapByMap && data.mapByMap.maps.length > 0) {
tabs.push(TAB_KEYS.RESULT);
}
if (
data.mapByMap?.maps.some((m) => m.reportedAt !== null) &&
data.mapByMap.viewerSide !== null
) {
tabs.push(TAB_KEYS.STATS);
}
return tabs;
}
function mapTeam(team: ScrimPost["team"]) {
if (!team) return undefined;
return {
@ -73,3 +126,23 @@ function mapTeam(team: ScrimPost["team"]) {
avatar: team.avatarUrl ?? undefined,
};
}
function resolveTimelineMaps(
data: ReturnType<typeof useLoaderData<typeof loader>>,
acceptedRequest: ScrimPost["requests"][number],
): TimelineMap[] {
const rosters = {
alpha: data.post.users,
bravo: acceptedRequest.users,
};
return (data.mapByMap?.maps ?? [])
.filter((m) => m.winnerSide !== null && m.reportedAt !== null)
.map((map) => ({
stageId: map.stageId,
mode: map.mode,
timestamp: databaseTimestampToJavascriptTimestamp(map.reportedAt!),
winner: map.winnerSide === "ALPHA" ? "ALPHA" : "BRAVO",
rosters,
}));
}

View File

@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
import {
applyFilters,
isTrackingLocked,
participantIdsListFromAccepted,
sideDisplayName,
sideOfUser,
} from "./Scrim";
type MockUser = { id: number };
@ -125,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,
};
}
@ -461,3 +469,84 @@ describe("applyFilters", () => {
});
});
});
describe("sideOfUser", () => {
it("returns ALPHA for users in the post's users list", () => {
const post = createPost(
[{ id: 1 }],
[{ isAccepted: true, users: [{ id: 2 }] }],
);
expect(sideOfUser(post, 1)).toBe("ALPHA");
});
it("returns BRAVO for users in the accepted request's users list", () => {
const post = createPost(
[{ id: 1 }],
[{ isAccepted: true, users: [{ id: 2 }] }],
);
expect(sideOfUser(post, 2)).toBe("BRAVO");
});
it("returns null for non-participants", () => {
const post = createPost(
[{ id: 1 }],
[{ isAccepted: true, users: [{ id: 2 }] }],
);
expect(sideOfUser(post, 99)).toBeNull();
});
it("ignores users only in non-accepted requests", () => {
const post = createPost(
[{ id: 1 }],
[{ isAccepted: false, users: [{ id: 2 }] }],
);
expect(sideOfUser(post, 2)).toBeNull();
});
});
describe("isTrackingLocked", () => {
const ONE_HOUR_MS = 60 * 60 * 1000;
const lockWindowMs = SCRIM_TRACKING_AUTO_LOCK_HOURS * ONE_HOUR_MS;
it("returns false when no map list submitted yet", () => {
expect(isTrackingLocked([], [], Date.now())).toBe(false);
});
it("returns false just inside the auto-lock window from list submission", () => {
const now = 1_000_000_000;
const updatedAt = (now - (lockWindowMs - ONE_HOUR_MS)) / 1000;
expect(isTrackingLocked([], [{ updatedAt }], now)).toBe(false);
});
it("returns true just past the auto-lock window from list submission", () => {
const now = 1_000_000_000;
const updatedAt = (now - (lockWindowMs + ONE_HOUR_MS)) / 1000;
expect(isTrackingLocked([], [{ updatedAt }], now)).toBe(true);
});
it("uses the most recent reported map as the reference point", () => {
const now = 1_000_000_000;
const oldUpdatedAt = (now - lockWindowMs * 2) / 1000;
const recentMapSeconds = (now - ONE_HOUR_MS) / 1000;
expect(
isTrackingLocked(
[{ reportedAt: recentMapSeconds }],
[{ updatedAt: oldUpdatedAt }],
now,
),
).toBe(false);
});
it("uses the most recent list update when there are no reported maps", () => {
const now = 1_000_000_000;
const oldUpdatedAt = (now - lockWindowMs * 2) / 1000;
const recentUpdatedAt = (now - ONE_HOUR_MS) / 1000;
expect(
isTrackingLocked(
[],
[{ updatedAt: oldUpdatedAt }, { updatedAt: recentUpdatedAt }],
now,
),
).toBe(false);
});
});

View File

@ -1,9 +1,10 @@
import { format, isWeekend } from "date-fns";
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { LUTI_DIVS } from "../scrims-constants";
import type { ScrimFilters, ScrimPost } from "../scrims-types";
import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants";
import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types";
/** Returns true if the original poster has accepted any of the requests. */
export function isAccepted(post: ScrimPost) {
@ -125,3 +126,69 @@ export function defaultFilters(): ScrimFilters {
export function filtersAreDefault(filters: ScrimFilters): boolean {
return R.isShallowEqual(filters, defaultFilters());
}
/**
* Returns the side ("ALPHA" or "BRAVO") the user belongs to in the scrim, or
* null when the user is not part of the accepted pairing.
*
* The post's own users list is treated as the ALPHA side; the accepted
* request's users list is treated as the BRAVO side.
*/
export function sideOfUser(post: ScrimPost, userId: number): ScrimSide | null {
if (post.users.some((u) => u.id === userId)) return "ALPHA";
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest?.users.some((u) => u.id === userId)) return "BRAVO";
return null;
}
/**
* Returns true when map-by-map tracking is locked: the auto-lock window has
* elapsed since the last activity (most recent reported map, falling back to
* the most recently updated submitted map list). Returns false when no map
* list has been submitted yet (tracking is not active).
*/
export function isTrackingLocked(
maps: Pick<Tables["ScrimMap"], "reportedAt">[] = [],
mapLists: Pick<Tables["ScrimMapList"], "updatedAt">[] = [],
now: number = Date.now(),
): boolean {
const latestReported = R.firstBy(
maps.filter((m) => m.reportedAt !== null),
[(m) => m.reportedAt!, "desc"],
);
const latestList = R.firstBy(mapLists, [(l) => l.updatedAt, "desc"]);
const referenceSeconds =
latestReported?.reportedAt ?? latestList?.updatedAt ?? null;
if (referenceSeconds === null) return false;
const elapsedHours = (now - referenceSeconds * 1000) / (60 * 60 * 1000);
return elapsedHours > SCRIM_TRACKING_AUTO_LOCK_HOURS;
}
/**
* Returns the next 0-based map index to be inserted given a list of existing
* maps. Existing maps need not be in any particular order.
*/
export function nextMapIndex(
maps: Pick<Tables["ScrimMap"], "index">[],
): number {
const latest = R.firstBy(maps, [(m) => m.index, "desc"]);
return latest ? latest.index + 1 : 0;
}
/**
* Returns the most recently reported map (by `index`), or undefined if no map
* has been reported yet.
*/
export function lastReportedMap<
T extends Pick<Tables["ScrimMap"], "index" | "reportedAt">,
>(maps: T[]): T | undefined {
return R.firstBy(
maps.filter((m) => m.reportedAt !== null),
[(m) => m.index, "desc"],
);
}

View File

@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import type { Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { stagesObj } from "~/modules/in-game-lists/stage-ids";
import type { StageId } from "~/modules/in-game-lists/types";
import { canUndo, generateNextMap, stats, unionPool } from "./ScrimMapByMap";
type MapRow = Pick<
Tables["ScrimMap"],
"index" | "mode" | "stageId" | "winnerSide" | "reportedAt"
>;
function makeMap(overrides: Partial<MapRow> & { index: number }): MapRow {
return {
index: overrides.index,
mode: overrides.mode ?? "SZ",
stageId: overrides.stageId ?? stagesObj.SCORCH_GORGE,
winnerSide: overrides.winnerSide ?? null,
reportedAt: overrides.reportedAt ?? null,
};
}
describe("ScrimMapByMap.unionPool", () => {
it("deduplicates stage-mode pairs across multiple lists", () => {
const pool = unionPool([
{
mapList: [
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE as StageId },
{ mode: "SZ", stageId: stagesObj.EELTAIL_ALLEY as StageId },
],
},
{
mapList: [
{ mode: "SZ", stageId: stagesObj.EELTAIL_ALLEY as StageId },
{ mode: "SZ", stageId: stagesObj.MAKOMART as StageId },
],
},
]);
expect([...pool.parsed.SZ].sort((a, b) => a - b)).toEqual(
[
stagesObj.SCORCH_GORGE,
stagesObj.EELTAIL_ALLEY,
stagesObj.MAKOMART,
].sort((a, b) => a - b),
);
});
it("merges entries across modes", () => {
const pool = unionPool([
{
mapList: [
{ mode: "SZ", stageId: stagesObj.HAMMERHEAD_BRIDGE as StageId },
{ mode: "TC", stageId: stagesObj.MAKOMART as StageId },
],
},
]);
expect(pool.parsed.SZ).toEqual([stagesObj.HAMMERHEAD_BRIDGE]);
expect(pool.parsed.TC).toEqual([stagesObj.MAKOMART]);
});
});
describe("ScrimMapByMap.generateNextMap", () => {
it("avoids the just-played stage when alternatives exist", () => {
const pool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART, stagesObj.WAHOO_WORLD],
TC: [],
CB: [],
RM: [],
TW: [],
});
for (let i = 0; i < 25; i++) {
const next = generateNextMap({
pool,
history: [{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE }],
});
expect(next.stageId).not.toBe(stagesObj.SCORCH_GORGE);
}
});
it("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("advances mode rotation after a manual pick inside the pool", () => {
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: "RM", stageId: stagesObj.WAHOO_WORLD },
],
});
expect(next.mode).toBe("CB");
});
it("falls back to the pool's first mode after a manual pick outside the pool's modes", () => {
const pool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE, stagesObj.MAKOMART],
TC: [stagesObj.HAMMERHEAD_BRIDGE],
RM: [],
CB: [],
TW: [],
});
const next = generateNextMap({
pool,
history: [
{ mode: "SZ", stageId: stagesObj.SCORCH_GORGE },
{ mode: "TW", stageId: stagesObj.WAHOO_WORLD },
],
});
expect(next.mode).toBe("SZ");
});
it("can still generate when only one stage is available", () => {
const pool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE],
TC: [],
CB: [],
RM: [],
TW: [],
});
const next = generateNextMap({ pool, history: [] });
expect(next).toEqual({ mode: "SZ", stageId: stagesObj.SCORCH_GORGE });
});
});
describe("ScrimMapByMap.canUndo", () => {
it("returns true for the most recent reported map", () => {
const history = [
makeMap({ index: 0, reportedAt: 100 }),
makeMap({ index: 1, reportedAt: 200 }),
];
expect(canUndo(history[1], history)).toBe(true);
});
it("returns false for unreported maps", () => {
const history = [makeMap({ index: 0, reportedAt: null })];
expect(canUndo(history[0], history)).toBe(false);
});
it("returns false for a non-latest reported map", () => {
const history = [
makeMap({ index: 0, reportedAt: 100 }),
makeMap({ index: 1, reportedAt: 200 }),
];
expect(canUndo(history[0], history)).toBe(false);
});
it("returns false when given undefined", () => {
expect(canUndo(undefined, [])).toBe(false);
});
it("returns true when an unreported next map exists after the latest reported", () => {
const history = [
makeMap({ index: 0, reportedAt: 100 }),
makeMap({ index: 1, reportedAt: 200 }),
makeMap({ index: 2, reportedAt: null }),
];
expect(canUndo(history[1], history)).toBe(true);
});
});
describe("ScrimMapByMap.stats", () => {
const history: MapRow[] = [
makeMap({
index: 0,
mode: "SZ",
stageId: stagesObj.SCORCH_GORGE,
winnerSide: "ALPHA",
reportedAt: 100,
}),
makeMap({
index: 1,
mode: "SZ",
stageId: stagesObj.MAKOMART,
winnerSide: "BRAVO",
reportedAt: 200,
}),
makeMap({
index: 2,
mode: "TC",
stageId: stagesObj.MAKOMART,
winnerSide: "ALPHA",
reportedAt: 300,
}),
makeMap({
index: 3,
mode: "SZ",
stageId: stagesObj.SCORCH_GORGE,
winnerSide: null,
reportedAt: null,
}),
];
it("aggregates wins/losses from the viewer's perspective", () => {
const result = stats(history, "ALPHA");
const szMode = result.byMode.find((r) => r.key === "SZ");
expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 });
const tcMode = result.byMode.find((r) => r.key === "TC");
expect(tcMode).toEqual({ key: "TC", wins: 1, losses: 0 });
});
it("flips wins/losses when viewing as BRAVO", () => {
const result = stats(history, "BRAVO");
const szMode = result.byMode.find((r) => r.key === "SZ");
expect(szMode).toEqual({ key: "SZ", wins: 1, losses: 1 });
const tcMode = result.byMode.find((r) => r.key === "TC");
expect(tcMode).toEqual({ key: "TC", wins: 0, losses: 1 });
});
it("filters out empty rows", () => {
const result = stats(history, "ALPHA");
for (const row of result.byMode) {
expect(row.wins + row.losses).toBeGreaterThan(0);
}
});
it("respects restrictToPool", () => {
const restrictToPool = new MapPool({
SZ: [stagesObj.SCORCH_GORGE],
TC: [],
CB: [],
RM: [],
TW: [],
});
const result = stats(history, "ALPHA", { restrictToPool });
expect(result.byMode).toEqual([{ key: "SZ", wins: 1, losses: 0 }]);
});
});

View File

@ -0,0 +1,174 @@
import type { Tables } from "~/db/tables";
import * as MapList from "~/features/map-list-generator/core/MapList";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { MapPoolObject } from "~/features/map-list-generator/core/map-pool-serializer/types";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
type ResolvedMapListRow = {
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
};
type ScrimMapRow = Pick<
Tables["ScrimMap"],
"index" | "mode" | "stageId" | "winnerSide" | "reportedAt"
>;
/**
* Merges the submitted map lists into a single deduplicated MapPool.
*/
export function unionPool(lists: ResolvedMapListRow[]): MapPool {
const buckets: Record<ModeShort, Set<StageId>> = {
TW: new Set(),
SZ: new Set(),
TC: new Set(),
RM: new Set(),
CB: new Set(),
};
for (const list of lists) {
for (const { mode, stageId } of list.mapList) {
buckets[mode].add(stageId);
}
}
const merged: MapPoolObject = {
TW: [...buckets.TW],
SZ: [...buckets.SZ],
TC: [...buckets.TC],
RM: [...buckets.RM],
CB: [...buckets.CB],
};
return new MapPool(merged);
}
/**
* 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;
history: Pick<Tables["ScrimMap"], "mode" | "stageId">[];
}): { mode: ModeShort; stageId: StageId } {
if (args.pool.isEmpty()) {
throw new Error("Cannot generate map from empty pool");
}
const generator = MapList.resume({
mapPool: args.pool,
history: args.history,
});
generator.next();
const result = generator.next({ amount: 1 }).value;
if (!result || result.length === 0) {
throw new Error("Failed to generate map");
}
return { mode: result[0].mode, stageId: result[0].stageId };
}
/**
* Returns true when the given map is the most recently reported one and is
* therefore eligible to be undone.
*/
export function canUndo(
map: ScrimMapRow | undefined,
history: ScrimMapRow[],
): boolean {
if (!map || map.reportedAt === null) return false;
for (const other of history) {
if (other.reportedAt === null) continue;
if (other.index > map.index) return false;
}
return true;
}
export type StatsRow = {
key: string;
wins: number;
losses: number;
};
export type Stats = {
byMode: StatsRow[];
byStage: StatsRow[];
byStageMode: StatsRow[];
};
/**
* Aggregates per-mode, per-stage, and per-(stage, mode) win/loss counts from
* the viewer's perspective. Maps outside `restrictToPool` (when provided) are
* skipped, as are unreported maps. Empty rows are filtered out.
*/
export function stats(
maps: ScrimMapRow[],
viewerSide: "ALPHA" | "BRAVO",
opts: { restrictToPool?: MapPool } = {},
): Stats {
const byMode = new Map<ModeShort, StatsRow>();
const byStage = new Map<StageId, StatsRow>();
const byStageMode = new Map<string, StatsRow>();
const stageModeKey = (mode: ModeShort, stageId: StageId) =>
`${stageId}-${mode}`;
const bump = <K>(
bucket: Map<K, StatsRow>,
key: K,
display: string,
isWin: boolean,
) => {
const existing = bucket.get(key);
if (existing) {
if (isWin) existing.wins += 1;
else existing.losses += 1;
return;
}
bucket.set(key, {
key: display,
wins: isWin ? 1 : 0,
losses: isWin ? 0 : 1,
});
};
for (const map of maps) {
if (map.reportedAt === null || map.winnerSide === null) continue;
if (
opts.restrictToPool &&
!opts.restrictToPool.has({ mode: map.mode, stageId: map.stageId })
) {
continue;
}
const isWin = map.winnerSide === viewerSide;
bump(byMode, map.mode, map.mode, isWin);
bump(byStage, map.stageId, String(map.stageId), isWin);
bump(
byStageMode,
stageModeKey(map.mode, map.stageId),
stageModeKey(map.mode, map.stageId),
isWin,
);
}
const filterEmpty = (rows: StatsRow[]) =>
rows.filter((r) => r.wins + r.losses > 0);
const orderedByMode: StatsRow[] = [];
for (const mode of modesShort) {
const row = byMode.get(mode);
if (row) orderedByMode.push(row);
}
return {
byMode: filterEmpty(orderedByMode),
byStage: filterEmpty([...byStage.values()]),
byStageMode: filterEmpty([...byStageMode.values()]),
};
}

View File

@ -1,7 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy } from "../../../utils/remix.server";
@ -10,6 +9,9 @@ import {
requireUser,
} from "../../auth/core/user.server";
import * as Scrim from "../core/Scrim";
import * as ScrimMapByMap from "../core/ScrimMapByMap";
import * as ScrimMapListRepository from "../ScrimMapListRepository.server";
import * as ScrimMapRepository from "../ScrimMapRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
@ -36,6 +38,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
RoomLinkRepository.findByUserIds(participantIds, 3),
]);
const mapByMap = await resolveMapByMap({ post, user });
return {
post,
chatCode:
@ -50,17 +54,38 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
anyUserPrefersNoScreen,
anyUserPrefersNoSplatnet,
roomLinks,
tournamentMapPool: post.mapsTournament
? await resolveTournamentMapPool(post.mapsTournament.id, user)
: null,
mapByMap,
};
};
async function resolveTournamentMapPool(
tournamentId: number,
user: AuthenticatedUser,
) {
const data = await tournamentDataCached({ tournamentId, user });
async function resolveMapByMap({
post,
user,
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: AuthenticatedUser;
}) {
const [mapLists, maps] = await Promise.all([
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
ScrimMapRepository.findMapsByScrimPostId(post.id),
]);
return data.ctx.toSetMapPool;
const pool = mapLists.length > 0 ? ScrimMapByMap.unionPool(mapLists) : null;
const currentMap = maps.find((m) => m.reportedAt === null) ?? null;
const viewerSide = Scrim.sideOfUser(post, user.id);
const locked = Scrim.isTrackingLocked(maps, mapLists);
const ownList = viewerSide
? mapLists.find((l) => l.side === viewerSide)
: undefined;
return {
mapLists,
maps,
currentMap,
viewerSide,
locked,
pool: pool ? pool.stageModePairs : null,
ownPool: ownList?.mapList ?? null,
};
}

View File

@ -21,3 +21,5 @@ export const SCRIM = {
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
ROOM_LINK_FRESHNESS_MINUTES: 30,
};
export const SCRIM_TRACKING_AUTO_LOCK_HOURS = 4;

View File

@ -5,15 +5,20 @@ import {
datetimeRequired,
dualSelectOptional,
idConstant,
radioGroupDynamic,
select,
selectDynamicOptional,
selectOptional,
stageSelect,
stringConstant,
textAreaOptional,
textAreaRequired,
textFieldOptional,
timeRangeOptional,
toggle,
tournamentSearchOptional,
} from "~/form/fields";
import { modesShort } from "~/modules/in-game-lists/modes";
import {
_action,
date,
@ -26,6 +31,7 @@ import {
} from "~/utils/zod";
import { associationIdentifierSchema } from "../associations/associations-schemas";
import { LUTI_DIVS, SCRIM } from "./scrims-constants";
import { parseMapPoolInput } from "./scrims-utils";
const deletePostSchema = z.object({
_action: _action("DELETE_POST"),
@ -71,7 +77,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",
@ -174,6 +181,89 @@ export const scrimsActionSchema = z.union([
persistScrimFiltersSchema,
]);
export const submitMapListFormSchema = z
.object({
_action: stringConstant("SUBMIT_MAP_LIST"),
source: radioGroupDynamic({
label: "labels.scrimMapSource",
}),
serializedPool: textFieldOptional({
label: "labels.scrimMapPool",
placeholder: "placeholders.scrimMapPool",
maxLength: 500,
validate: {
func: (val) => parseMapPoolInput(val) !== null,
message: "forms:errors.invalidMapPool",
},
}),
tournamentId: tournamentSearchOptional({
label: "labels.scrimMapsTournament",
}),
})
.superRefine((data, ctx) => {
if (!["POOL", "TOURNAMENT", "FROM_POST"].includes(data.source)) {
ctx.addIssue({
path: ["source"],
message: "forms:errors.required",
code: z.ZodIssueCode.custom,
});
}
if (data.source === "POOL" && !data.serializedPool) {
ctx.addIssue({
path: ["serializedPool"],
message: "forms:errors.invalidMapPool",
code: z.ZodIssueCode.custom,
});
}
if (data.source === "TOURNAMENT" && !data.tournamentId) {
ctx.addIssue({
path: ["tournamentId"],
message: "forms:errors.scrimTournamentRequired",
code: z.ZodIssueCode.custom,
});
}
});
const removeMapListSchema = z.object({
_action: _action("REMOVE_MAP_LIST"),
});
const reportMapSchema = z.object({
_action: _action("REPORT_MAP"),
mapId: id,
winnerSide: z.enum(["ALPHA", "BRAVO"]),
});
const undoMapSchema = z.object({
_action: _action("UNDO_MAP"),
});
const replayMapSchema = z.object({
_action: _action("REPLAY_MAP"),
});
export const pickMapFormSchema = z.object({
_action: stringConstant("PICK_MAP"),
mode: select({
label: "labels.vodMode",
items: modesShort.map((m) => ({
label: `modes.${m}` as const,
value: m,
})),
}),
stageId: stageSelect({ label: "labels.vodStage" }),
});
export const scrimIdActionSchema = z.union([
cancelScrimFormSchema,
submitMapListFormSchema,
removeMapListSchema,
reportMapSchema,
undoMapSchema,
replayMapSchema,
pickMapFormSchema,
]);
const MAX_SCRIM_POST_TEXT_LENGTH = 500;
export const RANGE_END_OPTIONS = [

View File

@ -4,6 +4,8 @@ import type { LUTI_DIVS } from "./scrims-constants";
export type LutiDiv = (typeof LUTI_DIVS)[number];
export type ScrimSide = "ALPHA" | "BRAVO";
export interface ScrimPost {
id: number;
at: number;
@ -33,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,23 @@ 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=")) {
return new URLSearchParams(trimmed).get("pool");
}
return trimmed;
}

View File

@ -435,7 +435,9 @@ function InProgressTab({
{ method: "post" },
);
}}
weaponReport={isStaffOnly ? undefined : weaponReport}
secondaryAction={
isStaffOnly ? null : <WeaponReporter {...weaponReport} />
}
actionButtons={
<>
{isStaffOnly ? (
@ -469,29 +471,28 @@ function InProgressTab({
</SendouButton>
</FormWithConfirm>
)}
{scoreIsNotZero ? (
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
isPending={undoFetcher.state !== "idle"}
onPress={() => {
const mapIndex = data.match.mapList.findLastIndex(
(m) => m.winnerGroupId !== null,
);
if (mapIndex < 0) return;
undoFetcher.submit(
{
_action: "UNDO_MAP_REPORT",
mapIndex: String(mapIndex),
},
{ method: "post" },
);
}}
>
{t("q:match.undoReport")}
</SendouButton>
) : null}
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
isPending={undoFetcher.state !== "idle"}
isDisabled={!scoreIsNotZero}
onPress={() => {
const mapIndex = data.match.mapList.findLastIndex(
(m) => m.winnerGroupId !== null,
);
if (mapIndex < 0) return;
undoFetcher.submit(
{
_action: "UNDO_MAP_REPORT",
mapIndex: String(mapIndex),
},
{ method: "post" },
);
}}
>
{t("q:match.undoReport")}
</SendouButton>
</>
}
/>

View File

@ -12,6 +12,8 @@ import {
} from "~/components/match-page/MatchBanner";
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
@ -159,18 +161,18 @@ function SendouQMatchBannerTopRow({
count: SENDOUQ_BEST_OF,
bestOf: true,
}}
time={
data.match.isLocked || awaitingConfirmation
? undefined
: {
currentMinutes: Math.max(
0,
differenceInMinutes(now, lastReportAt),
),
totalMinutes: Math.max(0, differenceInMinutes(now, startedAt)),
}
}
/>
>
{data.match.isLocked || awaitingConfirmation ? (
<MatchBannerStartedAt time={startedAt} />
) : (
<MatchBannerTimer
time={{
currentMinutes: Math.max(0, differenceInMinutes(now, lastReportAt)),
totalMinutes: Math.max(0, differenceInMinutes(now, startedAt)),
}}
/>
)}
</MatchBannerTopRow>
);
}

View File

@ -1,4 +1,3 @@
import clsx from "clsx";
import { Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
@ -130,7 +129,7 @@ export function TournamentMatchActionTab({
size="miniscule"
icon={<Undo2 size={16} />}
isPending={undoFetcher.state !== "idle"}
className={clsx({ invisible: scoreSum === 0 })}
isDisabled={scoreSum === 0}
onPress={() => {
undoFetcher.submit(
{
@ -145,7 +144,9 @@ export function TournamentMatchActionTab({
{t("q:match.undoReport")}
</SendouButton>
}
weaponReport={weaponReport ?? undefined}
secondaryAction={
weaponReport ? <WeaponReporter {...weaponReport} /> : null
}
/>
);
}

View File

@ -19,6 +19,8 @@ import {
} from "~/components/match-page/MatchBanner";
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import type { TournamentRoundMaps } from "~/db/tables";
import { useTournament } from "~/features/tournament/routes/to.$id";
@ -206,10 +208,8 @@ function TournamentMatchBannerTopRow({
)
return null;
const totalMinutes = differenceInMinutes(
currentTime,
databaseTimestampToDate(data.match.startedAt),
);
const startedAt = databaseTimestampToDate(data.match.startedAt);
const totalMinutes = differenceInMinutes(currentTime, startedAt);
const currentMinutes = resolveCurrentMinutes({
data,
@ -228,15 +228,13 @@ function TournamentMatchBannerTopRow({
count: data.match.roundMaps.count,
bestOf: data.match.roundMaps.type === "BEST_OF",
}}
time={
data.matchIsOver
? undefined
: {
currentMinutes,
totalMinutes,
}
}
/>
>
{data.matchIsOver ? (
<MatchBannerStartedAt time={startedAt} />
) : (
<MatchBannerTimer time={{ currentMinutes, totalMinutes }} />
)}
</MatchBannerTopRow>
);
}

View File

@ -17,6 +17,7 @@ import { StageSelectFormField } from "./fields/StageSelectFormField";
import { SwitchFormField } from "./fields/SwitchFormField";
import { TextareaFormField } from "./fields/TextareaFormField";
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
import { UserSearchFormField } from "./fields/UserSearchFormField";
import {
WeaponPoolFormField,
@ -28,6 +29,7 @@ import type {
ArrayItemRenderContext,
BadgeOption,
CustomFieldRenderProps,
FormFieldItemsWithImage,
FormField as FormFieldType,
SelectOption,
} from "./types";
@ -224,6 +226,22 @@ export function FormField({
);
}
if (formField.type === "radio-group-dynamic") {
if (!options) {
throw new Error("Dynamic radio group form field requires options prop");
}
const radioItems = options as FormFieldItemsWithImage<string>;
return (
<RadioGroupFormField
{...commonProps}
{...formField}
items={radioItems}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "checkbox-group") {
return (
<CheckboxGroupFormField
@ -371,6 +389,17 @@ export function FormField({
);
}
if (formField.type === "tournament-search") {
return (
<TournamentSearchFormField
{...commonProps}
{...formField}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
/>
);
}
if (formField.type === "badges") {
if (!options) {
throw new Error("Badges form field requires options prop");

View File

@ -21,6 +21,7 @@ import type {
FormFieldFieldset,
FormFieldInputGroup,
FormFieldItems,
FormFieldItemsWithImage,
FormFieldSelect,
FormsTranslationKey,
SelectOption,
@ -32,9 +33,13 @@ export type RequiresDefault<T extends z.ZodType> = T & {
_requiresDefault: true;
};
type WithTypedTranslationKeys<T> = Omit<T, "label" | "bottomText"> & {
type WithTypedTranslationKeys<T> = Omit<
T,
"label" | "bottomText" | "placeholder"
> & {
label?: FormsTranslationKey;
bottomText?: FormsTranslationKey;
placeholder?: FormsTranslationKey;
};
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
@ -102,6 +107,7 @@ export function textFieldOptional(
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
placeholder: prefixKey(args.placeholder),
required: false,
type: "text-field",
initialValue: "",
@ -125,6 +131,7 @@ export function textFieldRequired(
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
placeholder: prefixKey(args.placeholder),
required: true,
type: "text-field",
initialValue: "",
@ -429,6 +436,24 @@ export function radioGroup<V extends string>(
});
}
export function radioGroupDynamic(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "radio-group-dynamic" }>,
"type" | "initialValue"
>
>,
) {
return z.string().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "radio-group-dynamic",
initialValue: null,
}) as unknown as z.ZodType<string> &
FieldWithOptions<FormFieldItemsWithImage<string>>;
}
type DateTimeArgs = WithTypedTranslationKeys<
Omit<FormFieldDatetime<"datetime">, "type" | "initialValue" | "required">
> & {
@ -707,6 +732,24 @@ export function userSearchOptional(
});
}
export function tournamentSearchOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "tournament-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "tournament-search",
initialValue: null,
required: false,
});
}
export function badges(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "badges" }>, "type" | "initialValue">

View File

@ -1,4 +1,5 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
@ -14,6 +15,7 @@ export function InputFormField({
label,
bottomText,
leftAddon,
placeholder,
maxLength,
error,
onBlur,
@ -24,6 +26,11 @@ export function InputFormField({
onChange,
}: InputFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["forms"]);
const translatedPlaceholder = placeholder?.includes(":")
? t(placeholder as never)
: placeholder;
return (
<FormFieldWrapper
@ -45,6 +52,7 @@ export function InputFormField({
onBlur={() => onBlur?.()}
maxLength={maxLength}
disabled={disabled}
placeholder={translatedPlaceholder}
{...ariaAttributes({
id,
bottomText,

View File

@ -0,0 +1,39 @@
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type TournamentSearchFormFieldProps = FormFieldProps<"tournament-search"> & {
value: number | null;
onChange: (value: number | null) => void;
};
export function TournamentSearchFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onBlur,
}: TournamentSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<TournamentSearch
initialTournamentId={value ?? undefined}
onChange={(tournament) => onChange(tournament?.id ?? null)}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@ -24,14 +24,16 @@ export function UserSearchFormField({
return (
<div className={styles.root}>
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => onChange(user?.id ?? null)}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
<div className="stack xs">
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => onChange(user?.id ?? null)}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@ -23,6 +23,7 @@ interface FormFieldText<T extends string> extends FormFieldBase<T> {
maxLength: number;
toLowerCase?: boolean;
leftAddon?: string;
placeholder?: string;
required: boolean;
inputType?: "text" | "number";
regExp?: {
@ -139,6 +140,10 @@ interface FormFieldUserSearch<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldTournamentSearch<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldBadges<T extends string> extends FormFieldBase<T> {
maxCount?: number;
}
@ -148,6 +153,11 @@ interface FormFieldSelectDynamic<T extends string> extends FormFieldBase<T> {
searchable?: boolean;
}
interface FormFieldRadioGroupDynamic<T extends string>
extends FormFieldBase<T> {
minLength?: number;
}
interface FormFieldStageSelect<T extends string> extends FormFieldBase<T> {
required: boolean;
}
@ -165,6 +175,7 @@ export type FormField<V extends string = string> =
| FormFieldSelectDynamic<"select-dynamic">
| FormFieldDualSelect<"dual-select", V>
| FormFieldInputGroup<"radio-group", V>
| FormFieldRadioGroupDynamic<"radio-group-dynamic">
| FormFieldInputGroup<"checkbox-group", V>
| FormFieldDatetime<"datetime">
| FormFieldDatetime<"date">
@ -178,6 +189,7 @@ export type FormField<V extends string = string> =
| FormFieldTimeRange<"time-range">
| FormFieldFieldset<"fieldset", z.ZodRawShape>
| FormFieldUserSearch<"user-search">
| FormFieldTournamentSearch<"tournament-search">
| FormFieldBadges<"badges">
| FormFieldStageSelect<"stage-select">
| FormFieldWeaponSelect<"weapon-select">;

Binary file not shown.

View File

@ -1,7 +1,11 @@
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 {
scrimsNewFormSchema,
submitMapListFormSchema,
} from "~/features/scrims/scrims-schemas";
import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls";
import {
expect,
impersonate,
@ -10,9 +14,12 @@ import {
selectUser,
submit,
test,
waitForPOSTResponse,
} from "./helpers/playwright";
import { createFormHelpers } from "./helpers/playwright-form";
const TEST_POOL_SERIALIZED = "sz:3a14000;tc:2c98000";
test.describe("Scrims", () => {
test("creates a new scrim & deletes it", async ({ page }) => {
await seed(page);
@ -234,9 +241,126 @@ test.describe("Scrims", () => {
await page.getByTestId("booked-scrims-tab").click();
await page.getByRole("link", { name: "Contact" }).click();
await page.getByAltText("Generate maplist").click();
await page.getByRole("tab", { name: "Action" }).click();
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
});
// on /maps page
await expect(page.getByText("Create map list")).toBeVisible();
test("map-by-map: lists, report, undo, replay, change list, stats", async ({
page,
}) => {
await seed(page);
const scrimUrl = scrimPage(1);
const mapListForm = createFormHelpers(page, submitMapListFormSchema, {
submitTestId: "submit-map-list-button",
});
// 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();
// ADMIN submits a map list — the post's tournament (Swim or Sink) is the
// default source for the scrim author's team, so they can submit without
// running the tournament search. A first map is generated immediately
// so the page transitions to the report UI with the map-list manager
// collapsed.
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("report-score-button")).toBeVisible();
await page.getByRole("button", { name: /Manage map lists/i }).click();
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText(
"Swim or Sink",
);
// NZAP submits a pool-URL-based map list. They have no list yet so the
// map-list manager is already expanded on mount.
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await page.getByLabel("Pool URL").click();
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("report-score-button")).toBeVisible();
await expect(page.getByTestId("map-list-row-BRAVO")).toContainText("Pool");
// Map 1: ALPHA wins → next map auto-generated
await reportScrimMapWinner(page, "ALPHA");
await expect(page.getByTestId("report-score-button")).toBeVisible();
// Map 2: BRAVO wins → next map auto-generated
await reportScrimMapWinner(page, "BRAVO");
await expect(page.getByTestId("report-score-button")).toBeVisible();
// Map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4)
await reportScrimMapWinner(page, "ALPHA");
await expect(page.getByTestId("undo-map-button")).toBeVisible();
await submit(page, "undo-map-button");
await expect(page.getByTestId("report-score-button")).toBeVisible();
// Re-report map 3 as BRAVO wins → next map auto-generated
await reportScrimMapWinner(page, "BRAVO");
// Replay last map: replaces the current generated map with a copy of
// the previous reported one, then report ALPHA wins
await expect(page.getByTestId("replay-map-button")).toBeVisible();
await submit(page, "replay-map-button");
await reportScrimMapWinner(page, "ALPHA");
// Switch back to ADMIN to change their list
await impersonate(page, ADMIN_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await page.getByRole("button", { name: /Manage map lists/i }).click();
// Remove ALPHA's tournament list (trash icon opens a confirm dialog)
await page
.getByTestId("map-list-row-ALPHA")
.getByLabel(/Remove list/i)
.click();
await waitForPOSTResponse(page, () => submit(page, "confirm-button"));
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
// Re-submit ALPHA's list, this time as a pool URL
await page.getByLabel("Pool URL").click();
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText("Pool");
// Verify stats tab reflects the played maps
await page.getByRole("tab", { name: "Stats" }).click();
await expect(page.getByTestId("scrim-stats-root")).toBeVisible();
// Four reported maps total (Alpha 2 / Bravo 2 from ADMIN's POV).
// Switch to "Mode" view so each row groups by mode, and disable the
// pool restriction so maps outside ADMIN's resubmitted pool still count.
// Sum of wins+losses across rows should equal 4.
await page
.getByTestId("scrim-stats-root")
.getByText("Mode", { exact: true })
.click();
await page.getByRole("switch").click({ force: true });
const statsRoot = page.getByTestId("scrim-stats-root");
const winCells = await statsRoot
.locator("tbody tr td:nth-child(2)")
.allInnerTexts();
const lossCells = await statsRoot
.locator("tbody tr td:nth-child(3)")
.allInnerTexts();
const total =
winCells.reduce((acc, v) => acc + Number(v), 0) +
lossCells.reduce((acc, v) => acc + Number(v), 0);
expect(total).toBe(4);
});
});
async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2";
await expect(
page.locator('[data-testid^="winner-radio-"][data-selected="true"]'),
).toHaveCount(0);
await page.getByTestId(testId).click();
await submit(page, "report-score-button");
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -320,6 +320,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "",
"labels.scrimMaxDiv": "",
"labels.scrimMinDiv": "",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "",
"options.scrimFlexibility.+30min": "",
"options.scrimFlexibility.+1hour": "",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
"errors.divBothOrNeither": "",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "",
"errors.tournamentOnlyWhenMapsIsTournament": "",
"errors.visibilityMustBeDifferent": "",

View File

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

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
"associations.explanation": "",
"associations.join.title": "",
@ -89,5 +90,30 @@
"banner.canceled.header": "",
"banner.canceled.subtitle": "",
"banner.freeForm.header": "",
"banner.freeForm.subtitle": ""
"banner.freeForm.subtitle": "",
"mapByMap.nonParticipantNotice": "",
"mapByMap.noCurrentMap": "",
"mapByMap.undo": "",
"mapByMap.replay": "",
"mapByMap.pick": "",
"mapByMap.pickDialog.heading": "",
"mapByMap.removeList": "",
"mapByMap.removeListConfirm": "",
"mapByMap.submitListHeading": "",
"mapByMap.noListYet": "",
"mapByMap.manageMapLists": "",
"mapByMap.poolList": "",
"mapByMap.result.replayTag": "",
"mapByMap.stats.empty": "",
"mapByMap.stats.restrictToPool": "",
"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.winPct": ""
}

View File

@ -320,6 +320,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "",
"labels.scrimMaxDiv": "",
"labels.scrimMinDiv": "",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "",
"options.scrimFlexibility.+30min": "",
"options.scrimFlexibility.+1hour": "",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
"errors.divBothOrNeither": "",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "",
"errors.tournamentOnlyWhenMapsIsTournament": "",
"errors.visibilityMustBeDifferent": "",

View File

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

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "Geplanter Scrim",
"page.vs": "",
"associations.title": "Assoziationen",
"associations.explanation": "Erstelle eine Assoziation, um in einer kleineren Gruppe zu suchen (zum Beispiel mit regelmäßgien Übungsgegnern deines Teams oder deiner LUTI-Division).",
"associations.join.title": "Assoziation {{name}} beitreten?",
@ -89,5 +90,30 @@
"banner.canceled.header": "",
"banner.canceled.subtitle": "",
"banner.freeForm.header": "",
"banner.freeForm.subtitle": ""
"banner.freeForm.subtitle": "",
"mapByMap.nonParticipantNotice": "",
"mapByMap.noCurrentMap": "",
"mapByMap.undo": "",
"mapByMap.replay": "",
"mapByMap.pick": "",
"mapByMap.pickDialog.heading": "",
"mapByMap.removeList": "",
"mapByMap.removeListConfirm": "",
"mapByMap.submitListHeading": "",
"mapByMap.noListYet": "",
"mapByMap.manageMapLists": "",
"mapByMap.poolList": "",
"mapByMap.result.replayTag": "",
"mapByMap.stats.empty": "",
"mapByMap.stats.restrictToPool": "",
"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.winPct": ""
}

View File

@ -320,6 +320,8 @@
"chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked",
"chat.systemMsg.cancelRefused": "{{name}} refused canceling the match",
"chat.systemMsg.userLeft": "{{name}} left the group",
"chat.systemMsg.mapReplayed": "{{name}} replayed the previous map",
"chat.systemMsg.mapPicked": "{{name}} picked a map",
"chat.newMessages": "New messages",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No active chats",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "Maps",
"labels.scrimMaxDiv": "Max div",
"labels.scrimMinDiv": "Min div",
"labels.scrimMapSource": "Source",
"labels.scrimMapPool": "Map pool",
"labels.scrimMapsTournament": "Tournament",
"placeholders.scrimMapPool": "https://sendou.ink/maps?pool=sz%3A3ffffff%3Btc%3A3555555",
"options.scrimMapSource.POOL": "Pool URL",
"options.scrimMapSource.TOURNAMENT": "Tournament",
"options.scrimFlexibility.notFlexible": "Not flexible",
"options.scrimFlexibility.+30min": "+30 minutes",
"options.scrimFlexibility.+1hour": "+1 hour",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "Must have at least {{min}} users excluding yourself",
"errors.usersMustBeUnique": "Users must be unique",
"errors.divBothOrNeither": "Both min and max div must be set or neither",
"errors.invalidMapPool": "Invalid map pool",
"errors.scrimTournamentRequired": "Please select a tournament",
"errors.tournamentMustBeSelected": "Tournament must be selected when maps is tournament",
"errors.tournamentOnlyWhenMapsIsTournament": "Tournament should only be selected when maps is tournament",
"errors.visibilityMustBeDifferent": "Not found visibility must be different from base visibility",

View File

@ -207,6 +207,7 @@
"match.tabs.rosters": "Rosters",
"match.tabs.action": "Action",
"match.tabs.result": "Result",
"match.tabs.stats": "Stats",
"preparing.joinQ": "Join the queue",
"tiers.currentCriteria": "Current criteria",
"tiers.info.p1": "For example, Leviathan is the top 5% of players. Diamond is the 85th percentile etc.",

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "Tournament...",
"forms.mapsTournament.title": "Tournament",
"page.scheduledScrim": "Scheduled scrim",
"page.vs": "vs. {{opponent}}",
"associations.title": "Associations",
"associations.explanation": "Create an association to look in a smaller group (for example, make one with your team's regular practice opponents or LUTI division).",
"associations.join.title": "Join {{name}} association?",
@ -89,5 +90,30 @@
"banner.canceled.header": "Canceled by {{user}}",
"banner.canceled.subtitle": "Reason: {{reason}}",
"banner.freeForm.header": "Free form practice",
"banner.freeForm.subtitle": "Communicate the maplist with the opponents"
"banner.freeForm.subtitle": "Set a map list to start drawing maps (optional)",
"mapByMap.nonParticipantNotice": "Only participants can manage map tracking.",
"mapByMap.noCurrentMap": "Waiting for the next map to be generated.",
"mapByMap.undo": "Undo",
"mapByMap.replay": "Replay",
"mapByMap.pick": "Pick",
"mapByMap.pickDialog.heading": "Pick map",
"mapByMap.removeList": "Remove list",
"mapByMap.removeListConfirm": "Remove your map list?",
"mapByMap.submitListHeading": "Submit your map list",
"mapByMap.noListYet": "Not submitted yet",
"mapByMap.manageMapLists": "Manage map lists",
"mapByMap.poolList": "Pool ({{count}} maps)",
"mapByMap.result.replayTag": "Replay of map {{index}}",
"mapByMap.stats.empty": "No reported maps yet",
"mapByMap.stats.restrictToPool": "Restrict to my submitted pool",
"mapByMap.stats.byMode": "By mode",
"mapByMap.stats.byStage": "By stage",
"mapByMap.stats.byStageMode": "By stage & mode",
"mapByMap.stats.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.winPct": "Win %"
}

View File

@ -322,6 +322,8 @@
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "{{name}} abandonó el grupo",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "Mapas",
"labels.scrimMaxDiv": "Div. máxima",
"labels.scrimMinDiv": "Div. mínima",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "Sin flexibilidad",
"options.scrimFlexibility.+30min": "+30 minutos",
"options.scrimFlexibility.+1hour": "+1 hora",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "Debe haber al menos {{min}} usuarios sin contarte a ti",
"errors.usersMustBeUnique": "Los usuarios deben ser únicos",
"errors.divBothOrNeither": "Deben establecerse tanto la div. mínima como la máxima, o ninguna",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "Debe seleccionarse un torneo cuando los mapas son de torneo",
"errors.tournamentOnlyWhenMapsIsTournament": "El torneo solo debe seleccionarse cuando los mapas son de torneo",
"errors.visibilityMustBeDifferent": "La visibilidad de 'no encontrado' debe ser diferente a la visibilidad base",

View File

@ -207,6 +207,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "Unirte a la fila",
"tiers.currentCriteria": "Criterios actuales",
"tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.",

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "Torneo...",
"forms.mapsTournament.title": "Torneo",
"page.scheduledScrim": "Scrim programado",
"page.vs": "",
"associations.title": "Asociaciones",
"associations.explanation": "Crea una asociación para buscar en un grupo más pequeño (por ejemplo, con los oponentes habituales de tus prácticas o la división LUTI).",
"associations.join.title": "¿Unirse a la asociación {{name}}?",
@ -89,5 +90,30 @@
"banner.canceled.header": "",
"banner.canceled.subtitle": "",
"banner.freeForm.header": "",
"banner.freeForm.subtitle": ""
"banner.freeForm.subtitle": "",
"mapByMap.nonParticipantNotice": "",
"mapByMap.noCurrentMap": "",
"mapByMap.undo": "",
"mapByMap.replay": "",
"mapByMap.pick": "",
"mapByMap.pickDialog.heading": "",
"mapByMap.removeList": "",
"mapByMap.removeListConfirm": "",
"mapByMap.submitListHeading": "",
"mapByMap.noListYet": "",
"mapByMap.manageMapLists": "",
"mapByMap.poolList": "",
"mapByMap.result.replayTag": "",
"mapByMap.stats.empty": "",
"mapByMap.stats.restrictToPool": "",
"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.winPct": ""
}

View File

@ -322,6 +322,8 @@
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar el partido. El partido está cerrado",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "{{name}} se salió del grupo",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "Nuevo mensajes",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "",
"labels.scrimMaxDiv": "",
"labels.scrimMinDiv": "",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "",
"options.scrimFlexibility.+30min": "",
"options.scrimFlexibility.+1hour": "",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
"errors.divBothOrNeither": "",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "",
"errors.tournamentOnlyWhenMapsIsTournament": "",
"errors.visibilityMustBeDifferent": "",

View File

@ -207,6 +207,7 @@
"match.tabs.rosters": "",
"match.tabs.action": "",
"match.tabs.result": "",
"match.tabs.stats": "",
"preparing.joinQ": "Unirte a la fila",
"tiers.currentCriteria": "Criterios actuales",
"tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.",

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
"associations.explanation": "",
"associations.join.title": "",
@ -89,5 +90,30 @@
"banner.canceled.header": "",
"banner.canceled.subtitle": "",
"banner.freeForm.header": "",
"banner.freeForm.subtitle": ""
"banner.freeForm.subtitle": "",
"mapByMap.nonParticipantNotice": "",
"mapByMap.noCurrentMap": "",
"mapByMap.undo": "",
"mapByMap.replay": "",
"mapByMap.pick": "",
"mapByMap.pickDialog.heading": "",
"mapByMap.removeList": "",
"mapByMap.removeListConfirm": "",
"mapByMap.submitListHeading": "",
"mapByMap.noListYet": "",
"mapByMap.manageMapLists": "",
"mapByMap.poolList": "",
"mapByMap.result.replayTag": "",
"mapByMap.stats.empty": "",
"mapByMap.stats.restrictToPool": "",
"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.winPct": ""
}

View File

@ -322,6 +322,8 @@
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "",
"labels.scrimMaxDiv": "",
"labels.scrimMinDiv": "",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "",
"options.scrimFlexibility.+30min": "",
"options.scrimFlexibility.+1hour": "",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
"errors.divBothOrNeither": "",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "",
"errors.tournamentOnlyWhenMapsIsTournament": "",
"errors.visibilityMustBeDifferent": "",

View File

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

View File

@ -72,6 +72,7 @@
"forms.maps.tournament": "",
"forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
"associations.explanation": "",
"associations.join.title": "",
@ -89,5 +90,30 @@
"banner.canceled.header": "",
"banner.canceled.subtitle": "",
"banner.freeForm.header": "",
"banner.freeForm.subtitle": ""
"banner.freeForm.subtitle": "",
"mapByMap.nonParticipantNotice": "",
"mapByMap.noCurrentMap": "",
"mapByMap.undo": "",
"mapByMap.replay": "",
"mapByMap.pick": "",
"mapByMap.pickDialog.heading": "",
"mapByMap.removeList": "",
"mapByMap.removeListConfirm": "",
"mapByMap.submitListHeading": "",
"mapByMap.noListYet": "",
"mapByMap.manageMapLists": "",
"mapByMap.poolList": "",
"mapByMap.result.replayTag": "",
"mapByMap.stats.empty": "",
"mapByMap.stats.restrictToPool": "",
"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.winPct": ""
}

View File

@ -322,6 +322,8 @@
"chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé",
"chat.systemMsg.cancelRefused": "",
"chat.systemMsg.userLeft": "{{name}} a quitté le groupe",
"chat.systemMsg.mapReplayed": "",
"chat.systemMsg.mapPicked": "",
"chat.newMessages": "Nouveau message",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@ -58,6 +58,12 @@
"labels.scrimMaps": "",
"labels.scrimMaxDiv": "",
"labels.scrimMinDiv": "",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
"labels.scrimMapsTournament": "",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
"options.scrimFlexibility.notFlexible": "",
"options.scrimFlexibility.+30min": "",
"options.scrimFlexibility.+1hour": "",
@ -80,6 +86,8 @@
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
"errors.divBothOrNeither": "",
"errors.invalidMapPool": "",
"errors.scrimTournamentRequired": "",
"errors.tournamentMustBeSelected": "",
"errors.tournamentOnlyWhenMapsIsTournament": "",
"errors.visibilityMustBeDifferent": "",

Some files were not shown because too many files have changed in this diff Show More