diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts index 27ab6a913..2a0d3a189 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts @@ -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, })), }; diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index b3a0faae0..7c1a58bee 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -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; }; }>; } diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index a2f690313..d27ebfc24 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -88,7 +88,7 @@ export default function BracketTestLayout() { requiresCheckIn: false, startTime: null, simulatedMatch: () => undefined, - currentStandings: () => [], + liveStandings: [], participantTournamentTeamIds: teamIds, everyMatchOver: false, isUnderground: false, diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index e4218f916..27e0fec4b 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -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(); + 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: ["/"], diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index d547d2bd2..3671dab79 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -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) { diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index f4f8b6bd8..3097b0ed3 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -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]); diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 0967ef346..299aa5fe9 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -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; diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index 892e75f9f..634266c13 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -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; } diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index 0d2d25083..3ac2d26ed 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -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;