Custom flow in pick/ban (#2923)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-03-29 18:00:15 +03:00 committed by GitHub
parent a1788ba89b
commit 7300693ba9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 4146 additions and 180 deletions

View File

@ -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<number>;
}
@ -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[];
}
/**

View File

@ -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<number> | null;
/** (round robin only) points of the match used for tiebreaker purposes. e.g. [100, 0] indicates a knockout. */

View File

@ -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;

View File

@ -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<CustomPickBanFlow | null>(
preparedMaps?.maps.find((m) => m.customFlow)?.customFlow ?? null,
);
const [hoveredMap, setHoveredMap] = React.useState<string | null>(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({
</SendouButton>
) : null}
</div>
{pickBanStyle === "CUSTOM" && !needsToPickEliminationTeamCount ? (
<CustomFlowBuilder value={customFlow} onChange={setCustomFlow} />
) : null}
{needsToPickEliminationTeamCount ? (
<div className="text-center text-lg font-bold my-24">
Pick the expected teams count above to prepare maps
@ -623,10 +652,19 @@ export function BracketMapListDialog({
})}
</div>
{!validateNoDecreasingCount() ? (
<div className="text-warning text-center">
<div className="mt-4 text-warning text-center">
Invalid selection: tournament progression decreases in map
count
</div>
) : !validateCustomFlow() ? (
<div className="mt-4 text-warning text-center">
Invalid selection: custom pick/ban flow is invalid
</div>
) : !validateCustomFlowRoundsSelected() ? (
<div className="mt-4 text-warning text-center">
Custom flow is configured but no rounds have pick/ban
enabled
</div>
) : (
<SubmitButton
testId="confirm-finalize-bracket-button"
@ -830,6 +868,7 @@ function PickBanSelect({
COUNTERPICK: "Counterpick",
COUNTERPICK_MODE_REPEAT_OK: "Counterpick (mode repeat allowed)",
BAN_2: "Ban 2",
CUSTOM: "Custom",
};
// selection doesn't make sense for one mode only tournaments as you have to repeat the mode
@ -952,44 +991,57 @@ function RoundMapList({
) : null}
</div>
<ol className="pl-0">
{nullFilledArray(
maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count,
).map((_, i) => {
const map = maps.list?.[i];
if (map) {
return (
<MapListRow
{maps.pickBan === "CUSTOM"
? nullFilledArray(maps.count).map((_, i) => (
<MysteryRow
key={i}
map={map}
number={i + 1}
onHoverMap={onHoverMap}
hoveredMap={hoveredMap}
onMapChange={(map) => {
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 (
<MysteryRow
key={i}
number={i + 1}
isCounterpicks={!isTeamsPick && maps.pickBan === "COUNTERPICK"}
isTiebreaker={
tournament.ctx.mapPickingStyle === "AUTO_ALL" && isLast
if (map) {
return (
<MapListRow
key={i}
map={map}
number={i + 1}
onHoverMap={onHoverMap}
hoveredMap={hoveredMap}
onMapChange={(map) => {
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 (
<MysteryRow
key={i}
number={i + 1}
isCounterpicks={
!isTeamsPick && maps.pickBan === "COUNTERPICK"
}
isTiebreaker={
tournament.ctx.mapPickingStyle === "AUTO_ALL" && isLast
}
/>
);
})}
</ol>
</div>
);
@ -1066,10 +1118,12 @@ function MysteryRow({
number,
isCounterpicks,
isTiebreaker,
isCustomFlow,
}: {
number: number;
isCounterpicks: boolean;
isTiebreaker: boolean;
isCustomFlow?: boolean;
}) {
return (
<li className={styles.mapListRow}>
@ -1079,11 +1133,13 @@ function MysteryRow({
})}
>
<span className="text-lg">{number}.</span>
{isCounterpicks
? "Counterpick"
: isTiebreaker
? "Tiebreaker"
: "Team's pick"}
{isCustomFlow
? "Custom flow"
: isCounterpicks
? "Counterpick"
: isTiebreaker
? "Tiebreaker"
: "Team's pick"}
</div>
</li>
);

View File

@ -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<typeof CustomFlowBuilder> = {
value: null,
onChange: vi.fn(),
};
function renderComponent(
props: Partial<ComponentProps<typeof CustomFlowBuilder>> = {},
) {
const router = createMemoryRouter(
[
{
path: "/",
element: <CustomFlowBuilder {...defaultProps} {...props} />,
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
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();
});
});
});

View File

@ -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;
}

View File

@ -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<WhoSide> = 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<PartialStep[]>(() =>
value?.preSet.length
? value.preSet.map((s) => ({ id: nanoid(), ...s }))
: [{ id: nanoid() }],
);
const [postGameSteps, setPostGameSteps] = React.useState<PartialStep[]>(() =>
value?.postGame.length
? value.postGame.map((s) => ({ id: nanoid(), ...s }))
: [{ id: nanoid() }],
);
const [activeDragId, setActiveDragId] = React.useState<string | null>(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 (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className={styles.container}>
<ChipPalette />
{isMobile ? (
<SendouTabs>
<SendouTabList>
<SendouTab id="preSet">
{t("tournament:customFlow.beforeSet")}
</SendouTab>
<SendouTab id="postGame">
{t("tournament:customFlow.afterMap")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="preSet">
<StepListSection
section="preSet"
steps={preSetSteps}
onStepsChange={updatePreSetSteps}
errors={preSetErrors}
dragOverInfo={dragOverInfo}
/>
</SendouTabPanel>
<SendouTabPanel id="postGame">
<StepListSection
section="postGame"
steps={postGameSteps}
onStepsChange={updatePostGameSteps}
errors={postGameErrors}
dragOverInfo={dragOverInfo}
/>
</SendouTabPanel>
</SendouTabs>
) : (
<div className={styles.sections}>
<StepListSection
title={t("tournament:customFlow.beforeSet")}
section="preSet"
steps={preSetSteps}
onStepsChange={updatePreSetSteps}
errors={preSetErrors}
dragOverInfo={dragOverInfo}
/>
<StepListSection
title={t("tournament:customFlow.afterMap")}
section="postGame"
steps={postGameSteps}
onStepsChange={updatePostGameSteps}
errors={postGameErrors}
dragOverInfo={dragOverInfo}
/>
</div>
)}
</div>
<DragOverlay className={styles.overlay}>
{activeDragId ? <DragOverlayChip dragId={activeDragId} /> : null}
</DragOverlay>
</DndContext>
);
}
function ChipPalette() {
const { t } = useTranslation(["tournament"]);
return (
<div className={styles.palette}>
<div className={styles.paletteGroup}>
<span className={styles.paletteLabel}>
{t("tournament:customFlow.who")}
</span>
{WHO_SIDES.map((who) => (
<PaletteChip
key={who}
id={`palette-${who}`}
type="who"
label={t(WHO_I18N_KEYS[who])}
/>
))}
</div>
<div className={styles.paletteGroup}>
<span className={styles.paletteLabel}>
{t("tournament:customFlow.action")}
</span>
{ACTION_TYPES.map((action) => (
<PaletteChip
key={action}
id={`palette-${action}`}
type="action"
label={t(ACTION_I18N_KEYS[action])}
/>
))}
</div>
</div>
);
}
function PaletteChip({
id,
type,
label,
}: {
id: string;
type: "who" | "action";
label: string;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
});
return (
<div
ref={setNodeRef}
className={clsx(styles.chip, {
[styles.chipWho]: type === "who",
[styles.chipAction]: type === "action",
[styles.chipDragging]: isDragging,
})}
{...listeners}
{...attributes}
>
{label}
</div>
);
}
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 (
<div className={styles.section}>
{title ? <div className={styles.sectionHeader}>{title}</div> : null}
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
<div className={styles.stepList}>
{steps.map((step, i) => (
<StepRow
key={step.id}
step={step}
index={i}
section={section}
canRemove={steps.length > 1}
onRemove={() => removeStep(i)}
dragOverInfo={dragOverInfo}
/>
))}
</div>
</SortableContext>
<SendouButton
className={styles.addStepButton}
size="small"
variant="outlined"
icon={<Plus />}
onPress={addStep}
>
{t("tournament:customFlow.addStep")}
</SendouButton>
{errors.map((error) => (
<div key={error} className={styles.validationError}>
{error}
</div>
))}
</div>
);
}
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 (
<div
ref={setNodeRef}
style={style}
className={clsx(styles.stepRow, {
[styles.stepRowDragging]: isDragging,
})}
>
<button
type="button"
className={styles.dragHandle}
{...listeners}
{...attributes}
>
<GripVertical size={16} />
</button>
{isRoll ? null : (
<DropZone
id={whoDropId}
type="who"
filled={step.side}
label={step.side ? t(WHO_I18N_KEYS[step.side]) : undefined}
dragOverInfo={dragOverInfo}
/>
)}
<DropZone
id={actionDropId}
type="action"
filled={step.action}
label={step.action ? t(ACTION_I18N_KEYS[step.action]) : undefined}
dragOverInfo={dragOverInfo}
/>
<button
type="button"
className={clsx(styles.removeButton, {
[styles.removeButtonHidden]: !canRemove,
})}
onClick={onRemove}
aria-label="Remove step"
>
<X size={14} />
</button>
</div>
);
}
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 (
<div
ref={setNodeRef}
className={clsx(styles.dropZone, {
[styles.dropZoneWho]: type === "who",
[styles.dropZoneAction]: type === "action",
[styles.dropZoneOver]: isOver && isValid,
[styles.dropZoneInvalid]: isOver && !isValid,
[styles.dropZoneFilled]: Boolean(filled),
})}
>
{label ??
(type === "who"
? t("tournament:customFlow.whoPlaceholder")
: t("tournament:customFlow.actionPlaceholder"))}
</div>
);
}
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 (
<div
className={clsx(styles.chip, {
[styles.chipWho]: isWho,
[styles.chipAction]: !isWho,
})}
>
{label}
</div>
);
}
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;
}

View File

@ -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 <MatchActionsBanPicker key={turnOf} teams={[teams[0], teams[1]]} />;
if (turnOf && bothTeamsHaveActiveRosters) {
return (
<MatchActionsBanPicker
key={`${turnOf.teamId}-${data.pickBanEventCount}`}
teams={[teams[0], teams[1]]}
/>
);
}
const bothTeamsHaveActiveRosters = teams.every((team) =>
tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam),
);
const canEditFinishedSet =
result && tournament.isOrganizer(user) && !tournament.ctx.isFinalized;

View File

@ -51,6 +51,10 @@
color: var(--color-error);
}
.mapButtonIconMuted {
color: var(--color-text-high);
}
.mapButtonNumber {
position: absolute;
background-color: var(--color-text-accent);

View File

@ -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<TournamentMatchLoaderData>();
const tournament = useTournament();
const maps = data.match.roundMaps!;
const [selected, setSelected] = React.useState<{
mode: ModeShort;
stageId: StageId;
}>();
const [selected, setSelected] = React.useState<BanPickerSelection>();
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 (
<div>
<MapPicker
selected={selected}
setSelected={setSelected}
pickerTeamId={pickerTeamId}
teams={teams}
/>
{isModeAction ? (
<ModePicker
selected={selected}
setSelected={setSelected}
pickerTeamId={pickerTeamId}
teams={teams}
/>
) : (
<MapPicker
selected={selected}
setSelected={setSelected}
pickerTeamId={pickerTeamId}
teams={teams}
actionType={actionType}
/>
)}
<CounterpickSubmitter
selected={selected}
pickingTeam={pickingTeam}
pickBan={data.match.roundMaps!.pickBan!}
actionType={actionType}
/>
</div>
);
@ -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<TournamentMatchLoaderData>();
@ -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({
);
})}
</div>
{pickersLastWonMode === mode && modes.length > 1 ? (
{data.match.roundMaps?.pickBan !== "CUSTOM" &&
pickersLastWonMode === mode &&
modes.length > 1 ? (
<div className="text-error text-xs text-center mt-2">
Can&apos;t pick the same mode team last won on
</div>
@ -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 ? (
<Check className={styles.mapButtonIcon} onClick={onClick} />
{selected && !disabled ? (
actionType === "BAN" || actionType === "MODE_BAN" ? (
<X
className={clsx(styles.mapButtonIcon, styles.mapButtonIconMuted)}
onClick={onClick}
/>
) : (
<Check
className={clsx(styles.mapButtonIcon, styles.mapButtonIconMuted)}
onClick={onClick}
/>
)
) : null}
{disabled ? (
<X className={clsx(styles.mapButtonIcon, styles.mapButtonIconError)} />
@ -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<TournamentMatchLoaderData>();
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 (
<div className="stack horizontal md justify-center flex-wrap">
{availableModes.map((mode) => (
<button
key={mode}
type="button"
className={clsx(styles.mapButton, {
[styles.mapButtonGreyedOut]: selected?.mode === mode,
})}
onClick={canPickBan ? () => setSelected({ mode }) : undefined}
disabled={!canPickBan}
data-testid={canPickBan ? "pick-ban-button" : undefined}
>
<ModeImage mode={mode} size={48} />
<div className={styles.mapButtonLabel}>
{t(`game-misc:MODE_SHORT_${mode}`)}
</div>
{selected?.mode === mode ? (
<Check className={styles.mapButtonIcon} />
) : null}
</button>
))}
</div>
);
}
function CounterpickSubmitter({
selected,
pickingTeam,
pickBan,
actionType,
}: {
selected?: {
mode: ModeShort;
stageId: StageId;
};
selected?: BanPickerSelection;
pickingTeam: TournamentDataTeam;
pickBan: NonNullable<TournamentRoundMaps["pickBan"]>;
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 (
<div className="mt-6 text-lighter text-sm text-center">
@ -272,36 +397,41 @@ function CounterpickSubmitter({
if (picking && !selected) {
return (
<div className="mt-6 text-lighter text-sm text-center">
{pickBan === "BAN_2"
? "Please select your team's ban above"
: "Please select your team's counterpick above"}
{promptLabel()}
</div>
);
}
invariant(selected, "CounterpickSubmitter: selected is undefined");
const stageId = isModeAction ? null : selected.stageId;
invariant(isModeAction || typeof stageId === "number", "Expected stageId");
return (
<div className="stack md items-center">
<div
className={clsx("mt-6 text-lighter text-sm", {
"text-warning": pickBan === "BAN_2",
"text-warning":
actionType === "BAN" ||
actionType === "MODE_BAN" ||
pickBan === "BAN_2",
})}
>
{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}
</div>
<div className="stack sm horizontal">
<ModeImage mode={selected.mode} size={32} />{" "}
<StageImage
stageId={selected.stageId}
height={32}
className="rounded-sm"
/>
<ModeImage mode={selected.mode} size={32} />
{typeof stageId === "number" ? (
<StageImage stageId={stageId} height={32} className="rounded-sm" />
) : null}
</div>
<fetcher.Form method="post">
<input type="hidden" name="stageId" value={selected.stageId} />
{typeof stageId === "number" ? (
<input type="hidden" name="stageId" value={stageId} />
) : null}
<input type="hidden" name="mode" value={selected.mode} />
<SubmitButton _action="BAN_PICK">Confirm</SubmitButton>
</fetcher.Form>

View File

@ -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;
}

View File

@ -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<TournamentMatchLoaderData>();
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 (
<div className={styles.container}>
<div className="stack md">
<BanSection teamName={teamOne?.name ?? "???"} bans={teamOneBans} />
<BanSection teamName={teamTwo?.name ?? "???"} bans={teamTwoBans} />
<PlayedSection results={data.results} />
</div>
</div>
);
}
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 (
<div className={styles.section}>
<h2 className={styles.heading}>
{t("tournament:match.mapInfo.bans", { teamName })}
</h2>
{mapBans.length === 0 && modeBans.length === 0 ? (
<div className={styles.emptyText}>
{t("tournament:match.mapInfo.noBans")}
</div>
) : null}
{mapBans.length > 0 ? (
<div className={styles.maps}>
{mapBans.map((ban, i) => (
<MapEntry key={i} stageId={ban.stageId} />
))}
</div>
) : null}
{modeBans.length > 0 ? (
<div className={styles.maps}>
{modeBans.map((ban, i) => (
<div key={i} className={styles.modeEntry}>
<ModeImage mode={ban.mode} size={24} />
{t(`game-misc:MODE_LONG_${ban.mode}`)}
</div>
))}
</div>
) : null}
</div>
);
}
function PlayedSection({
results,
}: {
results: Array<{ stageId: StageId; mode: ModeShort }>;
}) {
const { t } = useTranslation(["game-misc", "tournament"]);
return (
<div className={styles.section}>
<h2 className={styles.heading}>
{t("tournament:match.mapInfo.playedStages")}
</h2>
{results.length === 0 ? (
<div className={styles.emptyText}>
{t("tournament:match.mapInfo.noPlayedStages")}
</div>
) : (
<div className={styles.maps}>
{results.map((result, i) => (
<MapEntry key={i} stageId={result.stageId} />
))}
</div>
)}
</div>
);
}
function MapEntry({ stageId, mode }: { stageId: StageId; mode?: ModeShort }) {
const { t } = useTranslation(["game-misc"]);
return (
<div className={styles.mapEntry}>
<StageImage
stageId={stageId}
height={50}
width={90}
className={styles.stageImage}
/>
<div className={styles.mapLabel}>
{mode ? `${t(`game-misc:MODE_SHORT_${mode}`)} ` : null}
{t(`game-misc:STAGE_${stageId}`).split(" ")[0]}
</div>
</div>
);
}

View File

@ -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 ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">Banning phase</div>
<div>Waiting for {banPickingTeam()?.name}</div>
</div>
</div>
) : !stage ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">Counterpick</div>
<div>Waiting for {banPickingTeam()?.name}</div>
{children}
</div>
</div>
) : matchIsLocked ? (
{matchIsLocked ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
@ -433,6 +456,23 @@ function FancyStageBanner({
/>
) : null}
</div>
) : inBanPhase ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">Banning phase</div>
<div>Waiting for {banPickingTeam()?.name}</div>
</div>
</div>
) : !stage ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
{noStageHeading()}
</div>
<div>Waiting for {banPickingTeam()?.name}</div>
{children}
</div>
</div>
) : (
<div
className={clsx(styles.stageBanner, {
@ -643,13 +683,18 @@ function StartedMatchTabs({
teams: [TournamentDataTeam, TournamentDataTeam];
result?: Result;
}) {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<TournamentMatchLoaderData>();
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({
<SendouTab id="actions" data-testid="actions-tab">
{presentational ? "Score" : "Actions"}
</SendouTab>
{isCustomFlow ? (
<SendouTab id="map-info">
{t("tournament:match.tab.mapInfo")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="rosters">
@ -713,6 +763,12 @@ function StartedMatchTabs({
}
/>
</SendouTabPanel>
{isCustomFlow ? (
<SendouTabPanel id="map-info">
<MatchMapInfo teams={[teams[0].id, teams[1].id]} />
</SendouTabPanel>
) : null}
</SendouTabs>
</ActionSectionWrapper>
);

View File

@ -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" });
});
});

View File

@ -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<StageId> {
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<ModeShort> {
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<WhoSide> = 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;
}

View File

@ -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<typeof TournamentRepository.pickBanEventsByMatchId>
>;
results: ReturnType<typeof findResultsByMatchId>;
tournamentId: number;
teams: [TournamentDataTeam, TournamentDataTeam];
tieBreakerMapPool: ModeWithStage[];
}): Promise<boolean> {
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;
}

View File

@ -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);
}

View File

@ -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);
};

View File

@ -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,
})),
};
};

View File

@ -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({

View File

@ -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)}`);

View File

@ -91,7 +91,7 @@
.stageBanner {
&:has(.stageBannerEndSetButton)
.stageBannerUndoButton:not(.stageBannerEndSetButton) {
right: 72px;
right: 78px;
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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();
});
});

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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"
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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": ""
}

View File

@ -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();
})();
}