sendou.ink/app/modules/brackets-manager/create.ts
Kalle ef78d3a2c2
Tournament full (#1373)
* 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
2023-05-15 22:37:43 +03:00

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.");
}
}