sendou.ink/app/features/tournament-bracket/core/PickBan.test.ts

1308 lines
32 KiB
TypeScript

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,
currentTurnSessionStartedAt,
isModeLegal,
mapsListWithLegality,
type PickBanEvent,
type PickBanTeam,
resolveCurrentStep,
resolveTeamFromSide,
teamOfEvent,
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("mapsListWithLegality — pre-set MODE_BAN persists into postGame", () => {
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_BAN", side: "HIGHER_SEED" }, { action: "ROLL" }],
postGame: [
{ action: "BAN", side: "WINNER" },
{ action: "PICK", side: "LOSER" },
],
},
};
it("keeps a mode banned in pre-set unavailable for picks in later postGame cycles", () => {
// preSet: HIGHER_SEED bans mode SZ, ROLL lands on TC stage 3
// game 1: TC stage 3 played, team 200 wins
// postGame cycle 1: WINNER (200) bans stage 4 (TC); LOSER (100) is now at PICK
const pickBanEvents: PickBanEvent[] = [
{ type: "MODE_BAN", stageId: null, mode: SZ },
{ type: "ROLL", stageId: 3 as StageId, mode: TC },
{ type: "BAN", stageId: 4 as StageId, mode: TC },
];
const result = mapsListWithLegality({
results: [{ mode: TC, stageId: 3 as StageId, winnerTeamId: 200 }],
maps: customMaps,
mapList: null,
teams,
pickerTeamId: 100,
tieBreakerMapPool: [],
toSetMapPool,
pickBanEvents,
});
const legalModes = new Set(
result.filter((m) => m.isLegal).map((m) => m.mode),
);
expect(legalModes.has(SZ)).toBe(false);
expect(legalModes).toEqual(new Set([RM]));
});
});
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" });
});
it("returns null when match was completed without per-game results (drop-out)", () => {
const result = turnOf({
results: [],
maps: cpMaps,
teams,
mapList: [],
});
expect(result).toBeNull();
});
});
describe("teamOfEvent", () => {
const teams: [PickBanTeam, PickBanTeam] = [
{ id: 100, seed: 2 },
{ id: 200, seed: 1 },
];
it("returns null when setup is not pick/ban", () => {
const result = teamOfEvent({
eventIndex: 0,
maps: { count: 3, type: "BEST_OF" },
teams,
results: [],
});
expect(result).toBeNull();
});
describe("BAN_2", () => {
const ban2Maps: TournamentRoundMaps = {
count: 3,
type: "BEST_OF",
pickBan: "BAN_2",
};
it("assigns event 0 to teams[1] (first picker)", () => {
expect(
teamOfEvent({ eventIndex: 0, maps: ban2Maps, teams, results: [] }),
).toBe(200);
});
it("assigns event 1 to teams[0] (second picker)", () => {
expect(
teamOfEvent({ eventIndex: 1, maps: ban2Maps, teams, results: [] }),
).toBe(100);
});
it("returns null for further indices", () => {
expect(
teamOfEvent({ eventIndex: 2, maps: ban2Maps, teams, results: [] }),
).toBeNull();
});
});
describe("COUNTERPICK", () => {
const cpMaps: TournamentRoundMaps = {
count: 5,
type: "BEST_OF",
pickBan: "COUNTERPICK",
};
it("attributes the counterpick to the loser of the preceding result", () => {
const result = teamOfEvent({
eventIndex: 0,
maps: cpMaps,
teams,
results: [{ winnerTeamId: 100 }],
});
expect(result).toBe(200);
});
it("also works for COUNTERPICK_MODE_REPEAT_OK", () => {
const result = teamOfEvent({
eventIndex: 1,
maps: { ...cpMaps, pickBan: "COUNTERPICK_MODE_REPEAT_OK" },
teams,
results: [{ winnerTeamId: 100 }, { winnerTeamId: 200 }],
});
expect(result).toBe(100);
});
it("returns null when no corresponding result exists", () => {
const result = teamOfEvent({
eventIndex: 0,
maps: cpMaps,
teams,
results: [],
});
expect(result).toBeNull();
});
});
describe("CUSTOM", () => {
const customMaps: TournamentRoundMaps = {
count: 5,
type: "BEST_OF",
pickBan: "CUSTOM",
customFlow: {
preSet: [
{ action: "BAN", side: "HIGHER_SEED" },
{ action: "BAN", side: "LOWER_SEED" },
],
postGame: [
{ action: "BAN", side: "WINNER" },
{ action: "PICK", side: "LOSER" },
],
},
};
it("resolves preSet steps via side (HIGHER_SEED → teams[1])", () => {
expect(
teamOfEvent({ eventIndex: 0, maps: customMaps, teams, results: [] }),
).toBe(200);
});
it("resolves preSet steps via side (LOWER_SEED → teams[0])", () => {
expect(
teamOfEvent({ eventIndex: 1, maps: customMaps, teams, results: [] }),
).toBe(100);
});
it("resolves postGame WINNER using the result of that cycle", () => {
const result = teamOfEvent({
eventIndex: 2,
maps: customMaps,
teams,
results: [{ winnerTeamId: 100 }],
});
expect(result).toBe(100);
});
it("resolves postGame LOSER using the result of that cycle", () => {
const result = teamOfEvent({
eventIndex: 3,
maps: customMaps,
teams,
results: [{ winnerTeamId: 100 }],
});
expect(result).toBe(200);
});
it("uses the correct cycle's result across multiple post-game cycles", () => {
const result = teamOfEvent({
eventIndex: 4,
maps: customMaps,
teams,
results: [{ winnerTeamId: 100 }, { winnerTeamId: 200 }],
});
expect(result).toBe(200);
});
it("returns null when customFlow is missing", () => {
const result = teamOfEvent({
eventIndex: 0,
maps: { count: 3, type: "BEST_OF", pickBan: "CUSTOM" },
teams,
results: [],
});
expect(result).toBeNull();
});
it("returns null for ROLL steps (no side)", () => {
const rollMaps: TournamentRoundMaps = {
count: 3,
type: "BEST_OF",
pickBan: "CUSTOM",
customFlow: {
preSet: [{ action: "ROLL" }],
postGame: [{ action: "PICK", side: "LOSER" }],
},
};
expect(
teamOfEvent({ eventIndex: 0, maps: rollMaps, teams, results: [] }),
).toBeNull();
});
});
});
describe("currentTurnSessionStartedAt", () => {
const teams: [PickBanTeam, PickBanTeam] = [
{ id: 100, seed: 2 },
{ id: 200, seed: 1 },
];
it("returns null when there is no current turn", () => {
const result = currentTurnSessionStartedAt({
currentTurn: null,
events: [],
results: [],
matchStartedAt: 1000,
maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" },
teams,
});
expect(result).toBeNull();
});
it("returns null when matchStartedAt is null", () => {
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 200, action: "BAN" },
events: [],
results: [],
matchStartedAt: null,
maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" },
teams,
});
expect(result).toBeNull();
});
it("falls back to matchStartedAt when no events or results exist", () => {
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 200, action: "BAN" },
events: [],
results: [],
matchStartedAt: 1000,
maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" },
teams,
});
expect(result).toBe(1000);
});
it("BAN_2: second banner's session starts at the first ban's timestamp", () => {
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 100, action: "BAN" },
events: [{ createdAt: 1500 }],
results: [],
matchStartedAt: 1000,
maps: { count: 3, type: "BEST_OF", pickBan: "BAN_2" },
teams,
});
expect(result).toBe(1500);
});
it("COUNTERPICK: loser's session starts when the result is reported", () => {
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 200, action: "PICK" },
events: [],
results: [{ createdAt: 2000, winnerTeamId: 100 }],
matchStartedAt: 1000,
maps: { count: 5, type: "BEST_OF", pickBan: "COUNTERPICK" },
teams,
});
expect(result).toBe(2000);
});
it("CUSTOM: consecutive same-team events share the session start", () => {
const customMaps: 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" },
],
postGame: [{ action: "PICK", side: "LOSER" }],
},
};
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 200, action: "BAN" },
events: [{ createdAt: 1500 }],
results: [],
matchStartedAt: 1000,
maps: customMaps,
teams,
});
expect(result).toBe(1000);
});
it("CUSTOM: a result restarts the session even when the same team is responsible again", () => {
const customMaps: TournamentRoundMaps = {
count: 5,
type: "BEST_OF",
pickBan: "CUSTOM",
customFlow: {
preSet: [{ action: "BAN", side: "HIGHER_SEED" }],
postGame: [
{ action: "PICK", side: "LOSER" },
{ action: "BAN", side: "LOSER" },
],
},
};
const result = currentTurnSessionStartedAt({
currentTurn: { teamId: 100, action: "BAN" },
events: [{ createdAt: 1100 }, { createdAt: 2500 }],
results: [{ createdAt: 2000, winnerTeamId: 200 }],
matchStartedAt: 1000,
maps: customMaps,
teams,
});
expect(result).toBe(2000);
});
});