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.
|
* 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 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;
|
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));
|
||||||
|
|
|
||||||
|
|
@ -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 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", () => {
|
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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 T5–T8 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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue