diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 88d1ef665..ea800c980 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -171,6 +171,150 @@ describe("round robin standings", () => { }); }); +describe("round robin standings - dropped out teams", () => { + const droppedOutTournament = ({ + skipMatchups = [], + forfeitMatchups = [], + }: { + skipMatchups?: string[]; + forfeitMatchups?: string[]; + } = {}) => { + const storage = new InMemoryDatabase(); + const manager = new BracketsManager(storage); + + manager.create({ + name: "RR", + tournamentId: 1, + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { + groupCount: 1, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + const setResult = ( + matchId: number, + winnerId: number, + winnerScore: number, + loserScore: number, + ) => { + const match = storage.select("match", matchId); + invariant(match, `match ${matchId} not found`); + const winnerIsOpp1 = match.opponent1.id === winnerId; + manager.update.match({ + id: match.id, + opponent1: winnerIsOpp1 + ? { score: winnerScore, result: "win" } + : { score: loserScore }, + opponent2: winnerIsOpp1 + ? { score: loserScore } + : { score: winnerScore, result: "win" }, + }); + }; + + // Mimics endDroppedTeamMatches: sets a winner via result only, with no + // score recorded on either side (the match was never actually played). + const forfeitMatch = (matchId: number, winnerId: number) => { + const match = storage.select("match", matchId); + invariant(match, `match ${matchId} not found`); + manager.update.match({ + id: match.id, + opponent1: { + result: match.opponent1.id === winnerId ? "win" : "loss", + }, + opponent2: { + result: match.opponent2.id === winnerId ? "win" : "loss", + }, + }); + }; + + // Team 1 beat everyone, team 2 beat 3 and 4, team 3 beat 4. + const winnerByMatchup: Record = { + "1-2": 1, + "1-3": 1, + "1-4": 1, + "2-3": 2, + "2-4": 2, + "3-4": 3, + }; + for (const match of storage.select("match")!) { + const a = match.opponent1.id as number; + const b = match.opponent2.id as number; + const key = a < b ? `${a}-${b}` : `${b}-${a}`; + if (skipMatchups.includes(key)) continue; + const winnerId = winnerByMatchup[key]; + invariant(winnerId, `unexpected matchup ${key}`); + if (forfeitMatchups.includes(key)) { + forfeitMatch(match.id, winnerId); + } else { + setResult(match.id, winnerId, 2, 0); + } + } + + const data = manager.get.tournamentData(1); + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "round_robin", + name: "RR", + requiresCheckIn: false, + settings: {}, + }, + ], + }, + teams: [ + tournamentCtxTeam(1, { seed: 1 }), + tournamentCtxTeam(2, { seed: 2 }), + tournamentCtxTeam(3, { seed: 3 }), + tournamentCtxTeam(4, { seed: 4, droppedOut: 1 }), + ], + }, + data, + }); + }; + + 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 team1Standing = standings.find((s) => s.team.id === 1); + const team2Standing = standings.find((s) => s.team.id === 2); + const team3Standing = standings.find((s) => s.team.id === 3); + + expect(team1Standing?.stats?.setWins).toBe(2); + expect(team1Standing?.stats?.setLosses).toBe(0); + + expect(team2Standing?.stats?.setWins).toBe(1); + expect(team2Standing?.stats?.setLosses).toBe(1); + + expect(team3Standing?.stats?.setWins).toBe(0); + expect(team3Standing?.stats?.setLosses).toBe(2); + }); + + it("should still count matches against a team that dropped out only after all of their matches were reported", () => { + const tournament = droppedOutTournament(); + const standings = tournament.bracketByIdx(0)!.standings; + + const team1Standing = standings.find((s) => s.team.id === 1); + const team2Standing = standings.find((s) => s.team.id === 2); + const team3Standing = standings.find((s) => s.team.id === 3); + + expect(team1Standing?.stats?.setWins).toBe(3); + expect(team1Standing?.stats?.setLosses).toBe(0); + + expect(team2Standing?.stats?.setWins).toBe(2); + expect(team2Standing?.stats?.setLosses).toBe(1); + + expect(team3Standing?.stats?.setWins).toBe(1); + expect(team3Standing?.stats?.setLosses).toBe(2); + }); +}); + describe("round robin A/B divisions standings", () => { const abDivisionsTournament = () => { const storage = new InMemoryDatabase(); diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index 41535ce5d..44232ff27 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -95,6 +95,23 @@ export class RoundRobinBracket extends Bracket { if (!groupIsFinished && !includeUnfinishedGroups) continue; + const droppedOutWithIncompleteMatches = new Set(); + 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; @@ -153,6 +170,17 @@ export class RoundRobinBracket extends Bracket { 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;