diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx index 95b377b..5697465 100644 --- a/app/components/scoring/TabbedBracketLayout.tsx +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -152,7 +152,22 @@ export function TabbedBracketLayout({ const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); 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 phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 94b3892..1922d88 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -19,6 +19,18 @@ export interface BracketRound { * When set, the loser of each match in this round is placed into the target round. */ 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 + * T5–T8 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 { @@ -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 M1–4, Intl M5–8 +const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4 +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 1–3 + * + * Participant array layout (20 slots): + * [0–7] U.S. Opening Round teams, two per game (M1..M4) + * [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1 + * [10–17] 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 13th–16th + 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 11th–12th + nonScoringWinnerFloor: null, + }, + { + name: "Elimination Round 3", + matchCount: 4, + feedsInto: "Elimination Round 4", + isScoring: false, // losers finish 9th–10th + // 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 7th–8th + }, + { + name: "Elimination Final", + matchCount: 2, + feedsInto: "Bracket Championship", + isScoring: true, // losers share 5th–6th + }, + { + 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 */ @@ -951,6 +1214,7 @@ export const BRACKET_TEMPLATES: Record = { tennis_128: TENNIS_128, cfp_12: CFP_12, nba_20: NBA_20, + llws_20: LLWS_20, }; /** @@ -1006,6 +1270,18 @@ export function getScoringRoundType( const round = template.rounds.find((r) => r.name === roundName); 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 if (template.id === "afl_10") { if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts new file mode 100644 index 0000000..929b99a --- /dev/null +++ b/app/models/__tests__/llws-20-bracket.test.ts @@ -0,0 +1,561 @@ +/** + * LLWS 20-Team Double-Elimination Bracket Tests + * + * Verifies the llws_20 template against the official 2026 LLBWS bracket + * (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear + * throughout as `G` 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[] = []; +vi.mock("~/database/context", () => ({ + database: () => ({ + insert: () => ({ + values: (rows: Record[]) => ({ + 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 = { + // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8) + 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( + 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(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 = { + // 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 7–8 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(); + 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 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8. + 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 = {}; + 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 5–8 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 1–3 finish 9th–20th. + 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 7th–8th 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); + }); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 18deb60..101daad 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -6,6 +6,8 @@ import { getBracketTemplate, buildNCAA68SlotMap, matchIndexForSeedSlot, + llwsMatchNumber, + llwsSideAndLocal, STANDARD_BRACKET_SEEDING, } from "~/lib/bracket-templates"; @@ -467,6 +469,11 @@ export async function generateBracketFromTemplate( 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[] = []; // Generate matches for each round in the template @@ -980,6 +987,15 @@ export async function advanceWinnerTemplate( 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 // Phase 3.3: AFL has complex winner/loser advancement rules if (template.id === "afl_10") { @@ -1427,6 +1443,12 @@ export function doesLoserAdvance( if (templateId === "afl_10" && round === "Qualifying Finals") { 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; } @@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner( 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 +> = { + "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): + * [0–7] U.S. Opening Round teams, two per game + * [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1 + * [10–17] 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 { + 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 { + 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 }); + } +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 00d19a0..aa396d8 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -113,6 +113,21 @@ const TEMPLATE_ROUND_CONFIG: Record> // 3rd place game finalizes both positions distinctly. "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 7th–8th tier (8 teams alive at this point). + "Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 }, + // Elimination Final losers are the 5th–6th 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: { // R16 losers share 9th–16th; winner advances to QF (floor 5th–8th). "Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 }, @@ -126,28 +141,37 @@ const TEMPLATE_ROUND_CONFIG: Record> }; /** - * Returns true if a non-scoring round's winners are entering the first scoring round - * (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor). + * Returns the floor position that winners of a NON-scoring round should bank, or null + * to bank nothing. * - * For 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 — Round of 64 and Round of 32 winners should not receive any floor yet. + * Default: winners entering the first scoring round have guaranteed a top-8 fantasy + * placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For + * 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, bracketTemplateId: string | null | undefined -): boolean { - if (!bracketTemplateId) return true; // Legacy: preserve old behavior +): number | null { + if (!bracketTemplateId) return 5; // Legacy: preserve old behavior 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); - 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; - 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); - 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 // 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. - const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId); + const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId); for (const match of matches) { const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); if (match.loserId && !loserAdvances) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } - if (match.winnerId && awardFloor) { - await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); + if (match.winnerId && winnerFloor !== null) { + await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true); } } } else { @@ -441,8 +465,9 @@ export async function processMatchResult( if (!loserAdvances) { await upsertParticipantResult(loserId, sportsSeasonId, 0, db); } - if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { - await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId); + if (nonScoringFloor !== null) { + await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true); } // Non-scoring round wins are not surfaced in the Recent Scores feed. } else { diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index 9145332..a05ed8f 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints( * AFL is different: it has TWO distinct tiers in the 5–8 zone: * - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6]) * - 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 * (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. diff --git a/app/services/simulations/__tests__/llws-simulator.test.ts b/app/services/simulations/__tests__/llws-simulator.test.ts index 15c236a..7414633 100644 --- a/app/services/simulations/__tests__/llws-simulator.test.ts +++ b/app/services/simulations/__tests__/llws-simulator.test.ts @@ -126,22 +126,48 @@ describe("LLWSSimulator", () => { 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)); 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); 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)); const results = await new LLWSSimulator(1_000).simulate("season-1"); for (const r of results) { const p = r.probabilities; - expect(p.probFifth).toBeCloseTo(p.probSixth, 10); - expect(p.probSixth).toBeCloseTo(p.probSeventh, 10); - expect(p.probSeventh).toBeCloseTo(p.probEighth, 10); + // Each sim assigns a team at most one placement, so summing the distinct + // tiers (5th/6th and 7th/8th each count once) cannot exceed 1. + 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", () => { - it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => { + describe("legacy pool-suffix externalIds", () => { + it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => { setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS)); const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); @@ -190,10 +219,10 @@ describe("LLWSSimulator", () => { 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 = [ ...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" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); @@ -202,6 +231,17 @@ describe("LLWSSimulator", () => { const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); 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 ─────────────────────────────────────────────────────────── @@ -262,26 +302,13 @@ describe("LLWSSimulator", () => { 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 = [ - // 6 in Pool A, 4 in Pool B - ...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" })), + ...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })), + ...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/); - }); - - 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/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/); }); }); }); diff --git a/app/services/simulations/llws-simulator.ts b/app/services/simulations/llws-simulator.ts index 850e6f8..0156410 100644 --- a/app/services/simulations/llws-simulator.ts +++ b/app/services/simulations/llws-simulator.ts @@ -1,7 +1,13 @@ /** * Little League World Series (LLWS) Bracket Simulator * - * Monte Carlo simulation of the LLWS (20-team format, 2022–present). + * 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: * 1. Load all 20 participants for the sports season from DB @@ -10,48 +16,36 @@ * (entered via Admin → Futures Odds; American format) * 3. Convert odds to normalized championship probabilities (vig removed). * These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50. - * 4. Determine pool assignment mode from externalId: - * - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B" - * → use these exact pool assignments every simulation. - * - Randomized pools: externalId is "US" or "Intl" only - * → randomly shuffle each side into Pool A / Pool B each simulation. - * 5. Per simulation: - * a. Assign pools (fixed or random) - * b. Simulate pool play round-robin within each pool (10 games/pool) - * Top 2 by W-L record advance. Ties broken randomly. - * c. Simulate 4-team double-elimination bracket per side: - * 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. + * 4. Per simulation: + * a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round + * teams + 2 byes). The draw is modelled as random — a specific known draw + * is not yet expressible in participant config. + * b. Simulate the 10-team double-elimination bracket for each side + * (see simulateSideBracket for the exact game-by-game structure) + * c. Consolation game: US side loser vs Intl side loser → 3rd / 4th + * d. World Championship: US champion vs Intl champion → 1st / 2nd + * 5. Track placement counts across all simulations. + * 6. Convert counts to probability distributions. * - * Pool assignment (externalId format): - * "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode) - * "US" / "Intl" → randomized pools (pre-draw mode) - * Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must - * have one (throws otherwise). Sides can differ — US fixed while Intl randomized. + * Side assignment (externalId): "US" or "Intl". The legacy pool suffixes + * ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side + * alone, so seasons configured for the old pool-play format keep working — pools + * no longer exist, so the suffix has no effect. * - * Placement tiers → SimulationProbabilities mapping: - * probFirst = World Series Champion (1 per sim) - * probSecond = World Series Runner-up (1 per sim) - * probThird = Consolation game winner / 3rd place (1 per sim) - * probFourth = Consolation game loser / 4th place (1 per sim) - * probFifth–probEighth = Double-elim bracket losers before side championships - * (4 per sim — split evenly: 2 US + 2 Intl) - * Pool play losers → all 0 (12 teams, did not advance from pool play) + * Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring): + * probFirst = World Championship winner (1 per sim) + * probSecond = World Championship loser (1 per sim) + * probThird = Consolation winner (1 per sim) + * probFourth = Consolation loser (1 per sim) + * probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side) + * probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side) + * Everyone else → all 0 (12 teams out in Elimination Rounds 1–3) * * Admin setup: * 1. Create a Sport with simulatorType = "llws_bracket" * 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): - * Pre-draw: "US" or "Intl" (or leave null — names starting with "US " infer US, all others infer Intl) - * Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B" + * 3. Set externalId on each participant via Admin → Manage Participants to "US" or + * "Intl" (optional — names starting with "US " infer US, all others infer Intl) * 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds) * 5. Run simulation via Admin → Simulate */ @@ -68,7 +62,6 @@ import { positiveConfigNumber } from "./config-access"; const NUM_SIMULATIONS = 50_000; const US_TEAM_COUNT = 10; const INTL_TEAM_COUNT = 10; -const POOL_SIZE = 5; // teams per pool within each side // ─── Types ──────────────────────────────────────────────────────────────────── @@ -77,8 +70,6 @@ type Side = "US" | "Intl"; interface Team { participantId: string; side: Side; - /** Explicit pool ("A" or "B") if set in externalId; null if randomized. */ - fixedPool: "A" | "B" | null; /** Normalized championship win probability (0–1, vig removed). */ oddsProb: number; } @@ -88,11 +79,21 @@ interface PlacementCounts { finalist: number; thirdPlace: number; fourthPlace: number; - bracketLoser: number; + /** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */ + elimFinalLoser: number; + /** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */ + elimRound4Loser: number; } // ─── 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 } { // 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) @@ -118,101 +119,89 @@ function shuffle(arr: T[]): T[] { } /** - * Assign teams to Pool A / Pool B for one side. - * 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(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. + * Simulate one side's 10-team double-elimination bracket. * - * Seeds (pool results): - * poolA1 = Pool A winner, poolA2 = Pool A runner-up - * poolB1 = Pool B winner, poolB2 = Pool B runner-up + * `slots` holds the side's teams in bracket order, matching the llws_20 participant + * layout: slots[0..7] are the four opening-round games (two teams each) and + * slots[8], slots[9] are the two bye teams entering Winners Round 2. * - * Bracket: - * G1 (WB): A1 vs B2 - * G2 (WB): B1 vs A2 - * G3 (WB Final): G1W vs G2W - * G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser) - * G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser) - * G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser) + * Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match): + * Winners bracket + * OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7 + * WR2-1 s8 v OP1w WR2-2 s9 v OP2w + * WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w + * WF WSF1w v WSF2w → winner to the side championship + * 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 7th–8th tier + * EF WFl v ER4w → loser is the 5th–6th tier + * Side championship: WFw v EFw → loser drops to the consolation game * - * Returns: { sideChampion, sideLoser } - * bracketLosers (2) are bumped into counts directly. + * Note the double-chance path: the Winners Final loser is NOT out, it drops to the + * 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( - poolA1: Team, - poolA2: Team, - poolB1: Team, - poolB2: Team, + slots: Team[], bump: (id: string, key: keyof PlacementCounts) => void ): { 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 g1 = simGame(poolA1, poolB2); - const g2 = simGame(poolB1, poolA2); - const g3 = simGame(g1.winner, g2.winner); // WB Final + const wr21 = simGame(slots[8], op1.winner); + const wr22 = simGame(slots[9], op2.winner); - // Losers bracket - const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated - bump(g4.loser.participantId, "bracketLoser"); + const wsf1 = simGame(op3.winner, wr21.winner); + const wsf2 = simGame(wr22.winner, op4.winner); - const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated - bump(g5.loser.participantId, "bracketLoser"); + const wf = simGame(wsf1.winner, wsf2.winner); - // Side championship - const g6 = simGame(g3.winner, g5.winner); + // ── Elimination bracket ──────────────────────────────────────────────────── + 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"); // 7th–8th tier + + // The Winners Final loser gets its second chance here. + const ef = simGame(wf.loser, er4.winner); + bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier + + // ── Side championship ────────────────────────────────────────────────────── + const sideChampionship = simGame(wf.winner, ef.winner); + + return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser }; } // ─── Validation helpers ─────────────────────────────────────────────────────── -type PoolSuffix = "A" | "B" | null; - -function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null { +/** + * Parse a participant's externalId into a side. + * + * 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; - const upper = raw.toUpperCase(); - if (upper === "US") return { side: "US", pool: null }; - if (upper === "INTL") return { side: "Intl", pool: null }; - 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" }; + const side = raw.toUpperCase().split(":")[0]; + if (side === "US") return { side: "US" }; + if (side === "INTL") return { side: "Intl" }; return null; } @@ -227,36 +216,6 @@ function inferExternalIdFromName(name: string): string { 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 ──────────────────────────────────────────────────────────────── 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[] = []; for (const p of participants) { const raw = p.externalId ?? inferExternalIdFromName(p.name); @@ -312,13 +271,12 @@ export class LLWSSimulator implements Simulator { if (!parsed) { throw new Error( `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({ participantId: p.id, side: parsed.side, - fixedPool: parsed.pool, 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}.`); } - // 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. const allIds = participants.map((p) => p.id); - const counts = new Map( - allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }]) - ); + const counts = new Map(allIds.map((id) => [id, zeroCounts()])); const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); if (entry) entry[key]++; @@ -350,42 +302,35 @@ export class LLWSSimulator implements Simulator { // 6. Run Monte Carlo simulations. for (let s = 0; s < numSimulations; s++) { - // Assign pools for this simulation. - const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized); - 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. + // The draw is modelled as random: shuffle each side into the 10 bracket slots + // (8 opening-round teams, then the 2 bye teams). const { sideChampion: usChamp, sideLoser: usLose } = - simulateSideBracket(usA1, usA2, usB1, usB2, bump); + simulateSideBracket(shuffle([...usTeams]), bump); const { sideChampion: intlChamp, sideLoser: intlLose } = - simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump); + simulateSideBracket(shuffle([...intlTeams]), bump); // Consolation game: 3rd / 4th place. const consolation = simGame(usLose, intlLose); bump(consolation.winner.participantId, "thirdPlace"); bump(consolation.loser.participantId, "fourthPlace"); - // World Series: 1st / 2nd place. + // World Championship: 1st / 2nd place. const ws = simGame(usChamp, intlChamp); bump(ws.winner.participantId, "champion"); bump(ws.loser.participantId, "finalist"); } // 7. Convert counts to probability distributions. - // bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly. - const bracketLosersPerSim = 4; - const bracketDivisor = bracketLosersPerSim * numSimulations; + // Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and + // the teams within a tier are tied, so the tier probability is split across + // 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) => { - const c = counts.get(id) ?? zeroCounts; - const bracketProb = c.bracketLoser / bracketDivisor; + const c = counts.get(id) ?? empty; + const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th + const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th return { participantId: id, probabilities: { @@ -393,10 +338,10 @@ export class LLWSSimulator implements Simulator { probSecond: c.finalist / numSimulations, probThird: c.thirdPlace / numSimulations, probFourth: c.fourthPlace / numSimulations, - probFifth: bracketProb, - probSixth: bracketProb, - probSeventh: bracketProb, - probEighth: bracketProb, + probFifth: upperTier, + probSixth: upperTier, + probSeventh: lowerTier, + probEighth: lowerTier, }, source: "llws_monte_carlo", }; diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 530e41b..b10ec4b 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -183,7 +183,7 @@ const PROFILES: Record Simul llws_bracket: { info: { 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(), },