claude/llws-double-elimination-bracket-oaj093 #138

Merged
chrisp merged 2 commits from claude/llws-double-elimination-bracket-oaj093 into main 2026-08-04 04:54:48 +00:00
6 changed files with 231 additions and 22 deletions
Showing only changes of commit 37af2f1fc3 - Show all commits

View file

@ -196,16 +196,50 @@ export interface ConsolationRound {
/** /**
* Find the template's consolation round, if it has one. * Find the template's consolation round, if it has one.
*
* A consolation round must be TERMINAL its winner plays no further game, which is
* what lets its result split two exact positions. `loserFeedsInto` alone is not
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
* round to route losers into the elimination bracket, and those targets are ordinary
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
*
* Exported for unit testing. * Exported for unit testing.
*/ */
export function findConsolationRound( export function findConsolationRound(
template: BracketTemplate | undefined template: BracketTemplate | undefined
): ConsolationRound | undefined { ): ConsolationRound | undefined {
const feeder = template?.rounds.find((r) => r.loserFeedsInto); const isTerminal = (roundName: string) =>
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
const feeder = template?.rounds.find(
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
);
if (!feeder?.loserFeedsInto) return undefined; if (!feeder?.loserFeedsInto) return undefined;
return { round: feeder.loserFeedsInto, feederRound: feeder.name }; return { round: feeder.loserFeedsInto, feederRound: feeder.name };
} }
/**
* Round names whose losers are placed by some LATER round rather than finishing where
* they lost i.e. double-elimination winners-bracket rounds, whose losers drop into
* the elimination bracket.
*
* The consolation feeder is deliberately excluded: its losers do finish at that tier
* (the consolation game splits their two positions), so it still consumes them.
*
* Exported for unit testing.
*/
export function roundsWithLosersPlacedLater(
template: BracketTemplate | undefined,
consolation: ConsolationRound | undefined
): Set<string> {
return new Set(
(template?.rounds ?? [])
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
.map((r) => r.name)
);
}
/** /**
* Build the ordered final-rankings list from completed matches. * Build the ordered final-rankings list from completed matches.
* *
@ -228,7 +262,9 @@ export function computeRankedEntries(
rounds: string[], rounds: string[],
matchesByRound: Map<string, Match[]>, matchesByRound: Map<string, Match[]>,
consolation: ConsolationRound | undefined, consolation: ConsolationRound | undefined,
ownershipMap: Map<string, TeamOwnership> ownershipMap: Map<string, TeamOwnership>,
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
losersPlacedLater: Set<string> = new Set()
): EliminatedEntry[] { ): EliminatedEntry[] {
const eliminatedByRound = computeEliminatedByRound(matches, rounds); const eliminatedByRound = computeEliminatedByRound(matches, rounds);
@ -300,6 +336,14 @@ export function computeRankedEntries(
// consumes none of its own. // consumes none of its own.
if (consolationActive && roundName === consolation?.round) continue; if (consolationActive && roundName === consolation?.round) continue;
// A round normally consumes one position per match — its losers finish here,
// whether or not the games have been played yet (four semifinalists occupy 14
// regardless). But in a double-elimination bracket a winners-bracket loss places
// nobody: the loser drops into the elimination bracket and is ranked by whatever
// knocks them out later. Those rounds must consume nothing, or every position
// below inflates (a 20-team llws_20 bracket would end at "T23").
if (losersPlacedLater.has(roundName)) continue;
nextRank += matchesByRound.get(roundName)?.length ?? 0; nextRank += matchesByRound.get(roundName)?.length ?? 0;
} }
@ -371,7 +415,8 @@ export function PlayoffBracket({
rounds, rounds,
matchesByRound, matchesByRound,
consolation, consolation,
ownershipMap ownershipMap,
roundsWithLosersPlacedLater(template, consolation)
); );
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));

View file

@ -7,9 +7,11 @@ import {
computeEliminatedByRound, computeEliminatedByRound,
computeRankedEntries, computeRankedEntries,
findConsolationRound, findConsolationRound,
roundsWithLosersPlacedLater,
type Match, type Match,
} from "../PlayoffBracket"; } from "../PlayoffBracket";
import { getBracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate } from "~/lib/bracket-templates";
import { resolveLLWSAdvancement } from "~/models/playoff-match";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -426,6 +428,129 @@ function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
return entries.find((e) => e.participant.id === id)?.rankLabel; return entries.find((e) => e.participant.id === id)?.rankLabel;
} }
// ---------------------------------------------------------------------------
// llws_20 — double elimination, where a winners-bracket loss places nobody
// ---------------------------------------------------------------------------
/** Stable participant id for an llws_20 bracket slot. */
function llwsTeam(i: number): string {
return `t${String(i).padStart(2, "0")}`;
}
/**
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
* participant id so the outcome is deterministic, and return every match.
* Routing comes from the real advancement map rather than being hand-listed.
*/
function llwsMatches(): Match[] {
const template = getBracketTemplate("llws_20");
if (!template) throw new Error("llws_20 template missing");
// round → matchNumber → [p1, p2]
const slots = new Map<string, Map<number, [string | null, string | null]>>();
for (const round of template.rounds) {
const byNumber = new Map<number, [string | null, string | null]>();
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
slots.set(round.name, byNumber);
}
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
const pair = slots.get(round)?.get(n);
if (pair) pair[slot] = id;
};
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
for (let local = 0; local < 4; local++) {
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
}
}
put("Winners Round 2", 1, 0, llwsTeam(8));
put("Winners Round 2", 2, 0, llwsTeam(9));
put("Winners Round 2", 3, 0, llwsTeam(18));
put("Winners Round 2", 4, 0, llwsTeam(19));
const matches: Match[] = [];
for (const round of template.rounds) {
for (let n = 1; n <= round.matchCount; n++) {
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
// Deterministic: the lower id always wins.
const winnerId = p1 < p2 ? p1 : p2;
const loserId = p1 < p2 ? p2 : p1;
matches.push(
makeRankedMatch(round.name, n, winnerId, loserId, {
winnerSlot: winnerId === p1 ? 1 : 2,
})
);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
}
}
return matches;
}
describe("computeRankedEntries — llws_20 double elimination", () => {
const template = getBracketTemplate("llws_20");
const rounds = template?.rounds.map((r) => r.name) ?? [];
function rankLlws() {
const matches = llwsMatches();
const consolation = findConsolationRound(template);
return computeRankedEntries(
matches,
rounds,
groupMatchesByRound(matches),
consolation,
new Map(),
roundsWithLosersPlacedLater(template, consolation)
);
}
it("ranks all 19 non-champions exactly once", () => {
const entries = rankLlws();
expect(entries).toHaveLength(19);
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
});
it("gives the top 8 the positions the scoring tiers depend on", () => {
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
// game, then the two 56 and two 78 tier teams.
expect(labels[0]).toBe("T2");
expect(labels.filter((l) => l === "3")).toHaveLength(1);
expect(labels.filter((l) => l === "4")).toHaveLength(1);
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
});
it("does not inflate positions below the top 8", () => {
// Winners-bracket losses place nobody — those teams are ranked by the
// elimination-bracket game that actually knocks them out. If the winners
// rounds consumed positions, the last tier would read T23 in a 20-team field.
const entries = rankLlws();
const labels = entries.map((e) => e.rankLabel);
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
});
it("never ranks a winners-bracket loser at the round they first lost", () => {
const entries = rankLlws();
// t00 wins every game it plays (lowest id), so take a team that loses in the
// winners bracket but survives: the Opening Round M1 loser, t01.
const t01 = entries.find((e) => e.participant.id === "t01");
expect(t01).toBeDefined();
// Losing the opening game must not park them in the bottom tier — they got a
// second life in the elimination bracket.
expect(t01?.rankLabel).not.toBe("T17");
});
});
describe("findConsolationRound", () => { describe("findConsolationRound", () => {
it("identifies the fifa_48 third place game and the round that feeds it", () => { it("identifies the fifa_48 third place game and the round that feeds it", () => {
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({ expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
@ -441,6 +566,17 @@ describe("findConsolationRound", () => {
it("returns undefined when there is no template", () => { it("returns undefined when there is no template", () => {
expect(findConsolationRound(undefined)).toBeUndefined(); expect(findConsolationRound(undefined)).toBeUndefined();
}); });
it("ignores double-elimination loser routing and finds the real consolation game", () => {
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
// into the elimination bracket. Only the Bracket Championship feeds a terminal
// round; taking the first loserFeedsInto instead would mistake "Elimination
// Round 1" for a third-place game and corrupt the final rankings.
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
round: "Consolation Third Place",
feederRound: "Bracket Championship",
});
});
}); });
describe("computeRankedEntries", () => { describe("computeRankedEntries", () => {

View file

@ -1120,18 +1120,19 @@ export const LLWS_20: BracketTemplate = {
}, },
], ],
// Region assignments rotate year to year (which region draws the bye changes), so // Region assignments rotate year to year (which region draws the bye changes), so
// these are positional slot labels rather than region names. // these are positional slot labels rather than region names. Kept short — the admin
// form renders them in a narrow fixed-width column alongside each participant picker.
participantLabels: [ participantLabels: [
"U.S. Opening 1 — Home", "U.S. Opening 1 — Away", "US G1 Home", "US G1 Away",
"U.S. Opening 2 — Home", "U.S. Opening 2 — Away", "US G2 Home", "US G2 Away",
"U.S. Opening 3 — Home", "U.S. Opening 3 — Away", "US G3 Home", "US G3 Away",
"U.S. Opening 4 — Home", "U.S. Opening 4 — Away", "US G4 Home", "US G4 Away",
"U.S. Bye — Winners R2 G1", "U.S. Bye — Winners R2 G2", "US Bye 1", "US Bye 2",
"Intl Opening 1 — Home", "Intl Opening 1 — Away", "Intl G1 Home", "Intl G1 Away",
"Intl Opening 2 — Home", "Intl Opening 2 — Away", "Intl G2 Home", "Intl G2 Away",
"Intl Opening 3 — Home", "Intl Opening 3 — Away", "Intl G3 Home", "Intl G3 Away",
"Intl Opening 4 — Home", "Intl Opening 4 — Away", "Intl G4 Home", "Intl G4 Away",
"Intl Bye — Winners R2 G1", "Intl Bye — Winners R2 G2", "Intl Bye 1", "Intl Bye 2",
], ],
phases: [ phases: [
{ {

View file

@ -17,7 +17,11 @@ import {
llwsMatchNumber, llwsMatchNumber,
llwsSideAndLocal, llwsSideAndLocal,
} from "~/lib/bracket-templates"; } from "~/lib/bracket-templates";
import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match"; import {
doesLoserAdvance,
generateBracketFromTemplate,
resolveLLWSAdvancement,
} from "../playoff-match";
import { import {
calculateBracketPoints, calculateBracketPoints,
calculateAveragedPoints, calculateAveragedPoints,
@ -489,6 +493,30 @@ describe("LLWS 20 Bracket Template", () => {
expect(required(intl.loser).slot).toBe("participant2Id"); expect(required(intl.loser).slot).toBe("participant2Id");
}); });
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
// doesLoserAdvance is what stops the scoring engine writing a 0-point
// elimination (and announcing a knockout) for a team that is still alive.
// Winners Final and Bracket Championship are scoring rounds and are covered
// by loserIsPartial instead, so they are deliberately not listed here.
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
}
for (const round of [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
]) {
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
}
});
it("does not apply LLWS loser routing to other templates", () => {
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
});
it("advances nobody out of the two final games", () => { it("advances nobody out of the two final games", () => {
for (const round of ["Consolation Third Place", "World Championship"]) { for (const round of ["Consolation Third Place", "World Championship"]) {
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null }); expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });

View file

@ -305,11 +305,10 @@ export async function processPlayoffEvent(
} }
if (!isScoring) { if (!isScoring) {
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). // Non-scoring round: losers are permanently eliminated (0 pts) unless they
// Winners only bank a provisional T5T8 floor if they're entering the first // advance (double-elimination winners-bracket losers). Winners bank a
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences // provisional floor only when this round guarantees them one — see
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should // nonScoringWinnerFloorFor for how that is derived per template.
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId); const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
for (const match of matches) { for (const match of matches) {
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
@ -377,7 +376,7 @@ export async function processPlayoffEvent(
// Progressive floor scoring: assign guaranteed minimum points to winners. // Progressive floor scoring: assign guaranteed minimum points to winners.
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
// winner is already finalized as 1st above. For non-scoring rounds it also // winner is already finalized as 1st above. For non-scoring rounds it also
// returns null (winners were given floor 5 inline above). // returns null; those winners were given their floor inline above.
const guaranteedMinimum = getGuaranteedMinimumPosition( const guaranteedMinimum = getGuaranteedMinimumPosition(
round, round,
event.bracketTemplateId, event.bracketTemplateId,

View file

@ -889,7 +889,7 @@ export default function EventBracket({
return ( return (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0"> <Label className="w-28 text-sm text-muted-foreground shrink-0">
{slotLabel} {slotLabel}
</Label> </Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">