From b94b5fd503a05bb9f0cee0366e002f2ba29cf7fd Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:37:46 +0300 Subject: [PATCH] Break standings ties with underground bracket results, handle UG source not as first bracket in the order --- .../core/Progression.test.ts | 8 + .../tournament-bracket/core/Progression.ts | 28 +++- .../core/tests/test-utils.ts | 42 +++++ .../tournament/core/Standings.test.ts | 156 +++++++++++++++++- app/features/tournament/core/Standings.ts | 85 +++++++++- 5 files changed, 313 insertions(+), 6 deletions(-) diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 2f93d1d3a..e10a1e687 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -1392,6 +1392,14 @@ describe("bracketIdxsForStandings", () => { ), ).toEqual([0]); // missing 1 because it's underground when SE is the source }); + + it("does not treat a bracket as intermediate just because an underground bracket sources from it", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.swissToTwoSingleEliminationsWithUnderground, + ), + ).toEqual([1, 2, 0]); // missing 3 because it's underground + }); }); describe("startingBrackets", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 2cde3fdc9..e5817e662 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -931,11 +931,18 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) { const bracketsToConsider = bracketsReachableFrom(0, progression); const withoutIntermediateBrackets = bracketsToConsider.filter( - (bracket, bracketIdx) => { + (bracketIdx) => { if (bracketIdx === 0) return true; + // underground brackets don't make their source bracket an intermediate one + const undergrounds = new Set( + undergroundBracketIdxs(bracketIdx, progression), + ); + return progression.every( - (b) => !b.sources?.some((s) => s.bracketIdx === bracket), + (b, idx) => + undergrounds.has(idx) || + !b.sources?.some((s) => s.bracketIdx === bracketIdx), ); }, ); @@ -1033,6 +1040,23 @@ export function destinationsFromBracketIdx( return destinations; } +/** + * Returns the indexes of the underground brackets sourced from the given bracket. + * An underground bracket is one that takes teams eliminated from its source bracket (negative placements). + */ +export function undergroundBracketIdxs( + bracketIdx: number, + progression: ParsedBracket[], +): number[] { + return destinationsFromBracketIdx(bracketIdx, progression).filter((idx) => + progression[idx].sources?.some( + (source) => + source.bracketIdx === bracketIdx && + source.placements.some((placement) => placement < 0), + ), + ); +} + export function destinationByPlacement({ sourceBracketIdx, placement, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index a28ddbb7c..cfe53ed5c 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -336,4 +336,46 @@ export const progressions = { ], }, ], + swissToTwoSingleEliminationsWithUnderground: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "swiss", + settings: { + groupCount: 1, + }, + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Alpha", + sources: [ + { + bracketIdx: 0, + placements: [1, 2, 3, 4, 5, 6, 7, 8], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Beta", + sources: [ + { + bracketIdx: 0, + placements: [9, 10, 11, 12, 13, 14, 15, 16], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Alpha UG", + sources: [ + { + bracketIdx: 1, + placements: [-1], + }, + ], + }, + ], } satisfies Record; diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 7c19766e6..a13eee60f 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -80,6 +80,77 @@ describe("tournamentStandings", () => { expect(a.standings.map((s) => s.placement)).toEqual([1, 2]); expect(b.standings.map((s) => s.placement)).toEqual([1, 2]); }); + + it("breaks ties of a bracket with the results of its underground bracket", () => { + const tournament = singleEliminationWithUndergroundTournament(); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + // teams 5-8 all lost the quarterfinals so they are tied in the main bracket, + // the underground bracket (won by 8, then 7, 6, 5) decides their order + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 7, 6, 5, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + }); + + it("keeps teams that skipped the underground bracket tied below those who played it", () => { + const tournament = singleEliminationWithUndergroundTournament({ + undergroundSeeding: [7, 8], + }); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 7, 5, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 7, + ]); + }); + + it("does not break ties with an underground bracket that is still in progress", () => { + // only the semi-finals of the underground bracket have been played so the two teams + // still alive there have no placement yet + const tournament = singleEliminationWithUndergroundTournament({ + undergroundConsolationFinal: false, + undergroundMatchesPlayed: 2, + }); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + // teams 5-8 keep the order & tied placement they have in the main bracket, + // the teams eliminated from the underground bracket are not sorted above those still in it + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 5, 7, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 5, 5, 5, + ]); + }); + + it("does not break ties with an underground bracket that was never started", () => { + // an underground bracket set in the progression can be skipped altogether + const tournament = singleEliminationWithUndergroundTournament({ + undergroundStarted: false, + }); + expect(tournament.bracketByIdx(1)?.preview).toBe(true); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 5, 7, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 5, 5, 5, + ]); + }); }); describe("reNumberPlacements", () => { @@ -216,6 +287,68 @@ function singleEliminationTournament() { }); } +function singleEliminationWithUndergroundTournament({ + undergroundSeeding = [5, 6, 7, 8], + undergroundConsolationFinal = undergroundSeeding.length > 2, + undergroundMatchesPlayed, + undergroundStarted = true, +}: { + undergroundSeeding?: number[]; + undergroundConsolationFinal?: boolean; + undergroundMatchesPlayed?: number; + undergroundStarted?: boolean; +} = {}) { + const mainBracket = playOut( + createResolved({ + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { consolationFinal: true }, + }), + (one, two) => one < two, + ); + + const data = undergroundStarted + ? mergeStages( + mainBracket, + playOut( + createResolved({ + type: "single_elimination", + seeding: undergroundSeeding, + settings: { consolationFinal: undergroundConsolationFinal }, + }), + (one, two) => one > two, + undergroundMatchesPlayed, + ), + ) + : mainBracket; + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "Main Bracket", + requiresCheckIn: false, + settings: { thirdPlaceMatch: true }, + }, + { + type: "single_elimination", + name: "Underground", + requiresCheckIn: false, + settings: { thirdPlaceMatch: true }, + sources: [{ bracketIdx: 0, placements: [-1] }], + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) => + tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }), + ), + }, + data, + }); +} + function abDivisionsTournament() { let data = createResolved({ type: "round_robin", @@ -273,9 +406,22 @@ function abDivisionsTournament() { /** Plays every match of the bracket data, the lower team id always winning. */ function playOutLowerIdWins(data: BracketData) { - let played = data; + return playOut(data, (one, two) => one < two); +} - while (true) { +/** + * Plays every match of the bracket data, `opponent1Wins` deciding each match by team id. + * `maxMatches` can be given to leave the bracket in progress. + */ +function playOut( + data: BracketData, + opponent1Wins: (opponent1Id: number, opponent2Id: number) => boolean, + maxMatches = Number.POSITIVE_INFINITY, +) { + let played = data; + let playedCount = 0; + + while (playedCount < maxMatches) { const pending = played.match.find( (match) => typeof match.opponent1?.id === "number" && @@ -284,12 +430,16 @@ function playOutLowerIdWins(data: BracketData) { ); if (!pending) break; - const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; + const winnerIsOpp1 = opponent1Wins( + pending.opponent1!.id as number, + pending.opponent2!.id as number, + ); played = Engine.reportResult(played, { matchId: pending.id, scores: [winnerIsOpp1 ? 2 : 0, winnerIsOpp1 ? 0 : 2], winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", }).data; + playedCount++; } return played; diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index 44155254a..9207ae067 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -257,7 +257,11 @@ function tournamentStandingsForBracket( const standings = standingsToMergeable({ alreadyIncludedTeamIds, - standings: bracket.standings, + standings: tiebrokenByUndergroundBrackets({ + tournament, + bracketIdx: idx, + standings: bracket.standings, + }), teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size, }); result.push(...standings); @@ -273,6 +277,85 @@ function tournamentStandingsForBracket( return result; } +/** + * Underground brackets are left out of the standings but the teams playing them are tied in their source + * bracket (e.g. everyone who lost the quarterfinals shares the same placement), so their underground run + * decides the order within each such tie. Teams that skipped the underground bracket stay tied last. + * + * An underground bracket that is still in progress is ignored, as the teams still alive in it have no + * placement yet and would sort below the teams it already eliminated. + */ +function tiebrokenByUndergroundBrackets({ + tournament, + bracketIdx, + standings, +}: { + tournament: Tournament; + bracketIdx: number; + standings: Standing[]; +}): Standing[] { + const undergroundPlacements = new Map(); + + for (const undergroundIdx of Progression.undergroundBracketIdxs( + bracketIdx, + tournament.ctx.settings.bracketProgression, + )) { + const underground = tournament.bracketByIdx(undergroundIdx); + if (!underground?.everyMatchOver) continue; + + for (const standing of underground.standings) { + if (undergroundPlacements.has(standing.team.id)) continue; + + undergroundPlacements.set(standing.team.id, standing.placement); + } + } + + if (undergroundPlacements.size === 0) return standings; + + const result: Standing[] = []; + + for (const tied of groupedByPlacement(standings)) { + const sorted = R.sortBy( + tied, + (standing) => + undergroundPlacements.get(standing.team.id) ?? Number.POSITIVE_INFINITY, + ); + + let placement = tied[0].placement; + let previousUndergroundPlacement: number | null = null; + + for (const [index, standing] of sorted.entries()) { + const undergroundPlacement = + undergroundPlacements.get(standing.team.id) ?? null; + + if (index > 0 && undergroundPlacement !== previousUndergroundPlacement) { + placement = tied[0].placement + index; + } + previousUndergroundPlacement = undergroundPlacement; + + result.push({ ...standing, placement }); + } + } + + return result; +} + +function groupedByPlacement(standings: Standing[]): Standing[][] { + const result: Standing[][] = []; + + for (const standing of standings) { + const previous = result.at(-1); + + if (previous && previous[0].placement === standing.placement) { + previous.push(standing); + } else { + result.push([standing]); + } + } + + return result; +} + function standingsToMergeable< T extends { team: { id: number }; placement: number }, >({