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

816 lines
21 KiB
TypeScript

import * as R from "remeda";
import type { Tables, TournamentStageSettings } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import invariant from "../../../utils/invariant";
export interface DBSource {
/** Index of the bracket where the teams come from */
bracketIdx: number;
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. Can be empty array for Swiss brackets with early advance. */
placements: number[];
}
export interface EditableSource {
/** Bracket ID that exists in frontend only while editing. Once the sources are set an index is used to identifyer them instead. See DBSource.bracketIdx for more info. */
bracketId: string;
/** User editable string of placements. For example might be "1-3" or "1,2,3" which both mean same thing. See DBSource.placements for the validated and serialized version. */
placements: string;
}
interface BracketBase {
type: Tables["TournamentStage"]["type"];
settings: TournamentStageSettings;
name: string;
requiresCheckIn: boolean;
}
// Note sources is array for future proofing reasons. Currently the array is always of length 1 if it exists.
export interface InputBracket extends BracketBase {
id: string;
sources?: EditableSource[];
startTime?: Date;
/** This bracket cannot be edited (because it is already underway) */
disabled?: boolean;
}
export interface ParsedBracket extends BracketBase {
sources?: DBSource[];
startTime?: number;
}
export type ValidationError =
// user written placements can not be parsed
| {
type: "PLACEMENTS_PARSE_ERROR";
bracketIdx: number;
}
// tournament is ending with a format that does not resolve a winner such as round robin or grouped swiss
| {
type: "NOT_RESOLVING_WINNER";
}
// from each bracket one placement can lead to only one bracket
| {
type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS";
bracketIdxs: number[];
}
// from one bracket e.g. if 1st goes somewhere and 3rd goes somewhere then 2nd must also go somewhere
| {
type: "GAP_IN_PLACEMENTS";
bracketIdxs: number[];
}
// if round robin groups size is 4 then it doesn't make sense to have destination for 5
| {
type: "TOO_MANY_PLACEMENTS";
bracketIdx: number;
}
// two brackets can not have the same name
| {
type: "DUPLICATE_BRACKET_NAME";
bracketIdxs: number[];
}
// all brackets must have a name that is not an empty string
| {
type: "NAME_MISSING";
bracketIdx: number;
}
// negative progression (e.g. losers of first round go somewhere) is only for elimination bracket
| {
type: "NEGATIVE_PROGRESSION";
bracketIdx: number;
}
// single elimination is not a valid source bracket (might change in the future)
| {
type: "NO_SE_SOURCE";
bracketIdx: number;
}
// no DE positive placements (might change in the future)
| {
type: "NO_DE_POSITIVE";
bracketIdx: number;
}
// Swiss bracket with early advance/elimination must have a destination bracket
| {
type: "SWISS_EARLY_ADVANCE_NO_DESTINATION";
bracketIdx: number;
};
/** Takes validated brackets and returns them in the format that is ready for user input. */
export function validatedBracketsToInputFormat(
brackets: ParsedBracket[],
): InputBracket[] {
return brackets.map((bracket, bracketIdx) => {
return {
id: String(bracketIdx),
name: bracket.name,
settings: bracket.settings ?? {},
type: bracket.type,
requiresCheckIn: bracket.requiresCheckIn ?? false,
startTime: bracket.startTime
? databaseTimestampToDate(bracket.startTime)
: undefined,
sources: bracket.sources?.map((source) => ({
bracketId: String(source.bracketIdx),
placements:
source.placements.length > 0
? placementsToString(source.placements)
: "",
})),
};
});
}
function placementsToString(placements: number[]): string {
if (placements.length === 0) return "";
placements.sort((a, b) => a - b);
if (placements.some((p) => p < 0)) {
placements.sort((a, b) => b - a);
return placements.join(",");
}
const ranges: string[] = [];
let start = placements[0];
let end = placements[0];
for (let i = 1; i < placements.length; i++) {
if (placements[i] === end + 1) {
end = placements[i];
} else {
if (start === end) {
ranges.push(`${start}`);
} else {
ranges.push(`${start}-${end}`);
}
start = placements[i];
end = placements[i];
}
}
if (start === end) {
ranges.push(String(start));
} else {
ranges.push(`${start}-${end}`);
}
return ranges.join(",");
}
/** Takes bracket progression as entered by user as input and returns the validated brackets ready for input to the database or errors if any. */
export function validatedBrackets(
brackets: InputBracket[],
): ParsedBracket[] | ValidationError {
let parsed: ParsedBracket[];
try {
parsed = toOutputBracketFormat(brackets);
} catch (e) {
if ((e as { badBracketIdx: number }).badBracketIdx) {
return {
type: "PLACEMENTS_PARSE_ERROR",
bracketIdx: (e as { badBracketIdx: number }).badBracketIdx,
};
}
throw e;
}
const validationError = bracketsToValidationError(parsed);
if (validationError) {
return validationError;
}
return parsed;
}
/** Checks parsed brackets for any errors related to how the progression is laid out */
export function bracketsToValidationError(
brackets: ParsedBracket[],
): ValidationError | null {
if (!resolvesWinner(brackets)) {
return {
type: "NOT_RESOLVING_WINNER",
};
}
let faultyBracketIdxs: number[] | null = null;
faultyBracketIdxs = samePlacementToMultipleBrackets(brackets);
if (faultyBracketIdxs) {
return {
type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS",
bracketIdxs: faultyBracketIdxs,
};
}
faultyBracketIdxs = duplicateNames(brackets);
if (faultyBracketIdxs) {
return {
type: "DUPLICATE_BRACKET_NAME",
bracketIdxs: faultyBracketIdxs,
};
}
faultyBracketIdxs = gapInPlacements(brackets);
if (faultyBracketIdxs) {
return {
type: "GAP_IN_PLACEMENTS",
bracketIdxs: faultyBracketIdxs,
};
}
let faultyBracketIdx: number | null = null;
faultyBracketIdx = tooManyPlacements(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "TOO_MANY_PLACEMENTS",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = nameMissing(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NAME_MISSING",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = negativeProgression(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NEGATIVE_PROGRESSION",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = noSingleEliminationAsSource(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NO_SE_SOURCE",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = noDoubleEliminationPositive(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "NO_DE_POSITIVE",
bracketIdx: faultyBracketIdx,
};
}
faultyBracketIdx = swissEarlyAdvanceWithoutDestination(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "SWISS_EARLY_ADVANCE_NO_DESTINATION",
bracketIdx: faultyBracketIdx,
};
}
return null;
}
function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
const result = brackets.map((bracket, bracketIdx) => {
return {
type: bracket.type,
settings: bracket.settings,
name: bracket.name,
requiresCheckIn: bracket.requiresCheckIn,
startTime: bracket.startTime
? dateToDatabaseTimestamp(bracket.startTime)
: undefined,
sources: bracket.sources?.map((source) => {
const placements = parsePlacements(source.placements);
const sourceBracketIdx = brackets.findIndex(
(b) => b.id === source.bracketId,
);
const sourceBracket = brackets[sourceBracketIdx];
// Allow empty placements only for Swiss brackets with early advance
if (placements && placements.length === 0) {
const isSwissWithEarlyAdvance =
sourceBracket?.type === "swiss" &&
sourceBracket?.settings?.advanceThreshold;
if (!isSwissWithEarlyAdvance) {
throw { badBracketIdx: bracketIdx };
}
} else if (placements === null) {
throw { badBracketIdx: bracketIdx };
}
return {
bracketIdx: sourceBracketIdx,
placements: placements ?? [],
};
}),
};
});
invariant(
result.every(
(bracket) =>
!bracket.sources ||
bracket.sources.every((source) => source.bracketIdx >= 0),
"Bracket source not found",
),
);
return result;
}
function parsePlacements(placements: string) {
// Handle empty string case
if (placements.trim() === "") {
return [];
}
const parts = placements.split(",");
const result: number[] = [];
for (let part of parts) {
part = part.trim();
const isNegative = part.match(/^-\d+$/);
if (isNegative) {
result.push(Number(part));
continue;
}
const isValid = part.match(/^\d+(-\d+)?$/) && part !== "0";
if (!isValid) return null;
if (part.includes("-")) {
const [start, end] = part.split("-").map(Number);
for (let i = start; i <= end; i++) {
result.push(i);
}
} else {
result.push(Number(part));
}
}
return result;
}
function resolvesWinner(brackets: ParsedBracket[]) {
const finals = brackets.find((_, idx) => isFinals(idx, brackets));
if (!finals) return false;
if (finals?.type === "round_robin") return false;
if (
finals.type === "swiss" &&
(finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1
) {
return false;
}
return true;
}
function samePlacementToMultipleBrackets(brackets: ParsedBracket[]) {
const map = new Map<string, number[]>();
for (const [bracketIdx, bracket] of brackets.entries()) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
for (const placement of source.placements) {
const id = `${source.bracketIdx}-${placement}`;
if (!map.has(id)) {
map.set(id, []);
}
map.get(id)!.push(bracketIdx);
}
}
}
const result: number[] = [];
for (const [_, bracketIdxs] of map) {
if (bracketIdxs.length > 1) {
result.push(...bracketIdxs);
}
}
return result.length ? result : null;
}
function duplicateNames(brackets: ParsedBracket[]) {
const names = new Set<string>();
for (const [bracketIdx, bracket] of brackets.entries()) {
if (names.has(bracket.name)) {
return [brackets.findIndex((b) => b.name === bracket.name), bracketIdx];
}
names.add(bracket.name);
}
return null;
}
function gapInPlacements(brackets: ParsedBracket[]) {
const placementsMap = new Map<number, number[]>();
for (const bracket of brackets) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
if (!placementsMap.has(source.bracketIdx)) {
placementsMap.set(source.bracketIdx, []);
}
placementsMap.get(source.bracketIdx)!.push(...source.placements);
}
}
let problematicBracketIdx: number | null = null;
for (const [sourceBracketIdx, placements] of placementsMap.entries()) {
if (problematicBracketIdx !== null) break;
const placementsToConsider = placements
.filter((placement) => placement > 0)
.sort((a, b) => a - b);
for (let i = 0; i < placementsToConsider.length - 1; i++) {
if (placementsToConsider[i] + 1 !== placementsToConsider[i + 1]) {
problematicBracketIdx = sourceBracketIdx;
break;
}
}
}
if (problematicBracketIdx === null) return null;
return brackets.flatMap((bracket, bracketIdx) => {
if (!bracket.sources) return [];
return bracket.sources.flatMap(
(source) => source.bracketIdx === problematicBracketIdx,
)
? [bracketIdx]
: [];
});
}
function tooManyPlacements(brackets: ParsedBracket[]) {
const roundRobins = brackets.flatMap((bracket, bracketIdx) =>
bracket.type === "round_robin" ? [bracketIdx] : [],
);
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
if (!roundRobins.includes(source.bracketIdx)) continue;
const size =
brackets[source.bracketIdx].settings.teamsPerGroup ??
TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP;
if (source.placements.some((placement) => placement > size)) {
return bracketIdx;
}
}
}
return null;
}
function nameMissing(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
if (!bracket.name) {
return bracketIdx;
}
}
return null;
}
function negativeProgression(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (
sourceBracket.type === "double_elimination" ||
sourceBracket.type === "single_elimination"
) {
continue;
}
if (source.placements.some((placement) => placement < 0)) {
return bracketIdx;
}
}
}
return null;
}
function noSingleEliminationAsSource(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (sourceBracket.type === "single_elimination") {
return bracketIdx;
}
}
}
return null;
}
function noDoubleEliminationPositive(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
const sourceBracket = brackets[source.bracketIdx];
if (
sourceBracket.type === "double_elimination" &&
source.placements.some((placement) => placement > 0)
) {
return bracketIdx;
}
}
}
return null;
}
function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
if (bracket.type === "swiss" && bracket.settings.advanceThreshold) {
const hasDestination = brackets.some((otherBracket) =>
otherBracket.sources?.some(
(source) => source.bracketIdx === bracketIdx,
),
);
if (!hasDestination) {
return bracketIdx;
}
}
}
return null;
}
/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */
export function isBrackets(
input: ParsedBracket[] | ValidationError,
): input is ParsedBracket[] {
return Array.isArray(input);
}
/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a unsuccessful validation */
export function isError(
input: ParsedBracket[] | ValidationError,
): input is ValidationError {
return !Array.isArray(input);
}
/** Given bracketIdx and bracketProgression will resolve if this the "final stage" of the tournament that decides the final standings */
export function isFinals(idx: number, brackets: ParsedBracket[]) {
invariant(idx < brackets.length, "Bracket index out of bounds");
return resolveMainBracketProgression(brackets).at(-1) === idx;
}
/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket".
* Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers
*/
export function isUnderground(idx: number, brackets: ParsedBracket[]) {
invariant(idx < brackets.length, "Bracket index out of bounds");
const startBrackets = startingBrackets(brackets);
for (const startBracketIdx of startBrackets) {
if (
resolveMainBracketProgression(brackets, startBracketIdx).includes(idx)
) {
return false;
}
}
return true;
}
/**
* Returns the depth of a bracket in the tournament progression.
* Depth is the distance from a starting bracket (bracket with no sources).
* Starting brackets have depth 0, brackets sourced from them have depth 1, etc.
*/
export function bracketDepth(idx: number, brackets: ParsedBracket[]): number {
invariant(idx < brackets.length, "Bracket index out of bounds");
const bracket = brackets[idx];
if (!bracket.sources || bracket.sources.length === 0) {
return 0;
}
const sourceDepths = bracket.sources.map((source) =>
bracketDepth(source.bracketIdx, brackets),
);
return Math.max(...sourceDepths) + 1;
}
function resolveMainBracketProgression(
brackets: ParsedBracket[],
startBracketIdx = 0,
) {
if (brackets.length === 1) return [0];
let bracketIdxToFind = startBracketIdx;
const result = [startBracketIdx];
while (true) {
const bracket = brackets.findIndex((bracket) =>
bracket.sources?.some(
(source) =>
// empty array is the swiss early advance case
(source.placements.includes(1) || source.placements.length === 0) &&
source.bracketIdx === bracketIdxToFind,
),
);
if (bracket === -1) break;
bracketIdxToFind = bracket;
result.push(bracketIdxToFind);
}
return result;
}
/** Considering all fields. Returns array of bracket indexes that were changed */
export function changedBracketProgression(
oldProgression: ParsedBracket[],
newProgression: ParsedBracket[],
) {
const changed: number[] = [];
for (let i = 0; i < oldProgression.length; i++) {
const oldBracket = oldProgression[i];
const newBracket = newProgression.at(i);
if (!newBracket || !R.isDeepEqual(oldBracket, newBracket)) {
changed.push(i);
}
}
return changed;
}
/** Considering only fields that affect the format. Returns true if the tournament bracket format was changed and false otherwise */
export function changedBracketProgressionFormat(
oldProgression: ParsedBracket[],
newProgression: ParsedBracket[],
): boolean {
for (let i = 0; i < oldProgression.length; i++) {
const oldBracket = oldProgression[i];
const newBracket = newProgression.at(i);
// sources, startTime or requiresCheckIn are not considered
if (
!newBracket ||
newBracket.name !== oldBracket.name ||
newBracket.type !== oldBracket.type ||
!R.isDeepEqual(newBracket.settings, oldBracket.settings)
) {
return true;
}
}
return false;
}
/**
* Returns the order of brackets as is to be considered for standings. Teams from the bracket of lower index are considered to be above those from the lower bracket.
* A participant's standing is the first bracket to appear in order that has the participant in it.
*
* The order is so that most significant brackets (i.e. finals) appear first.
*/
export function bracketIdxsForStandings(progression: ParsedBracket[]) {
const bracketsToConsider = bracketsReachableFrom(0, progression);
const withoutIntermediateBrackets = bracketsToConsider.filter(
(bracket, bracketIdx) => {
if (bracketIdx === 0) return true;
return progression.every(
(b) => !b.sources?.some((s) => s.bracketIdx === bracket),
);
},
);
const withoutUnderground = withoutIntermediateBrackets.filter(
(bracketIdx) => {
const sources = progression[bracketIdx].sources;
if (!sources) return true;
return !sources.some(
(source) =>
progression[source.bracketIdx].type === "double_elimination",
);
},
);
return withoutUnderground.sort((a, b) => {
const minSourcedPlacementA = Math.min(
...(progression[a].sources?.flatMap((s) => s.placements) ?? [
Number.POSITIVE_INFINITY,
]),
);
const minSourcedPlacementB = Math.min(
...(progression[b].sources?.flatMap((s) => s.placements) ?? [
Number.POSITIVE_INFINITY,
]),
);
if (minSourcedPlacementA === minSourcedPlacementB) {
return a - b;
}
return minSourcedPlacementA - minSourcedPlacementB;
});
}
export function bracketsReachableFrom(
bracketIdx: number,
progression: ParsedBracket[],
): number[] {
const result = [bracketIdx];
for (const [newBracketIdx, bracket] of progression.entries()) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
if (source.bracketIdx === bracketIdx) {
result.push(...bracketsReachableFrom(newBracketIdx, progression));
}
}
}
return result;
}
export function destinationsFromBracketIdx(
sourceBracketIdx: number,
progression: ParsedBracket[],
): number[] {
const destinations: number[] = [];
for (const [destinationBracketIdx, bracket] of progression.entries()) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
if (source.bracketIdx === sourceBracketIdx) {
destinations.push(destinationBracketIdx);
}
}
}
return destinations;
}
export function destinationByPlacement({
sourceBracketIdx,
placement,
progression,
}: {
sourceBracketIdx: number;
placement: number;
progression: ParsedBracket[];
}): number | null {
const destinations = destinationsFromBracketIdx(
sourceBracketIdx,
progression,
);
const destination = destinations.find((destinationBracketIdx) =>
progression[destinationBracketIdx].sources?.some((source) =>
source.placements.includes(placement),
),
);
return destination ?? null;
}
export function startingBrackets(progression: ParsedBracket[]): number[] {
return progression
.map((bracket, idx) => ({ bracket, idx }))
.filter(({ bracket }) => !bracket.sources)
.map(({ idx }) => idx);
}