import type { GroupType, Match, MatchResults, ParticipantResult, Result, RoundRobinMode, Seeding, SeedOrdering, Stage, StageType, } from "~/modules/brackets-model"; import { Status } from "~/modules/brackets-model"; import { ordering } from "./ordering"; import type { Database, DeepPartial, Duel, IdMapping, Nullable, ParitySplit, ParticipantSlot, Side, } from "./types"; /** * Splits an array in two parts: one with even indices and the other with odd indices. * * @param array The array to split. */ export function splitByParity(array: T[]): ParitySplit { return { even: array.filter((_, i) => i % 2 === 0), odd: array.filter((_, i) => i % 2 === 1), }; } /** * Makes a list of rounds containing the matches of a round-robin group. * * @param participants The participants to distribute. * @param mode The round-robin mode. */ export function makeRoundRobinMatches( participants: T[], mode: RoundRobinMode = "simple", ): [T, T][][] { const distribution = makeRoundRobinDistribution(participants); if (mode === "simple") return distribution; // Reverse rounds and their content. const symmetry = distribution.map((round) => [...round].reverse()).reverse(); return [...distribution, ...symmetry]; } /** * Makes a list of rounds containing the matches of a bipartite (A/B divisions) round-robin group. * * Every A team plays every B team exactly once; there are no A-vs-A or B-vs-B matches. * Round 1 is cross-seeded (strongest A vs weakest B), and B is rotated cyclically downward * in each subsequent round. * * When the divisions have different sizes, the shorter side is padded with bye slots so the * rotation still works. Those bye pairings are filtered out of the output, so each round has * exactly `min(|A|, |B|)` real matches and the total is `|A| * |B|`. * * @param divisionA Participants in division A, ordered by seed. * @param divisionB Participants in division B, ordered by seed. */ export function makeAbDivisionRoundRobinMatches( divisionA: T[], divisionB: T[], ): [T, T][][] { const n = Math.max(divisionA.length, divisionB.length); const paddedA: (T | null)[] = [ ...divisionA, ...Array(n - divisionA.length).fill(null), ]; const paddedB: (T | null)[] = [ ...divisionB, ...Array(n - divisionB.length).fill(null), ]; const rounds: [T, T][][] = []; for (let roundIdx = 0; roundIdx < n; roundIdx++) { const matches: [T, T][] = []; for (let i = 0; i < n; i++) { const bIdx = (((n - 1 - i - roundIdx) % n) + n) % n; const a = paddedA[i]; const b = paddedB[bIdx]; if (a === null || b === null) continue; matches.push([a, b]); } rounds.push(matches); } return rounds; } /** * Distributes A/B division participants into groups such that each group has an * equal number of A and B participants. * * The snake ordering used by `groups.seed_optimized` is applied independently to * each pool, so that relative seed order within each pool is preserved within * every group. * * @param divisionA Participants in division A, ordered by seed. * @param divisionB Participants in division B, ordered by seed. * @param groupCount Number of groups to distribute into. */ export function makeAbDivisionGroups( divisionA: T[], divisionB: T[], groupCount: number, ): { a: T[]; b: T[] }[] { if (groupCount <= 0) throw Error("Group count must be strictly positive."); if (divisionA.length !== divisionB.length) { if (groupCount !== 1) throw Error( "Uneven A/B divisions are only supported with a single group.", ); return [{ a: divisionA, b: divisionB }]; } if (divisionA.length % groupCount !== 0) throw Error("Pool size must be divisible by group count."); const aOrdered = ordering["groups.seed_optimized"](divisionA, groupCount); const bOrdered = ordering["groups.seed_optimized"](divisionB, groupCount); const perPoolGroupSize = divisionA.length / groupCount; const groups: { a: T[]; b: T[] }[] = []; for (let i = 0; i < groupCount; i++) { groups.push({ a: aOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize), b: bOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize), }); } return groups; } /** * Distributes participants in rounds for a round-robin group. * * Conditions: * - Each participant plays each other once. * - Each participant plays once in each round. * * @param participants The participants to distribute. */ function makeRoundRobinDistribution(participants: T[]): [T, T][][] { const n = participants.length; const n1 = n % 2 === 0 ? n : n + 1; const roundCount = n1 - 1; const matchPerRound = n1 / 2; const rounds: [T, T][][] = []; for (let roundId = 0; roundId < roundCount; roundId++) { const matches: [T, T][] = []; for (let matchId = 0; matchId < matchPerRound; matchId++) { if (matchId === 0 && n % 2 === 1) continue; const opponentsIds = [ (roundId - matchId - 1 + n1) % (n1 - 1), matchId === 0 ? n1 - 1 : (roundId + matchId) % (n1 - 1), ]; matches.push([ participants[opponentsIds[0]], participants[opponentsIds[1]], ]); } rounds.push(matches); } return rounds; } /** * A helper to assert our generated round-robin is correct. * * @param input The input seeding. * @param output The resulting distribution of seeds in groups. */ export function assertRoundRobin( input: number[], output: [number, number][][], ): void { const n = input.length; const matchPerRound = Math.floor(n / 2); const roundCount = n % 2 === 0 ? n - 1 : n; if (output.length !== roundCount) throw Error("Round count is wrong"); if (!output.every((round) => round.length === matchPerRound)) throw Error("Not every round has the good number of matches"); const checkAllOpponents = Object.fromEntries( input.map((element) => [element, new Set()]), ) as Record>; for (const round of output) { const checkUnique = new Set(); for (const match of round) { if (match.length !== 2) throw Error("One match is not a pair"); if (checkUnique.has(match[0])) throw Error("This team is already playing"); checkUnique.add(match[0]); if (checkUnique.has(match[1])) throw Error("This team is already playing"); checkUnique.add(match[1]); if (checkAllOpponents[match[0]].has(match[1])) throw Error("The team has already matched this team"); checkAllOpponents[match[0]].add(match[1]); if (checkAllOpponents[match[1]].has(match[0])) throw Error("The team has already matched this team"); checkAllOpponents[match[1]].add(match[0]); } } } /** * A helper to assert our generated bipartite round-robin is correct. * * @param divisionA Seeds in division A (ordered by seed). * @param divisionB Seeds in division B (ordered by seed). * @param output The resulting rounds of matches. */ export function assertAbDivisionRoundRobin( divisionA: number[], divisionB: number[], output: [number, number][][], ): void { const roundCount = Math.max(divisionA.length, divisionB.length); const matchesPerRound = Math.min(divisionA.length, divisionB.length); if (output.length !== roundCount) throw Error("Round count is wrong"); if (!output.every((round) => round.length === matchesPerRound)) throw Error("Not every round has the good number of matches"); const aSet = new Set(divisionA); const bSet = new Set(divisionB); const seenPairings = new Set(); for (const round of output) { const playingInRound = new Set(); for (const match of round) { if (match.length !== 2) throw Error("One match is not a pair"); const [a, b] = match; if (!aSet.has(a)) throw Error(`${a} is not a division A participant`); if (!bSet.has(b)) throw Error(`${b} is not a division B participant`); if (playingInRound.has(a)) throw Error("This team is already playing"); playingInRound.add(a); if (playingInRound.has(b)) throw Error("This team is already playing"); playingInRound.add(b); const pairingKey = `${a}-${b}`; if (seenPairings.has(pairingKey)) throw Error("The teams have already been paired"); seenPairings.add(pairingKey); } } if (seenPairings.size !== divisionA.length * divisionB.length) throw Error("Not every A vs B pairing was generated"); } /** * Distributes elements in groups of equal size. * * @param elements A list of elements to distribute in groups. * @param groupCount The group count. */ export function makeGroups(elements: T[], groupCount: number): T[][] { const groupSize = Math.ceil(elements.length / groupCount); const result: T[][] = []; for (let i = 0; i < elements.length; i++) { if (i % groupSize === 0) result.push([]); result[result.length - 1].push(elements[i]); } return result; } /** * Balances BYEs to prevents having BYE against BYE in matches. * * @param seeding The seeding of the stage. * @param participantCount The number of participants in the stage. */ export function balanceByes( seeding: Seeding, participantCount?: number, ): Seeding { // biome-ignore lint/style/noParameterAssign: biome migration seeding = seeding.filter((v) => v !== null); // biome-ignore lint/style/noParameterAssign: biome migration participantCount = participantCount || getNearestPowerOfTwo(seeding.length); if (seeding.length < participantCount / 2) { const flat = seeding.flatMap((v) => [v, null]); return setArraySize(flat, participantCount, null); } const nonNullCount = seeding.length; const nullCount = participantCount - nonNullCount; const againstEachOther = seeding .slice(0, nonNullCount - nullCount) .filter((_, i) => i % 2 === 0) .map((_, i) => [seeding[2 * i], seeding[2 * i + 1]]); const againstNull = seeding .slice(nonNullCount - nullCount, nonNullCount) .map((v) => [v, null]); const flat = [...againstEachOther.flat(), ...againstNull.flat()]; return setArraySize(flat, participantCount, null); } /** * Normalizes IDs in a database. * * All IDs (and references to them) are remapped to consecutive IDs starting from 0. * * @param data Data to normalize. */ export function normalizeIds(data: Database): Database { const mappings = { stage: makeNormalizedIdMapping(data.stage), group: makeNormalizedIdMapping(data.group), round: makeNormalizedIdMapping(data.round), match: makeNormalizedIdMapping(data.match), }; return { stage: data.stage.map((value) => ({ ...value, id: mappings.stage[value.id], })), group: data.group.map((value) => ({ ...value, id: mappings.group[value.id], stage_id: mappings.stage[value.stage_id], })), round: data.round.map((value) => ({ ...value, id: mappings.round[value.id], stage_id: mappings.stage[value.stage_id], group_id: mappings.group[value.group_id], })), match: data.match.map((value) => ({ ...value, id: mappings.match[value.id], stage_id: mappings.stage[value.stage_id], group_id: mappings.group[value.group_id], round_id: mappings.round[value.round_id], opponent1: value.opponent1, opponent2: value.opponent2, })), }; } /** * Makes a mapping between old IDs and new normalized IDs. * * @param elements A list of elements with IDs. */ function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping { let currentId = 0; return elements.reduce( (acc, current) => ({ // biome-ignore lint/performance/noAccumulatingSpread: biome migration ...acc, [current.id]: currentId++, }), {}, ) as IdMapping; } /** * Sets the size of an array with a placeholder if the size is bigger. * * @param array The original array. * @param length The new length. * @param placeholder A placeholder to use to fill the empty space. */ function setArraySize(array: T[], length: number, placeholder: T): T[] { return Array.from(Array(length), (_, i) => array[i] || placeholder); } /** * Makes pairs with each element and its next one. * * @example [1, 2, 3, 4] --> [[1, 2], [3, 4]] * @param array A list of elements. */ export function makePairs(array: T[]): [T, T][] { return array .map((_, i) => (i % 2 === 0 ? [array[i], array[i + 1]] : [])) .filter((v): v is [T, T] => v.length === 2); } /** * Ensures there are no duplicates in a list of elements. * * @param array A list of elements. */ export function ensureNoDuplicates(array: Nullable[]): void { const nonNull = getNonNull(array); const unique = nonNull.filter((item, index) => { const stringifiedItem = JSON.stringify(item); return ( nonNull.findIndex((obj) => JSON.stringify(obj) === stringifiedItem) === index ); }); if (unique.length < nonNull.length) throw new Error("The seeding has a duplicate participant."); } /** * Fixes the seeding by enlarging it if it's not complete. * * @param seeding The seeding of the stage. * @param participantCount The number of participants in the stage. */ export function fixSeeding( seeding: Seeding, participantCount: number, ): Seeding { if (seeding.length > participantCount) throw Error( "The seeding has more participants than the size of the stage.", ); if (seeding.length < participantCount) return setArraySize(seeding, participantCount, null); return seeding; } /** * Ensures that the participant count is valid. * * @param stageType Type of the stage to test. * @param participantCount The number to test. */ export function ensureValidSize( stageType: StageType, participantCount: number, ): void { if (participantCount === 0) throw Error( "Impossible to create an empty stage. If you want an empty seeding, just set the size of the stage.", ); if (participantCount < 2) throw Error("Impossible to create a stage with less than 2 participants."); if (stageType === "round_robin") { // Round robin supports any number of participants. return; } if (!Number.isInteger(Math.log2(participantCount))) throw Error( "The library only supports a participant count which is a power of two.", ); } /** * Converts a participant slot to a result stored in storage, with the position the participant is coming from. * * @param slot A participant slot. */ export function toResultWithPosition(slot: ParticipantSlot): ParticipantSlot { return ( slot && { id: slot.id, position: slot.position, } ); } /** * Returns the pre-computed winner for a match because of BYEs. * * @param opponents Two opponents. */ export function byeWinner(opponents: Duel): ParticipantSlot { if (opponents[0] === null && opponents[1] === null) // Double BYE. return null; // BYE. if (opponents[0] === null && opponents[1] !== null) // opponent1 BYE. return { id: opponents[1].id }; // opponent2. if (opponents[0] !== null && opponents[1] === null) // opponent2 BYE. return { id: opponents[0].id }; // opponent1. return { id: null }; // Normal. } /** * Returns the pre-computed winner for a match because of BYEs in a lower bracket. * * @param opponents Two opponents. */ export function byeWinnerToGrandFinal(opponents: Duel): ParticipantSlot { const winner = byeWinner(opponents); if (winner) winner.position = 1; return winner; } /** * Returns the pre-computed loser for a match because of BYEs. * * Only used for loser bracket. * * @param opponents Two opponents. * @param index The index of the duel in the round. */ export function byeLoser(opponents: Duel, index: number): ParticipantSlot { if (opponents[0] === null || opponents[1] === null) // At least one BYE. return null; // BYE. return { id: null, position: index + 1 }; // Normal. } /** * Returns the winner side or `null` if no winner. * * @param match A match's results. */ export function getMatchResult(match: MatchResults): Side | null { if (!isMatchCompleted(match)) return null; if (match.opponent1 === null && match.opponent2 === null) return null; let winner: Side | null = null; if (match.opponent1?.result === "win" || match.opponent2 === null) winner = "opponent1"; if (match.opponent2?.result === "win" || match.opponent1 === null) { if (winner !== null) throw Error("There are two winners."); winner = "opponent2"; } return winner; } /** * Gets the side where the winner of the given match will go in the next match. * * @param matchNumber Number of the match. */ function getSide(matchNumber: number): Side { return matchNumber % 2 === 1 ? "opponent1" : "opponent2"; } /** * Gets the other side of a match. * * @param side The side that we don't want. */ export function getOtherSide(side: Side): Side { return side === "opponent1" ? "opponent2" : "opponent1"; } /** * Checks if a match is started. * * @param match Partial match results. */ function isMatchStarted(match: DeepPartial): boolean { return ( match.opponent1?.score !== undefined || match.opponent2?.score !== undefined ); } /** * Checks if a match is completed. * * @param match Partial match results. */ function isMatchCompleted(match: DeepPartial): boolean { return isMatchByeCompleted(match) || isMatchResultCompleted(match); } /** * Checks if a match is completed because of a either a draw or a win. * * @param match Partial match results. */ function isMatchResultCompleted(match: DeepPartial): boolean { return isMatchWinCompleted(match); } /** * Checks if a match is completed because of a win. * * @param match Partial match results. */ function isMatchWinCompleted(match: DeepPartial): boolean { return ( match.opponent1?.result === "win" || match.opponent2?.result === "win" || match.opponent1?.result === "loss" || match.opponent2?.result === "loss" ); } /** * Checks if a match is completed because of at least one BYE. * * A match "BYE vs. TBD" isn't considered completed yet. * * @param match Partial match results. */ export function isMatchByeCompleted(match: DeepPartial): boolean { return ( (match.opponent1 === null && match.opponent2?.id !== null) || // BYE vs. someone (match.opponent2 === null && match.opponent1?.id !== null) || // someone vs. BYE (match.opponent1 === null && match.opponent2 === null) ); // BYE vs. BYE } /** * Checks if a match's results can't be updated. * * @param match The match to check. */ export function isMatchUpdateLocked(match: MatchResults): boolean { return match.status === Status.Locked || match.status === Status.Waiting; } /** * Indicates whether a match has at least one BYE or not. * * @param match Partial match results. */ export function hasBye(match: DeepPartial): boolean { return match.opponent1 === null || match.opponent2 === null; } /** * Returns the status of a match based on the opponents of a match. * * @param opponents The opponents of a match. */ export function getMatchStatus(opponents: Duel): Status; /** * Returns the status of a match based on the results of a match. * * @param match Partial match results. */ export function getMatchStatus(match: MatchResults): Status; /** * Returns the status of a match based on information about it. * * @param arg The opponents or partial results of the match. */ export function getMatchStatus(arg: Duel | MatchResults): Status { const match = Array.isArray(arg) ? { opponent1: arg[0], opponent2: arg[1], } : arg; if (hasBye(match)) // At least one BYE. return Status.Locked; if (match.opponent1?.id === null && match.opponent2?.id === null) // Two TBD opponents. return Status.Locked; if (match.opponent1?.id === null || match.opponent2?.id === null) // One TBD opponent. return Status.Waiting; if (isMatchCompleted(match)) return Status.Completed; if (isMatchStarted(match)) return Status.Running; return Status.Ready; } /** * Updates a match results based on an input. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. * @param inRoundRobin Indicates whether the match is in a round-robin stage. */ export function setMatchResults( stored: MatchResults, match: DeepPartial, ): { statusChanged: boolean; resultChanged: boolean; } { const completed = isMatchCompleted(match); const currentlyCompleted = isMatchCompleted(stored); setExtraFields(stored, match); handleOpponentsInversion(stored, match); const statusChanged = setScores(stored, match); if (completed && currentlyCompleted) { // Ensure everything is good. setCompleted(stored, match); return { statusChanged: false, resultChanged: true }; } if (completed && !currentlyCompleted) { setCompleted(stored, match); return { statusChanged: true, resultChanged: true }; } if (!completed && currentlyCompleted) { resetMatchResults(stored); return { statusChanged: true, resultChanged: true }; } return { statusChanged, resultChanged: false }; } /** * Resets the results of a match. (status, forfeit, result) * * @param stored A reference to what will be updated in the storage. */ export function resetMatchResults(stored: MatchResults): void { if (stored.opponent1) { stored.opponent1.result = undefined; } if (stored.opponent2) { stored.opponent2.result = undefined; } stored.status = getMatchStatus(stored); } /** * Passes user-defined extra fields to the stored match. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. */ function setExtraFields( stored: MatchResults, match: DeepPartial, ): void { const partialAssign = ( target: unknown, update: unknown, ignoredKeys: string[], ): void => { if (!target || !update) return; const retainedKeys = Object.keys(update).filter( (key) => !ignoredKeys.includes(key), ); for (const key of retainedKeys) { (target as Record)[key] = ( update as Record )[key]; } }; const ignoredKeys: Array = [ "id", "number", "stage_id", "group_id", "round_id", "status", "opponent1", "opponent2", ]; const ignoredOpponentKeys: Array = [ "id", "score", "position", "forfeit", "result", ]; partialAssign(stored, match, ignoredKeys); partialAssign(stored.opponent1, match.opponent1, ignoredOpponentKeys); partialAssign(stored.opponent2, match.opponent2, ignoredOpponentKeys); } /** * Gets the id of the opponent at the given side of the given match. * * @param match The match to get the opponent from. * @param side The side where to get the opponent from. */ function getOpponentId(match: MatchResults, side: Side): number | null { const opponent = match[side]; return opponent?.id ?? null; } /** * Gets the origin position of a side of a match. * * @param match The match. * @param side The side. */ export function getOriginPosition(match: Match, side: Side): number { const matchNumber = match[side]?.position; if (matchNumber === undefined) throw Error("Position is undefined."); return matchNumber; } /** * Gets the side the winner of the current match will go to in the next match. * * @param matchNumber Number of the current match. * @param roundNumber Number of the current round. * @param roundCount Count of rounds. * @param matchLocation Location of the current match. */ export function getNextSide( matchNumber: number, roundNumber: number, roundCount: number, matchLocation: GroupType, ): Side { // The nextSide comes from the same bracket. if (matchLocation === "loser_bracket" && roundNumber % 2 === 1) return "opponent2"; // The nextSide comes from the loser bracket to the final group. if (matchLocation === "loser_bracket" && roundNumber === roundCount) return "opponent2"; return getSide(matchNumber); } /** * Gets the side the winner of the current match in loser bracket will go in the next match. * * @param matchNumber Number of the match. * @param nextMatch The next match. * @param roundNumber Number of the current round. */ export function getNextSideLoserBracket( matchNumber: number, nextMatch: Match, roundNumber: number, ): Side { // The nextSide comes from the WB. if (roundNumber > 1) return "opponent1"; // The nextSide comes from the WB round 1. if (nextMatch.opponent1?.position === matchNumber) return "opponent1"; return "opponent2"; } export type SetNextOpponent = ( nextMatch: Match, nextSide: Side, match?: Match, currentSide?: Side, ) => void; /** * Sets an opponent in the next match he has to go. * * @param nextMatch A match which follows the current one. * @param nextSide The side the opponent will be on in the next match. * @param match The current match. * @param currentSide The side the opponent is currently on. */ export function setNextOpponent( nextMatch: Match, nextSide: Side, match?: Match, currentSide?: Side, ): void { nextMatch[nextSide] = match![currentSide!] && { // Keep BYE. id: getOpponentId(match!, currentSide!), // This implementation of SetNextOpponent always has those arguments. position: nextMatch[nextSide]?.position, // Keep position. }; nextMatch.status = getMatchStatus(nextMatch); } /** * Resets an opponent in the match following the current one. * * @param nextMatch A match which follows the current one. * @param nextSide The side the opponent will be on in the next match. */ export function resetNextOpponent(nextMatch: Match, nextSide: Side): void { nextMatch[nextSide] = nextMatch[nextSide] && { // Keep BYE. id: null, position: nextMatch[nextSide]?.position, // Keep position. }; nextMatch.status = Status.Locked; } /** * Inverts opponents if requested by the input. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. */ function handleOpponentsInversion( stored: MatchResults, match: DeepPartial, ): void { const id1 = match.opponent1?.id; const id2 = match.opponent2?.id; const storedId1 = stored.opponent1?.id; const storedId2 = stored.opponent2?.id; if (Number.isInteger(id1) && id1 !== storedId1 && id1 !== storedId2) throw Error("The given opponent1 ID does not exist in this match."); if (Number.isInteger(id2) && id2 !== storedId1 && id2 !== storedId2) throw Error("The given opponent2 ID does not exist in this match."); if ( (Number.isInteger(id1) && id1 === storedId2) || (Number.isInteger(id2) && id2 === storedId1) ) invertOpponents(match); } /** * Inverts `opponent1` and `opponent2` in a match. * * @param match A match to update. */ function invertOpponents(match: DeepPartial): void { [match.opponent1, match.opponent2] = [match.opponent2, match.opponent1]; } /** * Updates the scores of a match. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. * @returns `true` if the status of the match changed, `false` otherwise. */ function setScores( stored: MatchResults, match: DeepPartial, ): boolean { // Skip if no score update. if ( match.opponent1?.score === stored.opponent1?.score && match.opponent2?.score === stored.opponent2?.score ) return false; const oldStatus = stored.status; stored.status = Status.Running; if (match.opponent1 && stored.opponent1) stored.opponent1.score = match.opponent1.score; if (match.opponent2 && stored.opponent2) stored.opponent2.score = match.opponent2.score; return stored.status !== oldStatus; } /** * Completes a match and handles results and forfeits. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. */ function setCompleted( stored: MatchResults, match: DeepPartial, ): void { stored.status = Status.Completed; setResults(stored, match, "win", "loss"); setResults(stored, match, "loss", "win"); if (stored.opponent1 && !stored.opponent2) stored.opponent1.result = "win"; // Win against opponent 2 BYE. if (!stored.opponent1 && stored.opponent2) stored.opponent2.result = "win"; // Win against opponent 1 BYE. } /** * Enforces the symmetry between opponents. * * Sets an opponent's result to something, based on the result on the other opponent. * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. * @param check A result to check in each opponent. * @param change A result to set in each other opponent if `check` is correct. */ function setResults( stored: MatchResults, match: DeepPartial, check: Result, change: Result, ): void { if (match.opponent1 && match.opponent2) { if (match.opponent1.result === "win" && match.opponent2.result === "win") throw Error("There are two winners."); if (match.opponent1.result === "loss" && match.opponent2.result === "loss") throw Error("There are two losers."); } if (match.opponent1?.result === check) { if (stored.opponent1) stored.opponent1.result = check; else stored.opponent1 = { id: null, result: check }; if (stored.opponent2) stored.opponent2.result = change; else stored.opponent2 = { id: null, result: change }; } if (match.opponent2?.result === check) { if (stored.opponent2) stored.opponent2.result = check; else stored.opponent2 = { id: null, result: check }; if (stored.opponent1) stored.opponent1.result = change; else stored.opponent1 = { id: null, result: change }; } } /** * Returns only the non null elements. * * @param array The array to process. */ function getNonNull(array: Nullable[]): T[] { // Use a TS type guard to exclude null from the resulting type. const nonNull = array.filter((element): element is T => element !== null); return nonNull; } /** * Makes the transition to a major round for duels of the previous round. The duel count is divided by 2. * * @param previousDuels The previous duels to transition from. */ export function transitionToMajor(previousDuels: Duel[]): Duel[] { const currentDuelCount = previousDuels.length / 2; const currentDuels: Duel[] = []; for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) { const prevDuelId = duelIndex * 2; currentDuels.push([ byeWinner(previousDuels[prevDuelId]), byeWinner(previousDuels[prevDuelId + 1]), ]); } return currentDuels; } /** * Makes the transition to a minor round for duels of the previous round. The duel count stays the same. * * @param previousDuels The previous duels to transition from. * @param losers Losers from the previous major round. * @param method The ordering method for the losers. */ export function transitionToMinor( previousDuels: Duel[], losers: ParticipantSlot[], method?: SeedOrdering, ): Duel[] { const orderedLosers = method ? ordering[method](losers) : losers; const currentDuelCount = previousDuels.length; const currentDuels: Duel[] = []; for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) { const prevDuelId = duelIndex; currentDuels.push([ orderedLosers[prevDuelId], byeWinner(previousDuels[prevDuelId]), ]); } return currentDuels; } /** * Gets the values which need to be updated in a match when it's updated on insertion. * * @param match The up to date match. * @param existing The base match. * @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding. */ export function getUpdatedMatchResults( match: T, existing: T, enableByes: boolean, ): T { return { ...existing, ...match, ...(enableByes ? { opponent1: match.opponent1 === null ? null : { ...existing.opponent1, ...match.opponent1 }, opponent2: match.opponent2 === null ? null : { ...existing.opponent2, ...match.opponent2 }, } : { opponent1: match.opponent1 === null ? { id: null } : { ...existing.opponent1, ...match.opponent1 }, opponent2: match.opponent2 === null ? { id: null } : { ...existing.opponent2, ...match.opponent2 }, }), }; } /** * Indicates whether the ordering is supported in loser bracket, given the round number. * * @param roundNumber The number of the round. * @param roundCount The count of rounds. */ export function isOrderingSupportedLoserBracket( roundNumber: number, roundCount: number, ): boolean { return ( roundNumber === 1 || (roundNumber % 2 === 0 && roundNumber < roundCount) ); } /** * Returns the number of rounds an upper bracket has given the number of participants in the stage. * * @param participantCount The number of participants in the stage. */ export function getUpperBracketRoundCount(participantCount: number): number { return Math.log2(participantCount); } /** * Returns the count of round pairs (major & minor) in a loser bracket. * * @param participantCount The number of participants in the stage. */ export function getRoundPairCount(participantCount: number): number { return getUpperBracketRoundCount(participantCount) - 1; } /** * Determines whether a double elimination stage is really necessary. * * If the size is only two (less is impossible), then a lower bracket and a grand final are not necessary. * * @param participantCount The number of participants in the stage. */ export function isDoubleEliminationNecessary( participantCount: number, ): boolean { return participantCount > 2; } /** * Returns the real (because of loser ordering) number of a match in a loser bracket. * * @param participantCount The number of participants in a stage. * @param roundNumber Number of the round. * @param matchNumber Number of the match. * @param method The method used for the round. */ export function findLoserMatchNumber( participantCount: number, roundNumber: number, matchNumber: number, method?: SeedOrdering, ): number { const loserCount = getLoserRoundLoserCount(participantCount, roundNumber); const losers = Array.from(Array(loserCount), (_, i) => i + 1); const ordered = method ? ordering[method](losers) : losers; const matchNumberLB = ordered.indexOf(matchNumber) + 1; // For LB round 1, the list of losers is spread over the matches 2 by 2. if (roundNumber === 1) return Math.ceil(matchNumberLB / 2); return matchNumberLB; } /** * Returns the count of matches in a round of a loser bracket. * * @param participantCount The number of participants in a stage. * @param roundNumber Number of the round. */ function getLoserRoundMatchCount( participantCount: number, roundNumber: number, ): number { const roundPairIndex = Math.ceil(roundNumber / 2) - 1; const roundPairCount = getRoundPairCount(participantCount); const matchCount = 2 ** (roundPairCount - roundPairIndex - 1); return matchCount; } /** * Returns the count of losers in a round of a loser bracket. * * @param participantCount The number of participants in a stage. * @param roundNumber Number of the round. */ function getLoserRoundLoserCount( participantCount: number, roundNumber: number, ): number { const matchCount = getLoserRoundMatchCount(participantCount, roundNumber); // Two per match for LB round 1 (losers coming from WB round 1). if (roundNumber === 1) return matchCount * 2; return matchCount; // One per match for LB minor rounds. } /** * Returns the ordering method of a round of a loser bracket. * * @param seedOrdering The list of seed orderings. * @param roundNumber Number of the round. */ export function getLoserOrdering( seedOrdering: SeedOrdering[], roundNumber: number, ): SeedOrdering | undefined { const orderingIndex = 1 + Math.floor(roundNumber / 2); return seedOrdering[orderingIndex]; } /** * Returns the match number of the corresponding match in the next round by dividing by two. * * @param matchNumber The current match number. */ export function getDiagonalMatchNumber(matchNumber: number): number { return Math.ceil(matchNumber / 2); } /** * Returns the nearest power of two **greater than** or equal to the given number. * * @param input The input number. */ function getNearestPowerOfTwo(input: number): number { return 2 ** Math.ceil(Math.log2(input)); } /** * Checks if a stage is a round-robin stage. * * @param stage The stage to check. */ export function isRoundRobin(stage: Stage): boolean { return stage.type === "round_robin"; } export function isSwiss(stage: Stage): boolean { return stage.type === "swiss"; } /** * Checks if a group is a winner bracket. * * It's not always the opposite of `inLoserBracket()`: it could be the only bracket of a single elimination stage. * * @param stageType Type of the stage. * @param groupNumber Number of the group. */ function isWinnerBracket(stageType: StageType, groupNumber: number): boolean { return stageType === "double_elimination" && groupNumber === 1; } /** * Checks if a group is a loser bracket. * * @param stageType Type of the stage. * @param groupNumber Number of the group. */ function isLoserBracket(stageType: StageType, groupNumber: number): boolean { return stageType === "double_elimination" && groupNumber === 2; } /** * Checks if a group is a final group (consolation final or grand final). * * @param stageType Type of the stage. * @param groupNumber Number of the group. */ function isFinalGroup(stageType: StageType, groupNumber: number): boolean { return ( (stageType === "single_elimination" && groupNumber === 2) || (stageType === "double_elimination" && groupNumber === 3) ); } /** * Returns the type of group the match is located into. * * @param stageType Type of the stage. * @param groupNumber Number of the group. */ export function getMatchLocation( stageType: StageType, groupNumber: number, ): GroupType { if (isWinnerBracket(stageType, groupNumber)) return "winner_bracket"; if (isLoserBracket(stageType, groupNumber)) return "loser_bracket"; if (isFinalGroup(stageType, groupNumber)) return "final_group"; return "single_bracket"; }