claude/llws-double-elimination-bracket-oaj093 #138
6 changed files with 231 additions and 22 deletions
|
|
@ -196,16 +196,50 @@ export interface ConsolationRound {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function findConsolationRound(
|
||||
template: BracketTemplate | 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;
|
||||
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.
|
||||
*
|
||||
|
|
@ -228,7 +262,9 @@ export function computeRankedEntries(
|
|||
rounds: string[],
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
consolation: ConsolationRound | undefined,
|
||||
ownershipMap: Map<string, TeamOwnership>
|
||||
ownershipMap: Map<string, TeamOwnership>,
|
||||
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||
losersPlacedLater: Set<string> = new Set()
|
||||
): EliminatedEntry[] {
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
|
|
@ -300,6 +336,14 @@ export function computeRankedEntries(
|
|||
// consumes none of its own.
|
||||
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 1–4
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +415,8 @@ export function PlayoffBracket({
|
|||
rounds,
|
||||
matchesByRound,
|
||||
consolation,
|
||||
ownershipMap
|
||||
ownershipMap,
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
|
||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import {
|
|||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
findConsolationRound,
|
||||
roundsWithLosersPlacedLater,
|
||||
type Match,
|
||||
} from "../PlayoffBracket";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/models/playoff-match";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -426,6 +428,129 @@ function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
|||
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 5–6 and two 7–8 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", () => {
|
||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||
|
|
@ -441,6 +566,17 @@ describe("findConsolationRound", () => {
|
|||
it("returns undefined when there is no template", () => {
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -1120,18 +1120,19 @@ export const LLWS_20: BracketTemplate = {
|
|||
},
|
||||
],
|
||||
// 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: [
|
||||
"U.S. Opening 1 — Home", "U.S. Opening 1 — Away",
|
||||
"U.S. Opening 2 — Home", "U.S. Opening 2 — Away",
|
||||
"U.S. Opening 3 — Home", "U.S. Opening 3 — Away",
|
||||
"U.S. Opening 4 — Home", "U.S. Opening 4 — Away",
|
||||
"U.S. Bye — Winners R2 G1", "U.S. Bye — Winners R2 G2",
|
||||
"Intl Opening 1 — Home", "Intl Opening 1 — Away",
|
||||
"Intl Opening 2 — Home", "Intl Opening 2 — Away",
|
||||
"Intl Opening 3 — Home", "Intl Opening 3 — Away",
|
||||
"Intl Opening 4 — Home", "Intl Opening 4 — Away",
|
||||
"Intl Bye — Winners R2 G1", "Intl Bye — Winners R2 G2",
|
||||
"US G1 Home", "US G1 Away",
|
||||
"US G2 Home", "US G2 Away",
|
||||
"US G3 Home", "US G3 Away",
|
||||
"US G4 Home", "US G4 Away",
|
||||
"US Bye 1", "US Bye 2",
|
||||
"Intl G1 Home", "Intl G1 Away",
|
||||
"Intl G2 Home", "Intl G2 Away",
|
||||
"Intl G3 Home", "Intl G3 Away",
|
||||
"Intl G4 Home", "Intl G4 Away",
|
||||
"Intl Bye 1", "Intl Bye 2",
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import {
|
|||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match";
|
||||
import {
|
||||
doesLoserAdvance,
|
||||
generateBracketFromTemplate,
|
||||
resolveLLWSAdvancement,
|
||||
} from "../playoff-match";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
calculateAveragedPoints,
|
||||
|
|
@ -489,6 +493,30 @@ describe("LLWS 20 Bracket Template", () => {
|
|||
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", () => {
|
||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
||||
|
|
|
|||
|
|
@ -305,11 +305,10 @@ export async function processPlayoffEvent(
|
|||
}
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||
// provisional floor only when this round guarantees them one — see
|
||||
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||
for (const match of matches) {
|
||||
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.
|
||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||
// 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(
|
||||
round,
|
||||
event.bracketTemplateId,
|
||||
|
|
|
|||
|
|
@ -889,7 +889,7 @@ export default function EventBracket({
|
|||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<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}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue