mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-18 13:10:29 -05:00
1233 lines
32 KiB
TypeScript
1233 lines
32 KiB
TypeScript
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<T>(array: T[]): ParitySplit<T> {
|
|
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<T>(
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* 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<T>(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<number>()]),
|
|
) as Record<number, Set<number>>;
|
|
|
|
for (const round of output) {
|
|
const checkUnique = new Set<number>();
|
|
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<T>(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<T>(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<T>(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<T>(array: Nullable<T>[]): 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<MatchResults>): 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<MatchResults>): 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<MatchResults>): boolean {
|
|
return isMatchWinCompleted(match);
|
|
}
|
|
|
|
/**
|
|
* Checks if a match is completed because of a win.
|
|
*
|
|
* @param match Partial match results.
|
|
*/
|
|
function isMatchWinCompleted(match: DeepPartial<MatchResults>): 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<MatchResults>): 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<MatchResults>): 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<MatchResults>,
|
|
): {
|
|
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<MatchResults>,
|
|
): 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<string, unknown>)[key] = (
|
|
update as Record<string, unknown>
|
|
)[key];
|
|
}
|
|
};
|
|
|
|
const ignoredKeys: Array<keyof Match> = [
|
|
"id",
|
|
"number",
|
|
"stage_id",
|
|
"group_id",
|
|
"round_id",
|
|
"status",
|
|
"opponent1",
|
|
"opponent2",
|
|
];
|
|
|
|
const ignoredOpponentKeys: Array<keyof ParticipantResult> = [
|
|
"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<MatchResults>,
|
|
): 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<MatchResults>): 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<MatchResults>,
|
|
): 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<MatchResults>,
|
|
): 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<MatchResults>,
|
|
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<T>(array: Nullable<T>[]): 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<T extends MatchResults>(
|
|
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";
|
|
}
|