diff --git a/app/db/tables.ts b/app/db/tables.ts index e1ca1791c..823535c43 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -623,11 +623,11 @@ export interface TournamentMatch { /** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */ export interface TournamentMatchPickBanEvent { - type: "PICK" | "BAN"; - stageId: StageId; - mode: ModeShort; + type: "PICK" | "BAN" | "ROLL" | "MODE_PICK" | "MODE_BAN"; + stageId: StageId | null; + mode: ModeShort | null; matchId: number; - authorId: number; + authorId: number | null; number: number; createdAt: GeneratedAlways; } @@ -677,6 +677,36 @@ export interface TournamentRoundMaps { count: number; type: "BEST_OF" | "PLAY_ALL"; pickBan?: PickBan.Type | null; + customFlow?: CustomPickBanFlow | null; +} + +export const WHO_SIDES = [ + "ALPHA", + "BRAVO", + "HIGHER_SEED", + "LOWER_SEED", + "WINNER", + "LOSER", +] as const; +export type WhoSide = (typeof WHO_SIDES)[number]; + +export const ACTION_TYPES = [ + "ROLL", + "PICK", + "BAN", + "MODE_PICK", + "MODE_BAN", +] as const; +export type ActionType = (typeof ACTION_TYPES)[number]; + +export interface CustomPickBanStep { + action: ActionType; + side?: WhoSide; +} + +export interface CustomPickBanFlow { + preSet: CustomPickBanStep[]; + postGame: CustomPickBanStep[]; } /** diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 85452ac40..118a802ee 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -488,8 +488,16 @@ export type MapListMap = { * - "BOTH" both teams picked the map * - "TO" if it was a TO pick (from predefined maplist) * - "COUNTERPICK" if it was a counterpick + * - "ROLL" if it was randomly selected */ - source: number | "DEFAULT" | "TIEBREAKER" | "BOTH" | "TO" | "COUNTERPICK"; + source: + | number + | "DEFAULT" + | "TIEBREAKER" + | "BOTH" + | "TO" + | "COUNTERPICK" + | "ROLL"; winnerTeamId: number | null; participatedUserIds: Array | null; /** (round robin only) points of the match used for tiebreaker purposes. e.g. [100, 0] indicates a knockout. */ diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts index b624f224f..ec8a02ad9 100644 --- a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts @@ -17,10 +17,12 @@ import { } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; +import { executeRoll } from "../core/executeRoll.server"; import { resolveMapList } from "../core/mapList.server"; import * as PickBan from "../core/PickBan"; import { clearTournamentDataCache, + type TournamentDataTeam, tournamentFromDB, } from "../core/Tournament.server"; import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.server"; @@ -284,13 +286,27 @@ export const action: ActionFunction = async ({ params, request }) => { `Undoing score: Position: ${data.position}; User ID: ${user.id}; Match ID: ${match.id}`, ); - const pickBanEventToDeleteNumber = await (async () => { - if (!match.roundMaps?.pickBan) return; + const pickBanEventNumbersToDelete = await (async () => { + if (!match.roundMaps?.pickBan) return []; const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( match.id, ); + if (match.roundMaps.pickBan === "CUSTOM") { + const customFlow = match.roundMaps.customFlow; + if (!customFlow) return []; + + // event DB numbers are 1-indexed + const threshold = + customFlow.preSet.length + + (results.length - 1) * customFlow.postGame.length + + 1; + return pickBanEvents + .filter((e) => e.number >= threshold) + .map((e) => e.number); + } + const unplayedPicks = pickBanEvents .filter((e) => e.type === "PICK") .filter( @@ -301,7 +317,7 @@ export const action: ActionFunction = async ({ params, request }) => { ); invariant(unplayedPicks.length <= 1, "Too many unplayed picks"); - return unplayedPicks[0]?.number; + return unplayedPicks[0] ? [unplayedPicks[0].number] : []; })(); sql.transaction(() => { @@ -321,8 +337,8 @@ export const action: ActionFunction = async ({ params, request }) => { manager.reset.matchResults(match.id); } - if (typeof pickBanEventToDeleteNumber === "number") { - deletePickBanEvent({ matchId, number: pickBanEventToDeleteNumber }); + for (const number of pickBanEventNumbersToDelete) { + deletePickBanEvent({ matchId, number }); } })(); @@ -421,48 +437,99 @@ export const action: ActionFunction = async ({ params, request }) => { match.roundMaps && match.opponentOne?.id && match.opponentTwo?.id, "Missing fields to pick/ban", ); - const pickerTeamId = PickBan.turnOf({ + + const currentPickBanEvents = + await TournamentRepository.pickBanEventsByMatchId(match.id); + + const turnOfResult = PickBan.turnOf({ results, maps: match.roundMaps, - teams: [match.opponentOne.id, match.opponentTwo.id], + teams: [ + { id: match.opponentOne.id, seed: teamOne.seed }, + { id: match.opponentTwo.id, seed: teamTwo.seed }, + ], mapList, + pickBanEventCount: currentPickBanEvents.length, }); - errorToastIfFalsy(pickerTeamId, "Not time to pick/ban"); + errorToastIfFalsy(turnOfResult, "Not time to pick/ban"); + const pickerTeamId = turnOfResult.teamId; + const actionType = turnOfResult.action; errorToastIfFalsy( tournament.isOrganizer(user) || tournament.ownedTeamByUser(user)?.id === pickerTeamId, "Unauthorized", ); - errorToastIfFalsy( - PickBan.isLegal({ - results, - map: data, - maps: match.roundMaps, - toSetMapPool: - tournament.ctx.mapPickingStyle === "TO" - ? await TournamentRepository.findTOSetMapPoolById(tournamentId) - : [], - mapList, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - teams: [teamOne, teamTwo], - pickerTeamId, - }), - "Illegal pick", - ); + const isModeAction = + actionType === "MODE_PICK" || actionType === "MODE_BAN"; + + const pickBanLegalityArgs = { + results, + maps: match.roundMaps, + toSetMapPool: + tournament.ctx.mapPickingStyle === "TO" + ? await TournamentRepository.findTOSetMapPoolById(tournamentId) + : [], + mapList, + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + teams: [teamOne, teamTwo] as [TournamentDataTeam, TournamentDataTeam], + pickerTeamId, + pickBanEvents: currentPickBanEvents, + }; + + if (isModeAction) { + errorToastIfFalsy( + PickBan.isModeLegal({ + mode: data.mode, + ...pickBanLegalityArgs, + }), + "Illegal mode", + ); + } else { + errorToastIfFalsy( + typeof data.stageId === "number", + "Stage is required for map actions", + ); + errorToastIfFalsy( + PickBan.isLegal({ + map: { stageId: data.stageId, mode: data.mode }, + ...pickBanLegalityArgs, + }), + "Illegal pick", + ); + } + + const eventType = (() => { + if (match.roundMaps.pickBan === "CUSTOM") return actionType; + if (match.roundMaps.pickBan === "BAN_2") return "BAN" as const; + return "PICK" as const; + })(); - const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( - match.id, - ); await TournamentRepository.addPickBanEvent({ authorId: user.id, matchId: match.id, - stageId: data.stageId, + stageId: isModeAction ? null : data.stageId!, mode: data.mode, - number: pickBanEvents.length + 1, - type: match.roundMaps.pickBan === "BAN_2" ? "BAN" : "PICK", + number: currentPickBanEvents.length + 1, + type: eventType, }); + // Chain roll after action for CUSTOM flow + if (match.roundMaps.pickBan === "CUSTOM" && match.roundMaps.customFlow) { + const updatedEvents = await TournamentRepository.pickBanEventsByMatchId( + match.id, + ); + await executeRoll({ + matchId: match.id, + maps: match.roundMaps, + pickBanEvents: updatedEvents, + results, + tournamentId, + teams: [teamOne, teamTwo], + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + }); + } + emitMatchUpdate = true; break; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 8ab827222..4d7b4490d 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -17,7 +17,7 @@ import { InfoPopover } from "~/components/InfoPopover"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; -import type { TournamentRoundMaps } from "~/db/tables"; +import type { CustomPickBanFlow, TournamentRoundMaps } from "~/db/tables"; import { useTournament, useTournamentPreparedMaps, @@ -44,6 +44,7 @@ import { type TournamentRoundMapList, } from "../core/toMapList"; import styles from "./BracketMapListDialog.module.css"; +import { CustomFlowBuilder } from "./CustomFlowBuilder"; export function BracketMapListDialog({ close, @@ -166,6 +167,9 @@ export function BracketMapListDialog({ Array.from(maps.values()).find((round) => round.pickBan)?.pickBan ?? "COUNTERPICK", ); + const [customFlow, setCustomFlow] = React.useState( + preparedMaps?.maps.find((m) => m.customFlow)?.customFlow ?? null, + ); const [hoveredMap, setHoveredMap] = React.useState(null); const roundsWithPickBan = new Set( @@ -279,6 +283,27 @@ export function BracketMapListDialog({ return true; }; + const validateCustomFlow = () => { + if (pickBanStyle !== "CUSTOM") return true; + if (roundsWithPickBan.size === 0) return true; + if (!customFlow) return false; + + return ( + PickBan.validateCustomFlowSection(customFlow.preSet, "preSet").length === + 0 && + PickBan.validateCustomFlowSection(customFlow.postGame, "postGame") + .length === 0 + ); + }; + + const validateCustomFlowRoundsSelected = () => { + if (globalSelections) return true; + if (pickBanStyle !== "CUSTOM") return true; + if (!customFlow) return true; + + return roundsWithPickBan.size > 0; + }; + const lacksToSetMapPool = tournament.ctx.toSetMapPool.length === 0 && tournament.ctx.mapPickingStyle === "TO"; @@ -314,6 +339,7 @@ export function BracketMapListDialog({ roundId: key, groupId: rounds.find((r) => r.id === key)?.group_id, type: countType, + customFlow: value.pickBan === "CUSTOM" ? customFlow : undefined, })), )} /> @@ -466,6 +492,9 @@ export function BracketMapListDialog({ ) : null} + {pickBanStyle === "CUSTOM" && !needsToPickEliminationTeamCount ? ( + + ) : null} {needsToPickEliminationTeamCount ? (
Pick the expected teams count above to prepare maps @@ -623,10 +652,19 @@ export function BracketMapListDialog({ })}
{!validateNoDecreasingCount() ? ( -
+
Invalid selection: tournament progression decreases in map count
+ ) : !validateCustomFlow() ? ( +
+ Invalid selection: custom pick/ban flow is invalid +
+ ) : !validateCustomFlowRoundsSelected() ? ( +
+ Custom flow is configured but no rounds have pick/ban + enabled +
) : (
    - {nullFilledArray( - maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count, - ).map((_, i) => { - const map = maps.list?.[i]; - - if (map) { - return ( - ( + { - onRoundMapListChange({ - ...maps, - list: maps.list?.map((m, j) => (i === j ? map : m)), - }); - }} + isCounterpicks={false} + isTiebreaker={false} + isCustomFlow /> - ); - } + )) + : nullFilledArray( + maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count, + ).map((_, i) => { + const map = maps.list?.[i]; - const isTeamsPick = !maps.list && i === 0; - const isLast = - i === (maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count) - 1; - - return ( - { + onRoundMapListChange({ + ...maps, + list: maps.list?.map((m, j) => (i === j ? map : m)), + }); + }} + /> + ); } - /> - ); - })} + + const isTeamsPick = !maps.list && i === 0; + const isLast = + i === + (maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count) - 1; + + return ( + + ); + })}
); @@ -1066,10 +1118,12 @@ function MysteryRow({ number, isCounterpicks, isTiebreaker, + isCustomFlow, }: { number: number; isCounterpicks: boolean; isTiebreaker: boolean; + isCustomFlow?: boolean; }) { return (
  • @@ -1079,11 +1133,13 @@ function MysteryRow({ })} > {number}. - {isCounterpicks - ? "Counterpick" - : isTiebreaker - ? "Tiebreaker" - : "Team's pick"} + {isCustomFlow + ? "Custom flow" + : isCounterpicks + ? "Counterpick" + : isTiebreaker + ? "Tiebreaker" + : "Team's pick"}
  • ); diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.browser.test.tsx b/app/features/tournament-bracket/components/CustomFlowBuilder.browser.test.tsx new file mode 100644 index 000000000..a7e222ba2 --- /dev/null +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.browser.test.tsx @@ -0,0 +1,268 @@ +import type { ComponentProps } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { describe, expect, test, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import type { CustomPickBanFlow } from "~/db/tables"; +import { CustomFlowBuilder } from "./CustomFlowBuilder"; + +const defaultProps: ComponentProps = { + value: null, + onChange: vi.fn(), +}; + +function renderComponent( + props: Partial> = {}, +) { + const router = createMemoryRouter( + [ + { + path: "/", + element: , + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +describe("CustomFlowBuilder", () => { + describe("initial rendering", () => { + test("renders all who-side palette chips", async () => { + const screen = await renderComponent(); + + await expect.element(screen.getByText("Team Alpha")).toBeVisible(); + await expect.element(screen.getByText("Team Bravo")).toBeVisible(); + await expect.element(screen.getByText("Higher Seed")).toBeVisible(); + await expect.element(screen.getByText("Lower Seed")).toBeVisible(); + await expect.element(screen.getByText("Winner")).toBeVisible(); + await expect.element(screen.getByText("Loser")).toBeVisible(); + }); + + test("renders all action palette chips", async () => { + const screen = await renderComponent(); + + await expect.element(screen.getByText("Random legal map")).toBeVisible(); + await expect.element(screen.getByText("Pick (map)")).toBeVisible(); + await expect.element(screen.getByText("Ban (map)")).toBeVisible(); + await expect.element(screen.getByText("Pick (mode)")).toBeVisible(); + await expect.element(screen.getByText("Ban (mode)")).toBeVisible(); + }); + + test("renders section tab headers", async () => { + const screen = await renderComponent(); + + await expect.element(screen.getByText("Before set")).toBeVisible(); + await expect.element(screen.getByText("After map")).toBeVisible(); + }); + + test("renders Add step button", async () => { + const screen = await renderComponent(); + + await expect + .element(screen.getByRole("button", { name: "Add step" })) + .toBeVisible(); + }); + + test("renders empty step placeholders for active section", async () => { + const screen = await renderComponent(); + + const whoPlaceholders = screen.container.querySelectorAll( + '[class*="dropZoneWho"]', + ); + const actionPlaceholders = screen.container.querySelectorAll( + '[class*="dropZoneAction"]', + ); + + expect(whoPlaceholders.length).toBe(1); + expect(actionPlaceholders.length).toBe(1); + }); + + test("does not call onChange on mount", async () => { + const onChange = vi.fn(); + await renderComponent({ onChange }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe("add step", () => { + test("clicking Add step adds a new step row", async () => { + const screen = await renderComponent(); + + await screen.getByRole("button", { name: "Add step" }).click(); + + const stepRows = screen.container.querySelectorAll('[class*="stepRow"]'); + expect(stepRows.length).toBe(2); + }); + + test("adding step calls onChange with null", async () => { + const onChange = vi.fn(); + const screen = await renderComponent({ onChange }); + + await screen.getByRole("button", { name: "Add step" }).click(); + + expect(onChange).toHaveBeenCalledWith(null); + }); + }); + + describe("remove step", () => { + test("remove button is hidden when section has only one step", async () => { + const screen = await renderComponent(); + + const removeButtons = screen.container.querySelectorAll( + '[class*="removeButton"]', + ); + + for (const btn of removeButtons) { + expect(btn.className).toContain("removeButtonHidden"); + } + }); + + test("clicking remove reduces step count back to one", async () => { + const screen = await renderComponent(); + + await screen.getByRole("button", { name: "Add step" }).click(); + + let stepRows = screen.container.querySelectorAll('[class*="stepRow"]'); + expect(stepRows.length).toBe(2); + + await screen.getByLabelText("Remove step").first().click(); + + stepRows = screen.container.querySelectorAll('[class*="stepRow"]'); + expect(stepRows.length).toBe(1); + }); + + test("remove button becomes hidden after removing down to one step", async () => { + const screen = await renderComponent(); + + await screen.getByRole("button", { name: "Add step" }).click(); + + await screen.getByLabelText("Remove step").first().click(); + + const removeButtons = screen.container.querySelectorAll( + '[aria-label="Remove step"]', + ); + for (const btn of removeButtons) { + expect(btn.className).toContain("removeButtonHidden"); + } + }); + }); + + describe("pre-populated value", () => { + test("renders filled drop zones from value prop", async () => { + const value: CustomPickBanFlow = { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "WINNER" }], + }; + + const screen = await renderComponent({ value }); + + const filledDropZones = screen.container.querySelectorAll( + '[class*="dropZoneFilled"]', + ); + const filledTexts = Array.from(filledDropZones).map( + (el) => el.textContent, + ); + + expect(filledTexts).toContain("Higher Seed"); + expect(filledTexts).toContain("Ban (map)"); + expect(filledTexts).toContain("Lower Seed"); + expect(filledTexts).toContain("Pick (map)"); + }); + + test("ROLL steps do not render who drop zone", async () => { + const value: CustomPickBanFlow = { + preSet: [{ action: "ROLL" }], + postGame: [{ action: "PICK", side: "ALPHA" }], + }; + + const screen = await renderComponent({ value }); + + const filledDropZones = screen.container.querySelectorAll( + '[class*="dropZoneFilled"]', + ); + const filledTexts = Array.from(filledDropZones).map( + (el) => el.textContent, + ); + expect(filledTexts).toContain("Random legal map"); + + const whoDropZones = screen.container.querySelectorAll( + '[class*="dropZoneWho"]', + ); + expect(whoDropZones.length).toBe(0); + }); + + test("does not call onChange on mount with complete value", async () => { + const onChange = vi.fn(); + const value: CustomPickBanFlow = { + preSet: [{ action: "PICK", side: "HIGHER_SEED" }], + postGame: [{ action: "PICK", side: "WINNER" }], + }; + + await renderComponent({ value, onChange }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe("validation errors", () => { + test("shows missing action error for empty steps", async () => { + const screen = await renderComponent(); + + await expect + .element(screen.getByText("Every step must have an action")) + .toBeVisible(); + }); + + test("shows no validation errors for complete valid flow", async () => { + const value: CustomPickBanFlow = { + preSet: [{ action: "PICK", side: "HIGHER_SEED" }], + postGame: [{ action: "PICK", side: "WINNER" }], + }; + + const screen = await renderComponent({ value }); + + const errors = screen.container.querySelectorAll( + '[class*="validationError"]', + ); + expect(errors.length).toBe(0); + }); + + test("shows last step must be pick or roll error", async () => { + const value: CustomPickBanFlow = { + preSet: [{ action: "BAN", side: "HIGHER_SEED" }], + postGame: [{ action: "PICK", side: "WINNER" }], + }; + + const screen = await renderComponent({ value }); + + await expect + .element( + screen.getByText( + "Last step must be Pick or Roll (to determine the map)", + ), + ) + .toBeVisible(); + }); + + test("shows too many map picks error", async () => { + const value: CustomPickBanFlow = { + preSet: [ + { action: "PICK", side: "HIGHER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "WINNER" }], + }; + + const screen = await renderComponent({ value }); + + await expect + .element(screen.getByText("At most one Pick or Roll per section")) + .toBeVisible(); + }); + }); +}); diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css new file mode 100644 index 000000000..00c1ff33a --- /dev/null +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css @@ -0,0 +1,188 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--s-4); + padding: var(--s-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-box); + background-color: var(--color-bg); +} + +.palette { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.paletteGroup { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: center; +} + +.paletteLabel { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-second); + min-width: 3rem; +} + +.chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + font-size: var(--font-xs); + font-weight: var(--weight-semi); + cursor: grab; + user-select: none; + white-space: nowrap; + border: 1px solid var(--color-border); + background-color: var(--color-bg-high); + color: var(--color-text); + touch-action: none; +} + +.chipWho { + border-radius: 4px; +} + +.chipAction { + border-radius: var(--radius-full); +} + +.chipDragging { + opacity: 0.5; +} + +.sections { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s-4); +} + +.section { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.sectionHeader { + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.stepList { + display: flex; + flex-direction: column; + gap: var(--s-1); + min-height: 3rem; +} + +.stepRow { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1) var(--s-2); + background-color: var(--color-bg-high); + border-radius: var(--radius-field); + border: 1px solid var(--color-border); +} + +.stepRowDragging { + opacity: 0.5; +} + +.dragHandle { + cursor: grab; + color: var(--color-text-second); + display: flex; + align-items: center; + touch-action: none; + flex-shrink: 0; + background: none; + border: none; + padding: 0; +} + +.dropZone { + flex: 1; + min-height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--color-border); + font-size: var(--font-xs); + color: var(--color-text-high); + transition: + border-color 0.15s, + background-color 0.15s; +} + +.dropZoneWho { + border-radius: 4px; + max-width: 8rem; + min-width: 6rem; +} + +.dropZoneAction { + border-radius: var(--radius-full); +} + +.dropZoneOver { + border-color: var(--color-text-accent); + background-color: color-mix( + in srgb, + var(--color-text-accent) 10%, + transparent + ); +} + +.dropZoneInvalid { + border-color: var(--color-error); + background-color: color-mix(in srgb, var(--color-error) 10%, transparent); +} + +.dropZoneFilled { + border-style: solid; + border-color: var(--color-border); + background-color: var(--color-bg-higher); + color: var(--color-text); +} + +.removeButton { + background: none; + border: none; + color: var(--color-text-second); + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + flex-shrink: 0; + border-radius: var(--radius-full); + + &:hover { + color: var(--color-error); + background-color: color-mix(in srgb, var(--color-error) 10%, transparent); + } +} + +.removeButtonHidden { + visibility: hidden; +} + +.addStepButton { + font-size: var(--font-xs); + align-self: flex-start; +} + +.validationError { + font-size: var(--font-xs); + color: var(--color-error); + margin-block-start: var(--s-1); +} + +.overlay { + z-index: 999; +} diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.tsx b/app/features/tournament-bracket/components/CustomFlowBuilder.tsx new file mode 100644 index 000000000..14d317542 --- /dev/null +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.tsx @@ -0,0 +1,649 @@ +import type { + DragEndEvent, + DragOverEvent, + DragStartEvent, +} from "@dnd-kit/core"; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + pointerWithin, + TouchSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import clsx from "clsx"; +import { GripVertical, Plus, X } from "lucide-react"; +import { nanoid } from "nanoid"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; +import { + ACTION_TYPES, + type ActionType, + type CustomPickBanFlow, + type CustomPickBanStep, + WHO_SIDES, + type WhoSide, +} from "~/db/tables"; +import { useLayoutSize } from "~/hooks/useMainContentWidth"; +import { + type CustomFlowValidationError, + validateCustomFlowSection, +} from "../core/PickBan"; +import styles from "./CustomFlowBuilder.module.css"; + +const WHO_I18N_KEYS = { + ALPHA: "tournament:customFlow.who.ALPHA", + BRAVO: "tournament:customFlow.who.BRAVO", + HIGHER_SEED: "tournament:customFlow.who.HIGHER_SEED", + LOWER_SEED: "tournament:customFlow.who.LOWER_SEED", + WINNER: "tournament:customFlow.who.WINNER", + LOSER: "tournament:customFlow.who.LOSER", +} as const; + +const ACTION_I18N_KEYS = { + ROLL: "tournament:customFlow.action.ROLL", + PICK: "tournament:customFlow.action.PICK", + BAN: "tournament:customFlow.action.BAN", + MODE_PICK: "tournament:customFlow.action.MODE_PICK", + MODE_BAN: "tournament:customFlow.action.MODE_BAN", +} as const; + +const BEFORE_SET_INVALID_WHO: ReadonlySet = new Set([ + "WINNER", + "LOSER", +]); + +function validationErrorToI18nKey(error: CustomFlowValidationError) { + switch (error) { + case "STEP_MISSING_ACTION": + return "tournament:customFlow.validation.stepMissingAction" as const; + case "STEP_MISSING_WHO": + return "tournament:customFlow.validation.stepMissingWho" as const; + case "LAST_STEP_MUST_BE_PICK_OR_ROLL": + return "tournament:customFlow.validation.lastStepMustBePickOrRoll" as const; + case "WINNER_LOSER_IN_PRE_SET": + return "tournament:customFlow.validation.winnerLoserInPreSet" as const; + case "TOO_MANY_MODE_PICKS": + return "tournament:customFlow.validation.tooManyModePicks" as const; + case "TOO_MANY_MAP_PICKS": + return "tournament:customFlow.validation.tooManyMapPicks" as const; + case "SAME_TEAM_MODE_AND_MAP_PICK": + return "tournament:customFlow.validation.sameTeamModeAndMapPick" as const; + } +} + +interface PartialStep { + id: string; + action?: ActionType; + side?: WhoSide; +} + +export function CustomFlowBuilder({ + value, + onChange, +}: { + value: CustomPickBanFlow | null; + onChange: (flow: CustomPickBanFlow | null) => void; +}) { + const { t } = useTranslation(["tournament"]); + const [preSetSteps, setPreSetSteps] = React.useState(() => + value?.preSet.length + ? value.preSet.map((s) => ({ id: nanoid(), ...s })) + : [{ id: nanoid() }], + ); + const [postGameSteps, setPostGameSteps] = React.useState(() => + value?.postGame.length + ? value.postGame.map((s) => ({ id: nanoid(), ...s })) + : [{ id: nanoid() }], + ); + const [activeDragId, setActiveDragId] = React.useState(null); + const [dragOverInfo, setDragOverInfo] = React.useState<{ + overId: string; + valid: boolean; + } | null>(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const syncFlow = (newPreSet: PartialStep[], newPostGame: PartialStep[]) => { + const preSetComplete = stepsToFlow(newPreSet); + const postGameComplete = stepsToFlow(newPostGame); + + if (preSetComplete && postGameComplete) { + onChange({ preSet: preSetComplete, postGame: postGameComplete }); + } else { + onChange(null); + } + }; + + const updatePreSetSteps = (newSteps: PartialStep[]) => { + setPreSetSteps(newSteps); + syncFlow(newSteps, postGameSteps); + }; + + const updatePostGameSteps = (newSteps: PartialStep[]) => { + setPostGameSteps(newSteps); + syncFlow(preSetSteps, newSteps); + }; + + const handleDragStart = (event: DragStartEvent) => { + setActiveDragId(String(event.active.id)); + }; + + const handleDragOver = (event: DragOverEvent) => { + if (!event.over || !activeDragId) { + setDragOverInfo(null); + return; + } + + const overId = String(event.over.id); + const valid = isDropValid(activeDragId, overId); + setDragOverInfo({ overId, valid }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragId(null); + setDragOverInfo(null); + + const { active, over } = event; + if (!over) return; + + const activeId = String(active.id); + const overId = String(over.id); + + // palette chip drop onto a slot + if (activeId.startsWith("palette-")) { + handlePaletteDrop(activeId, overId); + return; + } + + // row reordering within a section + handleRowReorder(activeId, overId); + }; + + const handlePaletteDrop = (activeId: string, overId: string) => { + const chipType = activeId.replace("palette-", ""); + const isWhoChip = WHO_SIDES.includes(chipType as WhoSide); + const isActionChip = ACTION_TYPES.includes(chipType as ActionType); + + const dropParts = overId.split("-"); + const section = dropParts[0]; + const stepIdx = Number(dropParts[1]); + const slotType = dropParts[2]; // "who" or "action" + + if (slotType === "who" && !isWhoChip) return; + if (slotType === "action" && !isActionChip) return; + + // validate Winner/Loser in Before set + if ( + section === "preSet" && + slotType === "who" && + isWhoChip && + BEFORE_SET_INVALID_WHO.has(chipType as WhoSide) + ) { + return; + } + + const steps = section === "preSet" ? preSetSteps : postGameSteps; + const setSteps = + section === "preSet" ? updatePreSetSteps : updatePostGameSteps; + + const newSteps = [...steps]; + const step = { ...newSteps[stepIdx] }; + + if (slotType === "who") { + step.side = chipType as WhoSide; + } else { + step.action = chipType as ActionType; + if (chipType === "ROLL") { + step.side = undefined; + } + } + + newSteps[stepIdx] = step; + setSteps(newSteps); + }; + + const handleRowReorder = (activeId: string, overId: string) => { + if (!activeId.startsWith("row-") || !overId.startsWith("row-")) return; + + const activeParts = activeId.split("-"); + const overParts = overId.split("-"); + const activeSection = activeParts[1]; + const overSection = overParts[1]; + + if (activeSection !== overSection) return; + + const activeIdx = Number(activeParts[2]); + const overIdx = Number(overParts[2]); + + if (activeIdx === overIdx) return; + + const steps = activeSection === "preSet" ? preSetSteps : postGameSteps; + const setSteps = + activeSection === "preSet" ? updatePreSetSteps : updatePostGameSteps; + + setSteps(arrayMove(steps, activeIdx, overIdx)); + }; + + const layoutSize = useLayoutSize(); + const isMobile = layoutSize === "mobile"; + + const preSetErrors = validateCustomFlowSection(preSetSteps, "preSet").map( + (e) => t(validationErrorToI18nKey(e)), + ); + const postGameErrors = validateCustomFlowSection( + postGameSteps, + "postGame", + ).map((e) => t(validationErrorToI18nKey(e))); + + return ( + +
    + + + {isMobile ? ( + + + + {t("tournament:customFlow.beforeSet")} + + + {t("tournament:customFlow.afterMap")} + + + + + + + + + + ) : ( +
    + + +
    + )} +
    + + + {activeDragId ? : null} + +
    + ); +} + +function ChipPalette() { + const { t } = useTranslation(["tournament"]); + + return ( +
    +
    + + {t("tournament:customFlow.who")} + + {WHO_SIDES.map((who) => ( + + ))} +
    +
    + + {t("tournament:customFlow.action")} + + {ACTION_TYPES.map((action) => ( + + ))} +
    +
    + ); +} + +function PaletteChip({ + id, + type, + label, +}: { + id: string; + type: "who" | "action"; + label: string; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id, + }); + + return ( +
    + {label} +
    + ); +} + +function StepListSection({ + title, + section, + steps, + onStepsChange, + errors, + dragOverInfo, +}: { + title?: string; + section: "preSet" | "postGame"; + steps: PartialStep[]; + onStepsChange: (steps: PartialStep[]) => void; + errors: string[]; + dragOverInfo: { overId: string; valid: boolean } | null; +}) { + const { t } = useTranslation(["tournament"]); + + const addStep = () => { + onStepsChange([...steps, { id: nanoid() }]); + }; + + const removeStep = (idx: number) => { + if (steps.length <= 1) return; + onStepsChange(steps.filter((_, i) => i !== idx)); + }; + + const sortableIds = steps.map((_, i) => `row-${section}-${i}`); + + return ( +
    + {title ?
    {title}
    : null} + +
    + {steps.map((step, i) => ( + 1} + onRemove={() => removeStep(i)} + dragOverInfo={dragOverInfo} + /> + ))} +
    +
    + } + onPress={addStep} + > + {t("tournament:customFlow.addStep")} + + {errors.map((error) => ( +
    + {error} +
    + ))} +
    + ); +} + +function StepRow({ + step, + index, + section, + canRemove, + onRemove, + dragOverInfo, +}: { + step: PartialStep; + index: number; + section: "preSet" | "postGame"; + canRemove: boolean; + onRemove: () => void; + dragOverInfo: { overId: string; valid: boolean } | null; +}) { + const sortableId = `row-${section}-${index}`; + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sortableId }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const { t } = useTranslation(["tournament"]); + const isRoll = step.action === "ROLL"; + const whoDropId = `${section}-${index}-who`; + const actionDropId = `${section}-${index}-action`; + + return ( +
    + + {isRoll ? null : ( + + )} + + +
    + ); +} + +function DropZone({ + id, + type, + filled, + label, + dragOverInfo, +}: { + id: string; + type: "who" | "action"; + filled?: string; + label?: string; + dragOverInfo: { overId: string; valid: boolean } | null; +}) { + const { t } = useTranslation(["tournament"]); + const { setNodeRef, isOver } = useDroppable({ id }); + + const isTargeted = dragOverInfo?.overId === id; + const isValid = isTargeted ? dragOverInfo.valid : true; + + return ( +
    + {label ?? + (type === "who" + ? t("tournament:customFlow.whoPlaceholder") + : t("tournament:customFlow.actionPlaceholder"))} +
    + ); +} + +function DragOverlayChip({ dragId }: { dragId: string }) { + const { t } = useTranslation(["tournament"]); + + if (dragId.startsWith("palette-")) { + const chipType = dragId.replace("palette-", ""); + const isWho = WHO_SIDES.includes(chipType as WhoSide); + const label = isWho + ? t(WHO_I18N_KEYS[chipType as WhoSide]) + : t(ACTION_I18N_KEYS[chipType as ActionType]); + + return ( +
    + {label} +
    + ); + } + + return null; +} + +function isDropValid(activeId: string, overId: string): boolean { + if (!activeId.startsWith("palette-")) return true; + + const chipType = activeId.replace("palette-", ""); + const isWhoChip = WHO_SIDES.includes(chipType as WhoSide); + const isActionChip = ACTION_TYPES.includes(chipType as ActionType); + + const dropParts = overId.split("-"); + const section = dropParts[0]; + const slotType = dropParts[2]; + + if (slotType === "who" && !isWhoChip) return false; + if (slotType === "action" && !isActionChip) return false; + + if ( + section === "preSet" && + slotType === "who" && + isWhoChip && + BEFORE_SET_INVALID_WHO.has(chipType as WhoSide) + ) { + return false; + } + + return true; +} + +function stepsToFlow(steps: PartialStep[]): CustomPickBanStep[] | null { + const result: CustomPickBanStep[] = []; + for (const step of steps) { + if (!step.action) return null; + if (step.action !== "ROLL" && !step.side) return null; + result.push({ + action: step.action, + side: step.action === "ROLL" ? undefined : step.side, + }); + } + return result; +} diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index 875538058..3247d1c09 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -107,23 +107,32 @@ export function MatchActions({ [tournament, data.match.id], ); + const bothTeamsHaveActiveRosters = teams.every((team) => + tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam), + ); + const turnOf = data.match.roundMaps && PickBan.turnOf({ results: data.results, maps: data.match.roundMaps, - teams: [teams[0].id, teams[1].id], + teams: [ + { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, + { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, + ], mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, }); - if (turnOf) { - return ; + if (turnOf && bothTeamsHaveActiveRosters) { + return ( + + ); } - const bothTeamsHaveActiveRosters = teams.every((team) => - tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam), - ); - const canEditFinishedSet = result && tournament.isOrganizer(user) && !tournament.ctx.isFinalized; diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css b/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css index 3d7e6e361..1abe61f07 100644 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css +++ b/app/features/tournament-bracket/components/MatchActionsBanPicker.module.css @@ -51,6 +51,10 @@ color: var(--color-error); } +.mapButtonIconMuted { + color: var(--color-text-high); +} + .mapButtonNumber { position: absolute; background-color: var(--color-text-accent); diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx index 166b4176e..af4101881 100644 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx +++ b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx @@ -6,7 +6,7 @@ import { useFetcher, useLoaderData } from "react-router"; import { Divider } from "~/components/Divider"; import { ModeImage, StageImage } from "~/components/Image"; import { SubmitButton } from "~/components/SubmitButton"; -import type { TournamentRoundMaps } from "~/db/tables"; +import type { ActionType, TournamentRoundMaps } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { modesShort } from "~/modules/in-game-lists/modes"; @@ -19,38 +19,63 @@ import type { TournamentDataTeam } from "../core/Tournament.server"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import styles from "./MatchActionsBanPicker.module.css"; +/** stageId is omitted for mode-only actions (MODE_PICK / MODE_BAN) where no specific stage is selected */ +type BanPickerSelection = { + mode: ModeShort; + stageId?: StageId; +}; + export function MatchActionsBanPicker({ teams, }: { teams: [TournamentDataTeam, TournamentDataTeam]; }) { const data = useLoaderData(); + const tournament = useTournament(); const maps = data.match.roundMaps!; - const [selected, setSelected] = React.useState<{ - mode: ModeShort; - stageId: StageId; - }>(); + const [selected, setSelected] = React.useState(); - const pickerTeamId = PickBan.turnOf({ + const turnOfResult = PickBan.turnOf({ results: data.results, maps, - teams: [teams[0].id, teams[1].id], + teams: [ + { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, + { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, + ], mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, })!; + const pickerTeamId = turnOfResult.teamId; const pickingTeam = teams.find((team) => team.id === pickerTeamId)!; + const actionType = turnOfResult.action; + const isModePick = actionType === "MODE_PICK"; + const isModeBan = actionType === "MODE_BAN"; + const isModeAction = isModePick || isModeBan; + return (
    - + {isModeAction ? ( + + ) : ( + + )}
    ); @@ -61,11 +86,13 @@ function MapPicker({ setSelected, pickerTeamId, teams, + actionType, }: { - selected?: { mode: ModeShort; stageId: StageId }; - setSelected: (selected: { mode: ModeShort; stageId: StageId }) => void; + selected?: BanPickerSelection; + setSelected: (selected: BanPickerSelection) => void; pickerTeamId: number; teams: [TournamentDataTeam, TournamentDataTeam]; + actionType: ActionType; }) { const user = useUser(); const data = useLoaderData(); @@ -79,10 +106,11 @@ function MapPicker({ tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, pickerTeamId, results: data.results, + pickBanEvents: data.pickBanEvents, }); const modes = modesShort.filter((mode) => - pickBanMapPool.some((map) => map.mode === mode), + pickBanMapPool.some((map) => map.mode === mode && map.isLegal), ); const canPickBan = @@ -160,6 +188,7 @@ function MapPicker({ selected={ selected?.mode === mode && selected.stageId === stageId } + actionType={actionType} onClick={ canPickBan ? () => setSelected({ mode, stageId }) @@ -171,7 +200,9 @@ function MapPicker({ ); })} - {pickersLastWonMode === mode && modes.length > 1 ? ( + {data.match.roundMaps?.pickBan !== "CUSTOM" && + pickersLastWonMode === mode && + modes.length > 1 ? (
    Can't pick the same mode team last won on
    @@ -188,6 +219,7 @@ function MapButton({ onClick, selected, disabled, + actionType, number, from, }: { @@ -195,6 +227,7 @@ function MapButton({ onClick?: () => void; selected?: boolean; disabled?: boolean; + actionType?: ActionType; number?: number; from?: "US" | "THEM" | "BOTH"; }) { @@ -214,8 +247,18 @@ function MapButton({ disabled={!onClick} data-testid={!disabled && onClick ? "pick-ban-button" : undefined} /> - {selected ? ( - + {selected && !disabled ? ( + actionType === "BAN" || actionType === "MODE_BAN" ? ( + + ) : ( + + ) ) : null} {disabled ? ( @@ -239,17 +282,77 @@ function MapButton({ ); } +function ModePicker({ + selected, + setSelected, + pickerTeamId, + teams, +}: { + selected?: BanPickerSelection; + setSelected: (selected: BanPickerSelection) => void; + pickerTeamId: number; + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const user = useUser(); + const data = useLoaderData(); + const tournament = useTournament(); + const { t } = useTranslation(["game-misc"]); + + const pickBanMapPool = PickBan.mapsListWithLegality({ + toSetMapPool: tournament.ctx.toSetMapPool, + maps: data.match.roundMaps, + mapList: data.mapList, + teams, + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + pickerTeamId, + results: data.results, + pickBanEvents: data.pickBanEvents, + }); + + const availableModes = modesShort.filter((mode) => + pickBanMapPool.some((map) => map.mode === mode && map.isLegal), + ); + + const canPickBan = + tournament.isOrganizer(user) || + tournament.ownedTeamByUser(user)?.id === pickerTeamId; + + return ( +
    + {availableModes.map((mode) => ( + + ))} +
    + ); +} + function CounterpickSubmitter({ selected, pickingTeam, pickBan, + actionType, }: { - selected?: { - mode: ModeShort; - stageId: StageId; - }; + selected?: BanPickerSelection; pickingTeam: TournamentDataTeam; pickBan: NonNullable; + actionType: ActionType; }) { const fetcher = useFetcher(); const { t } = useTranslation(["game-misc"]); @@ -261,6 +364,28 @@ function CounterpickSubmitter({ const picking = tournament.isOrganizer(user) || ownedTeam?.id === pickingTeam.id; + const isModeAction = actionType === "MODE_PICK" || actionType === "MODE_BAN"; + + const isCustom = pickBan === "CUSTOM"; + + const actionLabel = () => { + if (actionType === "BAN" || pickBan === "BAN_2") return "Ban"; + if (actionType === "MODE_PICK") return "Pick mode"; + if (actionType === "MODE_BAN") return "Ban mode"; + if (isCustom) return "Pick"; + return "Counterpick"; + }; + + const promptLabel = () => { + if (actionType === "BAN" || pickBan === "BAN_2") { + return "Please select your team's ban above"; + } + if (actionType === "MODE_PICK") return "Please select a mode to pick above"; + if (actionType === "MODE_BAN") return "Please select a mode to ban above"; + if (isCustom) return "Please select your team's pick above"; + return "Please select your team's counterpick above"; + }; + if (!picking) { return (
    @@ -272,36 +397,41 @@ function CounterpickSubmitter({ if (picking && !selected) { return (
    - {pickBan === "BAN_2" - ? "Please select your team's ban above" - : "Please select your team's counterpick above"} + {promptLabel()}
    ); } invariant(selected, "CounterpickSubmitter: selected is undefined"); + const stageId = isModeAction ? null : selected.stageId; + invariant(isModeAction || typeof stageId === "number", "Expected stageId"); + return (
    - {pickBan === "BAN_2" ? "Ban" : "Counterpick"}:{" "} - {t(`game-misc:MODE_SHORT_${selected.mode}`)}{" "} - {t(`game-misc:STAGE_${selected.stageId}`)} + {actionLabel()}: {t(`game-misc:MODE_SHORT_${selected.mode}`)} + {typeof stageId === "number" + ? ` ${t(`game-misc:STAGE_${stageId}`)}` + : null}
    - {" "} - + + {typeof stageId === "number" ? ( + + ) : null}
    - + {typeof stageId === "number" ? ( + + ) : null} Confirm diff --git a/app/features/tournament-bracket/components/MatchMapInfo.module.css b/app/features/tournament-bracket/components/MatchMapInfo.module.css new file mode 100644 index 000000000..671e67744 --- /dev/null +++ b/app/features/tournament-bracket/components/MatchMapInfo.module.css @@ -0,0 +1,54 @@ +.container { + padding: var(--s-2); +} + +.section { + display: flex; + flex-direction: column; + gap: var(--s-1-5); +} + +.maps { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); +} + +.mapEntry { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-0-5); + width: 90px; +} + +.stageImage { + border-radius: var(--radius-box); +} + +.mapLabel { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-weight: var(--weight-semi); + text-align: center; +} + +.modeEntry { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.heading { + font-size: var(--font); + font-weight: var(--weight-bold); + margin-block: 0 var(--s-2); +} + +.emptyText { + font-size: var(--font-sm); + color: var(--color-text-high); + font-style: italic; +} diff --git a/app/features/tournament-bracket/components/MatchMapInfo.tsx b/app/features/tournament-bracket/components/MatchMapInfo.tsx new file mode 100644 index 000000000..e1a1f5c82 --- /dev/null +++ b/app/features/tournament-bracket/components/MatchMapInfo.tsx @@ -0,0 +1,194 @@ +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { ModeImage, StageImage } from "~/components/Image"; +import type { CustomPickBanStep } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import styles from "./MatchMapInfo.module.css"; + +export function MatchMapInfo({ teams }: { teams: [number, number] }) { + const data = useLoaderData(); + const tournament = useTournament(); + + const teamOne = tournament.teamById(teams[0]); + const teamTwo = tournament.teamById(teams[1]); + + const customFlow = data.match.roundMaps?.customFlow; + if (!customFlow) return null; + + const teamOneBans: BanEvent[] = []; + const teamTwoBans: BanEvent[] = []; + + for (let i = 0; i < data.pickBanEvents.length; i++) { + const event = data.pickBanEvents[i]!; + if (event.type !== "BAN" && event.type !== "MODE_BAN") continue; + + const teamId = resolveTeamForEvent({ + eventIndex: i, + preSet: customFlow.preSet, + postGame: customFlow.postGame, + teams, + results: data.results, + }); + + if (teamId === teams[0]) { + teamOneBans.push(event); + } else if (teamId === teams[1]) { + teamTwoBans.push(event); + } + } + + return ( +
    +
    + + + +
    +
    + ); +} + +function resolveTeamForEvent({ + eventIndex, + preSet, + postGame, + teams, + results, +}: { + eventIndex: number; + preSet: CustomPickBanStep[]; + postGame: CustomPickBanStep[]; + teams: [number, number]; + results: Array<{ winnerTeamId: number }>; +}): number | null { + const step = + eventIndex < preSet.length + ? preSet[eventIndex] + : postGame[(eventIndex - preSet.length) % postGame.length]; + + if (!step?.side) return null; + + switch (step.side) { + case "ALPHA": + return teams[0]; + case "BRAVO": + return teams[1]; + case "HIGHER_SEED": + return teams[1]; + case "LOWER_SEED": + return teams[0]; + case "WINNER": + case "LOSER": { + const cycleIndex = Math.floor( + (eventIndex - preSet.length) / postGame.length, + ); + const result = results[cycleIndex]; + if (!result) return null; + + if (step.side === "WINNER") return result.winnerTeamId; + return teams.find((t) => t !== result.winnerTeamId) ?? null; + } + } +} + +interface BanEvent { + stageId: StageId | null; + mode: ModeShort | null; + type: string; +} + +function BanSection({ + teamName, + bans, +}: { + teamName: string; + bans: BanEvent[]; +}) { + const { t } = useTranslation(["game-misc", "tournament"]); + const mapBans = bans.filter( + (b): b is BanEvent & { stageId: StageId; mode: ModeShort } => + b.type === "BAN" && b.stageId !== null && b.mode !== null, + ); + const modeBans = bans.filter( + (b): b is BanEvent & { mode: ModeShort } => + b.type === "MODE_BAN" && b.mode !== null, + ); + + return ( +
    +

    + {t("tournament:match.mapInfo.bans", { teamName })} +

    + {mapBans.length === 0 && modeBans.length === 0 ? ( +
    + {t("tournament:match.mapInfo.noBans")} +
    + ) : null} + {mapBans.length > 0 ? ( +
    + {mapBans.map((ban, i) => ( + + ))} +
    + ) : null} + {modeBans.length > 0 ? ( +
    + {modeBans.map((ban, i) => ( +
    + + {t(`game-misc:MODE_LONG_${ban.mode}`)} +
    + ))} +
    + ) : null} +
    + ); +} + +function PlayedSection({ + results, +}: { + results: Array<{ stageId: StageId; mode: ModeShort }>; +}) { + const { t } = useTranslation(["game-misc", "tournament"]); + + return ( +
    +

    + {t("tournament:match.mapInfo.playedStages")} +

    + {results.length === 0 ? ( +
    + {t("tournament:match.mapInfo.noPlayedStages")} +
    + ) : ( +
    + {results.map((result, i) => ( + + ))} +
    + )} +
    + ); +} + +function MapEntry({ stageId, mode }: { stageId: StageId; mode?: ModeShort }) { + const { t } = useTranslation(["game-misc"]); + + return ( +
    + +
    + {mode ? `${t(`game-misc:MODE_SHORT_${mode}`)} ` : null} + {t(`game-misc:STAGE_${stageId}`).split(" ")[0]} +
    +
    + ); +} diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 71cc4315c..965a3cb37 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -54,6 +54,7 @@ import { } from "../tournament-bracket-utils"; import { DeadlineInfoPopover } from "./DeadlineInfoPopover"; import { MatchActions } from "./MatchActions"; +import { MatchMapInfo } from "./MatchMapInfo"; import { MatchRosters } from "./MatchRosters"; import { MatchTimer } from "./MatchTimer"; @@ -291,7 +292,7 @@ function FancyStageBanner({ return `${stageImageUrl(stageId)}.avif`; }; - const banPickingTeam = () => { + const turnOfResult = (() => { if ( !data.match.roundMaps || !data.match.opponentOne?.id || @@ -300,14 +301,28 @@ function FancyStageBanner({ return null; } - const pickingTeamId = PickBan.turnOf({ + return PickBan.turnOf({ results: data.results, maps: data.match.roundMaps, - teams: [data.match.opponentOne.id, data.match.opponentTwo.id], + teams: [ + { + id: data.match.opponentOne.id, + seed: tournament.teamById(data.match.opponentOne.id)!.seed, + }, + { + id: data.match.opponentTwo.id, + seed: tournament.teamById(data.match.opponentTwo.id)!.seed, + }, + ], mapList: data.mapList, + pickBanEventCount: data.pickBanEventCount, }); + })(); - return pickingTeamId ? teams.find((t) => t.id === pickingTeamId) : null; + const banPickingTeam = () => { + return turnOfResult + ? teams.find((t) => t.id === turnOfResult.teamId) + : null; }; const style = { @@ -354,24 +369,32 @@ function FancyStageBanner({ data.match.roundId, ); + const noStageHeading = () => { + if (data.match.roundMaps?.pickBan === "CUSTOM" && turnOfResult) { + const stepCounter = + turnOfResult.stepTotal && turnOfResult.stepTotal > 1 + ? ` (${turnOfResult.stepCurrent}/${turnOfResult.stepTotal})` + : ""; + + switch (turnOfResult.action) { + case "PICK": + return t("tournament:pickBan.pickMap") + stepCounter; + case "BAN": + return t("tournament:pickBan.banMap") + stepCounter; + case "MODE_PICK": + return t("tournament:pickBan.pickMode") + stepCounter; + case "MODE_BAN": + return t("tournament:pickBan.banMode") + stepCounter; + default: + return t("tournament:pickBan.counterpick"); + } + } + return t("tournament:pickBan.counterpick"); + }; + return ( <> - {inBanPhase ? ( -
    -
    -
    Banning phase
    -
    Waiting for {banPickingTeam()?.name}
    -
    -
    - ) : !stage ? ( -
    -
    -
    Counterpick
    -
    Waiting for {banPickingTeam()?.name}
    - {children} -
    -
    - ) : matchIsLocked ? ( + {matchIsLocked ? (
    @@ -433,6 +456,23 @@ function FancyStageBanner({ /> ) : null}
    + ) : inBanPhase ? ( +
    +
    +
    Banning phase
    +
    Waiting for {banPickingTeam()?.name}
    +
    +
    + ) : !stage ? ( +
    +
    +
    + {noStageHeading()} +
    +
    Waiting for {banPickingTeam()?.name}
    + {children} +
    +
    ) : (
    (); + const isCustomFlow = data.match.roundMaps?.pickBan === "CUSTOM"; + const validTabs = isCustomFlow + ? ["rosters", "actions", "map-info"] + : ["rosters", "actions"]; const [selectedTabKey, setSelectedTabKey] = useSearchParamState({ defaultValue: "rosters", name: "tab", - revive: (value) => (["rosters", "actions"].includes(value) ? value : null), + revive: (value) => (validTabs.includes(value) ? value : null), }); const currentPosition = scores[0] + scores[1]; @@ -685,6 +730,11 @@ function StartedMatchTabs({ {presentational ? "Score" : "Actions"} + {isCustomFlow ? ( + + {t("tournament:match.tab.mapInfo")} + + ) : null} @@ -713,6 +763,12 @@ function StartedMatchTabs({ } /> + + {isCustomFlow ? ( + + + + ) : null} ); diff --git a/app/features/tournament-bracket/core/PickBan.test.ts b/app/features/tournament-bracket/core/PickBan.test.ts new file mode 100644 index 000000000..da52f851d --- /dev/null +++ b/app/features/tournament-bracket/core/PickBan.test.ts @@ -0,0 +1,934 @@ +import { describe, expect, it } from "vitest"; +import type { TournamentRoundMaps } from "~/db/tables"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { + CUSTOM_FLOW_VALIDATION_ERRORS, + isModeLegal, + mapsListWithLegality, + type PickBanEvent, + type PickBanTeam, + resolveCurrentStep, + resolveTeamFromSide, + turnOf, + validateCustomFlowSection, +} from "./PickBan"; + +describe("validateCustomFlowSection", () => { + it("returns no errors for valid preSet steps", () => { + const steps = [ + { action: "BAN" as const, side: "HIGHER_SEED" as const }, + { action: "BAN" as const, side: "LOWER_SEED" as const }, + { action: "PICK" as const, side: "HIGHER_SEED" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toEqual([]); + }); + + it("returns no errors for valid postGame steps", () => { + const steps = [ + { action: "BAN" as const, side: "WINNER" as const }, + { action: "PICK" as const, side: "LOSER" as const }, + ]; + + expect(validateCustomFlowSection(steps, "postGame")).toEqual([]); + }); + + it("returns STEP_MISSING_ACTION when a step has no action", () => { + const steps = [ + { side: "ALPHA" as const }, + { action: "PICK" as const, side: "ALPHA" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.STEP_MISSING_ACTION, + ); + }); + + it("returns STEP_MISSING_WHO when a non-ROLL step has no side", () => { + const steps = [{ action: "BAN" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.STEP_MISSING_WHO, + ); + }); + + it("does not require side for ROLL steps", () => { + const steps = [{ action: "ROLL" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toEqual([]); + }); + + it("returns LAST_STEP_MUST_BE_PICK_OR_ROLL when last step is BAN", () => { + const steps = [ + { action: "PICK" as const, side: "ALPHA" as const }, + { action: "BAN" as const, side: "BRAVO" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); + + it("returns LAST_STEP_MUST_BE_PICK_OR_ROLL when last step is MODE_BAN", () => { + const steps = [{ action: "MODE_BAN" as const, side: "ALPHA" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); + + it("allows PICK as last step", () => { + const steps = [{ action: "PICK" as const, side: "ALPHA" as const }]; + + const errors = validateCustomFlowSection(steps, "preSet"); + + expect(errors).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); + + it("allows ROLL as last step", () => { + const steps = [{ action: "ROLL" as const }]; + + const errors = validateCustomFlowSection(steps, "postGame"); + + expect(errors).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); + + it("returns WINNER_LOSER_IN_PRE_SET when WINNER is used in preSet", () => { + const steps = [{ action: "PICK" as const, side: "WINNER" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.WINNER_LOSER_IN_PRE_SET, + ); + }); + + it("returns WINNER_LOSER_IN_PRE_SET when LOSER is used in preSet", () => { + const steps = [{ action: "PICK" as const, side: "LOSER" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.WINNER_LOSER_IN_PRE_SET, + ); + }); + + it("allows WINNER/LOSER in postGame", () => { + const steps = [ + { action: "BAN" as const, side: "WINNER" as const }, + { action: "PICK" as const, side: "LOSER" as const }, + ]; + + expect(validateCustomFlowSection(steps, "postGame")).toEqual([]); + }); + + it("returns TOO_MANY_MODE_PICKS when more than one MODE_PICK", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "ALPHA" as const }, + { action: "MODE_PICK" as const, side: "BRAVO" as const }, + { action: "PICK" as const, side: "ALPHA" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MODE_PICKS, + ); + }); + + it("returns TOO_MANY_MAP_PICKS when section has PICK and ROLL", () => { + const steps = [ + { action: "BAN" as const, side: "ALPHA" as const }, + { action: "PICK" as const, side: "BRAVO" as const }, + { action: "ROLL" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS, + ); + }); + + it("returns TOO_MANY_MAP_PICKS when section has two ROLLs", () => { + const steps = [{ action: "ROLL" as const }, { action: "ROLL" as const }]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS, + ); + }); + + it("returns TOO_MANY_MAP_PICKS when section has two PICKs", () => { + const steps = [ + { action: "PICK" as const, side: "ALPHA" as const }, + { action: "MODE_BAN" as const, side: "BRAVO" as const }, + { action: "PICK" as const, side: "BRAVO" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS, + ); + }); + + it("allows exactly one PICK or ROLL", () => { + const stepsWithPick = [ + { action: "BAN" as const, side: "ALPHA" as const }, + { action: "PICK" as const, side: "BRAVO" as const }, + ]; + const stepsWithRoll = [ + { action: "BAN" as const, side: "ALPHA" as const }, + { action: "ROLL" as const }, + ]; + + expect(validateCustomFlowSection(stepsWithPick, "preSet")).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS, + ); + expect(validateCustomFlowSection(stepsWithRoll, "preSet")).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS, + ); + }); + + it("allows exactly one MODE_PICK", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "ALPHA" as const }, + { action: "PICK" as const, side: "BRAVO" as const }, + ]; + + const errors = validateCustomFlowSection(steps, "preSet"); + + expect(errors).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MODE_PICKS, + ); + }); + + it("returns LAST_STEP_MUST_BE_PICK_OR_ROLL for empty steps array", () => { + expect(validateCustomFlowSection([], "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); + + it("returns SAME_TEAM_MODE_AND_MAP_PICK when same side does MODE_PICK and PICK", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "ALPHA" as const }, + { action: "PICK" as const, side: "ALPHA" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK, + ); + }); + + it("returns SAME_TEAM_MODE_AND_MAP_PICK even with bans between", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "HIGHER_SEED" as const }, + { action: "BAN" as const, side: "LOWER_SEED" as const }, + { action: "PICK" as const, side: "HIGHER_SEED" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK, + ); + }); + + it("does not return SAME_TEAM_MODE_AND_MAP_PICK when different sides", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "ALPHA" as const }, + { action: "PICK" as const, side: "BRAVO" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK, + ); + }); + + it("does not return SAME_TEAM_MODE_AND_MAP_PICK for MODE_PICK followed by ROLL", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "ALPHA" as const }, + { action: "ROLL" as const }, + ]; + + expect(validateCustomFlowSection(steps, "preSet")).not.toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK, + ); + }); + + it("can return multiple errors at once", () => { + const steps = [ + { action: "MODE_PICK" as const, side: "WINNER" as const }, + { action: "MODE_PICK" as const, side: "LOSER" as const }, + { action: "MODE_BAN" as const, side: "ALPHA" as const }, + ]; + + const errors = validateCustomFlowSection(steps, "preSet"); + + expect(errors).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.WINNER_LOSER_IN_PRE_SET, + ); + expect(errors).toContain(CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MODE_PICKS); + expect(errors).toContain( + CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL, + ); + }); +}); + +describe("resolveCurrentStep", () => { + const preSet = [ + { action: "BAN" as const, side: "HIGHER_SEED" as const }, + { action: "BAN" as const, side: "LOWER_SEED" as const }, + { action: "PICK" as const, side: "HIGHER_SEED" as const }, + ]; + const postGame = [ + { action: "BAN" as const, side: "WINNER" as const }, + { action: "PICK" as const, side: "LOSER" as const }, + ]; + + it("returns preSet steps when eventCount < preSet.length", () => { + expect( + resolveCurrentStep({ eventCount: 0, preSet, postGame, resultsCount: 0 }), + ).toEqual(preSet[0]); + expect( + resolveCurrentStep({ eventCount: 1, preSet, postGame, resultsCount: 0 }), + ).toEqual(preSet[1]); + expect( + resolveCurrentStep({ eventCount: 2, preSet, postGame, resultsCount: 0 }), + ).toEqual(preSet[2]); + }); + + it("returns null when waiting for game result after preSet", () => { + expect( + resolveCurrentStep({ eventCount: 3, preSet, postGame, resultsCount: 0 }), + ).toBeNull(); + }); + + it("throws when postGame is empty", () => { + expect(() => + resolveCurrentStep({ + eventCount: 3, + preSet, + postGame: [], + resultsCount: 1, + }), + ).toThrow(); + }); + + it("returns postGame steps after first game result", () => { + expect( + resolveCurrentStep({ eventCount: 3, preSet, postGame, resultsCount: 1 }), + ).toEqual(postGame[0]); + expect( + resolveCurrentStep({ eventCount: 4, preSet, postGame, resultsCount: 1 }), + ).toEqual(postGame[1]); + }); + + it("returns null when waiting for next game result after postGame cycle", () => { + expect( + resolveCurrentStep({ eventCount: 5, preSet, postGame, resultsCount: 1 }), + ).toBeNull(); + }); + + it("cycles postGame steps after subsequent results", () => { + expect( + resolveCurrentStep({ eventCount: 5, preSet, postGame, resultsCount: 2 }), + ).toEqual(postGame[0]); + expect( + resolveCurrentStep({ eventCount: 6, preSet, postGame, resultsCount: 2 }), + ).toEqual(postGame[1]); + }); +}); + +describe("resolveTeamFromSide", () => { + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("resolves ALPHA to teams[0]", () => { + expect(resolveTeamFromSide({ side: "ALPHA", teams, results: [] })).toBe( + 100, + ); + }); + + it("resolves BRAVO to teams[1]", () => { + expect(resolveTeamFromSide({ side: "BRAVO", teams, results: [] })).toBe( + 200, + ); + }); + + it("resolves HIGHER_SEED to teams[1]", () => { + expect( + resolveTeamFromSide({ side: "HIGHER_SEED", teams, results: [] }), + ).toBe(200); + }); + + it("resolves LOWER_SEED to teams[0]", () => { + expect( + resolveTeamFromSide({ side: "LOWER_SEED", teams, results: [] }), + ).toBe(100); + }); + + it("resolves HIGHER_SEED by seed, not array position", () => { + const swappedTeams: [PickBanTeam, PickBanTeam] = [ + { id: 200, seed: 1 }, + { id: 100, seed: 2 }, + ]; + + expect( + resolveTeamFromSide({ + side: "HIGHER_SEED", + teams: swappedTeams, + results: [], + }), + ).toBe(200); + }); + + it("resolves LOWER_SEED by seed, not array position", () => { + const swappedTeams: [PickBanTeam, PickBanTeam] = [ + { id: 200, seed: 1 }, + { id: 100, seed: 2 }, + ]; + + expect( + resolveTeamFromSide({ + side: "LOWER_SEED", + teams: swappedTeams, + results: [], + }), + ).toBe(100); + }); + + it("resolves WINNER to last game winner", () => { + expect( + resolveTeamFromSide({ + side: "WINNER", + teams, + results: [{ winnerTeamId: 200 }], + }), + ).toBe(200); + }); + + it("resolves LOSER to last game loser", () => { + expect( + resolveTeamFromSide({ + side: "LOSER", + teams, + results: [{ winnerTeamId: 200 }], + }), + ).toBe(100); + }); +}); + +describe("turnOf — CUSTOM flow", () => { + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "PICK", side: "HIGHER_SEED" }, + ], + postGame: [ + { action: "BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }, + }; + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("returns first preSet step", () => { + const result = turnOf({ + results: [], + maps: customMaps, + teams, + pickBanEventCount: 0, + }); + + expect(result).toEqual({ + teamId: 200, + action: "BAN", + stepCurrent: 1, + stepTotal: 1, + }); + }); + + it("returns second preSet step", () => { + const result = turnOf({ + results: [], + maps: customMaps, + teams, + pickBanEventCount: 1, + }); + + expect(result).toEqual({ + teamId: 100, + action: "BAN", + stepCurrent: 1, + stepTotal: 1, + }); + }); + + it("returns null when waiting for game result", () => { + const result = turnOf({ + results: [], + maps: customMaps, + teams, + pickBanEventCount: 3, + }); + + expect(result).toBeNull(); + }); + + it("returns postGame step after result", () => { + const result = turnOf({ + results: [{ winnerTeamId: 200 }], + maps: customMaps, + teams, + pickBanEventCount: 3, + }); + + expect(result).toEqual({ + teamId: 200, + action: "BAN", + stepCurrent: 1, + stepTotal: 1, + }); + }); + + it("returns null for ROLL steps", () => { + const rollMaps: TournamentRoundMaps = { + count: 3, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [{ action: "ROLL" }], + postGame: [], + }, + }; + + const result = turnOf({ + results: [], + maps: rollMaps, + teams, + pickBanEventCount: 0, + }); + + expect(result).toBeNull(); + }); + + it("returns null when set is over", () => { + const result = turnOf({ + results: [ + { winnerTeamId: 200 }, + { winnerTeamId: 200 }, + { winnerTeamId: 200 }, + ], + maps: customMaps, + teams, + pickBanEventCount: 7, + }); + + expect(result).toBeNull(); + }); + + it("returns null when no customFlow defined", () => { + const result = turnOf({ + results: [], + maps: { count: 3, type: "BEST_OF", pickBan: "CUSTOM" }, + teams, + pickBanEventCount: 0, + }); + + expect(result).toBeNull(); + }); +}); + +describe("turnOf — CUSTOM flow stepCurrent/stepTotal", () => { + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("counts consecutive bans by same side in preSet", () => { + const maps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 0 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 2 }); + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 1 }), + ).toMatchObject({ stepCurrent: 2, stepTotal: 2 }); + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 2 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + }); + + it("counts consecutive bans by same side in postGame", () => { + const maps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [{ action: "PICK", side: "HIGHER_SEED" }], + postGame: [ + { action: "BAN", side: "WINNER" }, + { action: "BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }, + }; + + expect( + turnOf({ + results: [{ winnerTeamId: 200 }], + maps, + teams, + pickBanEventCount: 1, + }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 2 }); + + expect( + turnOf({ + results: [{ winnerTeamId: 200 }], + maps, + teams, + pickBanEventCount: 2, + }), + ).toMatchObject({ stepCurrent: 2, stepTotal: 2 }); + + expect( + turnOf({ + results: [{ winnerTeamId: 200 }], + maps, + teams, + pickBanEventCount: 3, + }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + }); + + it("does not group consecutive steps with different sides", () => { + const maps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "PICK", side: "HIGHER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 0 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 1 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + }); + + it("does not group consecutive steps with different actions", () => { + const maps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "MODE_BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "HIGHER_SEED" }, + { action: "PICK", side: "HIGHER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 0 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + + expect( + turnOf({ results: [], maps, teams, pickBanEventCount: 1 }), + ).toMatchObject({ stepCurrent: 1, stepTotal: 1 }); + }); +}); + +describe("turnOf — BAN_2 flow", () => { + const ban2Maps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "BAN_2", + }; + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("returns action BAN for first picker", () => { + const result = turnOf({ + results: [], + maps: ban2Maps, + teams, + mapList: [ + { + mode: "SZ", + stageId: 1, + source: "TO", + bannedByTournamentTeamId: undefined, + }, + { + mode: "SZ", + stageId: 2, + source: "TO", + bannedByTournamentTeamId: undefined, + }, + ], + }); + + expect(result).toEqual({ teamId: 200, action: "BAN" }); + }); + + it("returns null when both teams have banned", () => { + const result = turnOf({ + results: [], + maps: ban2Maps, + teams, + mapList: [ + { mode: "SZ", stageId: 1, source: "TO", bannedByTournamentTeamId: 200 }, + { mode: "SZ", stageId: 2, source: "TO", bannedByTournamentTeamId: 100 }, + ], + }); + + expect(result).toBeNull(); + }); +}); + +describe("mapsListWithLegality — MODE_PICK restriction survives intervening events", () => { + const SZ = "SZ" as ModeShort; + const TC = "TC" as ModeShort; + const RM = "RM" as ModeShort; + + const toSetMapPool = [ + { mode: SZ, stageId: 1 as StageId }, + { mode: SZ, stageId: 2 as StageId }, + { mode: TC, stageId: 3 as StageId }, + { mode: TC, stageId: 4 as StageId }, + { mode: RM, stageId: 5 as StageId }, + ]; + + const teams = [{ mapPool: [] }, { mapPool: [] }] as unknown as Parameters< + typeof mapsListWithLegality + >[0]["teams"]; + + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "MODE_PICK", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + it("restricts to picked mode even when bans happen after MODE_PICK", () => { + const pickBanEvents: PickBanEvent[] = [ + { type: "MODE_PICK", stageId: null, mode: SZ }, + { type: "BAN", stageId: 3 as StageId, mode: TC }, + { type: "BAN", stageId: 5 as StageId, mode: RM }, + ]; + + const result = mapsListWithLegality({ + results: [], + maps: customMaps, + mapList: null, + teams, + pickerTeamId: 100, + tieBreakerMapPool: [], + toSetMapPool, + pickBanEvents, + }); + + const legalModes = new Set( + result.filter((m) => m.isLegal).map((m) => m.mode), + ); + + // MODE_PICK chose SZ, so only SZ maps should be legal + expect(legalModes).toEqual(new Set([SZ])); + // TC and RM should not be legal + expect(legalModes.has(TC)).toBe(false); + expect(legalModes.has(RM)).toBe(false); + }); + + it("does not carry MODE_PICK restriction from a previous game section", () => { + const mapsWithPostGameModePick: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "MODE_PICK", side: "HIGHER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [ + { action: "MODE_BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }, + }; + + // preSet: MODE_PICK(SZ), PICK — then game 1 played + // postGame cycle 1: MODE_BAN(TC), now at PICK step with no MODE_PICK in this section + const pickBanEvents: PickBanEvent[] = [ + { type: "MODE_PICK", stageId: null, mode: SZ }, + { type: "PICK", stageId: 1 as StageId, mode: SZ }, + { type: "MODE_BAN", stageId: null, mode: TC }, + ]; + + const result = mapsListWithLegality({ + results: [{ mode: SZ, stageId: 1 as StageId, winnerTeamId: 200 }], + maps: mapsWithPostGameModePick, + mapList: null, + teams, + pickerTeamId: 100, + tieBreakerMapPool: [], + toSetMapPool, + pickBanEvents, + }); + + const legalModes = new Set( + result.filter((m) => m.isLegal).map((m) => m.mode), + ); + + // No MODE_PICK in the current postGame section, so SZ restriction should not apply + // Only TC is mode-banned, SZ and RM should be legal + expect(legalModes).toEqual(new Set([SZ, RM])); + expect(legalModes.has(TC)).toBe(false); + }); +}); + +describe("isModeLegal", () => { + const SZ = "SZ" as ModeShort; + const TC = "TC" as ModeShort; + const RM = "RM" as ModeShort; + const CB = "CB" as ModeShort; + + const toSetMapPool = [ + { mode: SZ, stageId: 1 as StageId }, + { mode: SZ, stageId: 2 as StageId }, + { mode: TC, stageId: 3 as StageId }, + { mode: RM, stageId: 5 as StageId }, + ]; + + const teams = [{ mapPool: [] }, { mapPool: [] }] as unknown as Parameters< + typeof mapsListWithLegality + >[0]["teams"]; + + const customMaps: TournamentRoundMaps = { + count: 5, + type: "BEST_OF", + pickBan: "CUSTOM", + customFlow: { + preSet: [ + { action: "MODE_BAN", side: "HIGHER_SEED" }, + { action: "MODE_PICK", side: "LOWER_SEED" }, + { action: "PICK", side: "LOWER_SEED" }, + ], + postGame: [{ action: "PICK", side: "LOSER" }], + }, + }; + + const baseArgs = { + results: [], + maps: customMaps, + mapList: null, + teams, + pickerTeamId: 100, + tieBreakerMapPool: [], + toSetMapPool, + }; + + it("returns true for a mode present in the pool with no bans", () => { + expect( + isModeLegal({ + mode: TC, + ...baseArgs, + pickBanEvents: [], + }), + ).toBe(true); + }); + + it("returns false for a mode that has been banned", () => { + const pickBanEvents: PickBanEvent[] = [ + { type: "MODE_BAN", stageId: null, mode: TC }, + ]; + + expect( + isModeLegal({ + mode: TC, + ...baseArgs, + pickBanEvents, + }), + ).toBe(false); + }); + + it("returns false for a mode not in the map pool", () => { + expect( + isModeLegal({ + mode: CB, + ...baseArgs, + pickBanEvents: [], + }), + ).toBe(false); + }); +}); + +describe("turnOf — COUNTERPICK flow", () => { + const cpMaps: TournamentRoundMaps = { + count: 3, + type: "BEST_OF", + pickBan: "COUNTERPICK", + }; + const teams: [PickBanTeam, PickBanTeam] = [ + { id: 100, seed: 2 }, + { id: 200, seed: 1 }, + ]; + + it("returns action PICK for loser of last game", () => { + const result = turnOf({ + results: [{ winnerTeamId: 200 }], + maps: cpMaps, + teams, + mapList: [ + { + mode: "SZ", + stageId: 1, + source: "TO", + bannedByTournamentTeamId: undefined, + }, + ], + }); + + expect(result).toEqual({ teamId: 100, action: "PICK" }); + }); +}); diff --git a/app/features/tournament-bracket/core/PickBan.ts b/app/features/tournament-bracket/core/PickBan.ts index ea2bafd6a..de766472a 100644 --- a/app/features/tournament-bracket/core/PickBan.ts +++ b/app/features/tournament-bracket/core/PickBan.ts @@ -1,5 +1,10 @@ import * as R from "remeda"; -import type { TournamentRoundMaps } from "~/db/tables"; +import type { + ActionType, + CustomPickBanStep, + TournamentRoundMaps, + WhoSide, +} from "~/db/tables"; import type { ModeShort, ModeWithStage, @@ -16,25 +21,40 @@ export const types = [ "COUNTERPICK", "COUNTERPICK_MODE_REPEAT_OK", "BAN_2", + "CUSTOM", ] as const; export type Type = (typeof types)[number]; +export interface PickBanTeam { + id: number; + seed: number; +} + +export interface TurnOfResult { + teamId: number; + action: ActionType; + stepCurrent?: number; + stepTotal?: number; +} + export function turnOf({ results, maps, teams, mapList, + pickBanEventCount, }: { results: Array<{ winnerTeamId: number }>; maps: TournamentRoundMaps; - teams: [number, number]; + teams: [PickBanTeam, PickBanTeam]; mapList?: TournamentMapListMap[] | null; -}) { + pickBanEventCount?: number; +}): TurnOfResult | null { if (!maps.pickBan) return null; - if (!mapList) return null; switch (maps.pickBan) { case "BAN_2": { + if (!mapList) return null; if ( isSetOverByResults({ count: maps.count, results, countType: maps.type }) ) { @@ -45,21 +65,22 @@ export function turnOf({ const [secondPicker, firstPicker] = teams; if ( - !mapList.some((map) => map.bannedByTournamentTeamId === firstPicker) + !mapList.some((map) => map.bannedByTournamentTeamId === firstPicker.id) ) { - return firstPicker; + return { teamId: firstPicker.id, action: "BAN" }; } if ( - !mapList.some((map) => map.bannedByTournamentTeamId === secondPicker) + !mapList.some((map) => map.bannedByTournamentTeamId === secondPicker.id) ) { - return secondPicker; + return { teamId: secondPicker.id, action: "BAN" }; } return null; } case "COUNTERPICK_MODE_REPEAT_OK": case "COUNTERPICK": { + if (!mapList) return null; // there exists an unplayed map if (mapList.length > results.length) return null; @@ -72,12 +93,13 @@ export function turnOf({ const latestWinner = results[results.length - 1]?.winnerTeamId; invariant(latestWinner, "turnOf: No winner found"); - const result = teams.find( - (tournamentTeamId) => latestWinner !== tournamentTeamId, - ); - invariant(result, "turnOf: No result found"); + const team = teams.find((team) => latestWinner !== team.id); + invariant(team, "turnOf: No result found"); - return result; + return { teamId: team.id, action: "PICK" }; + } + case "CUSTOM": { + return turnOfCustom({ results, maps, teams, pickBanEventCount }); } default: { assertUnreachable(maps.pickBan); @@ -85,6 +107,187 @@ export function turnOf({ } } +function turnOfCustom({ + results, + maps, + teams, + pickBanEventCount, +}: { + results: Array<{ winnerTeamId: number }>; + maps: TournamentRoundMaps; + teams: [PickBanTeam, PickBanTeam]; + pickBanEventCount?: number; +}): TurnOfResult | null { + if ( + isSetOverByResults({ count: maps.count, results, countType: maps.type }) + ) { + return null; + } + + const customFlow = maps.customFlow; + if (!customFlow) return null; + + const eventCount = pickBanEventCount ?? 0; + const preSet = customFlow.preSet; + const postGame = customFlow.postGame; + + const step = resolveCurrentStep({ + eventCount, + preSet, + postGame, + resultsCount: results.length, + }); + if (!step) return null; + + // ROLL steps are handled by the server, not by a team + if (step.action === "ROLL") return null; + + const teamId = resolveTeamFromSide({ + side: step.side!, + teams, + results, + }); + + const consecutiveInfo = resolveConsecutiveStepInfo({ + eventCount, + preSet, + postGame, + }); + + return { + teamId, + action: step.action, + stepCurrent: consecutiveInfo.current, + stepTotal: consecutiveInfo.total, + }; +} + +function resolveConsecutiveStepInfo({ + eventCount, + preSet, + postGame, +}: { + eventCount: number; + preSet: CustomPickBanStep[]; + postGame: CustomPickBanStep[]; +}): { current: number; total: number } { + const inPreSet = eventCount < preSet.length; + + if (inPreSet) { + const currentStep = preSet[eventCount]!; + let start = eventCount; + while ( + start > 0 && + preSet[start - 1]!.action === currentStep.action && + preSet[start - 1]!.side === currentStep.side + ) { + start--; + } + let end = eventCount; + while ( + end < preSet.length - 1 && + preSet[end + 1]!.action === currentStep.action && + preSet[end + 1]!.side === currentStep.side + ) { + end++; + } + + return { current: eventCount - start + 1, total: end - start + 1 }; + } + + if (postGame.length === 0) return { current: 1, total: 1 }; + + const stepIndex = (eventCount - preSet.length) % postGame.length; + const currentStep = postGame[stepIndex]!; + + let start = stepIndex; + while ( + start > 0 && + postGame[start - 1]!.action === currentStep.action && + postGame[start - 1]!.side === currentStep.side + ) { + start--; + } + let end = stepIndex; + while ( + end < postGame.length - 1 && + postGame[end + 1]!.action === currentStep.action && + postGame[end + 1]!.side === currentStep.side + ) { + end++; + } + + return { current: stepIndex - start + 1, total: end - start + 1 }; +} + +export function resolveCurrentStep({ + eventCount, + preSet, + postGame, + resultsCount, +}: { + eventCount: number; + preSet: CustomPickBanStep[]; + postGame: CustomPickBanStep[]; + resultsCount: number; +}): CustomPickBanStep | null { + if (eventCount < preSet.length) { + return preSet[eventCount]!; + } + + invariant( + postGame.length > 0, + "resolveCurrentStep: postGame must not be empty", + ); + + const eventsAfterPreSet = eventCount - preSet.length; + const stepInPostGame = eventsAfterPreSet % postGame.length; + const completedPostGameCycles = Math.floor( + eventsAfterPreSet / postGame.length, + ); + + // waiting for game result + if (completedPostGameCycles > resultsCount) return null; + if (completedPostGameCycles === resultsCount && stepInPostGame === 0) { + return null; + } + + return postGame[stepInPostGame]!; +} + +export function resolveTeamFromSide({ + side, + teams, + results, +}: { + side: WhoSide; + teams: [PickBanTeam, PickBanTeam]; + results: Array<{ winnerTeamId: number }>; +}): number { + switch (side) { + case "ALPHA": + return teams[0].id; + case "BRAVO": + return teams[1].id; + case "HIGHER_SEED": + return teams[0].seed <= teams[1].seed ? teams[0].id : teams[1].id; + case "LOWER_SEED": + return teams[0].seed <= teams[1].seed ? teams[1].id : teams[0].id; + case "WINNER": { + const lastWinner = results[results.length - 1]?.winnerTeamId; + invariant(lastWinner, "resolveTeamFromSide: No winner found"); + return lastWinner; + } + case "LOSER": { + const lastWinner = results[results.length - 1]?.winnerTeamId; + invariant(lastWinner, "resolveTeamFromSide: No winner found"); + return teams.find((t) => t.id !== lastWinner)!.id; + } + default: + assertUnreachable(side); + } +} + export function isLegal({ map, ...rest @@ -96,6 +299,21 @@ export function isLegal({ ); } +export function isModeLegal({ + mode, + ...rest +}: MapListWithStatusesArgs & { mode: ModeShort }) { + const pool = mapsListWithLegality(rest); + + return pool.some((m) => m.mode === mode && m.isLegal); +} + +export interface PickBanEvent { + type: string; + stageId: StageId | null; + mode: ModeShort | null; +} + interface MapListWithStatusesArgs { results: Array<{ mode: ModeShort; stageId: StageId; winnerTeamId: number }>; maps: TournamentRoundMaps | null; @@ -104,6 +322,7 @@ interface MapListWithStatusesArgs { pickerTeamId: number; tieBreakerMapPool: ModeWithStage[]; toSetMapPool: Array<{ mode: ModeShort; stageId: StageId }>; + pickBanEvents?: PickBanEvent[]; } export function mapsListWithLegality(args: MapListWithStatusesArgs) { const mapPool = (() => { @@ -141,6 +360,19 @@ export function mapsListWithLegality(args: MapListWithStatusesArgs) { return args.toSetMapPool; } + case "CUSTOM": { + if (args.toSetMapPool.length > 0) { + return args.toSetMapPool; + } + + const combinedPools = [ + ...(args.teams[0].mapPool ?? []), + ...(args.teams[1].mapPool ?? []), + ...args.tieBreakerMapPool, + ]; + + return R.uniqueBy(combinedPools, (m) => `${m.mode}-${m.stageId}`); + } default: { assertUnreachable(args.maps.pickBan); } @@ -150,7 +382,7 @@ export function mapsListWithLegality(args: MapListWithStatusesArgs) { const modesIncluded = R.unique(mapPool.map((m) => m.mode)); const unavailableStagesSet = unavailableStages(args); - const unavailableModesSetAll = unavailableModes(args); + const unavailableModesSetAll = unavailableModes({ ...args, modesIncluded }); const unavailableModesSet = // one mode tournament unavailableModesSetAll.size < modesIncluded.length @@ -177,10 +409,12 @@ function unavailableStages({ results, mapList, maps, + pickBanEvents, }: { results: Array<{ mode: ModeShort; stageId: StageId }>; mapList?: TournamentMapListMap[] | null; maps: TournamentRoundMaps | null; + pickBanEvents?: PickBanEvent[]; }): Set { if (!maps?.pickBan) return new Set(); @@ -196,6 +430,13 @@ function unavailableStages({ case "COUNTERPICK": { return new Set(results.map((result) => result.stageId)); } + case "CUSTOM": { + const bannedStages = (pickBanEvents ?? []) + .filter((e) => e.type === "BAN" && e.stageId !== null) + .map((e) => e.stageId!); + const playedStages = results.map((r) => r.stageId); + return new Set([...bannedStages, ...playedStages]); + } default: { assertUnreachable(maps.pickBan); } @@ -206,10 +447,14 @@ function unavailableModes({ results, pickerTeamId, maps, + pickBanEvents, + modesIncluded, }: { results: Array<{ mode: ModeShort; winnerTeamId: number }>; pickerTeamId: number; maps: TournamentRoundMaps | null; + pickBanEvents?: PickBanEvent[]; + modesIncluded: ModeShort[]; }): Set { if ( !maps?.pickBan || @@ -219,7 +464,27 @@ function unavailableModes({ return new Set(); } - // can't pick the same mode last won on + if (maps.pickBan === "CUSTOM") { + const currentSectionEvents = currentSectionPickBanEvents({ + pickBanEvents: pickBanEvents ?? [], + maps, + }); + + const modePick = currentSectionEvents.findLast( + (e) => e.type === "MODE_PICK", + ); + if (modePick?.mode) { + return new Set(modesIncluded.filter((m) => m !== modePick.mode)); + } + + const modeBans = currentSectionEvents + .filter((e) => e.type === "MODE_BAN" && e.mode !== null) + .map((e) => e.mode!); + + return new Set(modeBans); + } + + // COUNTERPICK: can't pick the same mode last won on const result = new Set( results .filter((result) => result.winnerTeamId === pickerTeamId) @@ -229,3 +494,110 @@ function unavailableModes({ return result; } + +function currentSectionPickBanEvents({ + pickBanEvents, + maps, +}: { + pickBanEvents: PickBanEvent[]; + maps: TournamentRoundMaps; +}): PickBanEvent[] { + const preSetLength = maps.customFlow?.preSet.length ?? 0; + const postGameLength = maps.customFlow?.postGame.length ?? 0; + + if (pickBanEvents.length <= preSetLength) { + return pickBanEvents; + } + + if (postGameLength === 0) return []; + + const eventsAfterPreSet = pickBanEvents.length - preSetLength; + const currentCycleStart = + preSetLength + + Math.floor(eventsAfterPreSet / postGameLength) * postGameLength; + + return pickBanEvents.slice(currentCycleStart); +} + +const BEFORE_SET_INVALID_WHO: ReadonlySet = new Set([ + "WINNER", + "LOSER", +]); + +export const CUSTOM_FLOW_VALIDATION_ERRORS = { + STEP_MISSING_ACTION: "STEP_MISSING_ACTION", + STEP_MISSING_WHO: "STEP_MISSING_WHO", + LAST_STEP_MUST_BE_PICK_OR_ROLL: "LAST_STEP_MUST_BE_PICK_OR_ROLL", + WINNER_LOSER_IN_PRE_SET: "WINNER_LOSER_IN_PRE_SET", + TOO_MANY_MODE_PICKS: "TOO_MANY_MODE_PICKS", + TOO_MANY_MAP_PICKS: "TOO_MANY_MAP_PICKS", + SAME_TEAM_MODE_AND_MAP_PICK: "SAME_TEAM_MODE_AND_MAP_PICK", +} as const; +export type CustomFlowValidationError = + (typeof CUSTOM_FLOW_VALIDATION_ERRORS)[keyof typeof CUSTOM_FLOW_VALIDATION_ERRORS]; + +interface ValidatableStep { + action?: ActionType; + side?: WhoSide; +} + +export function validateCustomFlowSection( + steps: ValidatableStep[], + section: "preSet" | "postGame", +): CustomFlowValidationError[] { + const errors: CustomFlowValidationError[] = []; + + for (const step of steps) { + if (!step.action) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.STEP_MISSING_ACTION); + break; + } + if (step.action !== "ROLL" && !step.side) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.STEP_MISSING_WHO); + break; + } + } + + const lastStep = steps.at(-1); + if ( + !lastStep || + (lastStep.action && + lastStep.action !== "PICK" && + lastStep.action !== "ROLL") + ) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.LAST_STEP_MUST_BE_PICK_OR_ROLL); + } + + if (section === "preSet") { + for (const step of steps) { + if (step.side && BEFORE_SET_INVALID_WHO.has(step.side)) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.WINNER_LOSER_IN_PRE_SET); + break; + } + } + } + + const modePickCount = steps.filter((s) => s.action === "MODE_PICK").length; + if (modePickCount > 1) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MODE_PICKS); + } + + const mapPickCount = steps.filter( + (s) => s.action === "PICK" || s.action === "ROLL", + ).length; + if (mapPickCount > 1) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.TOO_MANY_MAP_PICKS); + } + + const modePickStep = steps.find((s) => s.action === "MODE_PICK"); + const mapPickStep = steps.find((s) => s.action === "PICK"); + if ( + modePickStep?.side && + mapPickStep?.side && + modePickStep.side === mapPickStep.side + ) { + errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK); + } + + return errors; +} diff --git a/app/features/tournament-bracket/core/executeRoll.server.ts b/app/features/tournament-bracket/core/executeRoll.server.ts new file mode 100644 index 000000000..e3d0f1bbd --- /dev/null +++ b/app/features/tournament-bracket/core/executeRoll.server.ts @@ -0,0 +1,78 @@ +import type { TournamentRoundMaps } from "~/db/tables"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import type { ModeWithStage } from "~/modules/in-game-lists/types"; +import invariant from "~/utils/invariant"; +import { seededRandom } from "~/utils/random"; +import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; +import type { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; +import * as PickBan from "./PickBan"; +import type { TournamentDataTeam } from "./Tournament.server"; + +export async function executeRoll({ + matchId, + maps, + pickBanEvents, + results, + tournamentId, + teams, + tieBreakerMapPool, +}: { + matchId: number; + maps: TournamentRoundMaps; + pickBanEvents: Awaited< + ReturnType + >; + results: ReturnType; + tournamentId: number; + teams: [TournamentDataTeam, TournamentDataTeam]; + tieBreakerMapPool: ModeWithStage[]; +}): Promise { + const customFlow = maps.customFlow; + if (!customFlow) return false; + + const step = PickBan.resolveCurrentStep({ + eventCount: pickBanEvents.length, + preSet: customFlow.preSet, + postGame: customFlow.postGame, + resultsCount: results.length, + }); + + if (!step || step.action !== "ROLL") return false; + + const toSetMapPool = + await TournamentRepository.findTOSetMapPoolById(tournamentId); + const legalMaps = PickBan.mapsListWithLegality({ + toSetMapPool, + maps, + mapList: null, + teams, + tieBreakerMapPool, + pickerTeamId: teams[0].id, + results, + pickBanEvents, + }).filter((m) => m.isLegal); + + invariant(legalMaps.length > 0, "Unexpected no legal maps"); + + const eventNumber = pickBanEvents.length + 1; + const { randomInteger } = seededRandom(`roll-${matchId}-${eventNumber}`); + const selectedMap = legalMaps[randomInteger(legalMaps.length)]!; + + try { + await TournamentRepository.addPickBanEvent({ + authorId: null, + matchId, + stageId: selectedMap.stageId, + mode: selectedMap.mode, + number: eventNumber, + type: "ROLL", + }); + } catch (error) { + if (!errorIsSqliteUniqueConstraintFailure(error)) { + throw error; + } + // unique constraint violation — another request already handled this roll + } + + return true; +} diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts index d58edf40c..e253c764b 100644 --- a/app/features/tournament-bracket/core/mapList.server.ts +++ b/app/features/tournament-bracket/core/mapList.server.ts @@ -25,8 +25,8 @@ interface ResolveCurrentMapListArgs { teams: [teamOneId: number, teamTwoId: number]; maps: TournamentRoundMaps; pickBanEvents: Array<{ - mode: ModeShort; - stageId: StageId; + mode: ModeShort | null; + stageId: StageId | null; type: Tables["TournamentMatchPickBanEvent"]["type"]; }>; /** Maps that both teams (interleaved) have recently played in the tournament with the most recent being first. */ @@ -36,6 +36,11 @@ interface ResolveCurrentMapListArgs { export function resolveMapList( args: ResolveCurrentMapListArgs, ): TournamentMapListMap[] { + // CUSTOM flow: map list is built from pick/ban events + if (args.maps.pickBan === "CUSTOM") { + return resolveCustomMapList(args); + } + const baseMaps = args.mapPickingStyle === "TO" ? args.maps!.list?.map((m) => ({ ...m, source: "TO" as const })) @@ -65,6 +70,12 @@ export function resolveMapList( .concat( ...args.pickBanEvents .filter((event) => event.type === "PICK") + .filter( + ( + event, + ): event is typeof event & { mode: ModeShort; stageId: StageId } => + event.mode !== null && event.stageId !== null, + ) .map((map) => ({ mode: map.mode, stageId: map.stageId, @@ -74,6 +85,25 @@ export function resolveMapList( ); } +function resolveCustomMapList( + args: ResolveCurrentMapListArgs, +): TournamentMapListMap[] { + return args.pickBanEvents + .filter((event) => event.type === "PICK" || event.type === "ROLL") + .filter( + (event): event is typeof event & { mode: ModeShort; stageId: StageId } => + event.mode !== null && event.stageId !== null, + ) + .map((event) => ({ + mode: event.mode, + stageId: event.stageId, + source: (event.type === "ROLL" + ? "ROLL" + : "COUNTERPICK") as TournamentMaplistSource, + bannedByTournamentTeamId: undefined, + })); +} + export function mapListFromResults( results: Array<{ mode: ModeShort; @@ -136,6 +166,8 @@ function resolveFreshTeamPickedMapList( case "COUNTERPICK": case "COUNTERPICK_MODE_REPEAT_OK": return 1; + case "CUSTOM": + return 0; default: assertUnreachable(pickBan); } diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index 8b6278df0..712bb3378 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -64,6 +64,7 @@ export function generateTournamentRoundMaplist( return 1; } if (args.pickBanStyle === "BAN_2") return count + 2; + if (args.pickBanStyle === "CUSTOM") return 0; assertUnreachable(args.pickBanStyle); }; diff --git a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts index a33b30baa..04eb9c67b 100644 --- a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts @@ -11,7 +11,9 @@ import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { tournamentMatchPage } from "~/utils/urls"; +import { executeRoll } from "../core/executeRoll.server"; import { mapListFromResults, resolveMapList } from "../core/mapList.server"; +import * as PickBan from "../core/PickBan"; import { tournamentFromDBCached } from "../core/Tournament.server"; import { findMatchById } from "../queries/findMatchById.server"; import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; @@ -38,7 +40,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { throw new Response(null, { status: 404 }); } - const pickBanEvents = match.roundMaps?.pickBan + let pickBanEvents = match.roundMaps?.pickBan ? await TournamentRepository.pickBanEventsByMatchId(match.id) : []; @@ -47,6 +49,41 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const matchIsOver = match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; + if ( + !matchIsOver && + match.roundMaps?.pickBan === "CUSTOM" && + match.roundMaps.customFlow && + match.opponentOne?.id && + match.opponentTwo?.id + ) { + const currentStep = PickBan.resolveCurrentStep({ + eventCount: pickBanEvents.length, + preSet: match.roundMaps.customFlow.preSet, + postGame: match.roundMaps.customFlow.postGame, + resultsCount: results.length, + }); + if (currentStep?.action === "ROLL") { + const teamOne = tournament.teamById(match.opponentOne.id); + const teamTwo = tournament.teamById(match.opponentTwo.id); + if (teamOne && teamTwo) { + const rollExecuted = await executeRoll({ + matchId, + maps: match.roundMaps, + pickBanEvents, + results, + tournamentId, + teams: [teamOne, teamTwo], + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + }); + if (rollExecuted) { + pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( + match.id, + ); + } + } + } + } + // cached so that some user changing their noScreen preference doesn't // change the selection once the match has started const noScreen = @@ -146,5 +183,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { endedEarly, noScreen, chatCode: visibleChatCode, + pickBanEventCount: pickBanEvents.length, + pickBanEvents: pickBanEvents.map((e) => ({ + type: e.type, + stageId: e.stageId, + mode: e.mode, + })), }; }; diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 3eb131703..93b6680d4 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ACTION_TYPES, WHO_SIDES } from "~/db/tables"; import { _action, checkboxValueToBoolean, @@ -66,7 +67,7 @@ export const matchSchema = z.union([ }), z.object({ _action: _action("BAN_PICK"), - stageId, + stageId: stageId.optional(), mode: modeShort, }), z.object({ @@ -104,6 +105,18 @@ export const matchSchema = z.union([ export const bracketIdx = z.coerce.number().int().min(0).max(100); +const customPickBanStep = z.object({ + action: z.enum(ACTION_TYPES), + side: z.enum(WHO_SIDES).optional(), +}); + +const customPickBanFlow = z + .object({ + preSet: z.array(customPickBanStep), + postGame: z.array(customPickBanStep), + }) + .nullish(); + const tournamentRoundMaps = z.object({ roundId: z.number().int().min(0), groupId: z.number().int().min(0), @@ -118,6 +131,7 @@ const tournamentRoundMaps = z.object({ count: numericEnum(TOURNAMENT.AVAILABLE_BEST_OF), type: z.enum(["BEST_OF", "PLAY_ALL"]), pickBan: z.enum(PickBan.types).nullish(), + customFlow: customPickBanFlow, }); export const bracketSchema = z.union([ z.object({ diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 492edf7c0..f06c2bf0e 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -128,6 +128,9 @@ export function pickInfoText({ if (map.source === "COUNTERPICK") { return t("tournament:pickInfo.counterpick"); } + if (map.source === "ROLL") { + return t("tournament:pickInfo.roll"); + } if (map.source === "TO") return ""; logger.error(`Unknown source: ${String(map.source)}`); diff --git a/app/features/tournament-bracket/tournament-bracket.module.css b/app/features/tournament-bracket/tournament-bracket.module.css index eb53b5adf..39c87d0ce 100644 --- a/app/features/tournament-bracket/tournament-bracket.module.css +++ b/app/features/tournament-bracket/tournament-bracket.module.css @@ -91,7 +91,7 @@ .stageBanner { &:has(.stageBannerEndSetButton) .stageBannerUndoButton:not(.stageBannerEndSetButton) { - right: 72px; + right: 78px; } } diff --git a/app/modules/tournament-map-list-generator/constants.ts b/app/modules/tournament-map-list-generator/constants.ts index 15ee52e23..f46287cba 100644 --- a/app/modules/tournament-map-list-generator/constants.ts +++ b/app/modules/tournament-map-list-generator/constants.ts @@ -6,6 +6,7 @@ export const sourceTypes = [ "BOTH", "TO", "COUNTERPICK", + "ROLL", ] as const; // this is only used as a fallback, in the case that map list generation has a bug diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index 212ebad75..1c653180d 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -118,10 +118,21 @@ export async function expectIsHydrated(page: Page) { await expect(page.getByTestId("hydrated")).toHaveCount(1); } -export function seed(page: Page, variation?: SeedVariation) { - return page.request.post("/seed", { - form: { variation: variation ?? "DEFAULT", source: "e2e" }, - }); +export async function seed(page: Page, variation?: SeedVariation) { + const MAX_ATTEMPTS = 3; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await page.request.post("/seed", { + form: { variation: variation ?? "DEFAULT", source: "e2e" }, + timeout: 7_500, + }); + } catch (error) { + if (attempt === MAX_ATTEMPTS) throw error; + } + } + + throw new Error("seed: unreachable"); } export function impersonate(page: Page, userId = ADMIN_ID) { diff --git a/app/utils/sql.ts b/app/utils/sql.ts index c82332807..28320dd4e 100644 --- a/app/utils/sql.ts +++ b/app/utils/sql.ts @@ -7,6 +7,15 @@ export function errorIsSqliteForeignKeyConstraintFailure( ); } +export function errorIsSqliteUniqueConstraintFailure( + error: unknown, +): error is Error { + return ( + error instanceof Error && + error?.message?.includes("UNIQUE constraint failed") + ); +} + export function parseDBJsonArray(value: any) { const parsed = JSON.parse(value); diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 5fa26dbaa..0518e46e0 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 5301627d4..2b50bc48e 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-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index f537b84df..11bb450a1 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 1ed33e66a..e4fdfc473 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 70a774a84..4b7caefc8 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 5e7ee40f8..6eec995ce 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 345aa9ee9..7a93187be 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 9bd85db8b..919cad16f 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/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 0621a0297..8d7069d18 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -12,6 +12,7 @@ import { startBracket, submit, test, + waitForPOSTResponse, } from "~/utils/playwright"; import { createFormHelpers } from "~/utils/playwright-form"; import { @@ -1309,4 +1310,141 @@ test.describe("Tournament bracket", () => { await expect(page.getByText("Match ended early")).toBeVisible(); await expect(page.getByText("dropped out of the tournament")).toBeVisible(); }); + + test("ban/pick CUSTOM flow", async ({ page }) => { + test.slow(); + const tournamentId = 4; + const matchId = 2; + const higherSeedCaptainId = 29; + const lowerSeedCaptainId = 33; + + const customFlow = { + preSet: [ + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "HIGHER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "BAN", side: "LOWER_SEED" }, + { action: "ROLL" }, + ], + postGame: [ + { action: "BAN", side: "WINNER" }, + { action: "BAN", side: "WINNER" }, + { action: "PICK", side: "LOSER" }, + ], + }; + + // 1) Start bracket with CUSTOM pick/ban flow + await seed(page); + await impersonate(page); + + await navigate({ + page, + url: tournamentBracketsPage({ tournamentId }), + }); + + await page.getByTestId("finalize-bracket-button").click(); + await page.getByLabel("Pick/ban").selectOption("CUSTOM"); + await expect(page.getByText("Before set")).toBeVisible(); + + await waitForPOSTResponse(page, async () => { + await page.evaluate((cfStr) => { + const input = document.querySelector( + 'input[name="maps"]', + ) as HTMLInputElement; + const maps = JSON.parse(input.value); + const cf = JSON.parse(cfStr); + for (const m of maps) { + if (m.pickBan === "CUSTOM") { + m.customFlow = cf; + } + } + input.value = JSON.stringify(maps); + + const form = input.closest("form")!; + const btn = document.createElement("button"); + btn.type = "submit"; + btn.name = "_action"; + btn.value = "START_BRACKET"; + btn.style.display = "none"; + form.appendChild(btn); + btn.click(); + }, JSON.stringify(customFlow)); + }); + + // 2) PreSet: Higher seed bans 2 maps + await impersonate(page, higherSeedCaptainId); + await navigate({ + page, + url: tournamentMatchPage({ tournamentId, matchId }), + }); + await page.getByTestId("actions-tab").click(); + + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + // 3) PreSet: Lower seed bans 2 maps + await impersonate(page, lowerSeedCaptainId); + await navigate({ + page, + url: tournamentMatchPage({ tournamentId, matchId }), + }); + await page.getByTestId("actions-tab").click(); + + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + // 4) Roll auto-executed after last ban; report game 1 score + await expect(page.getByTestId("stage-banner")).toBeVisible(); + await page.getByTestId("actions-tab").click(); + + await page.getByTestId("winner-radio-1").click(); + await page.getByTestId("points-input-1").fill("100"); + await submit(page, "report-score-button"); + await expectScore(page, [1, 0]); + + // 5) PostGame: Winner (team 1, captain 33) bans 2 maps + await expect(page.getByText(/Ban a map/)).toBeVisible(); + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + await expect(page.getByText(/Ban a map \(2\/2\)/)).toBeVisible(); + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + // PostGame: Loser (team 2, captain 29) picks a map + await impersonate(page, higherSeedCaptainId); + await navigate({ + page, + url: tournamentMatchPage({ tournamentId, matchId }), + }); + await page.getByTestId("actions-tab").click(); + + await expect(page.getByText(/Pick a map/)).toBeVisible(); + await page.getByTestId("pick-ban-button").first().click(); + await submit(page); + + // 6) Undo game 1 score — also deletes postGame pick/ban events + await expect(page.getByTestId("stage-banner")).toBeVisible(); + await submit(page, "undo-score-button"); + + await expectScore(page, [0, 0]); + await expect(page.getByTestId("stage-banner")).toBeVisible(); + + // 7) Re-report game 1 and verify postGame cycle restarts + await page.getByTestId("actions-tab").click(); + await page.getByTestId("winner-radio-1").click(); + await page.getByTestId("points-input-1").fill("100"); + await submit(page, "report-score-button"); + await expectScore(page, [1, 0]); + + await expect(page.getByText(/Ban a map/)).toBeVisible(); + }); }); diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 6697729bd..34d252c8f 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Modvalg", + "pickInfo.roll": "", "generator.error": "Ændringer, som du har lavet, blev ikke gemt, da turneringen nu er begyndt. ", "teams.mapsPickedStatus": "Status for banevalg", "admin.download": "Hent liste over deltagere", @@ -151,5 +152,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 2ef0d64d8..31900e6b6 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Counterpick", + "pickInfo.roll": "", "generator.error": "Die Änderungen wurden nicht gespeichert, weil das Turnier bereits begonnen hat", "teams.mapsPickedStatus": "Status ausgewählter Arenen", "admin.download": "Teilnehmerliste herunterladen", @@ -151,5 +152,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/en/tournament.json b/locales/en/tournament.json index fc80ea80d..2d1fc2b5f 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "{{count}} vote", "pickInfo.votes_other": "{{count}} votes", "pickInfo.counterpick": "Counterpick", + "pickInfo.roll": "Random", "generator.error": "Changes you made weren't saved since the tournament started", "teams.mapsPickedStatus": "Maps picked status", "admin.download": "Download participants", @@ -151,5 +152,40 @@ "progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket", "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket", - "lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue" + "lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue", + "customFlow.beforeSet": "Before set", + "customFlow.afterMap": "After map", + "customFlow.who": "Who", + "customFlow.action": "Action", + "customFlow.addStep": "Add step", + "customFlow.whoPlaceholder": "Who?", + "customFlow.actionPlaceholder": "What?", + "customFlow.validation.stepMissingAction": "Every step must have an action", + "customFlow.validation.stepMissingWho": "Every step must specify who performs the action", + "customFlow.validation.lastStepMustBePickOrRoll": "Last step must be Pick or Roll (to determine the map)", + "customFlow.validation.winnerLoserInPreSet": "Winner/Loser cannot be used in Before set steps", + "customFlow.validation.tooManyModePicks": "At most one Mode Pick per section", + "customFlow.validation.tooManyMapPicks": "At most one Pick or Roll per section", + "customFlow.validation.sameTeamModeAndMapPick": "Same team cannot pick both mode and map", + "customFlow.who.ALPHA": "Team Alpha", + "customFlow.who.BRAVO": "Team Bravo", + "customFlow.who.HIGHER_SEED": "Higher Seed", + "customFlow.who.LOWER_SEED": "Lower Seed", + "customFlow.who.WINNER": "Winner", + "customFlow.who.LOSER": "Loser", + "customFlow.action.ROLL": "Random legal map", + "customFlow.action.PICK": "Pick (map)", + "customFlow.action.BAN": "Ban (map)", + "customFlow.action.MODE_PICK": "Pick (mode)", + "customFlow.action.MODE_BAN": "Ban (mode)", + "pickBan.counterpick": "Counterpick", + "pickBan.pickMap": "Pick a map", + "pickBan.banMap": "Ban a map", + "pickBan.pickMode": "Pick a mode", + "pickBan.banMode": "Ban a mode", + "match.tab.mapInfo": "Map info", + "match.mapInfo.bans": "{{teamName}} bans", + "match.mapInfo.playedStages": "Played stages", + "match.mapInfo.noBans": "No bans", + "match.mapInfo.noPlayedStages": "No stages played yet" } diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 66d1dbfb2..96e35e5cd 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "{{count}} votos", "pickInfo.votes_other": "{{count}} votos", "pickInfo.counterpick": "Contraselección", + "pickInfo.roll": "", "generator.error": "Los cambios no fueron guardados ya que el torneo comenzó", "teams.mapsPickedStatus": "Estado de mapas elegidos", "admin.download": "Descarga participantes", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "La eliminación simple no es un cuadro de origen válido", "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index dffbe024b..338d39274 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Contraselección", + "pickInfo.roll": "", "generator.error": "Cambios no fueron guardados ya que el torneo comenzó", "teams.mapsPickedStatus": "Estado de escenarios elegidos", "admin.download": "Descarga participantes", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 890c6f187..ca9736fdc 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "", + "pickInfo.roll": "", "generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi", "teams.mapsPickedStatus": "Statut des stages sélectionnés", "admin.download": "Télécharger les participants", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 676ce27c7..65209bad2 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Counterpick", + "pickInfo.roll": "", "generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi", "teams.mapsPickedStatus": "Statut des stages sélectionnés", "admin.download": "Télécharger les participants", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket", "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/he/tournament.json b/locales/he/tournament.json index a0c34b8d2..930ca0994 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_two": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "", + "pickInfo.roll": "", "generator.error": "שינויים שביצעת לא נשמרו מאז תחילת הטורניר", "teams.mapsPickedStatus": "סטטוס בחירת המפות", "admin.download": "הורדת משתתפים", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 1cc0dba06..a1c250bb4 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Counterpick", + "pickInfo.roll": "", "generator.error": "I cambiamenti che hai fatto non sono stati salvati visto che il torneo è già cominciato.", "teams.mapsPickedStatus": "Stato scenari selezionati", "admin.download": "Download partecipanti", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "Eliminazione singola non è un bracket sorgente valido", "progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index a6c5735dd..764ff8f13 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -53,6 +53,7 @@ "pickInfo.default": "", "pickInfo.default.explanation": "", "pickInfo.counterpick": "順番に選ぶ", + "pickInfo.roll": "", "generator.error": "トーナメントが開始済みなので変更は保存されませんでした。", "teams.mapsPickedStatus": "マップの選択状況", "admin.download": "参加者リストをダウンロード", @@ -147,5 +148,40 @@ "progression.error.NO_SE_SOURCE": "シングルエリ三ネーションは妥当なブラケットではないです", "progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 17ba79516..3e31f7bf3 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -53,6 +53,7 @@ "pickInfo.default": "", "pickInfo.default.explanation": "", "pickInfo.counterpick": "", + "pickInfo.roll": "", "generator.error": "", "teams.mapsPickedStatus": "", "admin.download": "", @@ -147,5 +148,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 646089e82..c7e4c53eb 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "", + "pickInfo.roll": "", "generator.error": "", "teams.mapsPickedStatus": "", "admin.download": "", @@ -151,5 +152,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 805f36542..ab2fbf4a3 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -57,6 +57,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "", + "pickInfo.roll": "", "generator.error": "Zmiany nie zostały zapisane z powodu rozpoczęcia turnieju", "teams.mapsPickedStatus": "Status wybranych map", "admin.download": "Zapisz uczestników", @@ -155,5 +156,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 325f20765..775231cc2 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Counterpick", + "pickInfo.roll": "", "generator.error": "Mudanças que você fez não foram salvas uma vez que o torneio já começou", "teams.mapsPickedStatus": "Status dos mapas escolhidos", "admin.download": "Download dos participantes", @@ -153,5 +154,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index e3630f0d9..9a1e117e1 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -57,6 +57,7 @@ "pickInfo.votes_many": "", "pickInfo.votes_other": "", "pickInfo.counterpick": "Контрвыбор", + "pickInfo.roll": "", "generator.error": "Ваши изменения не были сохранены так как турнир начался", "teams.mapsPickedStatus": "Статус выбранных карт", "admin.download": "Скачать участников", @@ -155,5 +156,40 @@ "progression.error.NO_SE_SOURCE": "Single elimination не валидная сетка-исток", "progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 82958c0e7..53c721180 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -53,6 +53,7 @@ "pickInfo.default": "", "pickInfo.default.explanation": "", "pickInfo.counterpick": "反选", + "pickInfo.roll": "", "generator.error": "由于比赛已经开始,您所更改的内容无法保存", "teams.mapsPickedStatus": "地图的选择情况", "admin.download": "下载参赛者", @@ -147,5 +148,40 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", - "lfg.askCaptainToJoinQueue": "" + "lfg.askCaptainToJoinQueue": "", + "customFlow.beforeSet": "", + "customFlow.afterMap": "", + "customFlow.who": "", + "customFlow.action": "", + "customFlow.addStep": "", + "customFlow.whoPlaceholder": "", + "customFlow.actionPlaceholder": "", + "customFlow.validation.stepMissingAction": "", + "customFlow.validation.stepMissingWho": "", + "customFlow.validation.lastStepMustBePickOrRoll": "", + "customFlow.validation.winnerLoserInPreSet": "", + "customFlow.validation.tooManyModePicks": "", + "customFlow.validation.tooManyMapPicks": "", + "customFlow.validation.sameTeamModeAndMapPick": "", + "customFlow.who.ALPHA": "", + "customFlow.who.BRAVO": "", + "customFlow.who.HIGHER_SEED": "", + "customFlow.who.LOWER_SEED": "", + "customFlow.who.WINNER": "", + "customFlow.who.LOSER": "", + "customFlow.action.ROLL": "", + "customFlow.action.PICK": "", + "customFlow.action.BAN": "", + "customFlow.action.MODE_PICK": "", + "customFlow.action.MODE_BAN": "", + "pickBan.counterpick": "", + "pickBan.pickMap": "", + "pickBan.banMap": "", + "pickBan.pickMode": "", + "pickBan.banMode": "", + "match.tab.mapInfo": "", + "match.mapInfo.bans": "", + "match.mapInfo.playedStages": "", + "match.mapInfo.noBans": "", + "match.mapInfo.noPlayedStages": "" } diff --git a/migrations/126-pick-ban-custom-flow.js b/migrations/126-pick-ban-custom-flow.js new file mode 100644 index 000000000..a9b9db8d6 --- /dev/null +++ b/migrations/126-pick-ban-custom-flow.js @@ -0,0 +1,41 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /*sql*/ ` + create table "TournamentMatchPickBanEvent_new" ( + "type" text not null, + "stageId" integer, + "mode" text, + "matchId" integer not null, + "authorId" integer, + "number" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("authorId") references "User"("id") on delete restrict, + foreign key ("matchId") references "TournamentMatch"("id") on delete cascade, + unique("matchId", "number") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /*sql*/ ` + insert into "TournamentMatchPickBanEvent_new" ("type", "stageId", "mode", "matchId", "authorId", "number", "createdAt") + select "type", "stageId", "mode", "matchId", "authorId", "number", "createdAt" + from "TournamentMatchPickBanEvent" + `, + ).run(); + + db.prepare(/*sql*/ `drop table "TournamentMatchPickBanEvent"`).run(); + + db.prepare( + /*sql*/ `alter table "TournamentMatchPickBanEvent_new" rename to "TournamentMatchPickBanEvent"`, + ).run(); + + db.prepare( + /*sql*/ `create index pick_ban_event_author_id on "TournamentMatchPickBanEvent"("authorId")`, + ).run(); + db.prepare( + /*sql*/ `create index pick_ban_event_match_id on "TournamentMatchPickBanEvent"("matchId")`, + ).run(); + })(); +}