mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
* Got something going * Style overwrites * width != height * More playing with lines * Migrations * Start bracket initial * Unhardcode stage generation params * Link to match page * Matches page initial * Support directly adding seed to map list generator * Add docs * Maps in matches page * Add invariant about tie breaker map pool * Fix PICNIC lacking tie breaker maps * Only link in bracket when tournament has started * Styled tournament roster inputs * Prefer IGN in tournament match page * ModeProgressIndicator * Some conditional rendering * Match action initial + better error display * Persist bestOf in DB * Resolve best of ahead of time * Move brackets-manager to core * Score reporting works * Clear winner on score report * ModeProgressIndicator: highlight winners * Fix inconsistent input * Better text when submitting match * mapCountPlayedInSetWithCertainty that works * UNDO_REPORT_SCORE implemented * Permission check when starting tournament * Remove IGN from upsert * View match results page * Source in DB * Match page waiting for teams * Move tournament bracket to feature folder * REOPEN_MATCH initial * Handle proper resetting of match * Inline bracket-manager * Syncify * Transactions * Handle match is locked gracefully * Match page auto refresh * Fix match refresh called "globally" * Bracket autoupdate * Move fillWithNullTillPowerOfTwo to utils with testing * Fix map lists not visible after tournament started * Optimize match events * Show UI while in progress to members * Fix start tournament alert not being responsive * Teams can check in * Fix map list 400 * xxx -> TODO * Seeds page * Remove map icons for team page * Don't display link to seeds after tournament has started * Admin actions initial * Change captain admin action * Make all hooks ts * Admin actions functioning * Fix validate error not displaying in CatchBoundary * Adjust validate args order * Remove admin loader * Make delete team button menancing * Only include checked in teams to bracket * Optimize to.id route loads * Working show map list generator toggle * Update full tournaments flow * Make full tournaments work with many start times * Handle undefined in crud * Dynamic stage banner * Handle default strat if map list generation fails * Fix crash on brackets if less than 2 teams * Add commented out test for reference * Add TODO * Add players from team during register * TrustRelationship * Prefers not to host feature * Last before merge * Rename some vars * More renames
1034 lines
29 KiB
TypeScript
1034 lines
29 KiB
TypeScript
import type {
|
|
Group,
|
|
InputStage,
|
|
Match,
|
|
MatchGame,
|
|
Participant,
|
|
Round,
|
|
Seeding,
|
|
SeedOrdering,
|
|
Stage,
|
|
} from "brackets-model";
|
|
import { defaultMinorOrdering, ordering } from "./ordering";
|
|
import type {
|
|
Duel,
|
|
Storage,
|
|
OmitId,
|
|
ParticipantSlot,
|
|
StandardBracketResults,
|
|
} from "./types";
|
|
import type { BracketsManager } from ".";
|
|
import * as helpers from "./helpers";
|
|
|
|
/**
|
|
* Creates a stage.
|
|
*
|
|
* @param this Instance of BracketsManager.
|
|
* @param stage The stage to create.
|
|
*/
|
|
export function create(this: BracketsManager, stage: InputStage): Stage {
|
|
const instance = new Create(this.storage, stage);
|
|
return instance.run();
|
|
}
|
|
|
|
export class Create {
|
|
private storage: Storage;
|
|
private stage: InputStage;
|
|
private readonly seedOrdering: SeedOrdering[];
|
|
private updateMode: boolean;
|
|
private enableByesInUpdate: boolean;
|
|
private currentStageId!: number;
|
|
|
|
/**
|
|
* Creates an instance of Create, which will handle the creation of the stage.
|
|
*
|
|
* @param storage The implementation of Storage.
|
|
* @param stage The stage to create.
|
|
*/
|
|
constructor(storage: Storage, stage: InputStage) {
|
|
this.storage = storage;
|
|
this.stage = stage;
|
|
this.stage.settings = this.stage.settings || {};
|
|
this.seedOrdering = this.stage.settings.seedOrdering || [];
|
|
this.updateMode = false;
|
|
this.enableByesInUpdate = false;
|
|
|
|
if (!this.stage.name) throw Error("You must provide a name for the stage.");
|
|
|
|
if (!Number.isInteger(this.stage.tournamentId))
|
|
throw Error("You must provide a tournament id for the stage.");
|
|
|
|
if (stage.type === "round_robin")
|
|
this.stage.settings.roundRobinMode =
|
|
this.stage.settings.roundRobinMode || "simple";
|
|
|
|
if (stage.type === "single_elimination")
|
|
this.stage.settings.consolationFinal =
|
|
this.stage.settings.consolationFinal || false;
|
|
|
|
if (stage.type === "double_elimination")
|
|
this.stage.settings.grandFinal = this.stage.settings.grandFinal || "none";
|
|
|
|
this.stage.settings.matchesChildCount =
|
|
this.stage.settings.matchesChildCount || 0;
|
|
}
|
|
|
|
/**
|
|
* Run the creation process.
|
|
*/
|
|
public run(): Stage {
|
|
let stage: Stage;
|
|
|
|
switch (this.stage.type) {
|
|
case "round_robin":
|
|
stage = this.roundRobin();
|
|
break;
|
|
case "single_elimination":
|
|
stage = this.singleElimination();
|
|
break;
|
|
case "double_elimination":
|
|
stage = this.doubleElimination();
|
|
break;
|
|
default:
|
|
throw Error("Unknown stage type.");
|
|
}
|
|
|
|
if (stage.id === -1)
|
|
throw Error("Something went wrong when creating the stage.");
|
|
|
|
this.ensureSeedOrdering(stage.id);
|
|
|
|
return stage;
|
|
}
|
|
|
|
/**
|
|
* Enables the update mode.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
* @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding.
|
|
*/
|
|
public setExisting(stageId: number, enableByes: boolean): void {
|
|
this.updateMode = true;
|
|
this.currentStageId = stageId;
|
|
this.enableByesInUpdate = enableByes;
|
|
}
|
|
|
|
/**
|
|
* Creates a round-robin stage.
|
|
*
|
|
* Group count must be given. It will distribute participants in groups and rounds.
|
|
*/
|
|
private roundRobin(): Stage {
|
|
const groups = this.getRoundRobinGroups();
|
|
const stage = this.createStage();
|
|
|
|
for (let i = 0; i < groups.length; i++)
|
|
this.createRoundRobinGroup(stage.id, i + 1, groups[i]);
|
|
|
|
return stage;
|
|
}
|
|
|
|
/**
|
|
* Creates a single elimination stage.
|
|
*
|
|
* One bracket and optionally a consolation final between semi-final losers.
|
|
*/
|
|
private singleElimination(): Stage {
|
|
if (
|
|
Array.isArray(this.stage.settings?.seedOrdering) &&
|
|
this.stage.settings?.seedOrdering.length !== 1
|
|
)
|
|
throw Error("You must specify one seed ordering method.");
|
|
|
|
const slots = this.getSlots();
|
|
const stage = this.createStage();
|
|
const method = this.getStandardBracketFirstRoundOrdering();
|
|
const ordered = ordering[method](slots);
|
|
|
|
const { losers } = this.createStandardBracket(stage.id, 1, ordered);
|
|
this.createConsolationFinal(stage.id, losers);
|
|
|
|
return stage;
|
|
}
|
|
|
|
/**
|
|
* Creates a double elimination stage.
|
|
*
|
|
* One upper bracket (winner bracket, WB), one lower bracket (loser bracket, LB) and optionally a grand final
|
|
* between the winner of both bracket, which can be simple or double.
|
|
*/
|
|
private doubleElimination(): Stage {
|
|
if (
|
|
this.stage.settings &&
|
|
Array.isArray(this.stage.settings.seedOrdering) &&
|
|
this.stage.settings.seedOrdering.length < 1
|
|
)
|
|
throw Error("You must specify at least one seed ordering method.");
|
|
|
|
const slots = this.getSlots();
|
|
const stage = this.createStage();
|
|
const method = this.getStandardBracketFirstRoundOrdering();
|
|
const ordered = ordering[method](slots);
|
|
|
|
if (this.stage.settings?.skipFirstRound)
|
|
this.createDoubleEliminationSkipFirstRound(stage.id, ordered);
|
|
else this.createDoubleElimination(stage.id, ordered);
|
|
|
|
return stage;
|
|
}
|
|
|
|
/**
|
|
* Creates a double elimination stage with skip first round option.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
* @param slots A list of slots.
|
|
*/
|
|
private createDoubleEliminationSkipFirstRound(
|
|
stageId: number,
|
|
slots: ParticipantSlot[]
|
|
): void {
|
|
const { even: directInWb, odd: directInLb } = helpers.splitByParity(slots);
|
|
const { losers: losersWb, winner: winnerWb } = this.createStandardBracket(
|
|
stageId,
|
|
1,
|
|
directInWb
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
|
if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) {
|
|
const winnerLb = this.createLowerBracket(stageId, 2, [
|
|
directInLb,
|
|
...losersWb,
|
|
]);
|
|
this.createGrandFinal(stageId, winnerWb, winnerLb);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a double elimination stage.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
* @param slots A list of slots.
|
|
*/
|
|
private createDoubleElimination(
|
|
stageId: number,
|
|
slots: ParticipantSlot[]
|
|
): void {
|
|
const { losers: losersWb, winner: winnerWb } = this.createStandardBracket(
|
|
stageId,
|
|
1,
|
|
slots
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
|
if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) {
|
|
const winnerLb = this.createLowerBracket(stageId, 2, losersWb);
|
|
this.createGrandFinal(stageId, winnerWb, winnerLb);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a round-robin group.
|
|
*
|
|
* This will make as many rounds as needed to let each participant match every other once.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param number Number in the stage.
|
|
* @param slots A list of slots.
|
|
*/
|
|
private createRoundRobinGroup(
|
|
stageId: number,
|
|
number: number,
|
|
slots: ParticipantSlot[]
|
|
): void {
|
|
const groupId = this.insertGroup({
|
|
stage_id: stageId,
|
|
number,
|
|
});
|
|
|
|
if (groupId === -1) throw Error("Could not insert the group.");
|
|
|
|
const rounds = helpers.makeRoundRobinMatches(
|
|
slots,
|
|
this.stage.settings?.roundRobinMode
|
|
);
|
|
|
|
for (let i = 0; i < rounds.length; i++)
|
|
this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]);
|
|
}
|
|
|
|
/**
|
|
* Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination.
|
|
*
|
|
* This will make as many rounds as needed to end with one winner.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param number Number in the stage.
|
|
* @param slots A list of slots.
|
|
*/
|
|
private createStandardBracket(
|
|
stageId: number,
|
|
number: number,
|
|
slots: ParticipantSlot[]
|
|
): StandardBracketResults {
|
|
const roundCount = helpers.getUpperBracketRoundCount(slots.length);
|
|
const groupId = this.insertGroup({
|
|
stage_id: stageId,
|
|
number,
|
|
});
|
|
|
|
if (groupId === -1) throw Error("Could not insert the group.");
|
|
|
|
let duels = helpers.makePairs(slots);
|
|
let roundNumber = 1;
|
|
|
|
const losers: ParticipantSlot[][] = [];
|
|
|
|
for (let i = roundCount - 1; i >= 0; i--) {
|
|
const matchCount = Math.pow(2, i);
|
|
duels = this.getCurrentDuels(duels, matchCount);
|
|
losers.push(duels.map(helpers.byeLoser));
|
|
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
|
|
}
|
|
|
|
return { losers, winner: helpers.byeWinner(duels[0]) };
|
|
}
|
|
|
|
/**
|
|
* Creates a lower bracket, alternating between major and minor rounds.
|
|
*
|
|
* - A major round is a regular round.
|
|
* - A minor round matches the previous (major) round's winners against upper bracket losers of the corresponding round.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param number Number in the stage.
|
|
* @param losers One list of losers per upper bracket round.
|
|
*/
|
|
private createLowerBracket(
|
|
stageId: number,
|
|
number: number,
|
|
losers: ParticipantSlot[][]
|
|
): ParticipantSlot {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
|
const participantCount = this.stage.settings?.size!;
|
|
const roundPairCount = helpers.getRoundPairCount(participantCount);
|
|
|
|
let losersId = 0;
|
|
|
|
const method = this.getMajorOrdering(participantCount);
|
|
const ordered = ordering[method](losers[losersId++]);
|
|
|
|
const groupId = this.insertGroup({
|
|
stage_id: stageId,
|
|
number,
|
|
});
|
|
|
|
if (groupId === -1) throw Error("Could not insert the group.");
|
|
|
|
let duels = helpers.makePairs(ordered);
|
|
let roundNumber = 1;
|
|
|
|
for (let i = 0; i < roundPairCount; i++) {
|
|
const matchCount = Math.pow(2, roundPairCount - i - 1);
|
|
|
|
// Major round.
|
|
duels = this.getCurrentDuels(duels, matchCount, true);
|
|
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
|
|
|
|
// Minor round.
|
|
const minorOrdering = this.getMinorOrdering(
|
|
participantCount,
|
|
i,
|
|
roundPairCount
|
|
);
|
|
duels = this.getCurrentDuels(
|
|
duels,
|
|
matchCount,
|
|
false,
|
|
losers[losersId++],
|
|
minorOrdering
|
|
);
|
|
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
|
|
}
|
|
|
|
return helpers.byeWinnerToGrandFinal(duels[0]);
|
|
}
|
|
|
|
/**
|
|
* Creates a bracket with rounds that only have 1 match each. Used for finals.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param number Number in the stage.
|
|
* @param duels A list of duels.
|
|
*/
|
|
private createUniqueMatchBracket(
|
|
stageId: number,
|
|
number: number,
|
|
duels: Duel[]
|
|
): void {
|
|
const groupId = this.insertGroup({
|
|
stage_id: stageId,
|
|
number,
|
|
});
|
|
|
|
if (groupId === -1) throw Error("Could not insert the group.");
|
|
|
|
for (let i = 0; i < duels.length; i++)
|
|
this.createRound(stageId, groupId, i + 1, 1, [duels[i]]);
|
|
}
|
|
|
|
/**
|
|
* Creates a round, which contain matches.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param groupId ID of the parent group.
|
|
* @param roundNumber Number in the group.
|
|
* @param matchCount Duel/match count.
|
|
* @param duels A list of duels.
|
|
*/
|
|
private createRound(
|
|
stageId: number,
|
|
groupId: number,
|
|
roundNumber: number,
|
|
matchCount: number,
|
|
duels: Duel[]
|
|
): void {
|
|
const matchesChildCount = this.getMatchesChildCount();
|
|
|
|
const roundId = this.insertRound({
|
|
number: roundNumber,
|
|
stage_id: stageId,
|
|
group_id: groupId,
|
|
});
|
|
|
|
if (roundId === -1) throw Error("Could not insert the round.");
|
|
|
|
for (let i = 0; i < matchCount; i++)
|
|
this.createMatch(
|
|
stageId,
|
|
groupId,
|
|
roundId,
|
|
i + 1,
|
|
duels[i],
|
|
matchesChildCount
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a match, possibly with match games.
|
|
*
|
|
* - If `childCount` is 0, then there is no children. The score of the match is directly its intrinsic score.
|
|
* - If `childCount` is greater than 0, then the score of the match will automatically be calculated based on its child games.
|
|
*
|
|
* @param stageId ID of the parent stage.
|
|
* @param groupId ID of the parent group.
|
|
* @param roundId ID of the parent round.
|
|
* @param matchNumber Number in the round.
|
|
* @param opponents The two opponents matching against each other.
|
|
* @param childCount Child count for this match (number of games).
|
|
*/
|
|
private createMatch(
|
|
stageId: number,
|
|
groupId: number,
|
|
roundId: number,
|
|
matchNumber: number,
|
|
opponents: Duel,
|
|
childCount: number
|
|
): void {
|
|
const opponent1 = helpers.toResultWithPosition(opponents[0]);
|
|
const opponent2 = helpers.toResultWithPosition(opponents[1]);
|
|
|
|
// Round-robin matches can easily be removed. Prevent BYE vs. BYE matches.
|
|
if (
|
|
this.stage.type === "round_robin" &&
|
|
opponent1 === null &&
|
|
opponent2 === null
|
|
)
|
|
return;
|
|
|
|
let existing: Match | null = null;
|
|
let status = helpers.getMatchStatus(opponents);
|
|
|
|
if (this.updateMode) {
|
|
existing = this.storage.selectFirst("match", {
|
|
round_id: roundId,
|
|
number: matchNumber,
|
|
});
|
|
|
|
const currentChildCount = existing?.child_count;
|
|
childCount =
|
|
currentChildCount === undefined ? childCount : currentChildCount;
|
|
|
|
if (existing) {
|
|
// Keep the most advanced status when updating a match.
|
|
const existingStatus = helpers.getMatchStatus(existing);
|
|
if (existingStatus > status) status = existingStatus;
|
|
}
|
|
}
|
|
|
|
const parentId = this.insertMatch(
|
|
{
|
|
number: matchNumber,
|
|
stage_id: stageId,
|
|
group_id: groupId,
|
|
round_id: roundId,
|
|
child_count: childCount,
|
|
status: status,
|
|
opponent1,
|
|
opponent2,
|
|
},
|
|
existing
|
|
);
|
|
|
|
if (parentId === -1) throw Error("Could not insert the match.");
|
|
|
|
for (let i = 0; i < childCount; i++) {
|
|
const id = this.insertMatchGame({
|
|
number: i + 1,
|
|
stage_id: stageId,
|
|
parent_id: parentId,
|
|
status: status,
|
|
opponent1: helpers.toResult(opponents[0]),
|
|
opponent2: helpers.toResult(opponents[1]),
|
|
});
|
|
|
|
if (id === -1) throw Error("Could not insert the match game.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the duels for the current round based on the previous one. No ordering is done, it must be done beforehand for the first round.
|
|
*
|
|
* @param previousDuels Duels of the previous round.
|
|
* @param currentDuelCount Count of duels (matches) in the current round.
|
|
*/
|
|
private getCurrentDuels(
|
|
previousDuels: Duel[],
|
|
currentDuelCount: number
|
|
): Duel[];
|
|
|
|
/**
|
|
* Gets the duels for a major round in the LB. No ordering is done, it must be done beforehand for the first round.
|
|
*
|
|
* @param previousDuels Duels of the previous round.
|
|
* @param currentDuelCount Count of duels (matches) in the current round.
|
|
* @param major Indicates that the round is a major round in the LB.
|
|
*/
|
|
private getCurrentDuels(
|
|
previousDuels: Duel[],
|
|
currentDuelCount: number,
|
|
major: true
|
|
): Duel[];
|
|
|
|
/**
|
|
* Gets the duels for a minor round in the LB. Ordering is done.
|
|
*
|
|
* @param previousDuels Duels of the previous round.
|
|
* @param currentDuelCount Count of duels (matches) in the current round.
|
|
* @param major Indicates that the round is a minor round in the LB.
|
|
* @param losers The losers going from the WB.
|
|
* @param method The ordering method to apply to the losers.
|
|
*/
|
|
private getCurrentDuels(
|
|
previousDuels: Duel[],
|
|
currentDuelCount: number,
|
|
major: false,
|
|
losers: ParticipantSlot[],
|
|
method?: SeedOrdering
|
|
): Duel[];
|
|
|
|
/**
|
|
* Generic implementation.
|
|
*
|
|
* @param previousDuels Always given.
|
|
* @param currentDuelCount Always given.
|
|
* @param major Only for loser bracket.
|
|
* @param losers Only for minor rounds of loser bracket.
|
|
* @param method Only for minor rounds. Ordering method for the losers.
|
|
*/
|
|
private getCurrentDuels(
|
|
previousDuels: Duel[],
|
|
currentDuelCount: number,
|
|
major?: boolean,
|
|
losers?: ParticipantSlot[],
|
|
method?: SeedOrdering
|
|
): Duel[] {
|
|
if (
|
|
(major === undefined || major) &&
|
|
previousDuels.length === currentDuelCount
|
|
) {
|
|
// First round.
|
|
return previousDuels;
|
|
}
|
|
|
|
if (major === undefined || major) {
|
|
// From major to major (WB) or minor to major (LB).
|
|
return helpers.transitionToMajor(previousDuels);
|
|
}
|
|
|
|
// From major to minor (LB).
|
|
// Losers and method won't be undefined.
|
|
return helpers.transitionToMinor(previousDuels, losers!, method);
|
|
}
|
|
|
|
/**
|
|
* Returns a list of slots.
|
|
* - If `seeding` was given, inserts them in the storage.
|
|
* - If `size` was given, only returns a list of empty slots.
|
|
*
|
|
* @param positions An optional list of positions (seeds) for a manual ordering.
|
|
*/
|
|
public getSlots(positions?: number[]): ParticipantSlot[] {
|
|
const size = this.stage.settings?.size || this.stage.seeding?.length || 0;
|
|
helpers.ensureValidSize(this.stage.type, size);
|
|
|
|
if (size && !this.stage.seeding)
|
|
return Array.from(Array(size), (_: ParticipantSlot, i) => ({
|
|
id: null,
|
|
position: i + 1,
|
|
}));
|
|
|
|
if (!this.stage.seeding)
|
|
throw Error("Either size or seeding must be given.");
|
|
|
|
this.stage.settings = {
|
|
...this.stage.settings,
|
|
size, // Always set the size.
|
|
};
|
|
|
|
helpers.ensureNoDuplicates(this.stage.seeding);
|
|
this.stage.seeding = helpers.fixSeeding(this.stage.seeding, size);
|
|
|
|
if (this.stage.type !== "round_robin" && this.stage.settings.balanceByes)
|
|
this.stage.seeding = helpers.balanceByes(
|
|
this.stage.seeding,
|
|
this.stage.settings.size
|
|
);
|
|
|
|
if (helpers.isSeedingWithIds(this.stage.seeding))
|
|
return this.getSlotsUsingIds(this.stage.seeding, positions);
|
|
|
|
return this.getSlotsUsingNames(this.stage.seeding, positions);
|
|
}
|
|
|
|
/**
|
|
* Returns the list of slots with a seeding containing names. Participants may be added to database.
|
|
*
|
|
* @param seeding The seeding (names).
|
|
* @param positions An optional list of positions (seeds) for a manual ordering.
|
|
*/
|
|
private getSlotsUsingNames(
|
|
seeding: Seeding,
|
|
positions?: number[]
|
|
): ParticipantSlot[] {
|
|
const participants = helpers.extractParticipantsFromSeeding(
|
|
this.stage.tournamentId,
|
|
seeding
|
|
);
|
|
|
|
if (!this.registerParticipants(participants))
|
|
throw Error("Error registering the participants.");
|
|
|
|
// Get participants back with IDs.
|
|
const added = this.storage.select("participant", {
|
|
tournament_id: this.stage.tournamentId,
|
|
});
|
|
if (!added) throw Error("Error getting registered participant.");
|
|
|
|
return helpers.mapParticipantsNamesToDatabase(seeding, added, positions);
|
|
}
|
|
|
|
/**
|
|
* Returns the list of slots with a seeding containing IDs. No database mutation.
|
|
*
|
|
* @param seeding The seeding (IDs).
|
|
* @param positions An optional list of positions (seeds) for a manual ordering.
|
|
*/
|
|
private getSlotsUsingIds(
|
|
seeding: Seeding,
|
|
positions?: number[]
|
|
): ParticipantSlot[] {
|
|
const participants = this.storage.select("participant", {
|
|
tournament_id: this.stage.tournamentId,
|
|
});
|
|
if (!participants) throw Error("No available participants.");
|
|
|
|
return helpers.mapParticipantsIdsToDatabase(
|
|
seeding,
|
|
participants,
|
|
positions
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets the current stage number based on existing stages.
|
|
*/
|
|
private getStageNumber(): number {
|
|
const stages = this.storage.select("stage", {
|
|
tournament_id: this.stage.tournamentId,
|
|
});
|
|
const stageNumbers = stages?.map((stage) => stage.number);
|
|
|
|
if (this.stage.number !== undefined) {
|
|
if (stageNumbers?.includes(this.stage.number))
|
|
throw Error("The given stage number already exists.");
|
|
|
|
return this.stage.number;
|
|
}
|
|
|
|
if (!stageNumbers?.length) return 1;
|
|
|
|
const maxNumber = Math.max(...stageNumbers);
|
|
return maxNumber + 1;
|
|
}
|
|
|
|
/**
|
|
* Safely gets `matchesChildCount` in the stage input settings.
|
|
*/
|
|
private getMatchesChildCount(): number {
|
|
if (!this.stage.settings?.matchesChildCount) return 0;
|
|
|
|
return this.stage.settings.matchesChildCount;
|
|
}
|
|
|
|
/**
|
|
* Safely gets an ordering by its index in the stage input settings.
|
|
*
|
|
* @param orderingIndex Index of the ordering.
|
|
* @param stageType A value indicating if the method should be a group method or not.
|
|
* @param defaultMethod The default method to use if not given.
|
|
*/
|
|
private getOrdering(
|
|
orderingIndex: number,
|
|
stageType: "elimination" | "groups",
|
|
defaultMethod: SeedOrdering
|
|
): SeedOrdering {
|
|
if (!this.stage.settings?.seedOrdering) {
|
|
this.seedOrdering.push(defaultMethod);
|
|
return defaultMethod;
|
|
}
|
|
|
|
const method = this.stage.settings.seedOrdering[orderingIndex];
|
|
if (!method) {
|
|
this.seedOrdering.push(defaultMethod);
|
|
return defaultMethod;
|
|
}
|
|
|
|
if (stageType === "elimination" && method.match(/^groups\./))
|
|
throw Error(
|
|
"You must specify a seed ordering method without a 'groups' prefix"
|
|
);
|
|
|
|
if (
|
|
stageType === "groups" &&
|
|
method !== "natural" &&
|
|
!method.match(/^groups\./)
|
|
)
|
|
throw Error(
|
|
"You must specify a seed ordering method with a 'groups' prefix"
|
|
);
|
|
|
|
return method;
|
|
}
|
|
|
|
/**
|
|
* Gets the duels in groups for a round-robin stage.
|
|
*/
|
|
private getRoundRobinGroups(): ParticipantSlot[][] {
|
|
if (
|
|
this.stage.settings?.groupCount === undefined ||
|
|
!Number.isInteger(this.stage.settings.groupCount)
|
|
)
|
|
throw Error("You must specify a group count for round-robin stages.");
|
|
|
|
if (this.stage.settings.groupCount <= 0)
|
|
throw Error("You must provide a strictly positive group count.");
|
|
|
|
if (this.stage.settings?.manualOrdering) {
|
|
if (
|
|
this.stage.settings?.manualOrdering.length !==
|
|
this.stage.settings?.groupCount
|
|
)
|
|
throw Error(
|
|
"Group count in the manual ordering does not correspond to the given group count."
|
|
);
|
|
|
|
const positions = this.stage.settings?.manualOrdering.flat();
|
|
const slots = this.getSlots(positions);
|
|
|
|
return helpers.makeGroups(slots, this.stage.settings.groupCount);
|
|
}
|
|
|
|
if (
|
|
Array.isArray(this.stage.settings.seedOrdering) &&
|
|
this.stage.settings.seedOrdering.length !== 1
|
|
)
|
|
throw Error("You must specify one seed ordering method.");
|
|
|
|
const method = this.getRoundRobinOrdering();
|
|
const slots = this.getSlots();
|
|
const ordered = ordering[method](slots, this.stage.settings.groupCount);
|
|
return helpers.makeGroups(ordered, this.stage.settings.groupCount);
|
|
}
|
|
|
|
/**
|
|
* Returns the ordering method for the groups in a round-robin stage.
|
|
*/
|
|
public getRoundRobinOrdering(): SeedOrdering {
|
|
return this.getOrdering(0, "groups", "groups.effort_balanced");
|
|
}
|
|
|
|
/**
|
|
* Returns the ordering method for the first round of the upper bracket of an elimination stage.
|
|
*/
|
|
public getStandardBracketFirstRoundOrdering(): SeedOrdering {
|
|
return this.getOrdering(0, "elimination", "inner_outer");
|
|
}
|
|
|
|
/**
|
|
* Safely gets the only major ordering for the lower bracket.
|
|
*
|
|
* @param participantCount Number of participants in the stage.
|
|
*/
|
|
private getMajorOrdering(participantCount: number): SeedOrdering {
|
|
return this.getOrdering(
|
|
1,
|
|
"elimination",
|
|
defaultMinorOrdering[participantCount]?.[0] || "natural"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Safely gets a minor ordering for the lower bracket by its index.
|
|
*
|
|
* @param participantCount Number of participants in the stage.
|
|
* @param index Index of the minor round.
|
|
* @param minorRoundCount Number of minor rounds.
|
|
*/
|
|
private getMinorOrdering(
|
|
participantCount: number,
|
|
index: number,
|
|
minorRoundCount: number
|
|
): SeedOrdering | undefined {
|
|
// No ordering for the last minor round. There is only one participant to order.
|
|
if (index === minorRoundCount - 1) return undefined;
|
|
|
|
return this.getOrdering(
|
|
2 + index,
|
|
"elimination",
|
|
defaultMinorOrdering[participantCount]?.[1 + index] || "natural"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inserts a stage or finds an existing one.
|
|
*
|
|
* @param stage The stage to insert.
|
|
*/
|
|
private insertStage(stage: OmitId<Stage>): number {
|
|
let existing: Stage | null = null;
|
|
|
|
if (this.updateMode)
|
|
existing = this.storage.select("stage", this.currentStageId);
|
|
|
|
if (!existing) return this.storage.insert("stage", stage);
|
|
|
|
return existing.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts a group or finds an existing one.
|
|
*
|
|
* @param group The group to insert.
|
|
*/
|
|
private insertGroup(group: OmitId<Group>): number {
|
|
let existing: Group | null = null;
|
|
|
|
if (this.updateMode) {
|
|
existing = this.storage.selectFirst("group", {
|
|
stage_id: group.stage_id,
|
|
number: group.number,
|
|
});
|
|
}
|
|
|
|
if (!existing) return this.storage.insert("group", group);
|
|
|
|
return existing.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts a round or finds an existing one.
|
|
*
|
|
* @param round The round to insert.
|
|
*/
|
|
private insertRound(round: OmitId<Round>): number {
|
|
let existing: Round | null = null;
|
|
|
|
if (this.updateMode) {
|
|
existing = this.storage.selectFirst("round", {
|
|
group_id: round.group_id,
|
|
number: round.number,
|
|
});
|
|
}
|
|
|
|
if (!existing) return this.storage.insert("round", round);
|
|
|
|
return existing.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts a match or updates an existing one.
|
|
*
|
|
* @param match The match to insert.
|
|
* @param existing An existing match corresponding to the current one.
|
|
*/
|
|
private insertMatch(match: OmitId<Match>, existing: Match | null): number {
|
|
if (!existing) return this.storage.insert("match", match);
|
|
|
|
const updated = helpers.getUpdatedMatchResults(
|
|
match,
|
|
existing,
|
|
this.enableByesInUpdate
|
|
) as Match;
|
|
if (!this.storage.update("match", existing.id, updated))
|
|
throw Error("Could not update the match.");
|
|
|
|
return existing.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts a match game or finds an existing one (and updates it).
|
|
*
|
|
* @param matchGame The match game to insert.
|
|
*/
|
|
private insertMatchGame(matchGame: OmitId<MatchGame>): number {
|
|
let existing: MatchGame | null = null;
|
|
|
|
if (this.updateMode) {
|
|
existing = this.storage.selectFirst("match_game", {
|
|
parent_id: matchGame.parent_id,
|
|
number: matchGame.number,
|
|
});
|
|
}
|
|
|
|
if (!existing) return this.storage.insert("match_game", matchGame);
|
|
|
|
const updated = helpers.getUpdatedMatchResults(
|
|
matchGame,
|
|
existing,
|
|
this.enableByesInUpdate
|
|
) as MatchGame;
|
|
if (!this.storage.update("match_game", existing.id, updated))
|
|
throw Error("Could not update the match game.");
|
|
|
|
return existing.id;
|
|
}
|
|
|
|
/**
|
|
* Inserts missing participants.
|
|
*
|
|
* @param participants The list of participants to process.
|
|
*/
|
|
private registerParticipants(participants: OmitId<Participant>[]): boolean {
|
|
const existing = this.storage.select("participant", {
|
|
tournament_id: this.stage.tournamentId,
|
|
});
|
|
|
|
// Insert all if nothing.
|
|
if (!existing || existing.length === 0)
|
|
return this.storage.insert("participant", participants);
|
|
|
|
// Insert only missing otherwise.
|
|
for (const participant of participants) {
|
|
if (existing.some((value) => value.name === participant.name)) continue;
|
|
|
|
const result = this.storage.insert("participant", participant);
|
|
if (result === -1) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Creates a new stage.
|
|
*/
|
|
private createStage(): Stage {
|
|
const number = this.getStageNumber();
|
|
const stage: OmitId<Stage> = {
|
|
tournament_id: this.stage.tournamentId,
|
|
name: this.stage.name,
|
|
type: this.stage.type,
|
|
number: number,
|
|
settings: this.stage.settings || {},
|
|
};
|
|
|
|
const stageId = this.insertStage(stage);
|
|
|
|
if (stageId === -1) throw Error("Could not insert the stage.");
|
|
|
|
return { ...stage, id: stageId };
|
|
}
|
|
|
|
/**
|
|
* Creates a consolation final for the semi final losers of a single elimination stage.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
* @param losers The semi final losers who will play the consolation final.
|
|
*/
|
|
private createConsolationFinal(
|
|
stageId: number,
|
|
losers: ParticipantSlot[][]
|
|
): void {
|
|
if (!this.stage.settings?.consolationFinal) return;
|
|
|
|
const semiFinalLosers = losers[losers.length - 2] as Duel;
|
|
this.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]);
|
|
}
|
|
|
|
/**
|
|
* Creates a grand final (none, simple or double) for winners of both bracket in a double elimination stage.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
* @param winnerWb The winner of the winner bracket.
|
|
* @param winnerLb The winner of the loser bracket.
|
|
*/
|
|
private createGrandFinal(
|
|
stageId: number,
|
|
winnerWb: ParticipantSlot,
|
|
winnerLb: ParticipantSlot
|
|
): void {
|
|
// No Grand Final by default.
|
|
const grandFinal = this.stage.settings?.grandFinal;
|
|
if (grandFinal === "none") return;
|
|
|
|
// One duel by default.
|
|
const finalDuels: Duel[] = [[winnerWb, winnerLb]];
|
|
|
|
// Second duel.
|
|
if (grandFinal === "double") finalDuels.push([{ id: null }, { id: null }]);
|
|
|
|
this.createUniqueMatchBracket(stageId, 3, finalDuels);
|
|
}
|
|
|
|
/**
|
|
* Ensures that the seed ordering list is stored even if it was not given in the first place.
|
|
*
|
|
* @param stageId ID of the stage.
|
|
*/
|
|
private ensureSeedOrdering(stageId: number): void {
|
|
if (this.stage.settings?.seedOrdering?.length === this.seedOrdering.length)
|
|
return;
|
|
|
|
const stage = this.storage.select("stage", stageId);
|
|
if (!stage) throw Error("Stage not found.");
|
|
|
|
stage.settings = {
|
|
...stage.settings,
|
|
seedOrdering: this.seedOrdering,
|
|
};
|
|
|
|
if (!this.storage.update("stage", stageId, stage))
|
|
throw Error("Could not update the stage.");
|
|
}
|
|
}
|