mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-26 13:28:32 -05:00
867 lines
22 KiB
TypeScript
867 lines
22 KiB
TypeScript
import * as R from "remeda";
|
|
import type {
|
|
ActionType,
|
|
CustomPickBanStep,
|
|
TournamentRoundMaps,
|
|
WhoSide,
|
|
} from "~/db/tables";
|
|
import { isSetOverByResults } from "~/features/tournament-match/tournament-match-utils";
|
|
import type {
|
|
ModeShort,
|
|
ModeWithStage,
|
|
StageId,
|
|
} from "~/modules/in-game-lists/types";
|
|
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
|
import invariant from "~/utils/invariant";
|
|
import { logger } from "~/utils/logger";
|
|
import { seededRandom } from "~/utils/random";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import type { TournamentDataTeam } from "./Tournament.server";
|
|
|
|
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,
|
|
matchId,
|
|
}: {
|
|
results: Array<{ winnerTeamId: number }>;
|
|
maps: TournamentRoundMaps;
|
|
teams: [PickBanTeam, PickBanTeam];
|
|
mapList?: TournamentMapListMap[] | null;
|
|
pickBanEventCount?: number;
|
|
matchId: number;
|
|
}): TurnOfResult | null {
|
|
if (!maps.pickBan) return null;
|
|
|
|
switch (maps.pickBan) {
|
|
case "BAN_2": {
|
|
if (!mapList) return null;
|
|
if (
|
|
isSetOverByResults({ count: maps.count, results, countType: maps.type })
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// typically lower seed is the "bottom team" and they pick first
|
|
const [secondPicker, firstPicker] = teams;
|
|
|
|
if (
|
|
!mapList.some((map) => map.bannedByTournamentTeamId === firstPicker.id)
|
|
) {
|
|
return { teamId: firstPicker.id, action: "BAN" };
|
|
}
|
|
|
|
if (
|
|
!mapList.some((map) => map.bannedByTournamentTeamId === secondPicker.id)
|
|
) {
|
|
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;
|
|
// match was completed without per-game results (e.g. drop-out)
|
|
if (results.length === 0) return null;
|
|
|
|
if (
|
|
isSetOverByResults({ count: maps.count, results, countType: maps.type })
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const latestWinner = results[results.length - 1]?.winnerTeamId;
|
|
invariant(latestWinner, "turnOf: No winner found");
|
|
|
|
const team = teams.find((team) => latestWinner !== team.id);
|
|
invariant(team, "turnOf: No result found");
|
|
|
|
return { teamId: team.id, action: "PICK" };
|
|
}
|
|
case "CUSTOM": {
|
|
return turnOfCustom({ results, maps, teams, pickBanEventCount, matchId });
|
|
}
|
|
default: {
|
|
assertUnreachable(maps.pickBan);
|
|
}
|
|
}
|
|
}
|
|
|
|
function turnOfCustom({
|
|
results,
|
|
maps,
|
|
teams,
|
|
pickBanEventCount,
|
|
matchId,
|
|
}: {
|
|
results: Array<{ winnerTeamId: number }>;
|
|
maps: TournamentRoundMaps;
|
|
teams: [PickBanTeam, PickBanTeam];
|
|
pickBanEventCount?: number;
|
|
matchId: 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,
|
|
randomTeamIndex: randomWhoTeamIndex({
|
|
matchId,
|
|
eventIndex: eventCount,
|
|
preSetLength: preSet.length,
|
|
postGameLength: postGame.length,
|
|
}),
|
|
});
|
|
|
|
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 stepInPostGame = (eventCount - preSet.length) % postGame.length;
|
|
const completedPostGameCycles = postGameCycleIndex({
|
|
eventIndex: eventCount,
|
|
preSetLength: preSet.length,
|
|
postGameLength: postGame.length,
|
|
});
|
|
|
|
// waiting for game result
|
|
if (completedPostGameCycles > resultsCount) return null;
|
|
if (completedPostGameCycles === resultsCount && stepInPostGame === 0) {
|
|
return null;
|
|
}
|
|
|
|
return postGame[stepInPostGame]!;
|
|
}
|
|
|
|
/**
|
|
* Returns the 0-based post-game cycle index for an event position. For an
|
|
* event at `eventIndex`, this is the cycle the event belongs to; for a count
|
|
* of events done so far, this is how many post-game cycles have been at least
|
|
* started past the pre-set.
|
|
*
|
|
* Caller must ensure `eventIndex >= preSetLength` and `postGameLength > 0`.
|
|
*/
|
|
export function postGameCycleIndex({
|
|
eventIndex,
|
|
preSetLength,
|
|
postGameLength,
|
|
}: {
|
|
eventIndex: number;
|
|
preSetLength: number;
|
|
postGameLength: number;
|
|
}): number {
|
|
return Math.floor((eventIndex - preSetLength) / postGameLength);
|
|
}
|
|
|
|
export function resolveTeamFromSide({
|
|
side,
|
|
teams,
|
|
results,
|
|
randomTeamIndex,
|
|
}: {
|
|
side: WhoSide;
|
|
teams: [PickBanTeam, PickBanTeam];
|
|
results: Array<{ winnerTeamId: number }>;
|
|
/**
|
|
* The team index (0 or 1) the per-map coin flip landed on. Required when
|
|
* `side` is RANDOM or RANDOM_OTHER. Compute with {@link randomWhoTeamIndex}.
|
|
*/
|
|
randomTeamIndex?: 0 | 1;
|
|
}): number {
|
|
switch (side) {
|
|
case "RANDOM":
|
|
case "RANDOM_OTHER": {
|
|
invariant(
|
|
randomTeamIndex !== undefined,
|
|
"resolveTeamFromSide: randomTeamIndex required for RANDOM sides",
|
|
);
|
|
const teamIndex =
|
|
side === "RANDOM" ? randomTeamIndex : 1 - randomTeamIndex;
|
|
return teams[teamIndex].id;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves which of the two teams (0 or 1) the "RANDOM" side maps to for the
|
|
* given event via a deterministic per-map coin flip. A single flip covers one
|
|
* decision group: all pre-set steps share one flip, and each post-game cycle
|
|
* (one per map) has its own. "RANDOM_OTHER" is the complement of this index.
|
|
*/
|
|
export function randomWhoTeamIndex({
|
|
matchId,
|
|
eventIndex,
|
|
preSetLength,
|
|
postGameLength,
|
|
}: {
|
|
matchId: number;
|
|
eventIndex: number;
|
|
preSetLength: number;
|
|
postGameLength: number;
|
|
}): 0 | 1 {
|
|
const drawKey =
|
|
eventIndex < preSetLength
|
|
? "pre"
|
|
: `post-${postGameCycleIndex({ eventIndex, preSetLength, postGameLength })}`;
|
|
|
|
const { randomInteger } = seededRandom(`random-who-${matchId}-${drawKey}`);
|
|
return randomInteger(2) as 0 | 1;
|
|
}
|
|
|
|
/**
|
|
* Resolves which team is responsible for the pick/ban event at a given index,
|
|
* across all pick/ban variants (BAN_2, COUNTERPICK, COUNTERPICK_MODE_REPEAT_OK,
|
|
* CUSTOM). Returns null when the setup is not pick/ban or the team cannot be
|
|
* determined (e.g. a CUSTOM ROLL step, or insufficient results).
|
|
*/
|
|
export function teamOfEvent({
|
|
eventIndex,
|
|
maps,
|
|
teams,
|
|
results,
|
|
matchId,
|
|
}: {
|
|
eventIndex: number;
|
|
maps: TournamentRoundMaps;
|
|
teams: [PickBanTeam, PickBanTeam];
|
|
results: Array<{ winnerTeamId: number }>;
|
|
matchId: number;
|
|
}): number | null {
|
|
if (!maps.pickBan) return null;
|
|
|
|
switch (maps.pickBan) {
|
|
case "BAN_2": {
|
|
// turnOf uses: [secondPicker, firstPicker] = teams, so teams[1] bans
|
|
// first (event 0), teams[0] second (event 1).
|
|
if (eventIndex === 0) return teams[1].id;
|
|
if (eventIndex === 1) return teams[0].id;
|
|
return null;
|
|
}
|
|
case "COUNTERPICK":
|
|
case "COUNTERPICK_MODE_REPEAT_OK": {
|
|
// Each counterpick follows a played map; the loser of that map picks.
|
|
const result = results[eventIndex];
|
|
if (!result) return null;
|
|
return teams.find((t) => t.id !== result.winnerTeamId)?.id ?? null;
|
|
}
|
|
case "CUSTOM": {
|
|
const customFlow = maps.customFlow;
|
|
if (!customFlow) return null;
|
|
|
|
const { preSet, postGame } = customFlow;
|
|
const step =
|
|
eventIndex < preSet.length
|
|
? preSet[eventIndex]
|
|
: postGame.length > 0
|
|
? postGame[(eventIndex - preSet.length) % postGame.length]
|
|
: null;
|
|
if (!step?.side) return null;
|
|
|
|
// WINNER/LOSER sides are relative to the latest result at the time
|
|
// of the event, so slice results to the correct post-game cycle.
|
|
if (step.side === "WINNER" || step.side === "LOSER") {
|
|
const cycleIndex = postGameCycleIndex({
|
|
eventIndex,
|
|
preSetLength: preSet.length,
|
|
postGameLength: postGame.length,
|
|
});
|
|
if (!results[cycleIndex]) return null;
|
|
return resolveTeamFromSide({
|
|
side: step.side,
|
|
teams,
|
|
results: results.slice(0, cycleIndex + 1),
|
|
});
|
|
}
|
|
|
|
return resolveTeamFromSide({
|
|
side: step.side,
|
|
teams,
|
|
results,
|
|
randomTeamIndex: randomWhoTeamIndex({
|
|
matchId,
|
|
eventIndex,
|
|
preSetLength: preSet.length,
|
|
postGameLength: postGame.length,
|
|
}),
|
|
});
|
|
}
|
|
default: {
|
|
assertUnreachable(maps.pickBan);
|
|
}
|
|
}
|
|
}
|
|
|
|
type TimelineItem =
|
|
| { kind: "event"; index: number; createdAt: number }
|
|
| { kind: "result"; createdAt: number };
|
|
|
|
/**
|
|
* Resolves the timestamp at which the currently responsible team's pick/ban
|
|
* session started. The session begins when responsibility transitions to that
|
|
* team and continues across multiple consecutive actions by the same team.
|
|
* A game result always ends the prior session, so a team that becomes
|
|
* responsible after a result starts a fresh session at the result's timestamp.
|
|
*
|
|
* Returns `null` when there is no current pick/ban turn or `matchStartedAt` is
|
|
* not available. When no transitions exist yet, returns `matchStartedAt`.
|
|
*/
|
|
export function currentTurnSessionStartedAt({
|
|
currentTurn,
|
|
events,
|
|
results,
|
|
matchStartedAt,
|
|
maps,
|
|
teams,
|
|
matchId,
|
|
}: {
|
|
currentTurn: TurnOfResult | null;
|
|
events: Array<{ createdAt: number }>;
|
|
results: Array<{ createdAt: number; winnerTeamId: number }>;
|
|
matchStartedAt: number | null;
|
|
maps: TournamentRoundMaps;
|
|
teams: [PickBanTeam, PickBanTeam];
|
|
matchId: number;
|
|
}): number | null {
|
|
if (!currentTurn || matchStartedAt == null) return null;
|
|
|
|
const timeline: TimelineItem[] = [];
|
|
for (let i = 0; i < events.length; i++) {
|
|
timeline.push({ kind: "event", index: i, createdAt: events[i]!.createdAt });
|
|
}
|
|
for (const result of results) {
|
|
timeline.push({ kind: "result", createdAt: result.createdAt });
|
|
}
|
|
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
const item = timeline[i]!;
|
|
if (item.kind === "event") {
|
|
const teamMadeIt = teamOfEvent({
|
|
eventIndex: item.index,
|
|
maps,
|
|
teams,
|
|
results,
|
|
matchId,
|
|
});
|
|
if (teamMadeIt === currentTurn.teamId) continue;
|
|
}
|
|
return item.createdAt;
|
|
}
|
|
|
|
return matchStartedAt;
|
|
}
|
|
|
|
export function isLegal({
|
|
map,
|
|
...rest
|
|
}: MapListWithStatusesArgs & { map: ModeWithStage }) {
|
|
const pool = mapsListWithLegality(rest);
|
|
|
|
return pool.some(
|
|
(m) => m.mode === map.mode && m.stageId === map.stageId && m.isLegal,
|
|
);
|
|
}
|
|
|
|
export function isModeLegal({
|
|
mode,
|
|
...rest
|
|
}: MapListWithStatusesArgs & { mode: ModeShort }) {
|
|
const pool = mapsListWithLegality(rest);
|
|
|
|
return pool.some((m) => m.mode === mode && m.isLegal);
|
|
}
|
|
|
|
export function isStageLegal({
|
|
stageId,
|
|
...rest
|
|
}: MapListWithStatusesArgs & { stageId: StageId }) {
|
|
const pool = mapsListWithLegality(rest);
|
|
|
|
return pool.some((m) => m.stageId === stageId && 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;
|
|
mapList: TournamentMapListMap[] | null;
|
|
teams: [TournamentDataTeam, TournamentDataTeam];
|
|
pickerTeamId: number;
|
|
tieBreakerMapPool: ModeWithStage[];
|
|
toSetMapPool: Array<{ mode: ModeShort; stageId: StageId }>;
|
|
pickBanEvents?: PickBanEvent[];
|
|
}
|
|
export function mapsListWithLegality(args: MapListWithStatusesArgs) {
|
|
const mapPool = (() => {
|
|
if (!args.maps?.pickBan) return [];
|
|
switch (args.maps.pickBan) {
|
|
case "BAN_2": {
|
|
if (!args.mapList) {
|
|
logger.warn("mapsListWithLegality: mapList is empty");
|
|
return [];
|
|
}
|
|
return args.mapList;
|
|
}
|
|
case "COUNTERPICK_MODE_REPEAT_OK":
|
|
case "COUNTERPICK": {
|
|
if (args.toSetMapPool.length === 0) {
|
|
const combinedPools = [
|
|
...(args.teams[0].mapPool ?? []),
|
|
...(args.teams[1].mapPool ?? []),
|
|
...args.tieBreakerMapPool,
|
|
];
|
|
|
|
const result: ModeWithStage[] = [];
|
|
for (const map of combinedPools) {
|
|
if (
|
|
!result.some(
|
|
(m) => m.mode === map.mode && m.stageId === map.stageId,
|
|
)
|
|
) {
|
|
result.push(map);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
})();
|
|
|
|
const modesIncluded = R.unique(mapPool.map((m) => m.mode));
|
|
|
|
const unavailableStagesSet = unavailableStages(args);
|
|
const unavailableModesSetAll = unavailableModes({ ...args, modesIncluded });
|
|
const unavailableModesSet =
|
|
// one mode tournament
|
|
unavailableModesSetAll.size < modesIncluded.length
|
|
? unavailableModesSetAll
|
|
: new Set();
|
|
|
|
const result = mapPool.map((map) => {
|
|
const isLegal =
|
|
!unavailableStagesSet.has(map.stageId) &&
|
|
!unavailableModesSet.has(map.mode);
|
|
|
|
return { ...map, isLegal };
|
|
});
|
|
|
|
const everythingBanned = result.every((map) => !map.isLegal);
|
|
if (everythingBanned) {
|
|
return result.map((map) => ({ ...map, isLegal: true }));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
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();
|
|
|
|
switch (maps.pickBan) {
|
|
case "BAN_2": {
|
|
return new Set(
|
|
mapList
|
|
?.filter((m) => m.bannedByTournamentTeamId)
|
|
.map((map) => map.stageId) ?? [],
|
|
);
|
|
}
|
|
case "COUNTERPICK_MODE_REPEAT_OK":
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ||
|
|
maps.pickBan === "BAN_2" ||
|
|
maps.pickBan === "COUNTERPICK_MODE_REPEAT_OK"
|
|
) {
|
|
return new Set();
|
|
}
|
|
|
|
if (maps.pickBan === "CUSTOM") {
|
|
const events = pickBanEvents ?? [];
|
|
const currentSectionEvents = currentSectionPickBanEvents({
|
|
pickBanEvents: events,
|
|
maps,
|
|
});
|
|
|
|
const modePick = currentSectionEvents.findLast(
|
|
(e) => e.type === "MODE_PICK",
|
|
);
|
|
if (modePick?.mode) {
|
|
return new Set(modesIncluded.filter((m) => m !== modePick.mode));
|
|
}
|
|
|
|
const preSetLength = maps.customFlow?.preSet.length ?? 0;
|
|
const preSetModeBans = events
|
|
.slice(0, preSetLength)
|
|
.filter((e) => e.type === "MODE_BAN" && e.mode !== null)
|
|
.map((e) => e.mode!);
|
|
const currentSectionModeBans = currentSectionEvents
|
|
.filter((e) => e.type === "MODE_BAN" && e.mode !== null)
|
|
.map((e) => e.mode!);
|
|
|
|
const currentStep = maps.customFlow
|
|
? resolveCurrentStep({
|
|
eventCount: events.length,
|
|
preSet: maps.customFlow.preSet,
|
|
postGame: maps.customFlow.postGame,
|
|
resultsCount: results.length,
|
|
})
|
|
: null;
|
|
const noModeRepeatModes =
|
|
currentStep?.action === "PICK_NO_MODE_REPEAT"
|
|
? results.map((result) => result.mode)
|
|
: [];
|
|
|
|
return new Set([
|
|
...preSetModeBans,
|
|
...currentSectionModeBans,
|
|
...noModeRepeatModes,
|
|
]);
|
|
}
|
|
|
|
// COUNTERPICK: can't pick the same mode last won on
|
|
const result = new Set(
|
|
results
|
|
.filter((result) => result.winnerTeamId === pickerTeamId)
|
|
.slice(-1)
|
|
.map((result) => result.mode),
|
|
);
|
|
|
|
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 currentCycleStart =
|
|
preSetLength +
|
|
postGameCycleIndex({
|
|
eventIndex: pickBanEvents.length,
|
|
preSetLength,
|
|
postGameLength,
|
|
}) *
|
|
postGameLength;
|
|
|
|
return pickBanEvents.slice(currentCycleStart);
|
|
}
|
|
|
|
const BEFORE_SET_INVALID_WHO: ReadonlySet<WhoSide> = new Set([
|
|
"WINNER",
|
|
"LOSER",
|
|
]);
|
|
|
|
/** Actions where a team picks a map (as opposed to ROLL which has no picker). */
|
|
const MAP_PICK_ACTIONS: ReadonlySet<ActionType> = new Set([
|
|
"PICK",
|
|
"PICK_NO_MODE_REPEAT",
|
|
]);
|
|
|
|
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 !== "ROLL" &&
|
|
!MAP_PICK_ACTIONS.has(lastStep.action))
|
|
) {
|
|
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 === "ROLL" || (s.action && MAP_PICK_ACTIONS.has(s.action)),
|
|
).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 && MAP_PICK_ACTIONS.has(s.action),
|
|
);
|
|
if (
|
|
modePickStep?.side &&
|
|
mapPickStep?.side &&
|
|
modePickStep.side === mapPickStep.side
|
|
) {
|
|
errors.push(CUSTOM_FLOW_VALIDATION_ERRORS.SAME_TEAM_MODE_AND_MAP_PICK);
|
|
}
|
|
|
|
return errors;
|
|
}
|