diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index ef00fbbfc..2d3f74937 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -820,6 +820,115 @@ describe("single elimination standings - third place match", () => { }); }); +describe("single elimination standings - byes in later rounds", () => { + // Brackets created before the current engine paired the padded seeding + // naturally, so the byes ended up next to each other and could fill both + // sides of a first round match. The current engine spreads byes with + // `space_between`, which makes this impossible to create today, but such + // brackets are still stored (tournament 1252's playoffs is one). A first + // round match that is a bye on both sides leaves the second round match it + // feeds with a single opponent, so that match is won against a bye. The + // semifinal won that way produces no loser, leaving only one team for the + // third place match, which can therefore never be played. + const legacyByeBracketData = (): BracketData => { + const stageId = 0; + const thirdPlaceRoundId = 3; + + const match = ( + id: number, + roundId: number, + number: number, + opponent1: number | null, + opponent2: number | null, + winnerSide: MatchData["winnerSide"], + ): MatchData => ({ + id, + stageId, + groupId: roundId === thirdPlaceRoundId ? 1 : 0, + roundId, + number, + opponent1: opponent1 === null ? null : { id: opponent1 }, + opponent2: opponent2 === null ? null : { id: opponent2 }, + winnerSide, + }); + + return { + stage: [ + { + id: stageId, + type: "single_elimination", + settings: { consolationFinal: true }, + number: 1, + }, + ], + group: [ + { id: 0, stageId, number: 1 }, + { id: 1, stageId, number: 2 }, + ], + round: [ + { id: 0, stageId, groupId: 0, number: 1 }, + { id: 1, stageId, groupId: 0, number: 2 }, + { id: 2, stageId, groupId: 0, number: 3 }, + { id: thirdPlaceRoundId, stageId, groupId: 1, number: 1 }, + ], + match: [ + match(0, 0, 1, 1, 2, "opponent1"), + match(1, 0, 2, 3, 4, "opponent1"), + match(2, 0, 3, 5, 6, "opponent1"), + // six teams in an eight team bracket, both byes landed here + match(3, 0, 4, null, null, null), + match(4, 1, 1, 1, 3, "opponent1"), + // won against a bye + match(5, 1, 2, 5, null, "opponent1"), + match(6, 2, 1, 1, 5, "opponent1"), + // only one semifinal produced a loser + match(7, thirdPlaceRoundId, 1, 3, null, null), + ], + }; + }; + + const legacyByeTournament = () => + testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "SE", + requiresCheckIn: false, + settings: {}, + sources: [], + }, + ], + }, + }, + data: legacyByeBracketData(), + }); + + it("places every team when a match is won against a bye", () => { + const tournament = legacyByeTournament(); + + const standings = tournament.bracketByIdx(0)!.standings; + + expect(standings.map((s) => [s.team.id, s.placement])).toEqual([ + [1, 1], + [5, 2], + [3, 3], + [2, 4], + [4, 4], + [6, 4], + ]); + }); + + it("gives third place to the only semifinal loser when the third place match is a bye", () => { + const tournament = legacyByeTournament(); + + const standings = tournament.bracketByIdx(0)!.standings; + + expect(standings.find((s) => s.team.id === 3)?.placement).toBe(3); + }); +}); + describe("single elimination standings - projected ties", () => { // Two semifinal losers tie for 3rd (no consolation final). Reports only one // semifinal so the other is still in progress, mirroring the projected diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 1fb579169..841abacfc 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -2,6 +2,7 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; import type { BracketData, + MatchData, RoundData, } from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; @@ -86,6 +87,9 @@ export class SingleEliminationBracket extends Bracket { continue; } + // BYE + if (!match.opponent1 || !match.opponent2) continue; + const loser = match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1; invariant(loser?.id, "Loser id not found"); @@ -139,12 +143,7 @@ export class SingleEliminationBracket extends Bracket { const thirdPlaceMatch = this.hasThirdPlaceMatch() ? this.data.match.find((m) => m.groupId !== matches[0].groupId) : undefined; - const thirdPlaceMatchWinner = - thirdPlaceMatch?.winnerSide === "opponent1" - ? thirdPlaceMatch.opponent1 - : thirdPlaceMatch?.winnerSide === "opponent2" - ? thirdPlaceMatch.opponent2 - : undefined; + const thirdPlaceMatchWinner = winnerOfThirdPlaceMatch(thirdPlaceMatch); const resultWithThirdPlaceTiebroken = result .flatMap((standing) => { @@ -220,3 +219,20 @@ export class SingleEliminationBracket extends Bracket { }; } } + +/** + * A third place match with only one opponent is decided by a BYE: the semifinal + * on the other side was itself won against a BYE, so it produced no loser and + * the lone semifinal loser takes third place without playing. + */ +function winnerOfThirdPlaceMatch(match: MatchData | undefined) { + if (!match) return undefined; + + if (match.opponent1 && !match.opponent2) return match.opponent1; + if (!match.opponent1 && match.opponent2) return match.opponent2; + + if (match.winnerSide === "opponent1") return match.opponent1; + if (match.winnerSide === "opponent2") return match.opponent2; + + return undefined; +}