mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-18 13:10:29 -05:00
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import type {
|
|
Stage,
|
|
Group,
|
|
Round,
|
|
Match,
|
|
MatchGame,
|
|
Participant,
|
|
} from "~/modules/brackets-model";
|
|
import { Status } from "~/modules/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":
|
|
if (stage.settings.size === 2) {
|
|
return this.singleEliminationStandings(stageId);
|
|
}
|
|
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;
|
|
};
|