mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Fix standings public api, refactor for clarity (#3278)
Co-authored-by: Gaming32 <42721887+Gaming32@users.noreply.github.com>
This commit is contained in:
@@ -22,10 +22,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (bracket.preview) throw new Response(null, { status: 404 });
|
||||
|
||||
const result: GetTournamentBracketStandingsResponse = {
|
||||
standings: bracket.standings.map((standing) => ({
|
||||
finished: bracket.standingsAreFinal,
|
||||
standings: bracket.liveStandings.map((standing) => ({
|
||||
tournamentTeamId: standing.team.id,
|
||||
placement: standing.placement,
|
||||
stats: standing.stats,
|
||||
groupId: standing.groupId,
|
||||
stats: standing.stats
|
||||
? {
|
||||
setWins: standing.stats.setWins,
|
||||
setLosses: standing.stats.setLosses,
|
||||
mapWins: standing.stats.mapWins,
|
||||
mapLosses: standing.stats.mapLosses,
|
||||
koCount: standing.stats.koCount,
|
||||
winsAgainstTied: standing.stats.winsAgainstTied,
|
||||
lossesAgainstTied: standing.stats.lossesAgainstTied,
|
||||
opponentSetWinPercentage: standing.stats.opponentSetWinPercentage,
|
||||
opponentMapWinPercentage: standing.stats.opponentMapWinPercentage,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -360,9 +360,12 @@ export interface GetTournamentBracketResponse {
|
||||
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex}/standings */
|
||||
|
||||
export interface GetTournamentBracketStandingsResponse {
|
||||
finished: boolean;
|
||||
standings: Array<{
|
||||
tournamentTeamId: number;
|
||||
placement: number;
|
||||
/** (round robin & swiss only) id of the group the team played in. Placements are shared across groups, meaning e.g. every group's winner has the placement 1. */
|
||||
groupId?: number;
|
||||
stats?: {
|
||||
setWins: number;
|
||||
setLosses: number;
|
||||
@@ -374,8 +377,10 @@ export interface GetTournamentBracketStandingsResponse {
|
||||
koCount?: number;
|
||||
winsAgainstTied: number;
|
||||
lossesAgainstTied?: number;
|
||||
buchholzSets?: number;
|
||||
buchholzMaps?: number;
|
||||
/** (swiss only) average win percentage of the team's opponents in sets, used as a tiebreaker */
|
||||
opponentSetWinPercentage?: number;
|
||||
/** (swiss only) average win percentage of the team's opponents in maps, used as a tiebreaker */
|
||||
opponentMapWinPercentage?: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function BracketTestLayout() {
|
||||
requiresCheckIn: false,
|
||||
startTime: null,
|
||||
simulatedMatch: () => undefined,
|
||||
currentStandings: () => [],
|
||||
liveStandings: [],
|
||||
participantTournamentTeamIds: teamIds,
|
||||
everyMatchOver: false,
|
||||
isUnderground: false,
|
||||
|
||||
@@ -810,11 +810,43 @@ function createMockBracket(
|
||||
requiresCheckIn: false,
|
||||
startTime: null,
|
||||
simulatedMatch: () => undefined,
|
||||
currentStandings: () => [],
|
||||
liveStandings: mockLiveStandings(data),
|
||||
participantTournamentTeamIds: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
} as unknown as BracketType;
|
||||
}
|
||||
|
||||
/** Mirrors what the real implementation returns for a bracket where no match has finished yet: every participant, in seed order, with blank stats. */
|
||||
function mockLiveStandings(data: BracketData) {
|
||||
return data.group.flatMap((group) => {
|
||||
const teamIds = new Set<number>();
|
||||
for (const match of data.match) {
|
||||
if (match.groupId !== group.id) continue;
|
||||
|
||||
if (match.opponent1?.id) teamIds.add(match.opponent1.id);
|
||||
if (match.opponent2?.id) teamIds.add(match.opponent2.id);
|
||||
}
|
||||
|
||||
return Array.from(teamIds)
|
||||
.map((id) => mockTournament.ctx.teams.find((team) => team.id === id)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.seed - b.seed)
|
||||
.map((team, i) => ({
|
||||
team,
|
||||
placement: i + 1,
|
||||
groupId: group.id,
|
||||
stats: {
|
||||
setWins: 0,
|
||||
setLosses: 0,
|
||||
mapWins: 0,
|
||||
mapLosses: 0,
|
||||
koCount: 0,
|
||||
winsAgainstTied: 0,
|
||||
lossesAgainstTied: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithRouter(element: React.ReactNode) {
|
||||
const router = createMemoryRouter([{ path: "/", element }], {
|
||||
initialEntries: ["/"],
|
||||
|
||||
@@ -26,56 +26,7 @@ export function PlacementsTable({
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
const _standings = bracket
|
||||
.currentStandings(true)
|
||||
.filter((s) => s.groupId === groupId);
|
||||
|
||||
const missingTeams = bracket.data.match.reduce((acc, cur) => {
|
||||
if (cur.groupId !== groupId) return acc;
|
||||
|
||||
if (
|
||||
cur.opponent1?.id &&
|
||||
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
|
||||
!acc.includes(cur.opponent1.id)
|
||||
) {
|
||||
acc.push(cur.opponent1.id);
|
||||
}
|
||||
|
||||
if (
|
||||
cur.opponent2?.id &&
|
||||
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
|
||||
!acc.includes(cur.opponent2.id)
|
||||
) {
|
||||
acc.push(cur.opponent2.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as number[]);
|
||||
|
||||
const standings = _standings
|
||||
.concat(
|
||||
missingTeams.map((id) => ({
|
||||
team: bracket.tournament.teamById(id)!,
|
||||
stats: {
|
||||
mapLosses: 0,
|
||||
mapWins: 0,
|
||||
koCount: 0,
|
||||
setLosses: 0,
|
||||
setWins: 0,
|
||||
winsAgainstTied: 0,
|
||||
lossesAgainstTied: 0,
|
||||
},
|
||||
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
|
||||
groupId,
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.placement === b.placement && a.team.seed && b.team.seed) {
|
||||
return a.team.seed - b.team.seed;
|
||||
}
|
||||
|
||||
return a.placement - b.placement;
|
||||
});
|
||||
const standings = bracket.liveStandings.filter((s) => s.groupId === groupId);
|
||||
|
||||
const destinationBracket = (standing: Standing, placement: number) => {
|
||||
if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) {
|
||||
|
||||
@@ -21,8 +21,7 @@ describe("swiss standings - losses against tied", () => {
|
||||
|
||||
const standing = tournament
|
||||
.bracketByIdx(0)
|
||||
?.currentStandings(false)
|
||||
.find((standing) => standing.team.id === TEAM_THIS_IS_FINE_ID);
|
||||
?.standings.find((standing) => standing.team.id === TEAM_THIS_IS_FINE_ID);
|
||||
|
||||
invariant(standing, "Standing not found");
|
||||
|
||||
@@ -35,7 +34,7 @@ describe("swiss standings - losses against tied", () => {
|
||||
simulateBrackets: false,
|
||||
});
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
|
||||
const standings = tournament.bracketByIdx(0)!.standings;
|
||||
|
||||
// Both teams finished 4-2 in the same Swiss group. Team 16872 beat MORE of
|
||||
// its tied peers (winsAgainstTied=2) than team 17505 (winsAgainstTied=1),
|
||||
@@ -63,7 +62,7 @@ describe("swiss standings - losses against tied", () => {
|
||||
simulateBrackets: false,
|
||||
});
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
|
||||
const standings = tournament.bracketByIdx(0)!.standings;
|
||||
|
||||
// Both teams finished 4-2 in the same Swiss group. Team 16996 lost to none
|
||||
// of its tied peers while team 17067 lost to one, even though 17067 has the
|
||||
@@ -90,8 +89,7 @@ describe("swiss standings - losses against tied", () => {
|
||||
|
||||
const standing = tournament
|
||||
.bracketByIdx(0)
|
||||
?.currentStandings(false)
|
||||
.find((standing) => standing.team.id === TEAM_ERROR_404_ID);
|
||||
?.standings.find((standing) => standing.team.id === TEAM_ERROR_404_ID);
|
||||
invariant(standing, "Standing not found");
|
||||
|
||||
expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round
|
||||
@@ -134,7 +132,7 @@ describe("swiss standings - losses against tied", () => {
|
||||
it("should handle a team with only one bye", () => {
|
||||
const tournament = inProgressSwissTestTournament();
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
const standings = tournament.bracketByIdx(0)!.liveStandings;
|
||||
|
||||
const teamWithBye = standings.find((standing) => standing.team.id === 3);
|
||||
|
||||
@@ -146,14 +144,15 @@ describe("swiss standings - losses against tied", () => {
|
||||
expect(teamWithBye?.stats?.setLosses).toBe(0);
|
||||
});
|
||||
|
||||
it("team with only unfinished matches should not be in the current standings", () => {
|
||||
it("team with only unfinished matches should be in the current standings with blank stats", () => {
|
||||
const tournament = inProgressSwissTestTournament();
|
||||
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
const standings = tournament.bracketByIdx(0)!.liveStandings;
|
||||
|
||||
const playingTeam = standings.find((standing) => standing.team.id === 1);
|
||||
|
||||
expect(playingTeam).toBe(undefined);
|
||||
expect(playingTeam?.stats?.setWins).toBe(0);
|
||||
expect(playingTeam?.stats?.setLosses).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,7 +314,7 @@ describe("round robin standings - dropped out teams", () => {
|
||||
it("should not credit wins against a team that dropped out before completing all of their matches", () => {
|
||||
// Team 4 dropped out before playing their match against team 3.
|
||||
const tournament = droppedOutTournament({ skipMatchups: ["3-4"] });
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
const standings = tournament.bracketByIdx(0)!.liveStandings;
|
||||
|
||||
const team1Standing = standings.find((s) => s.team.id === 1);
|
||||
const team2Standing = standings.find((s) => s.team.id === 2);
|
||||
@@ -356,7 +355,7 @@ describe("round robin standings - dropped out teams", () => {
|
||||
// 4 should still be excluded from tiebreakers — same intent as the
|
||||
// skipMatchups variant above, but matching the real production shape.
|
||||
const tournament = droppedOutTournament({ forfeitMatchups: ["3-4"] });
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
const standings = tournament.bracketByIdx(0)!.liveStandings;
|
||||
|
||||
const team1Standing = standings.find((s) => s.team.id === 1);
|
||||
const team2Standing = standings.find((s) => s.team.id === 2);
|
||||
@@ -461,7 +460,7 @@ describe("round robin A/B divisions standings", () => {
|
||||
|
||||
it("filtering by abDivision preserves standard tiebreaker order within each division", () => {
|
||||
const tournament = abDivisionsTournament();
|
||||
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
|
||||
const standings = tournament.bracketByIdx(0)!.liveStandings;
|
||||
|
||||
expect(standings.map((s) => s.team.id)).toEqual([1, 2, 3, 4]);
|
||||
|
||||
|
||||
@@ -234,6 +234,10 @@ export abstract class Bracket {
|
||||
|
||||
abstract get type(): Tables["TournamentStage"]["type"];
|
||||
|
||||
/**
|
||||
* Standings that are settled i.e. teams still playing are left out. Safe to
|
||||
* use for deciding who advances to another bracket.
|
||||
*/
|
||||
abstract get standings(): Standing[];
|
||||
|
||||
/**
|
||||
@@ -253,7 +257,11 @@ export abstract class Bracket {
|
||||
) as number[];
|
||||
}
|
||||
|
||||
currentStandings(_includeUnfinishedGroups: boolean) {
|
||||
/**
|
||||
* Standings including teams that are still playing. Meant for displaying the
|
||||
* bracket's current state, not for deciding who advances.
|
||||
*/
|
||||
get liveStandings(): Standing[] {
|
||||
return this.standings;
|
||||
}
|
||||
|
||||
@@ -358,6 +366,14 @@ export abstract class Bracket {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the standings of this bracket are final i.e. no further match can
|
||||
* change them. While false the standings are provisional.
|
||||
*/
|
||||
get standingsAreFinal() {
|
||||
return this.everyMatchOver;
|
||||
}
|
||||
|
||||
get everyMatchOver() {
|
||||
if (this.preview) return false;
|
||||
|
||||
|
||||
@@ -87,10 +87,18 @@ export class RoundRobinBracket extends Bracket {
|
||||
}
|
||||
|
||||
get standings(): Standing[] {
|
||||
return this.currentStandings();
|
||||
return this.computeStandings({ includeUnfinishedGroups: false });
|
||||
}
|
||||
|
||||
currentStandings(includeUnfinishedGroups = false) {
|
||||
get liveStandings(): Standing[] {
|
||||
return this.computeStandings({ includeUnfinishedGroups: true });
|
||||
}
|
||||
|
||||
private computeStandings({
|
||||
includeUnfinishedGroups,
|
||||
}: {
|
||||
includeUnfinishedGroups: boolean;
|
||||
}): Standing[] {
|
||||
const groupIds = this.data.group.map((group) => group.id);
|
||||
|
||||
const placements: (Standing & { groupId: number })[] = [];
|
||||
@@ -139,18 +147,18 @@ export class RoundRobinBracket extends Bracket {
|
||||
|
||||
const updateTeam = ({
|
||||
teamId,
|
||||
setWins,
|
||||
setLosses,
|
||||
mapWins,
|
||||
mapLosses,
|
||||
koCount,
|
||||
setWins = 0,
|
||||
setLosses = 0,
|
||||
mapWins = 0,
|
||||
mapLosses = 0,
|
||||
koCount = 0,
|
||||
}: {
|
||||
teamId: number;
|
||||
setWins: number;
|
||||
setLosses: number;
|
||||
mapWins: number;
|
||||
mapLosses: number;
|
||||
koCount: number;
|
||||
setWins?: number;
|
||||
setLosses?: number;
|
||||
mapWins?: number;
|
||||
mapLosses?: number;
|
||||
koCount?: number;
|
||||
}) => {
|
||||
const team = teams.find((team) => team.id === teamId);
|
||||
if (team) {
|
||||
@@ -173,18 +181,23 @@ export class RoundRobinBracket extends Bracket {
|
||||
};
|
||||
|
||||
for (const match of matches) {
|
||||
const opp1Id = match.opponent1?.id;
|
||||
const opp2Id = match.opponent2?.id;
|
||||
const opponentIds = [opp1Id, opp2Id].filter(
|
||||
(id) => typeof id === "number",
|
||||
);
|
||||
|
||||
if (!match.winnerSide) {
|
||||
// teams yet to finish a match still belong in the standings, dropped out ones are seeded further below
|
||||
for (const teamId of opponentIds) {
|
||||
if (droppedOutWithIncompleteMatches.has(teamId)) continue;
|
||||
|
||||
updateTeam({ teamId });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const opp1Id = match.opponent1?.id;
|
||||
const opp2Id = match.opponent2?.id;
|
||||
if (
|
||||
(typeof opp1Id === "number" &&
|
||||
droppedOutWithIncompleteMatches.has(opp1Id)) ||
|
||||
(typeof opp2Id === "number" &&
|
||||
droppedOutWithIncompleteMatches.has(opp2Id))
|
||||
) {
|
||||
if (opponentIds.some((id) => droppedOutWithIncompleteMatches.has(id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,22 +31,7 @@ export class SwissBracket extends Bracket {
|
||||
}
|
||||
const standings = this.standings;
|
||||
|
||||
const relevantMatchesFinished = this.data.round.every((round) => {
|
||||
const roundsMatches = this.data.match.filter(
|
||||
(match) => match.roundId === round.id,
|
||||
);
|
||||
|
||||
// some round has not started yet
|
||||
if (roundsMatches.length === 0) return false;
|
||||
|
||||
return roundsMatches.every((match) => {
|
||||
if (match.opponent1 && match.opponent2 && !match.winnerSide) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const relevantMatchesFinished = this.standingsAreFinal;
|
||||
|
||||
if (advanceThreshold) {
|
||||
return {
|
||||
@@ -86,11 +71,28 @@ export class SwissBracket extends Bracket {
|
||||
};
|
||||
}
|
||||
|
||||
get standings(): Standing[] {
|
||||
return this.currentStandings();
|
||||
/** Swiss rounds are paired one at a time, so a round that has no matches yet can still change the standings. */
|
||||
get standingsAreFinal() {
|
||||
const everyRoundPaired = this.data.round.every((round) =>
|
||||
this.data.match.some((match) => match.roundId === round.id),
|
||||
);
|
||||
|
||||
return everyRoundPaired && this.everyMatchOver;
|
||||
}
|
||||
|
||||
currentStandings(includeUnfinishedGroups = false) {
|
||||
get standings(): Standing[] {
|
||||
return this.computeStandings({ includeUnfinishedGroups: false });
|
||||
}
|
||||
|
||||
get liveStandings(): Standing[] {
|
||||
return this.computeStandings({ includeUnfinishedGroups: true });
|
||||
}
|
||||
|
||||
private computeStandings({
|
||||
includeUnfinishedGroups,
|
||||
}: {
|
||||
includeUnfinishedGroups: boolean;
|
||||
}): Standing[] {
|
||||
const groupIds = this.data.group.map((group) => group.id);
|
||||
|
||||
const placements: (Standing & { groupId: number })[] = [];
|
||||
@@ -183,6 +185,13 @@ export class SwissBracket extends Bracket {
|
||||
}
|
||||
|
||||
if (!match.winnerSide) {
|
||||
// teams yet to finish a match still belong in the standings
|
||||
if (match.opponent1?.id) {
|
||||
updateTeam({ teamId: match.opponent1.id });
|
||||
}
|
||||
if (match.opponent2?.id) {
|
||||
updateTeam({ teamId: match.opponent2.id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -225,7 +234,7 @@ export class SwissBracket extends Bracket {
|
||||
const winner = match.opponent1 ? match.opponent1 : match.opponent2;
|
||||
|
||||
if (!winner?.id) {
|
||||
logger.warn("SwissBracket.currentStandings: winner not found");
|
||||
logger.warn("SwissBracket.computeStandings: winner not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -266,7 +275,7 @@ export class SwissBracket extends Bracket {
|
||||
for (const teamId of teamsWhoPlayedAgainst) {
|
||||
const opponent = teams.find((t) => t.id === teamId);
|
||||
if (!opponent) {
|
||||
logger.warn("SwissBracket.currentStandings: opponent not found", {
|
||||
logger.warn("SwissBracket.computeStandings: opponent not found", {
|
||||
teamId,
|
||||
});
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user