diff --git a/app/components/Table.module.css b/app/components/Table.module.css index d36329d2c..323822fd1 100644 --- a/app/components/Table.module.css +++ b/app/components/Table.module.css @@ -5,7 +5,8 @@ } .table { - width: 100%; + width: max-content; + min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--font-xs); diff --git a/app/components/elements/Label.module.css b/app/components/elements/Label.module.css index 64590faf0..263daed2b 100644 --- a/app/components/elements/Label.module.css +++ b/app/components/elements/Label.module.css @@ -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; } diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 0fd2aa7ae..5147fb063 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -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 { diff --git a/app/components/elements/TournamentSearch.tsx b/app/components/elements/TournamentSearch.tsx index 0a3d701ca..4e115f93a 100644 --- a/app/components/elements/TournamentSearch.tsx +++ b/app/components/elements/TournamentSearch.tsx @@ -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} > diff --git a/app/components/match-page/MatchActionPickBanTab.tsx b/app/components/match-page/MatchActionPickBanTab.tsx index 8ac69a0c7..7e7833c2b 100644 --- a/app/components/match-page/MatchActionPickBanTab.tsx +++ b/app/components/match-page/MatchActionPickBanTab.tsx @@ -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({ )} - {weaponReport ? : null} + {secondaryAction} ); } diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx index d20e1aa24..34536f839 100644 --- a/app/components/match-page/MatchActionTab.tsx +++ b/app/components/match-page/MatchActionTab.tsx @@ -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(null); @@ -188,7 +188,7 @@ export function MatchActionTab({ )} - {weaponReport ? : null} + {secondaryAction} ); } diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx index 5fae48a75..02944d369 100644 --- a/app/components/match-page/MatchBanner.tsx +++ b/app/components/match-page/MatchBanner.tsx @@ -23,7 +23,7 @@ interface MatchBannerProps { screenLegal?: boolean; joinPool?: string | null; joinViaQr?: boolean; - children: React.ReactNode; + children?: React.ReactNode; } export function MatchBanner({ diff --git a/app/components/match-page/MatchBannerScheduledTime.tsx b/app/components/match-page/MatchBannerScheduledTime.tsx new file mode 100644 index 000000000..7df2e56c1 --- /dev/null +++ b/app/components/match-page/MatchBannerScheduledTime.tsx @@ -0,0 +1,24 @@ +import TimePopover from "~/components/TimePopover"; + +interface MatchBannerScheduledTimeProps { + time: Date; +} + +export function MatchBannerScheduledTime({ + time, +}: MatchBannerScheduledTimeProps) { + return ( + + ); +} diff --git a/app/components/match-page/MatchBannerStartedAt.tsx b/app/components/match-page/MatchBannerStartedAt.tsx new file mode 100644 index 000000000..53601cdb1 --- /dev/null +++ b/app/components/match-page/MatchBannerStartedAt.tsx @@ -0,0 +1,22 @@ +import { LocaleTime } from "~/components/LocaleTime"; + +interface MatchBannerStartedAtProps { + time: Date; +} + +export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) { + return ( + + ); +} diff --git a/app/components/match-page/MatchBannerTimer.tsx b/app/components/match-page/MatchBannerTimer.tsx new file mode 100644 index 000000000..0f7952416 --- /dev/null +++ b/app/components/match-page/MatchBannerTimer.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/app/components/match-page/MatchBannerTopRow.tsx b/app/components/match-page/MatchBannerTopRow.tsx index ac7c4db43..e05c168f3 100644 --- a/app/components/match-page/MatchBannerTopRow.tsx +++ b/app/components/match-page/MatchBannerTopRow.tsx @@ -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 (
- - {time ? : null} + {score ? :
} + {children}
); } -function Score({ score }: { score: MatchBannerTopRowProps["score"] }) { +function Score({ + score, +}: { + score: NonNullable; +}) { 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}
); } - -function Timer({ - time, -}: { - time: NonNullable; -}) { - 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 ( -
- - -
- ); -} diff --git a/app/components/match-page/MatchTabs.tsx b/app/components/match-page/MatchTabs.tsx index 261c7ae3f..44d9d5adb 100644 --- a/app/components/match-page/MatchTabs.tsx +++ b/app/components/match-page/MatchTabs.tsx @@ -1,4 +1,11 @@ -import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react"; +import { + BarChart3, + DoorOpen, + Key, + ScrollText, + Tally5, + Users, +} from "lucide-react"; import type * as React from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router"; @@ -19,6 +26,7 @@ export const TAB_KEYS = { ACTION: "action", JOIN: "join", RESULT: "result", + STATS: "stats", ADMIN: "admin", } as const; @@ -27,6 +35,7 @@ const TAB_ICONS: Record = { action: , join: , result: , + stats: , admin: , }; @@ -35,6 +44,7 @@ const TAB_TRANSLATION_KEYS = { action: "q:match.tabs.action", join: "common:actions.join", result: "q:match.tabs.result", + stats: "q:match.tabs.stats", admin: "common:pages.admin", } as const; diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index 657f04594..fe8d8e40d 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -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}
- - {score.alpha}-{score.bravo} - + {score ? ( + + {score.alpha}-{score.bravo} + + ) : null} {isOngoing ? ( {t("q:match.timeline.live")} diff --git a/app/components/match-page/SecondaryAction.module.css b/app/components/match-page/SecondaryAction.module.css new file mode 100644 index 000000000..47f10509e --- /dev/null +++ b/app/components/match-page/SecondaryAction.module.css @@ -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; + } +} diff --git a/app/components/match-page/SecondaryAction.tsx b/app/components/match-page/SecondaryAction.tsx new file mode 100644 index 000000000..01f98c796 --- /dev/null +++ b/app/components/match-page/SecondaryAction.tsx @@ -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 ( +
+ onOpenChange(true)} + > + {collapsedLabel} + +
+ ); + } + + return ( +
+ {standalone ? null : ( + } + onPress={() => onOpenChange(false)} + className={styles.collapseButton} + aria-label={expandedAriaLabel ?? collapsedLabel} + /> + )} + {children} +
+ ); +} diff --git a/app/components/match-page/WeaponReporter.module.css b/app/components/match-page/WeaponReporter.module.css index ea167ec24..4b056b1e9 100644 --- a/app/components/match-page/WeaponReporter.module.css +++ b/app/components/match-page/WeaponReporter.module.css @@ -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; - } -} diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx index 8508caf1f..49c7ef6bd 100644 --- a/app/components/match-page/WeaponReporter.tsx +++ b/app/components/match-page/WeaponReporter.tsx @@ -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 ( -
- } - onPress={() => handleToggle(true)} - > - {t("q:match.actions.reportWeapons")} - -
- ); - } - return ( -
} + standalone={standalone} > - {standalone ? null : ( - } - onPress={() => handleToggle(false)} - className={styles.collapseButton} - aria-label={t("q:match.actions.reportWeapons")} - /> - )} {inputTargetMap ? (
@@ -153,7 +130,7 @@ export function WeaponReporter({ ))}
) : null} -
+ ); } diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index e1bb063ee..aa797acfd 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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() { diff --git a/app/db/tables.ts b/app/db/tables.ts index 142a0970b..23d734911 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1259,6 +1259,27 @@ export interface ScrimPost { updatedAt: Generated; } +export interface ScrimMapList { + id: GeneratedAlways; + scrimPostId: number; + side: "ALPHA" | "BRAVO"; + source: "TOURNAMENT" | "POOL"; + tournamentId: number | null; + serializedPool: string | null; + updatedAt: number; +} + +export interface ScrimMap { + id: GeneratedAlways; + scrimPostId: number; + index: number; + mode: ModeShort; + stageId: StageId; + winnerSide: "ALPHA" | "BRAVO" | null; + reportedAt: number | null; + reportedByUserId: number | null; +} + 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; diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 8fe14dee9..8ad7e2d77 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -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; diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 601a12088..83ee5be2a 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -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; } diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index ecd647426..ef819e728 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -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(); + 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); + }); +}); diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index 2df7c589b..6ffb18695 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -52,6 +52,8 @@ export function* generate(args: { initialWeights?: Map; /** 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, 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(); + 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, diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index 55f3618a6..6b38b79be 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -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, - }} - /> + > + + } header={t("q:match.cancelRequested")} diff --git a/app/features/scrims/ScrimMapListRepository.server.ts b/app/features/scrims/ScrimMapListRepository.server.ts new file mode 100644 index 000000000..f8e61f3e2 --- /dev/null +++ b/app/features/scrims/ScrimMapListRepository.server.ts @@ -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; + +/** + * 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 { + 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 { + 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, +): Promise { + 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; +} diff --git a/app/features/scrims/ScrimMapRepository.server.ts b/app/features/scrims/ScrimMapRepository.server.ts new file mode 100644 index 000000000..7b663279f --- /dev/null +++ b/app/features/scrims/ScrimMapRepository.server.ts @@ -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; + 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 { + 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 { + 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 { + 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, + scrimPostId: number, +): Promise { + 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(); +} diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index be1c79f6b..6873b5a60 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -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, diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index d78e2c1c2..9f5e60a75 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -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>>; + user: ReturnType; +}) { + 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>>; + user: ReturnType; +}) { + if (!post.chatCode) return; + ChatSystemMessage.send({ + room: post.chatCode, + revalidateOnly: true, + authorUserId: user.id, + }); +} + +function broadcastMapChange({ + post, + type, + user, +}: { + post: NonNullable>>; + type: "MAP_REPLAYED" | "MAP_PICKED"; + user: ReturnType; +}) { + if (!post.chatCode) return; + ChatSystemMessage.send({ + room: post.chatCode, + type, + context: { name: user.username }, + }); +} diff --git a/app/features/scrims/components/PickMapDialog.tsx b/app/features/scrims/components/PickMapDialog.tsx new file mode 100644 index 000000000..fcbd69e2e --- /dev/null +++ b/app/features/scrims/components/PickMapDialog.tsx @@ -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 ( + + + {({ FormField }) => ( + <> + + + + )} + + + ); +} diff --git a/app/features/scrims/components/ScrimCard.module.css b/app/features/scrims/components/ScrimCard.module.css index 0277e0dd4..c3cd176d2 100644 --- a/app/features/scrims/components/ScrimCard.module.css +++ b/app/features/scrims/components/ScrimCard.module.css @@ -78,6 +78,10 @@ font-size: var(--font-xs); } +.tournamentPopoverTrigger { + height: auto; +} + .textContent { padding-inline: var(--s-4); padding-bottom: var(--s-3); diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index 4197db587..b829500be 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -202,6 +202,7 @@ function ScrimTournamentPopover({ trigger={ (); + const postTournament = data.post.mapsTournament; + const isPostAuthorSide = data.mapByMap.viewerSide === "ALPHA"; + const useFromPost = postTournament != null && isPostAuthorSide; + const defaultSource: SourceValue = useFromPost ? "FROM_POST" : "TOURNAMENT"; + + return ( +
+ + {() => ( + <> + + + + )} + +
+ ); +} + +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 ; +} + +function SourceDependentFields() { + const { values } = useFormFieldContext(); + const source = values.source as SourceValue; + + if (source === "POOL") return ; + if (source === "TOURNAMENT") return ; + return null; +} diff --git a/app/features/scrims/components/ScrimMapListManager.module.css b/app/features/scrims/components/ScrimMapListManager.module.css new file mode 100644 index 000000000..e9aa81ac4 --- /dev/null +++ b/app/features/scrims/components/ScrimMapListManager.module.css @@ -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); +} diff --git a/app/features/scrims/components/ScrimMapListManager.tsx b/app/features/scrims/components/ScrimMapListManager.tsx new file mode 100644 index 000000000..5c157439f --- /dev/null +++ b/app/features/scrims/components/ScrimMapListManager.tsx @@ -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(); + const ownList = data.mapByMap.mapLists.find((l) => l.side === viewerSide); + const [isOpen, setIsOpen] = useState(() => !ownList); + + return ( + } + standalone={standalone} + > +
+ {ownList ? null : } + +
+
+ ); +} + +function MapListsSummary({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["scrims", "q"]); + const data = useLoaderData(); + const lists = data.mapByMap.mapLists; + + const sides: ScrimSide[] = ["ALPHA", "BRAVO"]; + + return ( +
+ {sides.map((side) => { + const list = lists.find((l) => l.side === side); + const isOwn = side === viewerSide; + return ( +
+
+ {side === "ALPHA" + ? t("q:match.sides.alpha") + : t("q:match.sides.bravo")} +
+
+ {list ? ( + <> + + {isOwn ? : null} + + ) : ( + + {t("scrims:mapByMap.noListYet")} + + )} +
+
+ ); + })} +
+ ); +} + +function RemoveOwnListButton() { + const { t } = useTranslation(["scrims", "common"]); + return ( + + } + aria-label={t("scrims:mapByMap.removeList")} + /> + + ); +} + +function MapListDisplay({ + tournament, + mapCount, +}: { + tournament: { id: number; name: string } | undefined; + mapCount: number; +}) { + const { t } = useTranslation(["scrims"]); + if (tournament) { + return {tournament.name}; + } + return {t("scrims:mapByMap.poolList", { count: mapCount })}; +} diff --git a/app/features/scrims/components/ScrimMatchActionTab.module.css b/app/features/scrims/components/ScrimMatchActionTab.module.css new file mode 100644 index 000000000..1d119c6e1 --- /dev/null +++ b/app/features/scrims/components/ScrimMatchActionTab.module.css @@ -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; +} diff --git a/app/features/scrims/components/ScrimMatchActionTab.tsx b/app/features/scrims/components/ScrimMatchActionTab.tsx new file mode 100644 index 000000000..62578ca5b --- /dev/null +++ b/app/features/scrims/components/ScrimMatchActionTab.tsx @@ -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(); + const user = useUser(); + + const viewerSide = user ? Scrim.sideOfUser(data.post, user.id) : null; + + if (data.mapByMap.locked) return null; + if (!viewerSide) return ; + + if (!data.mapByMap.currentMap) { + return ( + + + + ); + } + + return ; +} + +function NotParticipantSection() { + const { t } = useTranslation(["scrims"]); + return ( + +
+ {t("scrims:mapByMap.nonParticipantNotice")} +
+
+ ); +} + +function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) { + const { t } = useTranslation(["q"]); + const data = useLoaderData(); + const fetcher = useFetcher(); + const map = data.mapByMap!.currentMap!; + const acceptedRequest = data.post.requests.find((r) => r.isAccepted)!; + + const alphaName = data.post.team + ? Scrim.sideDisplayName(data.post) + : t("q:match.groupAlpha"); + const bravoName = acceptedRequest.team + ? Scrim.sideDisplayName(acceptedRequest) + : t("q:match.groupBravo"); + + const ownTeamId = viewerSide === "ALPHA" ? ALPHA_TEAM_ID : BRAVO_TEAM_ID; + + return ( + { + fetcher.submit( + { + _action: "REPORT_MAP", + mapId: String(map.id), + winnerSide: winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO", + }, + { method: "post" }, + ); + }} + actionButtons={} + secondaryAction={} + /> + ); +} + +function MapActionButtons() { + const { t } = useTranslation(["scrims"]); + const data = useLoaderData(); + 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 ( + <> + } + isPending={undoFetcher.state !== "idle"} + isDisabled={!undoAllowed} + onPress={() => { + undoFetcher.submit({ _action: "UNDO_MAP" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.undo")} + + } + isPending={replayFetcher.state !== "idle"} + isDisabled={!replayAllowed} + onPress={() => { + replayFetcher.submit({ _action: "REPLAY_MAP" }, { method: "post" }); + }} + > + {t("scrims:mapByMap.replay")} + + } + > + {t("scrims:mapByMap.pick")} +
+ } + /> + + ); +} diff --git a/app/features/scrims/components/ScrimMatchBanner.tsx b/app/features/scrims/components/ScrimMatchBanner.tsx index dd1558f54..d34e8253f 100644 --- a/app/features/scrims/components/ScrimMatchBanner.tsx +++ b/app/features/scrims/components/ScrimMatchBanner.tsx @@ -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 = ; + if (data.post.canceled) { return ( + {topRow} } 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 ( + + {topRow} + + + ); + } return ( + {topRow} } 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 ? ( - - ) : undefined - } /> ); } -function MapsLink({ - maps, - tournamentMapPool, -}: Pick & - Pick, "tournamentMapPool">) { - const mapPool = () => { - if (tournamentMapPool) return new MapPool(tournamentMapPool); +function ScrimMatchBannerTopRow() { + const data = useLoaderData(); - 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 ( - - Generate maplist - + + + ); } diff --git a/app/features/scrims/components/ScrimMatchHeader.tsx b/app/features/scrims/components/ScrimMatchHeader.tsx index de6546bc5..8e2a1f33e 100644 --- a/app/features/scrims/components/ScrimMatchHeader.tsx +++ b/app/features/scrims/components/ScrimMatchHeader.tsx @@ -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 ( - + {opponentSide + ? t("scrims:page.vs", { + opponent: Scrim.sideDisplayName(opponentSide), + }) + : null} ); } @@ -61,7 +61,7 @@ export function ScrimMatchHeader() { function CancelScrimForm() { return ( {({ FormField }) => } diff --git a/app/features/scrims/components/ScrimMatchStatsTab.module.css b/app/features/scrims/components/ScrimMatchStatsTab.module.css new file mode 100644 index 000000000..53706a5ac --- /dev/null +++ b/app/features/scrims/components/ScrimMatchStatsTab.module.css @@ -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); +} diff --git a/app/features/scrims/components/ScrimMatchStatsTab.tsx b/app/features/scrims/components/ScrimMatchStatsTab.tsx new file mode 100644 index 000000000..e090f50c0 --- /dev/null +++ b/app/features/scrims/components/ScrimMatchStatsTab.tsx @@ -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(); + + 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("BOTH"); + const [restrictToPool, setRestrictToPool] = React.useState(Boolean(ownPool)); + + if (!viewerSide || maps.length === 0) { + return ( + +
{t("scrims:mapByMap.stats.empty")}
+
+ ); + } + + const restrictPool = restrictToPool && ownPool ? ownPool : undefined; + + const stats = ScrimMapByMap.stats(maps, viewerSide, { + restrictToPool: restrictPool, + }); + + return ( + +
+
+ + {VIEW_OPTIONS.map((option) => ( + setView(value as View)} + > + {t(`scrims:mapByMap.stats.view.${option}` as const)} + + ))} + + {ownPool ? ( + + ) : null} +
+ + {view === "MODE" ? ( + ({ + key: r.key, + label: ( + + + {t(`game-misc:MODE_LONG_${r.key as "SZ"}` as const, { + defaultValue: r.key, + })} + + ), + wins: r.wins, + losses: r.losses, + }))} + /> + ) : null} + + {view === "STAGE" ? ( + { + const stageId = Number(r.key); + return { + key: r.key, + label: ( + + + {t(`game-misc:STAGE_${stageId}` as const, { + defaultValue: r.key, + })} + + ), + wins: r.wins, + losses: r.losses, + }; + })} + /> + ) : null} + + {view === "BOTH" ? ( + { + const [stageId, mode] = r.key.split("-"); + const stageLabel = t( + `game-misc:STAGE_${Number(stageId)}` as const, + { defaultValue: stageId }, + ); + return { + key: r.key, + label: ( + + + + {stageLabel} + + ), + wins: r.wins, + losses: r.losses, + }; + })} + /> + ) : null} +
+
+ ); +} + +function StatsTable({ + rows, +}: { + rows: Array<{ + key: string; + label: React.ReactNode; + wins: number; + losses: number; + }>; +}) { + const { t } = useTranslation(["scrims"]); + + if (rows.length === 0) { + return ( +
{t("scrims:mapByMap.stats.empty")}
+ ); + } + + 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 ( + + + + + + + + + + + {sortedRows.map((row) => ( + + + + + + + ))} + +
{t("scrims:mapByMap.stats.col.label")} + {t("scrims:mapByMap.stats.col.wins")} + + {t("scrims:mapByMap.stats.col.losses")} + + {t("scrims:mapByMap.stats.col.winPct")} +
{row.label}{row.wins}{row.losses}{Math.round(row.winRate * 100)}%
+ ); +} diff --git a/app/features/scrims/components/ScrimMatchTabs.tsx b/app/features/scrims/components/ScrimMatchTabs.tsx index f036eaf76..42c3aab67 100644 --- a/app/features/scrims/components/ScrimMatchTabs.tsx +++ b/app/features/scrims/components/ScrimMatchTabs.tsx @@ -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 ( - + + + + ); } +function resolveTabs(data: ReturnType>) { + 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>, + 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, + })); +} diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 602a96b48..5a250fda8 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import { SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; import type { ScrimFilters, ScrimPost } from "../scrims-types"; import { applyFilters, + isTrackingLocked, participantIdsListFromAccepted, sideDisplayName, + sideOfUser, } from "./Scrim"; type MockUser = { id: number }; @@ -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); + }); +}); diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index 1a45a425d..a4189b126 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -1,9 +1,10 @@ import { format, isWeekend } from "date-fns"; import * as R from "remeda"; +import type { Tables } from "~/db/tables"; import { databaseTimestampToDate } from "~/utils/dates"; import { logger } from "~/utils/logger"; -import { LUTI_DIVS } from "../scrims-constants"; -import type { ScrimFilters, ScrimPost } from "../scrims-types"; +import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; +import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types"; /** Returns true if the original poster has accepted any of the requests. */ export function isAccepted(post: ScrimPost) { @@ -125,3 +126,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[] = [], + mapLists: Pick[] = [], + 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[], +): 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, +>(maps: T[]): T | undefined { + return R.firstBy( + maps.filter((m) => m.reportedAt !== null), + [(m) => m.index, "desc"], + ); +} diff --git a/app/features/scrims/core/ScrimMapByMap.test.ts b/app/features/scrims/core/ScrimMapByMap.test.ts new file mode 100644 index 000000000..11341aaed --- /dev/null +++ b/app/features/scrims/core/ScrimMapByMap.test.ts @@ -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 & { 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 }]); + }); +}); diff --git a/app/features/scrims/core/ScrimMapByMap.ts b/app/features/scrims/core/ScrimMapByMap.ts new file mode 100644 index 000000000..d0b7d6224 --- /dev/null +++ b/app/features/scrims/core/ScrimMapByMap.ts @@ -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> = { + 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[]; +}): { 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(); + const byStage = new Map(); + const byStageMode = new Map(); + + const stageModeKey = (mode: ModeShort, stageId: StageId) => + `${stageId}-${mode}`; + + const bump = ( + bucket: Map, + key: K, + display: string, + isWin: boolean, + ) => { + const existing = bucket.get(key); + if (existing) { + if (isWin) existing.wins += 1; + else existing.losses += 1; + return; + } + bucket.set(key, { + key: display, + wins: isWin ? 1 : 0, + losses: isWin ? 0 : 1, + }); + }; + + for (const map of maps) { + if (map.reportedAt === null || map.winnerSide === null) continue; + if ( + opts.restrictToPool && + !opts.restrictToPool.has({ mode: map.mode, stageId: map.stageId }) + ) { + continue; + } + + const isWin = map.winnerSide === viewerSide; + + bump(byMode, map.mode, map.mode, isWin); + bump(byStage, map.stageId, String(map.stageId), isWin); + bump( + byStageMode, + stageModeKey(map.mode, map.stageId), + stageModeKey(map.mode, map.stageId), + isWin, + ); + } + + const filterEmpty = (rows: StatsRow[]) => + rows.filter((r) => r.wins + r.losses > 0); + + const orderedByMode: StatsRow[] = []; + for (const mode of modesShort) { + const row = byMode.get(mode); + if (row) orderedByMode.push(row); + } + + return { + byMode: filterEmpty(orderedByMode), + byStage: filterEmpty([...byStage.values()]), + byStageMode: filterEmpty([...byStageMode.values()]), + }; +} diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 127684040..fb5d45bf0 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -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>>; + 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, + }; } diff --git a/app/features/scrims/scrims-constants.ts b/app/features/scrims/scrims-constants.ts index c182b0bc9..0bef4aa9b 100644 --- a/app/features/scrims/scrims-constants.ts +++ b/app/features/scrims/scrims-constants.ts @@ -21,3 +21,5 @@ export const SCRIM = { MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours ROOM_LINK_FRESHNESS_MINUTES: 30, }; + +export const SCRIM_TRACKING_AUTO_LOCK_HOURS = 4; diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index d1f5bbc98..257a297b6 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -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 = [ diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts index 7781f8d98..36a5fbcb1 100644 --- a/app/features/scrims/scrims-types.ts +++ b/app/features/scrims/scrims-types.ts @@ -4,6 +4,8 @@ import type { LUTI_DIVS } from "./scrims-constants"; export type LutiDiv = (typeof LUTI_DIVS)[number]; +export type ScrimSide = "ALPHA" | "BRAVO"; + export interface ScrimPost { id: number; at: number; @@ -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) */ diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 18d6e306c..605c23233 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -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, + ); + }); +}); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 469678237..53ad090b9 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -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; +} diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index f50e19354..5b2f72dc9 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -435,7 +435,9 @@ function InProgressTab({ { method: "post" }, ); }} - weaponReport={isStaffOnly ? undefined : weaponReport} + secondaryAction={ + isStaffOnly ? null : + } actionButtons={ <> {isStaffOnly ? ( @@ -469,29 +471,28 @@ function InProgressTab({ )} - {scoreIsNotZero ? ( - } - 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")} - - ) : null} + } + 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")} + } /> diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx index df8200997..252f621ff 100644 --- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx +++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx @@ -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 ? ( + + ) : ( + + )} + ); } diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index f9c9ca885..118fe1c0d 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -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={} 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")} } - weaponReport={weaponReport ?? undefined} + secondaryAction={ + weaponReport ? : null + } /> ); } diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx index 0cf16d47e..e006c2ce4 100644 --- a/app/features/tournament-match/components/TournamentMatchBanner.tsx +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -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 ? ( + + ) : ( + + )} + ); } diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 7d2a3d44a..59166407f 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -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; + return ( + void} + /> + ); + } + if (formField.type === "checkbox-group") { return ( void} + /> + ); + } + if (formField.type === "badges") { if (!options) { throw new Error("Badges form field requires options prop"); diff --git a/app/form/fields.ts b/app/form/fields.ts index 576dce086..285a9ffec 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -21,6 +21,7 @@ import type { FormFieldFieldset, FormFieldInputGroup, FormFieldItems, + FormFieldItemsWithImage, FormFieldSelect, FormsTranslationKey, SelectOption, @@ -32,9 +33,13 @@ export type RequiresDefault = T & { _requiresDefault: true; }; -type WithTypedTranslationKeys = Omit & { +type WithTypedTranslationKeys = Omit< + T, + "label" | "bottomText" | "placeholder" +> & { label?: FormsTranslationKey; bottomText?: FormsTranslationKey; + placeholder?: FormsTranslationKey; }; type WithTypedItemLabels = Omit & { @@ -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( }); } +export function radioGroupDynamic( + args: WithTypedTranslationKeys< + Omit< + Extract, + "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 & + FieldWithOptions>; +} + type DateTimeArgs = WithTypedTranslationKeys< Omit, "type" | "initialValue" | "required"> > & { @@ -707,6 +732,24 @@ export function userSearchOptional( }); } +export function tournamentSearchOptional( + args: WithTypedTranslationKeys< + Omit< + Extract, + "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, "type" | "initialValue"> diff --git a/app/form/fields/InputFormField.tsx b/app/form/fields/InputFormField.tsx index 844032507..7ce4d92ea 100644 --- a/app/form/fields/InputFormField.tsx +++ b/app/form/fields/InputFormField.tsx @@ -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 ( onBlur?.()} maxLength={maxLength} disabled={disabled} + placeholder={translatedPlaceholder} {...ariaAttributes({ id, bottomText, diff --git a/app/form/fields/TournamentSearchFormField.tsx b/app/form/fields/TournamentSearchFormField.tsx new file mode 100644 index 000000000..7f5e2c11e --- /dev/null +++ b/app/form/fields/TournamentSearchFormField.tsx @@ -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 ( +
+
+ onChange(tournament?.id ?? null)} + onBlur={() => onBlur?.()} + label={translatedLabel} + isRequired={required} + /> + +
+
+ ); +} diff --git a/app/form/fields/UserSearchFormField.tsx b/app/form/fields/UserSearchFormField.tsx index 66619c45c..6c78a399c 100644 --- a/app/form/fields/UserSearchFormField.tsx +++ b/app/form/fields/UserSearchFormField.tsx @@ -24,14 +24,16 @@ export function UserSearchFormField({ return (
- onChange(user?.id ?? null)} - onBlur={() => onBlur?.()} - label={translatedLabel} - isRequired={required} - /> - +
+ onChange(user?.id ?? null)} + onBlur={() => onBlur?.()} + label={translatedLabel} + isRequired={required} + /> + +
); } diff --git a/app/form/types.ts b/app/form/types.ts index d9a5cd873..d69525370 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -23,6 +23,7 @@ interface FormFieldText extends FormFieldBase { maxLength: number; toLowerCase?: boolean; leftAddon?: string; + placeholder?: string; required: boolean; inputType?: "text" | "number"; regExp?: { @@ -139,6 +140,10 @@ interface FormFieldUserSearch extends FormFieldBase { required: boolean; } +interface FormFieldTournamentSearch extends FormFieldBase { + required: boolean; +} + interface FormFieldBadges extends FormFieldBase { maxCount?: number; } @@ -148,6 +153,11 @@ interface FormFieldSelectDynamic extends FormFieldBase { searchable?: boolean; } +interface FormFieldRadioGroupDynamic + extends FormFieldBase { + minLength?: number; +} + interface FormFieldStageSelect extends FormFieldBase { required: boolean; } @@ -165,6 +175,7 @@ export type FormField = | 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 = | FormFieldTimeRange<"time-range"> | FormFieldFieldset<"fieldset", z.ZodRawShape> | FormFieldUserSearch<"user-search"> + | FormFieldTournamentSearch<"tournament-search"> | FormFieldBadges<"badges"> | FormFieldStageSelect<"stage-select"> | FormFieldWeaponSelect<"weapon-select">; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 73ae08db9..69e1be911 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index fc6d640db..ca6f91bad 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -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"); +} diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index de65aceee..91ed39710 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 6f971c1ee..394c2e555 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 634e951c4..a38d6d8bc 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index fbf3c9c51..2bc0b7179 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 84dcb6a5a..24f14ea88 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 5e4227cff..e598df12b 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 49276f862..8b32b634d 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 1af73702d..fd9c8331d 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 3aaa35045..d164f07a6 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index c4b8c7bb8..7cfc15e74 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 5385c8c24..e598c3b1d 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/locales/da/common.json b/locales/da/common.json index ab824fe9d..3bb179584 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 253962d86..e5e96fd66 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/da/q.json b/locales/da/q.json index b3bdc1a9c..0e75faffc 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/da/scrims.json b/locales/da/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/da/scrims.json +++ b/locales/da/scrims.json @@ -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": "" } diff --git a/locales/de/common.json b/locales/de/common.json index 4e1058014..302684695 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index b462c6654..54d099479 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/de/q.json b/locales/de/q.json index b9c73e68b..6d01eb6e6 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/de/scrims.json b/locales/de/scrims.json index 4524dfcaa..b9a1bbf6f 100644 --- a/locales/de/scrims.json +++ b/locales/de/scrims.json @@ -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": "" } diff --git a/locales/en/common.json b/locales/en/common.json index c9d8b26df..1eeaa0ec7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/forms.json b/locales/en/forms.json index 23f12f4a3..7b48e5f59 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/en/q.json b/locales/en/q.json index 1a44877b0..2bf6f9529 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -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.", diff --git a/locales/en/scrims.json b/locales/en/scrims.json index c69545d48..1b2c351a3 100644 --- a/locales/en/scrims.json +++ b/locales/en/scrims.json @@ -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 %" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 8a06e85e3..bcd2bb2e1 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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": "", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 8c62f50d5..955e7eda2 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 7cad5f5c5..6a836902a 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -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.", diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json index ee145a923..c14c20ad6 100644 --- a/locales/es-ES/scrims.json +++ b/locales/es-ES/scrims.json @@ -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": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 1f8659249..bc04f0b25 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 1892162d2..f79a5c90c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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": "", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 34e494aab..6510854c8 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -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.", diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/es-US/scrims.json +++ b/locales/es-US/scrims.json @@ -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": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 04a4f39ae..0bb9b4433 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 2a9745d45..3ad2ea880 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index bc5decfbe..c3ad7af21 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/fr-CA/scrims.json +++ b/locales/fr-CA/scrims.json @@ -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": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index ab70d87f3..e0fa34bfa 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index cf33b2515..c1f3531de 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 1c36b1edb..5cd0057e0 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Rejoindre la queue", "tiers.currentCriteria": "Critères actuels", "tiers.info.p1": "Par exemple, Les Léviathans font partie des 5 % des meilleurs joueurs. Le diamant est le top 15%, etc.", diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json index 92d515ac0..78490b552 100644 --- a/locales/fr-EU/scrims.json +++ b/locales/fr-EU/scrims.json @@ -72,6 +72,7 @@ "forms.maps.tournament": "", "forms.mapsTournament.title": "", "page.scheduledScrim": "Scrim programmé", + "page.vs": "", "associations.title": "Association", "associations.explanation": "Créez une ''association'' pour regarder dans un groupe plus petit (par exemple, créez une association avec les adversaires habituels de votre équipe ou avec la division LUTI).", "associations.join.title": "Rejoindre l'association {{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": "" } diff --git a/locales/he/common.json b/locales/he/common.json index b2a529e35..ec724f49d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -321,6 +321,8 @@ "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 7ebccaeb3..e2849936c 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -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": "", diff --git a/locales/he/q.json b/locales/he/q.json index 19770995e..f407e1637 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/he/scrims.json b/locales/he/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/he/scrims.json +++ b/locales/he/scrims.json @@ -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": "" } diff --git a/locales/it/common.json b/locales/it/common.json index 5428e9098..426211289 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -322,6 +322,8 @@ "chat.systemMsg.cancelConfirmed": "{{name}} ha confermato la cancellazione del match. Il match è ora bloccato", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} ha lasciato il gruppo", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "Nuovi messaggi", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 4639f98d8..dc4a6d99e 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -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": "", diff --git a/locales/it/q.json b/locales/it/q.json index 7ea0c5461..4a5a092a2 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Unisciti alla coda", "tiers.currentCriteria": "Criterio corrente", "tiers.info.p1": "Per esempio Leviathan è la top 5% dei giocatori. Diamante è l' 85esimo percentile etc.", diff --git a/locales/it/scrims.json b/locales/it/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/it/scrims.json +++ b/locales/it/scrims.json @@ -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": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index ce6b52c44..250e5db0f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -316,6 +316,8 @@ "chat.systemMsg.cancelConfirmed": "{{name}} が試合キャンセルを承認しました。試合がロックされました。", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} がグループから出ました", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "新着メッセージ", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 9d75f36a2..185a3094e 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -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": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index c910349ee..eae413cba 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "列に入る", "tiers.currentCriteria": "現在の基準", "tiers.info.p1": "例として、Leviathanはプレイヤーの上位5%、Diamondは上位15%", diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/ja/scrims.json +++ b/locales/ja/scrims.json @@ -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": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 5a1cb1723..eceab418c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -316,6 +316,8 @@ "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index ea005f94c..512393cc9 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -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": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index b9c73e68b..6d01eb6e6 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/ko/scrims.json +++ b/locales/ko/scrims.json @@ -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": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index 97546220e..23c4d0ce6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 12c6231a5..036b6167a 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -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": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index b9c73e68b..6d01eb6e6 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/nl/scrims.json +++ b/locales/nl/scrims.json @@ -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": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index eafdef11b..7515735d4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -323,6 +323,8 @@ "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 5888d9bc8..ba965ad50 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -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": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index b9c73e68b..6d01eb6e6 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "", "tiers.currentCriteria": "", "tiers.info.p1": "", diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/pl/scrims.json +++ b/locales/pl/scrims.json @@ -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": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index e17797ddc..a36bd3d06 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -322,6 +322,8 @@ "chat.systemMsg.cancelConfirmed": "{{name}} confirmou o cancelamento da partida. A partida foi trancada", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} deixou o grupo", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 40b798750..3cb0badc7 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index a7e1f7abc..ca978ae88 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Entrar na fila", "tiers.currentCriteria": "Critérios atuais", "tiers.info.p1": "Por exemplo, Leviathan é o top 5% dos jogadores. Diamond é top 15% e etc.", diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/pt-BR/scrims.json +++ b/locales/pt-BR/scrims.json @@ -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": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index 8ae3ea72d..b441140b0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -323,6 +323,8 @@ "chat.systemMsg.cancelConfirmed": "{{name}} подтвердил отмену матча. Матч теперь закрыт", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} покинул группу", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "Новые сообщения", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 25a8427bb..6f0d8f2d0 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -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": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 41035eef8..a16a58207 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "Присоединиться к очереди", "tiers.currentCriteria": "Текущие критерии", "tiers.info.p1": "Например, Leviathan - топ 5% игроков, Diamond - 85 процентиль и т.д.", diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/ru/scrims.json +++ b/locales/ru/scrims.json @@ -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": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index 884034e3a..5d95abb09 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -316,6 +316,8 @@ "chat.systemMsg.cancelConfirmed": "{{name}} 确认取消对战,本次对战已锁定", "chat.systemMsg.cancelRefused": "", "chat.systemMsg.userLeft": "{{name}} 离开了小队", + "chat.systemMsg.mapReplayed": "", + "chat.systemMsg.mapPicked": "", "chat.newMessages": "新消息", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 26ac4d04f..146f5c7a7 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -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": "", diff --git a/locales/zh/q.json b/locales/zh/q.json index 325e17aa5..5a02f6950 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -207,6 +207,7 @@ "match.tabs.rosters": "", "match.tabs.action": "", "match.tabs.result": "", + "match.tabs.stats": "", "preparing.joinQ": "开始匹配", "tiers.currentCriteria": "当前规则", "tiers.info.p1": "比如说,Leviathan是前5%的玩家,Diamond是前15%的玩家。", diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json index 76c07c585..98ee8007a 100644 --- a/locales/zh/scrims.json +++ b/locales/zh/scrims.json @@ -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": "" } diff --git a/migrations/144-scrim-map-by-map.js b/migrations/144-scrim-map-by-map.js new file mode 100644 index 000000000..19982beca --- /dev/null +++ b/migrations/144-scrim-map-by-map.js @@ -0,0 +1,44 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "ScrimMapList" ( + "id" integer primary key autoincrement, + "scrimPostId" integer not null, + "side" text not null check ("side" in ('ALPHA','BRAVO')), + "source" text not null check ("source" in ('TOURNAMENT','POOL')), + "tournamentId" integer, + "serializedPool" text, + "updatedAt" integer not null, + foreign key ("scrimPostId") references "ScrimPost"("id") on delete cascade, + foreign key ("tournamentId") references "Tournament"("id"), + unique("scrimPostId", "side") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + create table "ScrimMap" ( + "id" integer primary key autoincrement, + "scrimPostId" integer not null, + "index" integer not null, + "mode" text not null, + "stageId" integer not null, + "winnerSide" text check ("winnerSide" in ('ALPHA','BRAVO')), + "reportedAt" integer, + "reportedByUserId" integer, + foreign key ("scrimPostId") references "ScrimPost"("id") on delete cascade, + foreign key ("reportedByUserId") references "User"("id"), + unique("scrimPostId", "index") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `create index scrim_map_scrim_post_id_index_idx on "ScrimMap"("scrimPostId", "index")`, + ).run(); + + db.pragma("foreign_key_check"); + })(); +}