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

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