import type { Stage, Group, Round, Match, MatchGame, Participant, } from "brackets-model"; import { Status } from "brackets-model"; import type { Database, FinalStandingsItem, ParticipantSlot } from "./types"; import { BaseGetter } from "./base/getter"; import * as helpers from "./helpers"; export class Get extends BaseGetter { /** * Returns the data needed to display a stage. * * @param stageId ID of the stage. */ public stageData(stageId: number): Database { const stageData = this.getStageSpecificData(stageId); const participants = this.storage.select("participant", { tournament_id: stageData.stage.tournament_id, }); if (!participants) throw Error("Error getting participants."); return { stage: [stageData.stage], group: stageData.groups, round: stageData.rounds, match: stageData.matches, match_game: stageData.matchGames, participant: participants, }; } /** * Returns the data needed to display a whole tournament with all its stages. * * @param tournamentId ID of the tournament. */ public tournamentData(tournamentId: number): Database { const stages = this.storage.select("stage", { tournament_id: tournamentId, }); if (!stages) throw Error("Error getting stages."); const stagesData = stages.map((stage) => this.getStageSpecificData(stage.id) ); const participants = this.storage.select("participant", { tournament_id: tournamentId, }); if (!participants) throw Error("Error getting participants."); return { stage: stages, group: stagesData.reduce( (acc, data) => [...acc, ...data.groups], [] as Group[] ), round: stagesData.reduce( (acc, data) => [...acc, ...data.rounds], [] as Round[] ), match: stagesData.reduce( (acc, data) => [...acc, ...data.matches], [] as Match[] ), match_game: stagesData.reduce( (acc, data) => [...acc, ...data.matchGames], [] as MatchGame[] ), participant: participants, }; } /** * Returns the match games associated to a list of matches. * * @param matches A list of matches. */ public matchGames(matches: Match[]): MatchGame[] { const parentMatches = matches.filter((match) => match.child_count > 0); const matchGamesQueries = parentMatches.map((match) => this.storage.select("match_game", { parent_id: match.id }) ); if (matchGamesQueries.some((game) => game === null)) throw Error("Error getting match games."); return helpers.getNonNull(matchGamesQueries).flat(); } /** * Returns the stage that is not completed yet, because of uncompleted matches. * If all matches are completed in this tournament, there is no "current stage", so `null` is returned. * * @param tournamentId ID of the tournament. */ public currentStage(tournamentId: number): Stage | null { const stages = this.storage.select("stage", { tournament_id: tournamentId, }); if (!stages) throw Error("Error getting stages."); for (const stage of stages) { const matches = this.storage.select("match", { stage_id: stage.id, }); if (!matches) throw Error("Error getting matches."); if (matches.every((match) => match.status >= Status.Completed)) continue; return stage; } return null; } /** * Returns the round that is not completed yet, because of uncompleted matches. * If all matches are completed in this stage of a tournament, there is no "current round", so `null` is returned. * * Note: The consolation final of single elimination and the grand final of double elimination will be in a different `Group`. * * @param stageId ID of the stage. * @example * If you don't know the stage id, you can first get the current stage. * ```js * const tournamentId = 3; * const currentStage = manager.get.currentStage(tournamentId); * const currentRound = manager.get.currentRound(currentStage.id); * ``` */ public currentRound(stageId: number): Round | null { const matches = this.storage.select("match", { stage_id: stageId }); if (!matches) throw Error("Error getting matches."); const matchesByRound = helpers.splitBy(matches, "round_id"); for (const roundMatches of matchesByRound) { if (roundMatches.every((match) => match.status >= Status.Completed)) continue; const round = this.storage.select("round", roundMatches[0].round_id); if (!round) throw Error("Round not found."); return round; } return null; } /** * Returns the matches that can currently be played in parallel. * If all matches are completed in this stage of a tournament, an empty array is returned. * * Note: Completed matches are also returned. * * @param stageId ID of the stage. * @example * If you don't know the stage id, you can first get the current stage. * ```js * const tournamentId = 3; * const currentStage = manager.get.currentStage(tournamentId); * const currentMatches = manager.get.currentMatches(currentStage.id); * ``` */ public currentMatches(stageId: number): Match[] { const stage = this.storage.select("stage", stageId); if (!stage) throw Error("Stage not found."); // TODO: Implement this for all stage types. // - For round robin, 1 round per group can be played in parallel at their own pace. // - For double elimination, 1 round per bracket (upper and lower) can be played in parallel at their own pace. if (stage.type !== "single_elimination") throw Error( "Not implemented for round robin and double elimination. Ask if needed." ); const matches = this.storage.select("match", { stage_id: stageId }); if (!matches) throw Error("Error getting matches."); const matchesByRound = helpers.splitBy(matches, "round_id"); const roundCount = helpers.getUpperBracketRoundCount(stage.settings.size!); // Save multiple queries for `round`. let currentRoundIndex = -1; for (const roundMatches of matchesByRound) { currentRoundIndex++; if ( stage.settings.consolationFinal && currentRoundIndex === roundCount - 1 ) { // We are on the final of the single elimination. const [final] = roundMatches; const [consolationFinal] = matchesByRound[currentRoundIndex + 1]; const finals = [final, consolationFinal]; if (finals.every((match) => match.status >= Status.Completed)) return []; return finals; } if (roundMatches.every((match) => match.status >= Status.Completed)) continue; return roundMatches; } return []; } /** * Returns the seeding of a stage. * * @param stageId ID of the stage. */ public seeding(stageId: number): ParticipantSlot[] { const stage = this.storage.select("stage", stageId); if (!stage) throw Error("Stage not found."); const pickRelevantProps = (slot: ParticipantSlot): ParticipantSlot => { if (slot === null) return null; const { id, position } = slot; return { id, position }; }; if (stage.type === "round_robin") return this.roundRobinSeeding(stage).map(pickRelevantProps); return this.eliminationSeeding(stage).map(pickRelevantProps); } /** * Returns the final standings of a stage. * * @param stageId ID of the stage. */ public finalStandings(stageId: number): FinalStandingsItem[] { const stage = this.storage.select("stage", stageId); if (!stage) throw Error("Stage not found."); switch (stage.type) { case "round_robin": throw Error("A round-robin stage does not have standings."); case "single_elimination": return this.singleEliminationStandings(stageId); case "double_elimination": return this.doubleEliminationStandings(stageId); default: throw Error("Unknown stage type."); } } /** * Returns the seeding of a round-robin stage. * * @param stage The stage. */ private roundRobinSeeding(stage: Stage): ParticipantSlot[] { if (stage.settings.size === undefined) throw Error("The size of the seeding is undefined."); const matches = this.storage.select("match", { stage_id: stage.id }); if (!matches) throw Error("Error getting matches."); const slots = helpers.convertMatchesToSeeding(matches); // BYE vs. BYE matches of a round-robin stage are removed // when the stage is created. We need to add them back temporarily. if (slots.length < stage.settings.size) { const diff = stage.settings.size - slots.length; for (let i = 0; i < diff; i++) slots.push(null); } const unique = helpers.uniqueBy(slots, (item) => item && item.position); const seeding = helpers.setArraySize(unique, stage.settings.size, null); return seeding; } /** * Returns the seeding of an elimination stage. * * @param stage The stage. */ private eliminationSeeding(stage: Stage): ParticipantSlot[] { const round = this.storage.selectFirst("round", { stage_id: stage.id, number: 1, }); if (!round) throw Error("Error getting the first round."); const matches = this.storage.select("match", { round_id: round.id }); if (!matches) throw Error("Error getting matches."); return helpers.convertMatchesToSeeding(matches); } /** * Returns the final standings of a single elimination stage. * * @param stageId ID of the stage. */ private singleEliminationStandings(stageId: number): FinalStandingsItem[] { const grouped: Participant[][] = []; const { stage: stages, group: groups, match: matches, participant: participants, } = this.stageData(stageId); const [stage] = stages; const [singleBracket, finalGroup] = groups; const final = matches .filter((match) => match.group_id === singleBracket.id) .pop(); if (!final) throw Error("Final not found."); // 1st place: Final winner. grouped[0] = [ helpers.findParticipant(participants, getFinalWinnerIfDefined(final)), ]; // Rest: every loser in reverse order. const losers = helpers.getLosers( participants, matches.filter((match) => match.group_id === singleBracket.id) ); grouped.push(...losers.reverse()); if (stage.settings?.consolationFinal) { const consolationFinal = matches .filter((match) => match.group_id === finalGroup.id) .pop(); if (!consolationFinal) throw Error("Consolation final not found."); const consolationFinalWinner = helpers.findParticipant( participants, getFinalWinnerIfDefined(consolationFinal) ); const consolationFinalLoser = helpers.findParticipant( participants, helpers.getLoser(consolationFinal) ); // Overwrite semi-final losers with the consolation final results. grouped.splice(2, 1, [consolationFinalWinner], [consolationFinalLoser]); } return helpers.makeFinalStandings(grouped); } /** * Returns the final standings of a double elimination stage. * * @param stageId ID of the stage. */ private doubleEliminationStandings(stageId: number): FinalStandingsItem[] { const grouped: Participant[][] = []; const { stage: stages, group: groups, match: matches, participant: participants, } = this.stageData(stageId); const [stage] = stages; const [winnerBracket, loserBracket, finalGroup] = groups; if (stage.settings?.grandFinal === "none") { const finalWB = matches .filter((match) => match.group_id === winnerBracket.id) .pop(); if (!finalWB) throw Error("WB final not found."); const finalLB = matches .filter((match) => match.group_id === loserBracket.id) .pop(); if (!finalLB) throw Error("LB final not found."); // 1st place: WB Final winner. grouped[0] = [ helpers.findParticipant(participants, getFinalWinnerIfDefined(finalWB)), ]; // 2nd place: LB Final winner. grouped[1] = [ helpers.findParticipant(participants, getFinalWinnerIfDefined(finalLB)), ]; } else { const grandFinalMatches = matches.filter( (match) => match.group_id === finalGroup.id ); const decisiveMatch = helpers.getGrandFinalDecisiveMatch( stage.settings?.grandFinal || "none", grandFinalMatches ); // 1st place: Grand Final winner. grouped[0] = [ helpers.findParticipant( participants, getFinalWinnerIfDefined(decisiveMatch) ), ]; // 2nd place: Grand Final loser. grouped[1] = [ helpers.findParticipant(participants, helpers.getLoser(decisiveMatch)), ]; } // Rest: every loser in reverse order. const losers = helpers.getLosers( participants, matches.filter((match) => match.group_id === loserBracket.id) ); grouped.push(...losers.reverse()); return helpers.makeFinalStandings(grouped); } /** * Returns only the data specific to the given stage (without the participants). * * @param stageId ID of the stage. */ private getStageSpecificData(stageId: number): { stage: Stage; groups: Group[]; rounds: Round[]; matches: Match[]; matchGames: MatchGame[]; } { const stage = this.storage.select("stage", stageId); if (!stage) throw Error("Stage not found."); const groups = this.storage.select("group", { stage_id: stageId }); if (!groups) throw Error("Error getting groups."); const rounds = this.storage.select("round", { stage_id: stageId }); if (!rounds) throw Error("Error getting rounds."); const matches = this.storage.select("match", { stage_id: stageId }); if (!matches) throw Error("Error getting matches."); const matchGames = this.matchGames(matches); return { stage, groups, rounds, matches, matchGames, }; } } const getFinalWinnerIfDefined = (match: Match): ParticipantSlot => { const winner = helpers.getWinner(match); if (!winner) throw Error("The final match does not have a winner."); return winner; };