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
10 changed files with 1429 additions and 234 deletions
Showing only changes of commit 1bd23a4419 - Show all commits

View file

@ -152,7 +152,22 @@ export function TabbedBracketLayout({
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds; const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []])); // Restrict each round to the match numbers this phase's groups actually claim.
// Rounds can be shared across phases (LLWS runs U.S. and International through
// the same rounds), so without this the mobile view would merge both sides into
// one column. No-op where a phase's groups already cover every match in the
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
// group filter.
const phaseMatchesByRound = new Map(
phaseRounds.map((r) => {
const all = matchesByRound.get(r) ?? [];
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
const allowed = new Set(
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
);
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
})
);
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])); const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);

View file

@ -19,6 +19,18 @@ export interface BracketRound {
* When set, the loser of each match in this round is placed into the target round. * When set, the loser of each match in this round is placed into the target round.
*/ */
loserFeedsInto?: string | null; loserFeedsInto?: string | null;
/**
* Floor position banked by the WINNER of a *non-scoring* round.
*
* Omit for the default behavior: winners entering the first scoring round bank a
* T5T8 floor (position 5), everyone else banks nothing. Set an explicit number when
* that default is wrong in a double-elimination losers bracket a win can guarantee
* a worse finish than 5th (llws_20 "Elimination Round 3" 7). Set null to bank no
* floor even though the next round scores.
*
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
*/
nonScoringWinnerFloor?: number | null;
} }
export interface GroupStageConfig { export interface GroupStageConfig {
@ -934,6 +946,257 @@ export const NBA_20: BracketTemplate = {
], ],
}; };
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
/** Side-local match numbers → global match numbers, per round shape. */
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M14, Intl M58
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M12, Intl M34
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
"Winners Round 2",
"Elimination Round 1",
"Winners Semifinals",
"Elimination Round 2",
"Elimination Round 3",
]);
/**
* Returns the global match number for a side-local match in an LLWS round.
* side 0 = United States, side 1 = International.
*/
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
return localMatch + side * offset;
}
/**
* Inverse of llwsMatchNumber: global match number { side, localMatch }.
*/
export function llwsSideAndLocal(
round: string,
matchNumber: number
): { side: 0 | 1; localMatch: number } {
const offset =
round === "Opening Round"
? LLWS_OPENING_OFFSET
: LLWS_FOUR_MATCH_ROUNDS.has(round)
? LLWS_PAIR_OFFSET
: LLWS_SOLO_OFFSET;
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
return { side, localMatch: matchNumber - side * offset };
}
/**
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
*
* Two independent 10-team double-elimination brackets United States and
* International each producing a side champion, then a World Championship game and
* a Consolation Third Place game between the two side runners-up.
*
* Rounds are shared across both sides: U.S. matches take the low match numbers and
* International the high ones (see llwsMatchNumber). The phases/groups config splits
* them back apart for display.
*
* A loss in the winners bracket is NOT an elimination it drops the team into the
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
* A loss in the elimination bracket is final.
*
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
* it loses the Bracket Championship, dropping to the Consolation game rather than
* forcing a rematch. This is the official LLWS modified double-elimination format.
*
* Placement tiers (only 8 teams score the field is exactly 8 when Elim R4 begins):
* 1st / 2nd World Championship
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
* 5th / 6th Elimination Final losers
* 7th / 8th Elimination Round 4 losers
* 0 pts the 12 teams eliminated in Elimination Rounds 13
*
* Participant array layout (20 slots):
* [07] U.S. Opening Round teams, two per game (M1..M4)
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
* [1017] International Opening Round teams, two per game (M5..M8)
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
*/
export const LLWS_20: BracketTemplate = {
id: "llws_20",
name: "Little League World Series (20 teams)",
totalTeams: 20,
scoringStartsAtRound: "Winners Final",
// Ordered by the real schedule so non-phased views read chronologically.
rounds: [
{
name: "Opening Round",
matchCount: 8,
feedsInto: "Winners Round 2",
isScoring: false,
loserFeedsInto: "Elimination Round 1",
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
},
{
name: "Winners Round 2",
matchCount: 4,
feedsInto: "Winners Semifinals",
isScoring: false,
loserFeedsInto: "Elimination Round 2",
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 1",
matchCount: 4,
feedsInto: "Elimination Round 2",
isScoring: false, // losers finish 13th16th
nonScoringWinnerFloor: null,
},
{
name: "Winners Semifinals",
matchCount: 4,
feedsInto: "Winners Final",
isScoring: false,
loserFeedsInto: "Elimination Round 3",
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
// Elimination Final). Same value as the engine default, stated explicitly.
nonScoringWinnerFloor: 5,
},
{
name: "Elimination Round 2",
matchCount: 4,
feedsInto: "Elimination Round 3",
isScoring: false, // losers finish 11th12th
nonScoringWinnerFloor: null,
},
{
name: "Elimination Round 3",
matchCount: 4,
feedsInto: "Elimination Round 4",
isScoring: false, // losers finish 9th10th
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
nonScoringWinnerFloor: 7,
},
{
name: "Winners Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
loserFeedsInto: "Elimination Final",
},
{
name: "Elimination Round 4",
matchCount: 2,
feedsInto: "Elimination Final",
isScoring: true, // losers share 7th8th
},
{
name: "Elimination Final",
matchCount: 2,
feedsInto: "Bracket Championship",
isScoring: true, // losers share 5th6th
},
{
name: "Bracket Championship",
matchCount: 2,
feedsInto: "World Championship",
isScoring: true, // loser drops to the Consolation game (provisional 4th)
loserFeedsInto: "Consolation Third Place",
},
{
name: "Consolation Third Place",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 3rd, loser 4th
},
{
name: "World Championship",
matchCount: 1,
feedsInto: null,
isScoring: true, // winner 1st, loser 2nd
},
],
// Region assignments rotate year to year (which region draws the bye changes), so
// these are positional slot labels rather than region names.
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",
],
phases: [
{
name: "United States",
groups: [
{
name: "U.S. Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [1, 2, 3, 4],
"Winners Round 2": [1, 2],
"Winners Semifinals": [1, 2],
"Winners Final": [1],
},
},
{
name: "U.S. Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [1, 2],
"Elimination Round 2": [1, 2],
"Elimination Round 3": [1, 2],
"Elimination Round 4": [1],
"Elimination Final": [1],
},
},
{
name: "U.S. Championship",
roundMatchNumbers: { "Bracket Championship": [1] },
},
],
},
{
name: "International",
groups: [
{
name: "International Winner's Bracket",
roundMatchNumbers: {
"Opening Round": [5, 6, 7, 8],
"Winners Round 2": [3, 4],
"Winners Semifinals": [3, 4],
"Winners Final": [2],
},
},
{
name: "International Elimination Bracket",
roundMatchNumbers: {
"Elimination Round 1": [3, 4],
"Elimination Round 2": [3, 4],
"Elimination Round 3": [3, 4],
"Elimination Round 4": [2],
"Elimination Final": [2],
},
},
{
name: "International Championship",
roundMatchNumbers: { "Bracket Championship": [2] },
},
],
},
{
name: "Championship",
rounds: ["Consolation Third Place", "World Championship"],
},
],
};
/** /**
* All available bracket templates * All available bracket templates
*/ */
@ -951,6 +1214,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
tennis_128: TENNIS_128, tennis_128: TENNIS_128,
cfp_12: CFP_12, cfp_12: CFP_12,
nba_20: NBA_20, nba_20: NBA_20,
llws_20: LLWS_20,
}; };
/** /**
@ -1006,6 +1270,18 @@ export function getScoringRoundType(
const round = template.rounds.find((r) => r.name === roundName); const round = template.rounds.find((r) => r.name === roundName);
if (!round || !round.isScoring) return null; if (!round || !round.isScoring) return null;
// Special handling for LLWS double elimination: match counts don't identify the
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
// the Winners Final eliminates nobody.
if (template.id === "llws_20") {
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
if (roundName === "World Championship") return "finals"; // 1st and 2nd
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
}
// Special handling for AFL finals // Special handling for AFL finals
if (template.id === "afl_10") { if (template.id === "afl_10") {
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th

View file

@ -0,0 +1,561 @@
/**
* LLWS 20-Team Double-Elimination Bracket Tests
*
* Verifies the llws_20 template against the official 2026 LLBWS bracket
* (Williamsport, Aug 1930). The PDF numbers its games 138; those numbers appear
* throughout as `G<n>` so the routing can be checked against the printed bracket.
*
* The critical property under test is the double-elimination loser routing: a loss in
* the winners bracket drops a team into the elimination bracket at a specific slot,
* while a loss in the elimination bracket is final.
*/
import { describe, it, expect, vi } from "vitest";
import {
LLWS_20,
getScoringRoundType,
llwsMatchNumber,
llwsSideAndLocal,
} from "~/lib/bracket-templates";
import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match";
import {
calculateBracketPoints,
calculateAveragedPoints,
type ScoringRules,
} from "../scoring-rules";
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
// minimal stub is enough to capture the generated rows.
const insertedRows: Record<string, unknown>[] = [];
vi.mock("~/database/context", () => ({
database: () => ({
insert: () => ({
values: (rows: Record<string, unknown>[]) => ({
returning: async () => {
insertedRows.push(...rows);
return rows;
},
}),
}),
}),
}));
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 20,
pointsFor7th: 15,
pointsFor8th: 10,
};
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
//
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
// numbers in each round, International the high ones.
const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
// Opening Round — U.S. G2,4,6,8 (M14); Intl G1,3,5,7 (M58)
2: { round: "Opening Round", matchNumber: 1 },
4: { round: "Opening Round", matchNumber: 2 },
6: { round: "Opening Round", matchNumber: 3 },
8: { round: "Opening Round", matchNumber: 4 },
1: { round: "Opening Round", matchNumber: 5 },
3: { round: "Opening Round", matchNumber: 6 },
5: { round: "Opening Round", matchNumber: 7 },
7: { round: "Opening Round", matchNumber: 8 },
// Winners Round 2 — U.S. G10,12; Intl G9,11
10: { round: "Winners Round 2", matchNumber: 1 },
12: { round: "Winners Round 2", matchNumber: 2 },
9: { round: "Winners Round 2", matchNumber: 3 },
11: { round: "Winners Round 2", matchNumber: 4 },
// Elimination Round 1 — U.S. G14,16; Intl G13,15
14: { round: "Elimination Round 1", matchNumber: 1 },
16: { round: "Elimination Round 1", matchNumber: 2 },
13: { round: "Elimination Round 1", matchNumber: 3 },
15: { round: "Elimination Round 1", matchNumber: 4 },
// Winners Semifinals — U.S. G17,19; Intl G18,20
17: { round: "Winners Semifinals", matchNumber: 1 },
19: { round: "Winners Semifinals", matchNumber: 2 },
18: { round: "Winners Semifinals", matchNumber: 3 },
20: { round: "Winners Semifinals", matchNumber: 4 },
// Elimination Round 2 — U.S. G22,24; Intl G21,23
22: { round: "Elimination Round 2", matchNumber: 1 },
24: { round: "Elimination Round 2", matchNumber: 2 },
21: { round: "Elimination Round 2", matchNumber: 3 },
23: { round: "Elimination Round 2", matchNumber: 4 },
// Elimination Round 3 — U.S. G26,28; Intl G25,27
26: { round: "Elimination Round 3", matchNumber: 1 },
28: { round: "Elimination Round 3", matchNumber: 2 },
25: { round: "Elimination Round 3", matchNumber: 3 },
27: { round: "Elimination Round 3", matchNumber: 4 },
// Winners Final — U.S. G30; Intl G29
30: { round: "Winners Final", matchNumber: 1 },
29: { round: "Winners Final", matchNumber: 2 },
// Elimination Round 4 — U.S. G32; Intl G31
32: { round: "Elimination Round 4", matchNumber: 1 },
31: { round: "Elimination Round 4", matchNumber: 2 },
// Elimination Final — U.S. G34; Intl G33
34: { round: "Elimination Final", matchNumber: 1 },
33: { round: "Elimination Final", matchNumber: 2 },
// Bracket Championship — U.S. G36; Intl G35
36: { round: "Bracket Championship", matchNumber: 1 },
35: { round: "Bracket Championship", matchNumber: 2 },
// Finals
37: { round: "Consolation Third Place", matchNumber: 1 },
38: { round: "World Championship", matchNumber: 1 },
};
const MATCH_TO_GAME = new Map<string, number>(
Object.entries(GAME_TO_MATCH).map(([game, m]) => [
`${m.round}#${m.matchNumber}`,
Number(game),
])
);
function gameNumberFor(round: string, matchNumber: number): number {
const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
return game;
}
/** Narrows a destination that the test expects to exist. */
function required<T>(destination: T | null): T {
if (destination === null) throw new Error("Expected a destination, got null");
return destination;
}
/** PDF game number a destination points at. */
function destinationGame(
destination: { round: string; matchNumber: number } | null
): number {
const d = required(destination);
return gameNumberFor(d.round, d.matchNumber);
}
/**
* The official bracket printed as feed labels: for each game, which prior game's
* winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
*
* Transcribed from the PDF. This is the source of truth the routing must reproduce.
*/
const EXPECTED_SLOTS: Record<number, [string | null, string | null]> = {
// Opening Round — all directly seeded
1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
// Winners Round 2 — bye team, then an Opening Round winner
9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
// Elimination Round 1
13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
// Winners Semifinals
17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
// Elimination Round 2
21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
// Elimination Round 3 — cross-over
25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
// Winners Final
29: ["W18", "W20"], 30: ["W17", "W19"],
// Elimination Round 4
31: ["W27", "W25"], 32: ["W28", "W26"],
// Elimination Final
33: ["L29", "W31"], 34: ["L30", "W32"],
// Bracket Championship
35: ["W29", "W33"], 36: ["W30", "W34"],
// Finals
37: ["L36", "L35"], 38: ["W36", "W35"],
};
describe("LLWS 20 Bracket Template", () => {
describe("Template structure", () => {
it("has correct identity and size", () => {
expect(LLWS_20.id).toBe("llws_20");
expect(LLWS_20.totalTeams).toBe(20);
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
});
it("has 12 rounds totalling 38 matches", () => {
expect(LLWS_20.rounds).toHaveLength(12);
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
expect(total).toBe(38);
});
it("has the expected match count per round", () => {
const counts = Object.fromEntries(
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
);
expect(counts).toEqual({
"Opening Round": 8,
"Winners Round 2": 4,
"Elimination Round 1": 4,
"Winners Semifinals": 4,
"Elimination Round 2": 4,
"Elimination Round 3": 4,
"Winners Final": 2,
"Elimination Round 4": 2,
"Elimination Final": 2,
"Bracket Championship": 2,
"Consolation Third Place": 1,
"World Championship": 1,
});
});
it("marks exactly the point-awarding rounds as scoring", () => {
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
expect(scoring).toEqual([
"Winners Final",
"Elimination Round 4",
"Elimination Final",
"Bracket Championship",
"Consolation Third Place",
"World Championship",
]);
});
it("lists rounds in chronological order", () => {
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
const names = LLWS_20.rounds.map((r) => r.name);
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
names.indexOf("Winners Semifinals")
);
expect(names.indexOf("Winners Final")).toBeLessThan(
names.indexOf("Elimination Final")
);
});
it("gives elimination-bracket winners a floor matching their real worst case", () => {
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 78 tier),
// so the engine's default floor of 5 would overstate it.
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
// Reaching the Winners Final guarantees 5th at worst.
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
// Nothing is guaranteed earlier than that.
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
});
it("has 20 participant labels", () => {
expect(LLWS_20.participantLabels).toHaveLength(20);
});
it("splits display into U.S., International and Championship phases", () => {
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
"United States",
"International",
"Championship",
]);
});
it("assigns every match to exactly one phase group", () => {
const claimed = new Map<string, number>();
for (const phase of LLWS_20.phases ?? []) {
for (const group of phase.groups ?? []) {
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
for (const n of numbers) {
const key = `${round}#${n}`;
claimed.set(key, (claimed.get(key) ?? 0) + 1);
}
}
}
}
// Every per-side match claimed exactly once (36 games; the 2 finals live in
// the Championship phase's plain round list, not in a group).
expect(claimed.size).toBe(36);
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
});
});
describe("Bracket generation", () => {
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
async function generate() {
insertedRows.length = 0;
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
return insertedRows.map((r) => ({
round: r.round as string,
matchNumber: r.matchNumber as number,
participant1Id: (r.participant1Id ?? null) as string | null,
participant2Id: (r.participant2Id ?? null) as string | null,
isScoring: r.isScoring as boolean,
}));
}
it("creates all 38 matches", async () => {
const rows = await generate();
expect(rows).toHaveLength(38);
});
it("creates the right number of matches per round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
expect(
rows.filter((r) => r.round === round.name),
`${round.name} match count`
).toHaveLength(round.matchCount);
}
});
it("numbers matches 1..n within each round", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
const numbers = rows
.filter((r) => r.round === round.name)
.map((r) => r.matchNumber)
.toSorted((a, b) => a - b);
expect(numbers).toEqual(
Array.from({ length: round.matchCount }, (_, i) => i + 1)
);
}
});
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
const rows = await generate();
const opening = rows
.filter((r) => r.round === "Opening Round")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
// U.S. slots 07 fill matches 14; International slots 1017 fill matches 58.
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-0", "team-1"],
["team-2", "team-3"],
["team-4", "team-5"],
["team-6", "team-7"],
["team-10", "team-11"],
["team-12", "team-13"],
["team-14", "team-15"],
["team-16", "team-17"],
]);
});
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
const rows = await generate();
const wr2 = rows
.filter((r) => r.round === "Winners Round 2")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
["team-8", null],
["team-9", null],
["team-18", null],
["team-19", null],
]);
});
it("uses each participant exactly once and leaves every other slot empty", async () => {
const rows = await generate();
const seeded = rows
.flatMap((r) => [r.participant1Id, r.participant2Id])
.filter((id): id is string => id !== null);
expect(seeded).toHaveLength(20);
expect(new Set(seeded).size).toBe(20);
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
});
it("stamps isScoring from the template", async () => {
const rows = await generate();
for (const round of LLWS_20.rounds) {
for (const row of rows.filter((r) => r.round === round.name)) {
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
}
}
});
it("rejects a participant count other than 20", async () => {
await expect(
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
).rejects.toThrow(/requires 20 participants/);
});
});
describe("Side / match-number mapping", () => {
it("round-trips every match number through side-local form", () => {
for (const round of LLWS_20.rounds) {
if (round.matchCount === 1) continue; // shared finals have no side
for (let n = 1; n <= round.matchCount; n++) {
const { side, localMatch } = llwsSideAndLocal(round.name, n);
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
}
}
});
it("puts U.S. matches in the low half and International in the high half", () => {
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
});
});
describe("Advancement matches the official bracket", () => {
/**
* Replay the whole tournament through resolveLLWSAdvancement and record which
* feed label ends up in each slot, then compare against the printed bracket.
*/
const actualSlots: Record<number, [string | null, string | null]> = {};
for (const game of Object.keys(EXPECTED_SLOTS)) {
actualSlots[Number(game)] = [null, null];
}
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
const game = Number(gameStr);
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
for (const [dest, label] of [
[winner, `W${game}`],
[loser, `L${game}`],
] as const) {
if (!dest) continue;
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
actualSlots[targetGame][slotIndex] = label;
}
}
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
"Game %i has the printed participants",
(game) => {
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
}
);
it("fills every slot in the bracket exactly once", () => {
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
// leaving 56 to be filled by advancement.
const filled = Object.values(actualSlots)
.flat()
.filter((s) => s !== null).length;
expect(filled).toBe(56);
});
});
describe("Double-elimination loser routing", () => {
it("routes every winners-bracket loser into the elimination bracket", () => {
const winnersRounds = [
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
"Winners Final",
];
for (const roundName of winnersRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
expect(loser?.round.startsWith("Elimination")).toBe(true);
}
}
});
it("eliminates every elimination-bracket loser", () => {
const elimRounds = [
"Elimination Round 1",
"Elimination Round 2",
"Elimination Round 3",
"Elimination Round 4",
"Elimination Final",
];
for (const roundName of elimRounds) {
const round = LLWS_20.rounds.find((r) => r.name === roundName);
if (!round) throw new Error(`missing round ${roundName}`);
for (let n = 1; n <= round.matchCount; n++) {
const { loser } = resolveLLWSAdvancement(roundName, n);
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
}
}
});
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
// double-elimination behavior: a first loss never eliminates.
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
expect(destinationGame(loser)).toBe(34);
expect(destinationGame(winner)).toBe(36);
});
it("sends the side-championship loser to the consolation game, not out", () => {
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
// done in the bracket, but still plays G37 for 3rd/4th.
const us = resolveLLWSAdvancement("Bracket Championship", 1);
expect(destinationGame(us.winner)).toBe(38);
expect(destinationGame(us.loser)).toBe(37);
expect(required(us.winner).slot).toBe("participant1Id");
expect(required(us.loser).slot).toBe("participant1Id");
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
expect(required(intl.winner).slot).toBe("participant2Id");
expect(required(intl.loser).slot).toBe("participant2Id");
});
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 });
}
});
it("never crosses a team between the U.S. and International sides", () => {
for (const round of LLWS_20.rounds) {
if (round.name === "Bracket Championship") continue; // the crossover point
if (round.matchCount === 1) continue;
for (let n = 1; n <= round.matchCount; n++) {
const { side } = llwsSideAndLocal(round.name, n);
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
for (const dest of [winner, loser]) {
if (!dest) continue;
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
if (!destRound || destRound.matchCount === 1) continue;
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
}
}
}
});
});
describe("Placement tiers", () => {
it("classifies scoring rounds correctly", () => {
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
// Nobody is eliminated in the Winners Final — the loser drops to the
// elimination bracket — so it has no placement tier.
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
});
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
});
it("splits 58 into two two-team tiers", () => {
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
// Surviving Elimination Round 4 is worth more than losing it.
expect(upper).toBeGreaterThan(lower);
});
it("awards nothing below 8th", () => {
// The 12 teams knocked out in Elimination Rounds 13 finish 9th20th.
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
});
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
// Elimination Round 4 is the 7th8th tier, so the field must be 8 at that point:
// per side the Winners Final winner, the Winners Final loser, and the two
// Elimination Round 3 winners.
const eliminatedBeforeElimR4 =
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
expect(eliminatedBeforeElimR4).toBe(12);
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
});
});
});

View file

@ -6,6 +6,8 @@ import {
getBracketTemplate, getBracketTemplate,
buildNCAA68SlotMap, buildNCAA68SlotMap,
matchIndexForSeedSlot, matchIndexForSeedSlot,
llwsMatchNumber,
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING, STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates"; } from "~/lib/bracket-templates";
@ -467,6 +469,11 @@ export async function generateBracketFromTemplate(
return await generateNBA20Bracket(eventId, template, participantIds); return await generateNBA20Bracket(eventId, template, participantIds);
} }
// LLWS 20 requires special handling for its two double-elimination brackets
if (templateId === "llws_20") {
return await generateLLWS20Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = []; const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template // Generate matches for each round in the template
@ -980,6 +987,15 @@ export async function advanceWinnerTemplate(
return await advanceNBAPlayInWinner(match, winnerId, loserId); return await advanceNBAPlayInWinner(match, winnerId, loserId);
} }
// Special handling for LLWS 20 double elimination: winners-bracket losers route
// into the elimination bracket instead of being knocked out.
if (template.id === "llws_20") {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
return await advanceLLWSWinner(match, winnerId, loserId);
}
// Special handling for AFL 10 double-chance system // Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules // Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") { if (template.id === "afl_10") {
@ -1427,6 +1443,12 @@ export function doesLoserAdvance(
if (templateId === "afl_10" && round === "Qualifying Finals") { if (templateId === "afl_10" && round === "Qualifying Finals") {
return true; return true;
} }
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
// must not be recorded as an elimination. (Winners Final and Bracket Championship
// are scoring rounds and are handled via loserIsPartial instead.)
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
return true;
}
return false; return false;
} }
@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner(
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`); throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
} }
} }
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
/**
* Where one participant goes after an LLWS match: a round, a side-local match number,
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
*/
interface LLWSDestination {
round: string;
localMatch: number;
slot: "participant1Id" | "participant2Id";
}
/**
* LLWS advancement map, in SIDE-LOCAL match numbers.
*
* Keyed by round, then by the local match number of the completed game. Each entry
* says where the winner goes and where the loser goes (null = eliminated).
*
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
* cross-overs the elimination bracket does NOT feed straight across:
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
*
* A loss in the winners bracket routes into the elimination bracket rather than
* eliminating the team; a loss in the elimination bracket is final.
*/
const LLWS_ADVANCEMENT: Record<
string,
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
> = {
"Opening Round": {
1: {
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
},
3: {
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
},
4: {
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
},
},
"Winners Round 2": {
1: {
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
},
},
"Winners Semifinals": {
1: {
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
},
2: {
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
},
},
"Winners Final": {
1: {
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
// A winners-bracket final loss is not an elimination — it drops to the
// Elimination Final for a second chance at the side championship.
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
},
},
"Elimination Round 1": {
1: {
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
loser: null,
},
},
"Elimination Round 2": {
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
1: {
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
"Elimination Round 3": {
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
1: {
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
loser: null,
},
2: {
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
loser: null,
},
},
"Elimination Round 4": {
1: {
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
"Elimination Final": {
1: {
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
loser: null,
},
},
};
/** Rounds whose losers drop into the elimination bracket instead of going out. */
const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
"Opening Round",
"Winners Round 2",
"Winners Semifinals",
]);
/** A resolved LLWS destination, in global (not side-local) match numbers. */
export interface LLWSResolvedDestination {
round: string;
matchNumber: number;
slot: "participant1Id" | "participant2Id";
}
/**
* Resolve where the winner and loser of a completed LLWS match go, in global match
* numbers. `null` means that participant has no further game (eliminated, or the
* tournament is over for them).
*
* Pure no DB access so the whole 38-game routing can be verified against the
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
*/
export function resolveLLWSAdvancement(
round: string,
matchNumber: number
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
// Terminal rounds — nobody advances.
if (round === "Consolation Third Place" || round === "World Championship") {
return { winner: null, loser: null };
}
// Bracket Championship is the crossover: the winner goes to the World Championship
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
// participant1, International participant2), so the two sides can't collide.
if (round === "Bracket Championship") {
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
const slot: "participant1Id" | "participant2Id" =
side === 0 ? "participant1Id" : "participant2Id";
return {
winner: { round: "World Championship", matchNumber: 1, slot },
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
};
}
const roundMap = LLWS_ADVANCEMENT[round];
if (!roundMap) {
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
}
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
const routes = roundMap[localMatch];
if (!routes) {
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
}
// Winner and loser stay on their own side, so the same side offset applies to both.
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
d === null
? null
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
}
/**
* Generate the 20-team LLWS double-elimination bracket (38 matches).
*
* Only the Opening Round and the four bye slots receive participants up front;
* everything else is filled by advanceLLWSWinner as games complete.
*
* Participant array layout (see LLWS_20 in lib/bracket-templates):
* [07] U.S. Opening Round teams, two per game
* [8, 9] U.S. bye teams Winners Round 2 M1 / M2 participant1
* [1017] International Opening Round teams, two per game
* [18,19] International bye teams Winners Round 2 M3 / M4 participant1
*/
async function generateLLWS20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
const sides = [
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
];
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
for (const { side, label, openingBase } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
scoringEventId: eventId,
round: "Opening Round",
matchNumber: llwsMatchNumber("Opening Round", side, local),
participant1Id: p(openingBase + (local - 1) * 2),
participant2Id: p(openingBase + (local - 1) * 2 + 1),
isComplete: false,
isScoring: false,
templateRound: "Opening Round",
seedInfo: `${label} Opening ${local}`,
});
}
}
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
for (const { side, label, byeBase } of sides) {
for (let local = 1; local <= 2; local++) {
matches.push({
scoringEventId: eventId,
round: "Winners Round 2",
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
participant1Id: p(byeBase + (local - 1)),
participant2Id: null, // Opening Round winner
isComplete: false,
isScoring: false,
templateRound: "Winners Round 2",
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
});
}
}
// ── Every remaining round starts empty ──────────────────────────────────────
const remaining = template.rounds.filter(
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
);
for (const round of remaining) {
for (let i = 1; i <= round.matchCount; i++) {
// Championship/Consolation are single shared games; everything else is per-side.
const perSide = round.matchCount > 1;
const label = perSide
? llwsSideAndLocal(round.name, i).side === 0
? "U.S."
: "Intl"
: null;
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: label ? `${label} ${round.name}` : null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
* loser into the elimination bracket rather than eliminating them.
*
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
*/
async function advanceLLWSWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
// Winner and loser can land in different rounds, so resolve each independently.
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
if (winner) moves.push({ destination: winner, participantId: winnerId });
if (loser) moves.push({ destination: loser, participantId: loserId });
for (const { destination, participantId } of moves) {
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
eventId,
destination.round
);
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
if (!target) {
throw new Error(
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
);
}
if (target[destination.slot]) {
throw new Error(
`Next match ${destination.slot} is already filled ` +
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
);
}
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
}
}

View file

@ -113,6 +113,21 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
// 3rd place game finalizes both positions distinctly. // 3rd place game finalizes both positions distinctly.
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, "Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
}, },
llws_20: {
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
// winning that game lifts them back to a 4th-place floor.
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
// Elimination Round 4 losers are the 7th8th tier (8 teams alive at this point).
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
// Elimination Final losers are the 5th6th tier; the winner reaches the side
// championship, where the worst case is 4th (lose it, then lose the consolation).
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
// Side championship loser still has the consolation game — provisional 4th.
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
// Consolation finalizes 3rd and 4th distinctly.
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
},
tennis_128: { tennis_128: {
// R16 losers share 9th16th; winner advances to QF (floor 5th8th). // R16 losers share 9th16th; winner advances to QF (floor 5th8th).
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 }, "Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
@ -126,28 +141,37 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
}; };
/** /**
* Returns true if a non-scoring round's winners are entering the first scoring round * Returns the floor position that winners of a NON-scoring round should bank, or null
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5T8 floor). * to bank nothing.
* *
* For multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32 * Default: winners entering the first scoring round have guaranteed a top-8 fantasy
* Sweet Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring * placement and receive a T5T8 floor (5); everyone else gets nothing yet. For
* bracket Round of 64 and Round of 32 winners should not receive any floor yet. * multi-round pre-bracket sequences like NCAA (Round of 64 Round of 32 Sweet
* Sixteen Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
* *
* Falls back to true when template/round info is unavailable to preserve legacy behavior. * A round may override this with `nonScoringWinnerFloor` when the default is wrong
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
* (llws_20 "Elimination Round 3" 7), or nothing at all.
*
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
*/ */
function doesNonScoringRoundFeedIntoScoringRound( function nonScoringWinnerFloorFor(
round: string, round: string,
bracketTemplateId: string | null | undefined bracketTemplateId: string | null | undefined
): boolean { ): number | null {
if (!bracketTemplateId) return true; // Legacy: preserve old behavior if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
const template = BRACKET_TEMPLATES[bracketTemplateId]; const template = BRACKET_TEMPLATES[bracketTemplateId];
if (!template) return true; // Unknown template: preserve old behavior if (!template) return 5; // Unknown template: preserve old behavior
const currentRound = template.rounds.find((r) => r.name === round); const currentRound = template.rounds.find((r) => r.name === round);
if (!currentRound) return true; // Unknown round: preserve old behavior if (!currentRound) return 5; // Unknown round: preserve old behavior
// Explicit per-round override wins, including an explicit null (bank nothing).
if (currentRound.nonScoringWinnerFloor !== undefined) {
return currentRound.nonScoringWinnerFloor;
}
const nextRoundName = currentRound.feedsInto; const nextRoundName = currentRound.feedsInto;
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring) if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
const nextRound = template.rounds.find((r) => r.name === nextRoundName); const nextRound = template.rounds.find((r) => r.name === nextRoundName);
return nextRound?.isScoring === true; return nextRound?.isScoring === true ? 5 : null;
} }
/** /**
@ -286,14 +310,14 @@ export async function processPlayoffEvent(
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences // 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 // 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. // receive floor points — R64 and R32 winners are not yet guaranteed top-8.
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(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 ?? "");
if (match.loserId && !loserAdvances) { if (match.loserId && !loserAdvances) {
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
} }
if (match.winnerId && awardFloor) { if (match.winnerId && winnerFloor !== null) {
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
} }
} }
} else { } else {
@ -441,8 +465,9 @@ export async function processMatchResult(
if (!loserAdvances) { if (!loserAdvances) {
await upsertParticipantResult(loserId, sportsSeasonId, 0, db); await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
} }
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); if (nonScoringFloor !== null) {
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
} }
// Non-scoring round wins are not surfaced in the Recent Scores feed. // Non-scoring round wins are not surfaced in the Recent Scores feed.
} else { } else {

View file

@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints(
* AFL is different: it has TWO distinct tiers in the 58 zone: * AFL is different: it has TWO distinct tiers in the 58 zone:
* - T5-T6: Semi-Finals losers (positions 5 and 6) avg([5,6]) * - T5-T6: Semi-Finals losers (positions 5 and 6) avg([5,6])
* - T7-T8: Elimination Finals losers (positions 7 and 8) avg([7,8]) * - T7-T8: Elimination Finals losers (positions 7 and 8) avg([7,8])
*
* LLWS has the same shape from its two elimination brackets:
* - T5-T6: Elimination Final losers (one per side) avg([5,6])
* - T7-T8: Elimination Round 4 losers (one per side) avg([7,8])
*/ */
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]); const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
/** /**
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct * Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
* (not averaged). Standard brackets average them because both SF losers tie. * (not averaged). Standard brackets average them because both SF losers tie.
*
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
* the two side runners-up.
*/ */
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]); const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
/** /**
* Calculate fantasy points for a bracket placement, averaging tied positions. * Calculate fantasy points for a bracket placement, averaging tied positions.

View file

@ -126,22 +126,48 @@ describe("LLWSSimulator", () => {
expect(total).toBeCloseTo(1.0, 1); expect(total).toBeCloseTo(1.0, 1);
}); });
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => { it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1"); const results = await new LLWSSimulator(1_000).simulate("season-1");
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0); const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
expect(total).toBeCloseTo(1.0, 1); expect(total).toBeCloseTo(1.0, 1);
}); });
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => { it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
const results = await new LLWSSimulator(2_000).simulate("season-1");
for (const r of results) {
const p = r.probabilities;
// Within a tier the two positions are tied.
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
// The tiers are distinct outcomes (losing the Elimination Final vs losing
// Elimination Round 4), so they must not be forced equal across the field.
const differs = results.some(
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
);
expect(differs).toBe(true);
});
it("gives every team a total placement probability of at most 1", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1"); const results = await new LLWSSimulator(1_000).simulate("season-1");
for (const r of results) { for (const r of results) {
const p = r.probabilities; const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10); // Each sim assigns a team at most one placement, so summing the distinct
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10); // tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10); const total =
p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth * 2 + p.probSeventh * 2;
expect(total).toBeLessThanOrEqual(1 + 1e-9);
} }
}); });
}); });
@ -179,10 +205,13 @@ describe("LLWSSimulator", () => {
}); });
}); });
// ── Pool assignment modes ───────────────────────────────────────────────── // ── Legacy externalId formats ─────────────────────────────────────────────
//
// The tournament no longer has pool play, but seasons configured for the old
// format still carry pool suffixes. Those must keep loading, read as the side alone.
describe("pool assignment modes", () => { describe("legacy pool-suffix externalIds", () => {
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => { it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS)); setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1"); const results = await new LLWSSimulator(1_000).simulate("season-1");
expect(results).toHaveLength(20); expect(results).toHaveLength(20);
@ -190,10 +219,10 @@ describe("LLWSSimulator", () => {
expect(total).toBeCloseTo(1.0, 1); expect(total).toBeCloseTo(1.0, 1);
}); });
it("mixed mode: US fixed pools, Intl randomized", async () => { it("accepts a mix of suffixed and bare side ids", async () => {
const participants = [ const participants = [
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })), ...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })), ...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })), ...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
]; ];
setupMockDb(participants, makeEvRows(ALL_IDS)); setupMockDb(participants, makeEvRows(ALL_IDS));
@ -202,6 +231,17 @@ describe("LLWSSimulator", () => {
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1); expect(total).toBeCloseTo(1.0, 1);
}); });
it("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
const participants = [
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
const results = await new LLWSSimulator(1_000).simulate("season-1");
expect(results).toHaveLength(20);
});
}); });
// ── Error cases ─────────────────────────────────────────────────────────── // ── Error cases ───────────────────────────────────────────────────────────
@ -262,26 +302,13 @@ describe("LLWSSimulator", () => {
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/); await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
}); });
it("throws when fixed pools have unequal A/B split", async () => { it("throws when International team count is not 10", async () => {
const participants = [ const participants = [
// 6 in Pool A, 4 in Pool B ...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })), ...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
]; ];
setupMockDb(participants, makeEvRows(ALL_IDS)); setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/); await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
});
it("throws when US externalIds mix pool suffixes and bare side", async () => {
const participants = [
// Some US:A, some "US" (no pool suffix) → mixed
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
}); });
}); });
}); });

View file

@ -1,7 +1,13 @@
/** /**
* Little League World Series (LLWS) Bracket Simulator * Little League World Series (LLWS) Bracket Simulator
* *
* Monte Carlo simulation of the LLWS (20-team format, 2022present). * Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+).
*
* The tournament is two independent 10-team double-elimination brackets United
* States and International each producing a side champion, then a World
* Championship game and a Consolation game between the side runners-up. There is no
* pool play. This mirrors the llws_20 bracket template so simulated placements line
* up with the bracket admins actually score.
* *
* Algorithm: * Algorithm:
* 1. Load all 20 participants for the sports season from DB * 1. Load all 20 participants for the sports season from DB
@ -10,48 +16,36 @@
* (entered via Admin Futures Odds; American format) * (entered via Admin Futures Odds; American format)
* 3. Convert odds to normalized championship probabilities (vig removed). * 3. Convert odds to normalized championship probabilities (vig removed).
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50. * These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
* 4. Determine pool assignment mode from externalId: * 4. Per simulation:
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B" * a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
* use these exact pool assignments every simulation. * teams + 2 byes). The draw is modelled as random a specific known draw
* - Randomized pools: externalId is "US" or "Intl" only * is not yet expressible in participant config.
* randomly shuffle each side into Pool A / Pool B each simulation. * b. Simulate the 10-team double-elimination bracket for each side
* 5. Per simulation: * (see simulateSideBracket for the exact game-by-game structure)
* a. Assign pools (fixed or random) * c. Consolation game: US side loser vs Intl side loser 3rd / 4th
* b. Simulate pool play round-robin within each pool (10 games/pool) * d. World Championship: US champion vs Intl champion 1st / 2nd
* Top 2 by W-L record advance. Ties broken randomly. * 5. Track placement counts across all simulations.
* c. Simulate 4-team double-elimination bracket per side: * 6. Convert counts to probability distributions.
* G1: A1 vs B2 (WB)
* G2: B1 vs A2 (WB)
* G3: G1W vs G2W (WB Final)
* G4: G1L vs G2L (LB R1 loser eliminated)
* G5: G3L vs G4W (LB Final loser eliminated)
* G6: G3W vs G5W (Side Championship loser eliminated)
* d. Consolation game: US loser vs Intl loser 3rd / 4th
* e. World Series: US champion vs Intl champion 1st / 2nd
* 6. Track placement counts across all simulations.
* 7. Convert counts to probability distributions.
* *
* Pool assignment (externalId format): * Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
* "US:A" / "US:B" / "Intl:A" / "Intl:B" fixed pools (post-draw mode) * ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
* "US" / "Intl" randomized pools (pre-draw mode) * alone, so seasons configured for the old pool-play format keep working pools
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must * no longer exist, so the suffix has no effect.
* have one (throws otherwise). Sides can differ US fixed while Intl randomized.
* *
* Placement tiers SimulationProbabilities mapping: * Placement tiers SimulationProbabilities mapping (matches llws_20's scoring):
* probFirst = World Series Champion (1 per sim) * probFirst = World Championship winner (1 per sim)
* probSecond = World Series Runner-up (1 per sim) * probSecond = World Championship loser (1 per sim)
* probThird = Consolation game winner / 3rd place (1 per sim) * probThird = Consolation winner (1 per sim)
* probFourth = Consolation game loser / 4th place (1 per sim) * probFourth = Consolation loser (1 per sim)
* probFifthprobEighth = Double-elim bracket losers before side championships * probFifth/probSixth = Elimination Final losers (2 per sim 1 per side)
* (4 per sim split evenly: 2 US + 2 Intl) * probSeventh/probEighth = Elimination Round 4 losers (2 per sim 1 per side)
* Pool play losers all 0 (12 teams, did not advance from pool play) * Everyone else all 0 (12 teams out in Elimination Rounds 13)
* *
* Admin setup: * Admin setup:
* 1. Create a Sport with simulatorType = "llws_bracket" * 1. Create a Sport with simulatorType = "llws_bracket"
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International) * 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
* 3. Set externalId on each participant via Admin Manage Participants (optional if names follow the convention): * 3. Set externalId on each participant via Admin Manage Participants to "US" or
* Pre-draw: "US" or "Intl" (or leave null names starting with "US " infer US, all others infer Intl) * "Intl" (optional names starting with "US " infer US, all others infer Intl)
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
* 4. Enter championship futures odds via Admin Futures Odds (sourceOdds) * 4. Enter championship futures odds via Admin Futures Odds (sourceOdds)
* 5. Run simulation via Admin Simulate * 5. Run simulation via Admin Simulate
*/ */
@ -68,7 +62,6 @@ import { positiveConfigNumber } from "./config-access";
const NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
const US_TEAM_COUNT = 10; const US_TEAM_COUNT = 10;
const INTL_TEAM_COUNT = 10; const INTL_TEAM_COUNT = 10;
const POOL_SIZE = 5; // teams per pool within each side
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -77,8 +70,6 @@ type Side = "US" | "Intl";
interface Team { interface Team {
participantId: string; participantId: string;
side: Side; side: Side;
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
fixedPool: "A" | "B" | null;
/** Normalized championship win probability (01, vig removed). */ /** Normalized championship win probability (01, vig removed). */
oddsProb: number; oddsProb: number;
} }
@ -88,11 +79,21 @@ interface PlacementCounts {
finalist: number; finalist: number;
thirdPlace: number; thirdPlace: number;
fourthPlace: number; fourthPlace: number;
bracketLoser: number; /** Lost the Elimination Final — the 5th6th tier (1 per side per sim). */
elimFinalLoser: number;
/** Lost Elimination Round 4 — the 7th8th tier (1 per side per sim). */
elimRound4Loser: number;
} }
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function zeroCounts(): PlacementCounts {
return {
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
elimFinalLoser: 0, elimRound4Loser: 0,
};
}
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } { function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
// If either team has no odds entered, treat the game as a coin flip. // If either team has no odds entered, treat the game as a coin flip.
// The 50/50 fallback must cover the one-sided case (one team known, one not) // The 50/50 fallback must cover the one-sided case (one team known, one not)
@ -118,101 +119,89 @@ function shuffle<T>(arr: T[]): T[] {
} }
/** /**
* Assign teams to Pool A / Pool B for one side. * Simulate one side's 10-team double-elimination bracket.
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
*/
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
if (!randomized) {
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
}
const shuffled = shuffle([...teams]);
return [shuffled.slice(0, 5), shuffled.slice(5)];
}
/**
* Simulate round-robin pool play among 5 teams.
* Returns the top 2 teams by win count (ties broken randomly).
*/
function simulatePoolPlay(pool: Team[]): [Team, Team] {
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
// Each pair plays once.
for (let i = 0; i < pool.length; i++) {
for (let j = i + 1; j < pool.length; j++) {
const { winner } = simGame(pool[i], pool[j]);
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
}
}
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
// the comparator is consistent (Math.random() inside a comparator is a bug — the
// engine may call it multiple times per pair and get contradictory results).
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
const ranked = pool.toSorted((a, b) => {
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
});
return [ranked[0], ranked[1]];
}
/**
* Simulate a 4-team double-elimination bracket for one side.
* *
* Seeds (pool results): * `slots` holds the side's teams in bracket order, matching the llws_20 participant
* poolA1 = Pool A winner, poolA2 = Pool A runner-up * layout: slots[0..7] are the four opening-round games (two teams each) and
* poolB1 = Pool B winner, poolB2 = Pool B runner-up * slots[8], slots[9] are the two bye teams entering Winners Round 2.
* *
* Bracket: * Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
* G1 (WB): A1 vs B2 * Winners bracket
* G2 (WB): B1 vs A2 * OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
* G3 (WB Final): G1W vs G2W * WR2-1 s8 v OP1w WR2-2 s9 v OP2w
* G4 (LB R1): G1L vs G2L loser eliminated (bracketLoser) * WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
* G5 (LB Final): G3L vs G4W loser eliminated (bracketLoser) * WF WSF1w v WSF2w winner to the side championship
* G6 (Side Championship): G3W vs G5W loser eliminated (sideLoser) * Elimination bracket (a loss here is final)
* ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l
* ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w
* ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over)
* ER4 ER3-2w v ER3-1w loser is the 7th8th tier
* EF WFl v ER4w loser is the 5th6th tier
* Side championship: WFw v EFw loser drops to the consolation game
* *
* Returns: { sideChampion, sideLoser } * Note the double-chance path: the Winners Final loser is NOT out, it drops to the
* bracketLosers (2) are bumped into counts directly. * Elimination Final. There is no "if necessary" game, so the side championship is
* decided in one game.
*
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
* bumped into the counts directly.
*/ */
function simulateSideBracket( function simulateSideBracket(
poolA1: Team, slots: Team[],
poolA2: Team,
poolB1: Team,
poolB2: Team,
bump: (id: string, key: keyof PlacementCounts) => void bump: (id: string, key: keyof PlacementCounts) => void
): { sideChampion: Team; sideLoser: Team } { ): { sideChampion: Team; sideLoser: Team } {
// ── Winners bracket ────────────────────────────────────────────────────────
const op1 = simGame(slots[0], slots[1]);
const op2 = simGame(slots[2], slots[3]);
const op3 = simGame(slots[4], slots[5]);
const op4 = simGame(slots[6], slots[7]);
// Winners bracket const wr21 = simGame(slots[8], op1.winner);
const g1 = simGame(poolA1, poolB2); const wr22 = simGame(slots[9], op2.winner);
const g2 = simGame(poolB1, poolA2);
const g3 = simGame(g1.winner, g2.winner); // WB Final
// Losers bracket const wsf1 = simGame(op3.winner, wr21.winner);
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated const wsf2 = simGame(wr22.winner, op4.winner);
bump(g4.loser.participantId, "bracketLoser");
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated const wf = simGame(wsf1.winner, wsf2.winner);
bump(g5.loser.participantId, "bracketLoser");
// Side championship // ── Elimination bracket ────────────────────────────────────────────────────
const g6 = simGame(g3.winner, g5.winner); const er11 = simGame(op2.loser, op3.loser);
const er12 = simGame(op1.loser, op4.loser);
return { sideChampion: g6.winner, sideLoser: g6.loser }; const er21 = simGame(wr21.loser, er11.winner);
const er22 = simGame(wr22.loser, er12.winner);
// Cross-over: each semifinal loser meets the winner from the opposite half.
const er31 = simGame(wsf1.loser, er22.winner);
const er32 = simGame(wsf2.loser, er21.winner);
const er4 = simGame(er32.winner, er31.winner);
bump(er4.loser.participantId, "elimRound4Loser"); // 7th8th tier
// The Winners Final loser gets its second chance here.
const ef = simGame(wf.loser, er4.winner);
bump(ef.loser.participantId, "elimFinalLoser"); // 5th6th tier
// ── Side championship ──────────────────────────────────────────────────────
const sideChampionship = simGame(wf.winner, ef.winner);
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
} }
// ─── Validation helpers ─────────────────────────────────────────────────────── // ─── Validation helpers ───────────────────────────────────────────────────────
type PoolSuffix = "A" | "B" | null; /**
* Parse a participant's externalId into a side.
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null { *
* The legacy pool-play suffixes ("US:A", "Intl:B", ) are still accepted so seasons
* configured before the format change keep loading; the pool part is ignored because
* the tournament no longer has pools.
*/
function parseExternalId(raw: string | null): { side: Side } | null {
if (!raw) return null; if (!raw) return null;
const upper = raw.toUpperCase(); const side = raw.toUpperCase().split(":")[0];
if (upper === "US") return { side: "US", pool: null }; if (side === "US") return { side: "US" };
if (upper === "INTL") return { side: "Intl", pool: null }; if (side === "INTL") return { side: "Intl" };
if (upper === "US:A") return { side: "US", pool: "A" };
if (upper === "US:B") return { side: "US", pool: "B" };
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
return null; return null;
} }
@ -227,36 +216,6 @@ function inferExternalIdFromName(name: string): string {
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl"; return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
} }
/**
* Determine whether pool assignments should be randomized for one side.
* - If ALL teams on the side have a pool suffix fixed pools (returns false).
* - If NO teams have a pool suffix randomized (returns true).
* - Mixed throws.
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
*/
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
if (withPool.length > 0 && withoutPool.length > 0) {
throw new Error(
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
);
}
if (withPool.length === sideTeams.length) {
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
throw new Error(
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
);
}
return false; // fixed pools
}
return true; // randomized
}
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class LLWSSimulator implements Simulator { export class LLWSSimulator implements Simulator {
@ -304,7 +263,7 @@ export class LLWSSimulator implements Simulator {
} }
} }
// 4. Parse externalId for each participant to determine side and fixed pool. // 4. Parse externalId for each participant to determine which side they're on.
const teams: Team[] = []; const teams: Team[] = [];
for (const p of participants) { for (const p of participants) {
const raw = p.externalId ?? inferExternalIdFromName(p.name); const raw = p.externalId ?? inferExternalIdFromName(p.name);
@ -312,13 +271,12 @@ export class LLWSSimulator implements Simulator {
if (!parsed) { if (!parsed) {
throw new Error( throw new Error(
`Participant ${p.id} has invalid externalId "${p.externalId}". ` + `Participant ${p.id} has invalid externalId "${p.externalId}". ` +
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".` `Expected: "US" or "Intl".`
); );
} }
teams.push({ teams.push({
participantId: p.id, participantId: p.id,
side: parsed.side, side: parsed.side,
fixedPool: parsed.pool,
oddsProb: normalizedOddsMap.get(p.id) ?? 0, oddsProb: normalizedOddsMap.get(p.id) ?? 0,
}); });
} }
@ -334,15 +292,9 @@ export class LLWSSimulator implements Simulator {
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`); throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
} }
// Determine pool assignment mode for each side.
const usRandomized = determineRandomized(usTeams, "US");
const intlRandomized = determineRandomized(intlTeams, "International");
// 5. Initialise placement count accumulators for all participants. // 5. Initialise placement count accumulators for all participants.
const allIds = participants.map((p) => p.id); const allIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>( const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
);
const bump = (id: string, key: keyof PlacementCounts) => { const bump = (id: string, key: keyof PlacementCounts) => {
const entry = counts.get(id); const entry = counts.get(id);
if (entry) entry[key]++; if (entry) entry[key]++;
@ -350,42 +302,35 @@ export class LLWSSimulator implements Simulator {
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < numSimulations; s++) { for (let s = 0; s < numSimulations; s++) {
// Assign pools for this simulation. // The draw is modelled as random: shuffle each side into the 10 bracket slots
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized); // (8 opening-round teams, then the 2 bye teams).
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
// Pool play: top 2 from each pool advance.
const [usA1, usA2] = simulatePoolPlay(usPoolA);
const [usB1, usB2] = simulatePoolPlay(usPoolB);
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
// Double-elimination bracket per side.
const { sideChampion: usChamp, sideLoser: usLose } = const { sideChampion: usChamp, sideLoser: usLose } =
simulateSideBracket(usA1, usA2, usB1, usB2, bump); simulateSideBracket(shuffle([...usTeams]), bump);
const { sideChampion: intlChamp, sideLoser: intlLose } = const { sideChampion: intlChamp, sideLoser: intlLose } =
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump); simulateSideBracket(shuffle([...intlTeams]), bump);
// Consolation game: 3rd / 4th place. // Consolation game: 3rd / 4th place.
const consolation = simGame(usLose, intlLose); const consolation = simGame(usLose, intlLose);
bump(consolation.winner.participantId, "thirdPlace"); bump(consolation.winner.participantId, "thirdPlace");
bump(consolation.loser.participantId, "fourthPlace"); bump(consolation.loser.participantId, "fourthPlace");
// World Series: 1st / 2nd place. // World Championship: 1st / 2nd place.
const ws = simGame(usChamp, intlChamp); const ws = simGame(usChamp, intlChamp);
bump(ws.winner.participantId, "champion"); bump(ws.winner.participantId, "champion");
bump(ws.loser.participantId, "finalist"); bump(ws.loser.participantId, "finalist");
} }
// 7. Convert counts to probability distributions. // 7. Convert counts to probability distributions.
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly. // Each of the two 58 tiers takes exactly 2 teams per sim (one per side), and
const bracketLosersPerSim = 4; // the teams within a tier are tied, so the tier probability is split across
const bracketDivisor = bracketLosersPerSim * numSimulations; // its two positions.
const tierDivisor = 2 * numSimulations;
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }; const empty = zeroCounts();
return allIds.map((id) => { return allIds.map((id) => {
const c = counts.get(id) ?? zeroCounts; const c = counts.get(id) ?? empty;
const bracketProb = c.bracketLoser / bracketDivisor; const upperTier = c.elimFinalLoser / tierDivisor; // 5th6th
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th8th
return { return {
participantId: id, participantId: id,
probabilities: { probabilities: {
@ -393,10 +338,10 @@ export class LLWSSimulator implements Simulator {
probSecond: c.finalist / numSimulations, probSecond: c.finalist / numSimulations,
probThird: c.thirdPlace / numSimulations, probThird: c.thirdPlace / numSimulations,
probFourth: c.fourthPlace / numSimulations, probFourth: c.fourthPlace / numSimulations,
probFifth: bracketProb, probFifth: upperTier,
probSixth: bracketProb, probSixth: upperTier,
probSeventh: bracketProb, probSeventh: lowerTier,
probEighth: bracketProb, probEighth: lowerTier,
}, },
source: "llws_monte_carlo", source: "llws_monte_carlo",
}; };

View file

@ -183,7 +183,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"], setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
}, },
llws_bracket: { llws_bracket: {
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 }, defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
requiredInputs: ["sourceOdds"], requiredInputs: ["sourceOdds"],
optionalInputs: ["metadata"], optionalInputs: ["metadata"],
setupSections: ["participants", "futuresOdds"], setupSections: ["participants", "futuresOdds"],

View file

@ -168,7 +168,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
llws_bracket: { llws_bracket: {
info: { info: {
name: "LLWS Bracket Monte Carlo", name: "LLWS Bracket Monte Carlo",
description: "Simulates the 20-team Little League World Series: pool play round-robin (5 teams/pool, top 2 advance) → 4-team double-elimination bracket per side (US & International) → consolation game (3rd/4th) → World Series. Uses championship futures odds for all win probabilities. Set externalId to 'US'/'Intl' (randomized pools) or 'US:A'/'US:B'/'Intl:A'/'Intl:B' (fixed pools).", description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.",
}, },
create: () => new LLWSSimulator(), create: () => new LLWSSimulator(),
}, },