mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-25 21:13:41 -05:00
302 lines
8.6 KiB
TypeScript
302 lines
8.6 KiB
TypeScript
import * as R from "remeda";
|
|
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
|
|
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
|
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
|
import invariant from "~/utils/invariant";
|
|
import { getBracketProgressionLabel } from "../tournament-utils";
|
|
|
|
export type TournamentStandingsResult =
|
|
| { type: "single"; standings: Standing[] }
|
|
| {
|
|
type: "multi";
|
|
standings: Array<{
|
|
div: string;
|
|
standings: Standing[];
|
|
}>;
|
|
};
|
|
|
|
/** Converts tournament standings from single or multi-division format into a flat array */
|
|
export function flattenStandings(
|
|
standingsResult: TournamentStandingsResult,
|
|
): Standing[] {
|
|
return standingsResult.type === "single"
|
|
? standingsResult.standings
|
|
: standingsResult.standings.flatMap((div) => div.standings);
|
|
}
|
|
|
|
/**
|
|
* Re-numbers placements in a sorted standings array so that tied placements stay
|
|
* grouped (e.g. `[1, 1, 3, 3, 5]`) while non-tied positions reflect the true
|
|
* number of teams above them. Useful after filtering or merging standings where
|
|
* the original placement numbers no longer match the team count.
|
|
*
|
|
* Pass `offset` to shift every placement downwards — used when the returned
|
|
* standings will be appended below standings from another bracket.
|
|
*/
|
|
export function reNumberPlacements<T extends { placement: number }>(
|
|
standings: T[],
|
|
offset = 0,
|
|
): T[] {
|
|
let lastOriginalPlacement = 0;
|
|
let currentPlacement = 0;
|
|
|
|
return standings.map((standing, index) => {
|
|
if (standing.placement !== lastOriginalPlacement) {
|
|
lastOriginalPlacement = standing.placement;
|
|
currentPlacement = index + 1;
|
|
}
|
|
return {
|
|
...standing,
|
|
placement: currentPlacement + offset,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */
|
|
export function calculateSPR({
|
|
standings,
|
|
teamId,
|
|
}: {
|
|
standings: Standing[];
|
|
teamId: number;
|
|
}) {
|
|
const uniquePlacements = R.unique(
|
|
standings.map((standing) => standing.placement),
|
|
).sort((a, b) => a - b);
|
|
|
|
const teamStanding = standings.find(
|
|
(standing) => standing.team.id === teamId,
|
|
);
|
|
// defensive check to avoid crashing
|
|
if (!teamStanding) {
|
|
return 0;
|
|
}
|
|
|
|
const expectedPlacement =
|
|
standings[(teamStanding.team.seed ?? 0) - 1]?.placement;
|
|
// defensive check to avoid crashing
|
|
if (!expectedPlacement) {
|
|
return 0;
|
|
}
|
|
|
|
const teamPlacement = teamStanding.placement;
|
|
const actualIndex = uniquePlacements.indexOf(teamPlacement);
|
|
const expectedIndex = uniquePlacements.indexOf(expectedPlacement);
|
|
|
|
return expectedIndex - actualIndex;
|
|
}
|
|
|
|
/** Teams matches that contributed to the standings, in the order they were played in */
|
|
export function matchesPlayed({
|
|
tournament,
|
|
teamId,
|
|
}: {
|
|
tournament: Tournament;
|
|
teamId: number;
|
|
}) {
|
|
const startingBracketIdx = tournament.teamById(teamId)?.startingBracketIdx;
|
|
|
|
let bracketIdxs: number[];
|
|
|
|
if (typeof startingBracketIdx !== "number" || startingBracketIdx === 0) {
|
|
bracketIdxs = Progression.bracketIdxsForStandings(
|
|
tournament.ctx.settings.bracketProgression,
|
|
);
|
|
} else {
|
|
const reachableBrackets = Progression.bracketsReachableFrom(
|
|
startingBracketIdx,
|
|
tournament.ctx.settings.bracketProgression,
|
|
);
|
|
const reachableSet = new Set(reachableBrackets);
|
|
|
|
const allBracketIdxs = tournament.ctx.settings.bracketProgression
|
|
.map((_, idx) => idx)
|
|
.sort((a, b) => b - a);
|
|
bracketIdxs = allBracketIdxs.filter((idx) => reachableSet.has(idx));
|
|
}
|
|
|
|
const brackets = bracketIdxs
|
|
.reverse()
|
|
.map((bracketIdx) => tournament.bracketByIdx(bracketIdx)!);
|
|
|
|
const matches = brackets.flatMap((bracket, i) =>
|
|
bracket.data.match
|
|
.filter(
|
|
(match) =>
|
|
match.opponent1 &&
|
|
match.opponent2 &&
|
|
(match.opponent1?.id === teamId || match.opponent2?.id === teamId) &&
|
|
(match.opponent1.result === "win" ||
|
|
match.opponent2?.result === "win"),
|
|
)
|
|
.map((match) => ({
|
|
...match,
|
|
bracketIdx: bracketIdxs[bracketIdxs.length - 1 - i],
|
|
})),
|
|
);
|
|
|
|
return matches.map((match) => {
|
|
const opponentId = (
|
|
match.opponent1?.id === teamId ? match.opponent2?.id : match.opponent1?.id
|
|
)!;
|
|
const team = tournament.teamById(opponentId);
|
|
|
|
const result =
|
|
match.opponent1?.id === teamId
|
|
? match.opponent1.result
|
|
: match.opponent2?.result;
|
|
|
|
return {
|
|
id: match.id,
|
|
// defensive fallback
|
|
vsSeed: team?.seed ?? 0,
|
|
// defensive fallback
|
|
result: result ?? "win",
|
|
bracketIdx: match.bracketIdx,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Computes the standings for a given tournament by aggregating results from relevant brackets.
|
|
*
|
|
* For example if the tournament format is round robin (where 2 out of 4 teams per group advance) to single elimination,
|
|
* the top teams are decided by the single elimination bracket, and the teams who failed to make the bracket are ordered
|
|
* by their performance in the round robin group stage.
|
|
*
|
|
* Returns a discriminated union:
|
|
* - For tournaments with a single starting bracket, returns type 'single' with overall standings
|
|
* - For tournaments with multiple starting brackets, returns type 'multi' with standings per division
|
|
*/
|
|
export function tournamentStandings(
|
|
tournament: Tournament,
|
|
): TournamentStandingsResult {
|
|
const progression = tournament.ctx.settings.bracketProgression;
|
|
const startingBracketIdxs = Progression.startingBrackets(progression);
|
|
|
|
if (startingBracketIdxs.length <= 1) {
|
|
const standings = tournamentStandingsForBracket(tournament, undefined);
|
|
|
|
if (Progression.hasAbDivisionsFinals(progression)) {
|
|
return {
|
|
type: "multi",
|
|
standings: [
|
|
{
|
|
div: "A",
|
|
standings: reNumberPlacements(
|
|
standings.filter((s) => s.team.abDivision === 0),
|
|
),
|
|
},
|
|
{
|
|
div: "B",
|
|
standings: reNumberPlacements(
|
|
standings.filter((s) => s.team.abDivision === 1),
|
|
),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: "single",
|
|
standings,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: "multi",
|
|
standings: startingBracketIdxs.map((bracketIdx) => ({
|
|
div: getBracketProgressionLabel(bracketIdx, progression),
|
|
standings: tournamentStandingsForBracket(tournament, bracketIdx),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computes the standings for a given tournament starting from a specific bracket.
|
|
* If bracketIdx is undefined, computes overall standings for the entire tournament.
|
|
* Otherwise, only includes brackets that are reachable from the given bracketIdx.
|
|
*/
|
|
function tournamentStandingsForBracket(
|
|
tournament: Tournament,
|
|
bracketIdx: number | undefined,
|
|
): Standing[] {
|
|
let bracketIdxs: number[];
|
|
|
|
const isSingleStartingBracket = typeof bracketIdx !== "number";
|
|
|
|
if (isSingleStartingBracket) {
|
|
bracketIdxs = Progression.bracketIdxsForStandings(
|
|
tournament.ctx.settings.bracketProgression,
|
|
);
|
|
} else {
|
|
const reachableBrackets = Progression.bracketsReachableFrom(
|
|
bracketIdx,
|
|
tournament.ctx.settings.bracketProgression,
|
|
);
|
|
const reachableSet = new Set(reachableBrackets);
|
|
|
|
const allBracketIdxs = tournament.ctx.settings.bracketProgression
|
|
.map((_, idx) => idx)
|
|
.sort((a, b) => b - a);
|
|
bracketIdxs = allBracketIdxs.filter((idx) => reachableSet.has(idx));
|
|
}
|
|
|
|
const result: Standing[] = [];
|
|
const alreadyIncludedTeamIds = new Set<number>();
|
|
|
|
const finalBracketIsOver = tournament.brackets.some(
|
|
(bracket) => bracket.isFinals && bracket.everyMatchOver,
|
|
);
|
|
|
|
for (const idx of bracketIdxs) {
|
|
const bracket = tournament.bracketByIdx(idx);
|
|
invariant(bracket);
|
|
|
|
// sometimes a bracket might not be played so then we ignore it from the standings
|
|
if (isSingleStartingBracket && finalBracketIsOver && bracket.preview) {
|
|
continue;
|
|
}
|
|
|
|
const standings = standingsToMergeable({
|
|
alreadyIncludedTeamIds,
|
|
standings: bracket.standings,
|
|
teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size,
|
|
});
|
|
result.push(...standings);
|
|
|
|
for (const teamId of bracket.participantTournamentTeamIds) {
|
|
alreadyIncludedTeamIds.add(teamId);
|
|
}
|
|
for (const teamId of bracket.teamsPendingCheckIn ?? []) {
|
|
alreadyIncludedTeamIds.add(teamId);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function standingsToMergeable<
|
|
T extends { team: { id: number }; placement: number },
|
|
>({
|
|
alreadyIncludedTeamIds,
|
|
standings,
|
|
teamsAboveFromAnotherBracketsCount,
|
|
}: {
|
|
alreadyIncludedTeamIds: Set<number>;
|
|
standings: T[];
|
|
teamsAboveFromAnotherBracketsCount: number;
|
|
}) {
|
|
const filtered = standings.filter(
|
|
(standing) => !alreadyIncludedTeamIds.has(standing.team.id),
|
|
);
|
|
|
|
// e.g. if standings start at 3rd place, this must mean there is 2 teams left to finish _this_ bracket
|
|
const unfinishedTeamsCount = (standings.at(0)?.placement ?? 1) - 1;
|
|
|
|
return reNumberPlacements(
|
|
filtered,
|
|
teamsAboveFromAnotherBracketsCount + unfinishedTeamsCount,
|
|
);
|
|
}
|