sendou.ink/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts
2026-07-22 21:01:28 +03:00

355 lines
9.2 KiB
TypeScript

import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
export class RoundRobinBracket extends Bracket {
get collectsKos() {
return true;
}
source({ placements, rest }: { placements: number[]; rest?: boolean }): {
relevantMatchesFinished: boolean;
teams: number[];
} {
invariant(placements.length > 0, "Empty placements not supported");
if (placements.some((p) => p < 0)) {
throw new Error("Negative placements not implemented");
}
const standings = this.standings;
const relevantMatchesFinished =
standings.length === this.participantTournamentTeamIds.length;
const maxExplicit = Math.max(...placements);
const matchesPlacement = (p: number) =>
placements.includes(p) || (rest === true && p >= maxExplicit);
if (this.settings?.hasAbDivisions) {
return {
relevantMatchesFinished,
teams: this.teamsFromPlacementsPerAbDivision(
standings,
placements,
rest === true,
),
};
}
const uniquePlacements = R.unique(standings.map((s) => s.placement));
// 1,3,5 -> 1,2,3 e.g.
const placementNormalized = (p: number) => {
return uniquePlacements.indexOf(p) + 1;
};
return {
relevantMatchesFinished,
teams: standings
.filter((s) => matchesPlacement(placementNormalized(s.placement)))
.map((s) => s.team.id),
};
}
private teamsFromPlacementsPerAbDivision(
standings: Standing[],
placements: number[],
rest: boolean,
): number[] {
const groupIds = R.unique(
standings
.map((s) => s.groupId)
.filter((id): id is number => typeof id === "number"),
);
const maxExplicit = Math.max(...placements);
const teams: number[] = [];
for (const groupId of groupIds) {
for (const division of [0, 1] as const) {
const divisionStandings = standings.filter(
(s) => s.groupId === groupId && s.team.abDivision === division,
);
const maxPlacement = rest ? divisionStandings.length : maxExplicit;
for (let placement = 1; placement <= maxPlacement; placement++) {
if (
!placements.includes(placement) &&
!(rest && placement >= maxExplicit)
) {
continue;
}
const standing = divisionStandings[placement - 1];
if (standing) teams.push(standing.team.id);
}
}
}
return teams;
}
get standings(): Standing[] {
return this.currentStandings();
}
currentStandings(includeUnfinishedGroups = false) {
const groupIds = this.data.group.map((group) => group.id);
const placements: (Standing & { groupId: number })[] = [];
for (const groupId of groupIds) {
const matches = this.data.match.filter(
(match) => match.group_id === groupId,
);
const groupIsFinished = matches.every(
(match) =>
// BYE
match.opponent1 === null ||
match.opponent2 === null ||
// match was played out
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
);
if (!groupIsFinished && !includeUnfinishedGroups) continue;
const droppedOutWithIncompleteMatches = new Set<number>();
for (const team of this.tournament.ctx.teams) {
if (!team.droppedOut) continue;
const teamMatches = matches.filter(
(m) => m.opponent1?.id === team.id || m.opponent2?.id === team.id,
);
if (teamMatches.length === 0) continue;
const allPlayed = teamMatches.every(
(m) =>
m.opponent1 === null ||
m.opponent2 === null ||
typeof m.opponent1?.score === "number" ||
typeof m.opponent2?.score === "number",
);
if (!allPlayed) droppedOutWithIncompleteMatches.add(team.id);
}
const teams: {
id: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
winsAgainstTied: number;
koCount: number;
}[] = [];
const updateTeam = ({
teamId,
setWins,
setLosses,
mapWins,
mapLosses,
koCount,
}: {
teamId: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
koCount: number;
}) => {
const team = teams.find((team) => team.id === teamId);
if (team) {
team.setWins += setWins;
team.setLosses += setLosses;
team.mapWins += mapWins;
team.mapLosses += mapLosses;
team.koCount += koCount;
} else {
teams.push({
id: teamId,
setWins,
setLosses,
mapWins,
mapLosses,
winsAgainstTied: 0,
koCount,
});
}
};
for (const match of matches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
continue;
}
const opp1Id = match.opponent1?.id;
const opp2Id = match.opponent2?.id;
if (
(typeof opp1Id === "number" &&
droppedOutWithIncompleteMatches.has(opp1Id)) ||
(typeof opp2Id === "number" &&
droppedOutWithIncompleteMatches.has(opp2Id))
) {
continue;
}
const winner =
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
if (!winner || !loser) continue;
invariant(
typeof winner.id === "number" &&
typeof loser.id === "number" &&
"RoundRobinBracket.standings: winner or loser id not found",
);
// note: score might be missing in the case the set was ended early. In the future we might want to handle this differently than defaulting both to 0.
updateTeam({
teamId: winner.id,
setWins: 1,
setLosses: 0,
mapWins: winner.score ?? 0,
mapLosses: loser.score ?? 0,
koCount: winner.totalKos ?? 0,
});
updateTeam({
teamId: loser.id,
setWins: 0,
setLosses: 1,
mapWins: loser.score ?? 0,
mapLosses: winner.score ?? 0,
koCount: loser.totalKos ?? 0,
});
}
for (const team of teams) {
for (const team2 of teams) {
if (team.id === team2.id) continue;
if (team.setWins !== team2.setWins) continue;
// they are different teams and are tied, let's check who won
const wonTheirMatch = matches.some(
(match) =>
(match.opponent1?.id === team.id &&
match.opponent2?.id === team2.id &&
match.opponent1?.result === "win") ||
(match.opponent1?.id === team2.id &&
match.opponent2?.id === team.id &&
match.opponent2?.result === "win"),
);
if (wonTheirMatch) {
team.winsAgainstTied++;
}
}
}
// Seed dropped teams here (after wins-against-tied) so forfeit matches don't credit opponents while still keeping them in standings.
for (const teamId of droppedOutWithIncompleteMatches) {
teams.push({
id: teamId,
setWins: 0,
setLosses: 0,
mapWins: 0,
mapLosses: 0,
winsAgainstTied: 0,
koCount: 0,
});
}
const droppedOutTeams = this.tournament.ctx.teams
.filter((t) => t.droppedOut)
.map((t) => t.id);
placements.push(
...teams
.sort((a, b) => {
// TIEBREAKER 0) dropped out teams are always last
const aDroppedOut = droppedOutTeams.includes(a.id);
const bDroppedOut = droppedOutTeams.includes(b.id);
if (aDroppedOut && !bDroppedOut) return 1;
if (!aDroppedOut && bDroppedOut) return -1;
if (a.setWins > b.setWins) return -1;
if (a.setWins < b.setWins) return 1;
if (a.winsAgainstTied > b.winsAgainstTied) return -1;
if (a.winsAgainstTied < b.winsAgainstTied) return 1;
if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;
if (a.mapLosses < b.mapLosses) return -1;
if (a.mapLosses > b.mapLosses) return 1;
if (a.koCount > b.koCount) return -1;
if (a.koCount < b.koCount) return 1;
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
if (aSeed < bSeed) return -1;
if (aSeed > bSeed) return 1;
return 0;
})
.map((team, i) => {
return {
team: this.tournament.teamById(team.id)!,
placement: i + 1,
groupId,
stats: {
setWins: team.setWins,
setLosses: team.setLosses,
mapWins: team.mapWins,
mapLosses: team.mapLosses,
koCount: team.koCount,
winsAgainstTied: team.winsAgainstTied,
},
};
}),
);
}
const sorted = placements.sort((a, b) => {
if (a.placement < b.placement) return -1;
if (a.placement > b.placement) return 1;
if (a.groupId < b.groupId) return -1;
if (a.groupId > b.groupId) return 1;
return 0;
});
return this.standingsWithoutNonParticipants(
Standings.reNumberPlacements(sorted),
);
}
get type(): Tables["TournamentStage"]["type"] {
return "round_robin";
}
defaultRoundBestOfs(data: TournamentManagerDataSet) {
const result: BracketMapCounts = new Map();
for (const round of data.round) {
if (!result.get(round.group_id)) {
result.set(round.group_id, new Map());
}
result
.get(round.group_id)!
.set(round.number, { count: 3, type: "BEST_OF" });
}
return result;
}
}