Break standings ties with underground bracket results, handle UG source not as first bracket in the order

This commit is contained in:
Kalle 2026-07-27 14:37:46 +03:00
parent 2c9cc7502a
commit b94b5fd503
5 changed files with 313 additions and 6 deletions

View File

@ -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", () => {

View File

@ -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,

View File

@ -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<string, Progression.ParsedBracket[]>;

View File

@ -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;

View File

@ -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<number, number>();
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 },
>({