UNPKG

brackets-manager

Version:

A simple library to manage tournament brackets (round-robin, single elimination, double elimination)

735 lines 32.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StageCreator = exports.create = void 0; const ordering_1 = require("../../ordering"); const helpers = require("../../helpers"); /** * Creates a stage. * * @param this Instance of BracketsManager. * @param stage The stage to create. */ async function create(stage) { const creator = new StageCreator(this.storage, stage); return creator.run(); } exports.create = create; class StageCreator { /** * Creates an instance of StageCreator, which will handle the creation of the stage. * * @param storage The implementation of Storage. * @param stage The stage to create. */ constructor(storage, stage) { 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 (this.stage.tournamentId === undefined) 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. */ async run() { let stage; switch (this.stage.type) { case 'round_robin': stage = await this.roundRobin(); break; case 'single_elimination': stage = await this.singleElimination(); break; case 'double_elimination': stage = await this.doubleElimination(); break; default: throw Error('Unknown stage type.'); } if (stage.id === -1) throw Error('Something went wrong when creating the stage.'); await 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. */ setExisting(stageId, enableByes) { 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. */ async roundRobin() { const groups = await this.getRoundRobinGroups(); const stage = await this.createStage(); for (let i = 0; i < groups.length; i++) await 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. */ async singleElimination() { var _a, _b; if (Array.isArray((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.seedOrdering) && ((_b = this.stage.settings) === null || _b === void 0 ? void 0 : _b.seedOrdering.length) !== 1) throw Error('You must specify one seed ordering method.'); const slots = await this.getSlots(); const stage = await this.createStage(); const method = this.getStandardBracketFirstRoundOrdering(); const ordered = ordering_1.ordering[method](slots); const { losers } = await this.createStandardBracket(stage.id, 1, ordered); await 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. */ async doubleElimination() { var _a; 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 = await this.getSlots(); const stage = await this.createStage(); const method = this.getStandardBracketFirstRoundOrdering(); const ordered = ordering_1.ordering[method](slots); if ((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.skipFirstRound) await this.createDoubleEliminationSkipFirstRound(stage.id, ordered); else await 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. */ async createDoubleEliminationSkipFirstRound(stageId, slots) { var _a; const { even: directInWb, odd: directInLb } = helpers.splitByParity(slots); const { losers: losersWb, winner: winnerWb } = await this.createStandardBracket(stageId, 1, directInWb); if (helpers.isDoubleEliminationNecessary((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.size)) { const winnerLb = await this.createLowerBracket(stageId, 2, [directInLb, ...losersWb]); await this.createGrandFinal(stageId, winnerWb, winnerLb); } } /** * Creates a double elimination stage. * * @param stageId ID of the stage. * @param slots A list of slots. */ async createDoubleElimination(stageId, slots) { var _a; const { losers: losersWb, winner: winnerWb } = await this.createStandardBracket(stageId, 1, slots); if (helpers.isDoubleEliminationNecessary((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.size)) { const winnerLb = await this.createLowerBracket(stageId, 2, losersWb); const finalGroupId = await this.createGrandFinal(stageId, winnerWb, winnerLb); await this.createConsolationFinal(stageId, losersWb, { existingGroupId: finalGroupId, // Arbitrary way to differentiate the grand final and consolation final matches. // Grand final matches always have had `number: 1`. Now, consolation final matches always have `number: 2`. matchNumberStart: 2, }); } } /** * 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 groupNumber Number of the group in the stage. * @param slots A list of slots. */ async createRoundRobinGroup(stageId, groupNumber, slots) { var _a; const groupId = await this.insertGroup({ stage_id: stageId, number: groupNumber, }); if (groupId === -1) throw Error('Could not insert the group.'); const rounds = helpers.makeRoundRobinMatches(slots, (_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.roundRobinMode); for (let i = 0; i < rounds.length; i++) await 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 groupNumber Number of the group in the stage. * @param slots A list of slots. */ async createStandardBracket(stageId, groupNumber, slots) { const roundCount = helpers.getUpperBracketRoundCount(slots.length); const groupId = await this.insertGroup({ stage_id: stageId, number: groupNumber, }); if (groupId === -1) throw Error('Could not insert the group.'); let duels = helpers.makePairs(slots); let roundNumber = 1; const losers = []; 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)); await 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 groupNumber Number of the group in the stage. * @param losers One list of losers per upper bracket round. */ async createLowerBracket(stageId, groupNumber, losers) { var _a; const participantCount = (_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.size; const roundPairCount = helpers.getRoundPairCount(participantCount); let losersId = 0; const method = this.getMajorOrdering(participantCount); const ordered = ordering_1.ordering[method](losers[losersId++]); const groupId = await this.insertGroup({ stage_id: stageId, number: groupNumber, }); 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); await 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); await 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 groupNumber Number of the group in the stage. * @param duels A list of duels. * @param overrides Optional overrides. */ async createUniqueMatchBracket(stageId, groupNumber, duels, overrides = {}) { let groupId = overrides.existingGroupId; let roundNumberStart = 1; if (groupId !== undefined) { const rounds = await this.storage.select('round', { group_id: groupId }); if (!rounds) throw Error('Error getting rounds.'); // When we add rounds to an existing group, we resume the round numbering. roundNumberStart = rounds.length + 1; } else { groupId = await this.insertGroup({ stage_id: stageId, number: groupNumber, }); if (groupId === -1) throw Error('Could not insert the group.'); } for (let i = 0; i < duels.length; i++) await this.createRound(stageId, groupId, roundNumberStart + i, 1, [duels[i]], overrides.matchNumberStart); return groupId; } /** * 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. * @param matchNumberStart Optionally give the starting point for the match numbers. Starts at 1 by default. */ async createRound(stageId, groupId, roundNumber, matchCount, duels, matchNumberStart = 1) { const matchesChildCount = this.getMatchesChildCount(); const roundId = await 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++) await this.createMatch(stageId, groupId, roundId, matchNumberStart + i, 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). */ async createMatch(stageId, groupId, roundId, matchNumber, opponents, childCount) { 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 = null; let status = helpers.getMatchStatus(opponents); if (this.updateMode) { existing = await this.storage.selectFirst('match', { round_id: roundId, number: matchNumber, }); const currentChildCount = existing === null || existing === void 0 ? void 0 : 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 = await this.insertMatch({ number: matchNumber, stage_id: stageId, group_id: groupId, round_id: roundId, child_count: childCount, status: status, ...helpers.getInferredResult(opponent1, opponent2), }, existing); if (parentId === -1) throw Error('Could not insert the match.'); for (let i = 0; i < childCount; i++) { const id = await this.insertMatchGame({ number: i + 1, stage_id: stageId, parent_id: parentId, status: status, ...helpers.getInferredResult(helpers.toResult(opponents[0]), helpers.toResult(opponents[1])), }); if (id === -1) throw Error('Could not insert the match game.'); } } /** * 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. */ getCurrentDuels(previousDuels, currentDuelCount, major, losers, method) { 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. */ async getSlots(positions) { var _a; let seeding = this.stage.seedingIds || this.stage.seeding; const size = ((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.size) || (seeding === null || seeding === void 0 ? void 0 : seeding.length) || 0; helpers.ensureValidSize(this.stage.type, size); if (size && !seeding) return Array.from({ length: size }, (_, i) => ({ id: null, position: i + 1 })); if (!seeding) throw Error('Either size or seeding must be given.'); this.stage.settings = { ...this.stage.settings, size, // Always set the size. }; helpers.ensureNoDuplicates(seeding); seeding = helpers.fixSeeding(seeding, size); if (this.stage.type !== 'round_robin' && this.stage.settings.balanceByes) seeding = helpers.balanceByes(seeding, this.stage.settings.size); this.stage.seeding = seeding; if (this.stage.seedingIds !== undefined || helpers.isSeedingWithIds(seeding)) return this.getSlotsUsingIds(seeding, positions); return this.getSlotsUsingNames(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. */ async getSlotsUsingNames(seeding, positions) { const participants = helpers.extractParticipantsFromSeeding(this.stage.tournamentId, seeding); if (!await this.registerParticipants(participants)) throw Error('Error registering the participants.'); // Get participants back with IDs. const added = await 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. */ async getSlotsUsingIds(seeding, positions) { const participants = await 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. */ async getStageNumber() { const stages = await this.storage.select('stage', { tournament_id: this.stage.tournamentId }); const stageNumbers = stages === null || stages === void 0 ? void 0 : stages.map(stage => { var _a; return (_a = stage.number) !== null && _a !== void 0 ? _a : 0; }); if (this.stage.number !== undefined) { if (stageNumbers === null || stageNumbers === void 0 ? void 0 : stageNumbers.includes(this.stage.number)) throw Error('The given stage number already exists.'); return this.stage.number; } if (!(stageNumbers === null || stageNumbers === void 0 ? void 0 : stageNumbers.length)) return 1; const maxNumber = Math.max(...stageNumbers); return maxNumber + 1; } /** * Safely gets `matchesChildCount` in the stage input settings. */ getMatchesChildCount() { var _a; if (!((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.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. */ getOrdering(orderingIndex, stageType, defaultMethod) { var _a; if (!((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.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. */ async getRoundRobinGroups() { var _a, _b, _c, _d, _e; if (((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.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 ((_b = this.stage.settings) === null || _b === void 0 ? void 0 : _b.manualOrdering) { if (((_c = this.stage.settings) === null || _c === void 0 ? void 0 : _c.manualOrdering.length) !== ((_d = this.stage.settings) === null || _d === void 0 ? void 0 : _d.groupCount)) throw Error('Group count in the manual ordering does not correspond to the given group count.'); const positions = (_e = this.stage.settings) === null || _e === void 0 ? void 0 : _e.manualOrdering.flat(); const slots = await 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 = await this.getSlots(); const ordered = ordering_1.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. */ getRoundRobinOrdering() { return this.getOrdering(0, 'groups', 'groups.effort_balanced'); } /** * Returns the ordering method for the first round of the upper bracket of an elimination stage. */ getStandardBracketFirstRoundOrdering() { 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. */ getMajorOrdering(participantCount) { var _a; return this.getOrdering(1, 'elimination', ((_a = ordering_1.defaultMinorOrdering[participantCount]) === null || _a === void 0 ? void 0 : _a[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. */ getMinorOrdering(participantCount, index, minorRoundCount) { var _a; // 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', ((_a = ordering_1.defaultMinorOrdering[participantCount]) === null || _a === void 0 ? void 0 : _a[1 + index]) || 'natural'); } /** * Inserts a stage or finds an existing one. * * @param stage The stage to insert. */ async insertStage(stage) { let existing = null; if (this.updateMode) { existing = await this.storage.select('stage', this.currentStageId); if (!existing) throw Error('Stage not found.'); const update = { ...existing, ...stage, settings: { ...existing.settings, ...stage.settings, }, }; if (!await this.storage.update('stage', this.currentStageId, update)) throw Error('Could not update the stage.'); } 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. */ async insertGroup(group) { let existing = null; if (this.updateMode) { existing = await 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. */ async insertRound(round) { let existing = null; if (this.updateMode) { existing = await 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. */ async insertMatch(match, existing) { if (!existing) return this.storage.insert('match', match); const updated = helpers.getUpdatedMatchResults(match, existing, this.enableByesInUpdate); if (!await 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. */ async insertMatchGame(matchGame) { let existing = null; if (this.updateMode) { existing = await 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); if (!await 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. */ async registerParticipants(participants) { const existing = await 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 = await this.storage.insert('participant', participant); if (result === -1) return false; } return true; } /** * Creates a new stage. */ async createStage() { const stageNumber = await this.getStageNumber(); const stage = { tournament_id: this.stage.tournamentId, name: this.stage.name, type: this.stage.type, number: stageNumber, settings: this.stage.settings || {}, }; const stageId = await 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 an upper bracket (single or double elimination). * * @param stageId ID of the stage. * @param losers The semi final losers who will play the consolation final. * @param overrides Optional overrides. */ async createConsolationFinal(stageId, losers, overrides = {}) { var _a; if (!((_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.consolationFinal)) return; const semiFinalLosers = losers[losers.length - 2]; await this.createUniqueMatchBracket(stageId, 2, [semiFinalLosers], overrides); } /** * 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. */ async createGrandFinal(stageId, winnerWb, winnerLb) { var _a; // No Grand Final by default. const grandFinal = (_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.grandFinal; if (grandFinal === 'none') return; // One duel by default. const finalDuels = [[winnerWb, winnerLb]]; // Second duel. if (grandFinal === 'double') finalDuels.push([{ id: null }, { id: null }]); const groupId = await this.createUniqueMatchBracket(stageId, 3, finalDuels); return groupId; } /** * Ensures that the seed ordering list is stored even if it was not given in the first place. * * @param stageId ID of the stage. */ async ensureSeedOrdering(stageId) { var _a, _b; if (((_b = (_a = this.stage.settings) === null || _a === void 0 ? void 0 : _a.seedOrdering) === null || _b === void 0 ? void 0 : _b.length) === this.seedOrdering.length) return; const existing = await this.storage.select('stage', stageId); if (!existing) throw Error('Stage not found.'); const update = { ...existing, settings: { ...existing.settings, seedOrdering: this.seedOrdering, }, }; if (!await this.storage.update('stage', stageId, update)) throw Error('Could not update the stage.'); } } exports.StageCreator = StageCreator; //# sourceMappingURL=creator.js.map