mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-09 02:01:49 -05:00
Custom flow in pick/ban (#2923)
This commit is contained in:
parent
a1788ba89b
commit
7300693ba9
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
649
app/features/tournament-bracket/components/CustomFlowBuilder.tsx
Normal file
649
app/features/tournament-bracket/components/CustomFlowBuilder.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@
|
|||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mapButtonIconMuted {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.mapButtonNumber {
|
||||
position: absolute;
|
||||
background-color: var(--color-text-accent);
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
194
app/features/tournament-bracket/components/MatchMapInfo.tsx
Normal file
194
app/features/tournament-bracket/components/MatchMapInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
934
app/features/tournament-bracket/core/PickBan.test.ts
Normal file
934
app/features/tournament-bracket/core/PickBan.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
78
app/features/tournament-bracket/core/executeRoll.server.ts
Normal file
78
app/features/tournament-bracket/core/executeRoll.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
.stageBanner {
|
||||
&:has(.stageBannerEndSetButton)
|
||||
.stageBannerUndoButton:not(.stageBannerEndSetButton) {
|
||||
right: 72px;
|
||||
right: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
|||
41
migrations/126-pick-ban-custom-flow.js
Normal file
41
migrations/126-pick-ban-custom-flow.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user