From 1bd23a4419d75de3eebd844e8e05aea66bc4a6cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:06:56 +0000 Subject: [PATCH 1/2] Add LLWS 20-team double-elimination bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Little League Baseball World Series runs 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 side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj --- .../scoring/TabbedBracketLayout.tsx | 17 +- app/lib/bracket-templates.ts | 276 +++++++++ app/models/__tests__/llws-20-bracket.test.ts | 561 ++++++++++++++++++ app/models/playoff-match.ts | 339 +++++++++++ app/models/scoring-calculator.ts | 61 +- app/models/scoring-rules.ts | 11 +- .../__tests__/llws-simulator.test.ts | 83 ++- app/services/simulations/llws-simulator.ts | 311 ++++------ app/services/simulations/manifest.ts | 2 +- app/services/simulations/registry.ts | 2 +- 10 files changed, 1429 insertions(+), 234 deletions(-) create mode 100644 app/models/__tests__/llws-20-bracket.test.ts 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(), }, From 37af2f1fc37cb6f82a4e2038bb428d2ba9caf9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:33:22 +0000 Subject: [PATCH 2/2] Fix final rankings for double-elimination brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the llws_20 bracket turned up two display bugs in the shared ranking code, both triggered by the template setting loserFeedsInto on every winners-bracket round. findConsolationRound took the FIRST round with loserFeedsInto and treated its target as a third-place game. That is sound for fifa_48, whose only such round is the Semifinals, but llws_20 uses loserFeedsInto to route losers into the elimination bracket — so "Elimination Round 1" was being read as the consolation game. Its four matches were then placed as exact positions inside the Opening Round tier, corrupting the final rankings list. A consolation round now has to be terminal: its winner plays no further game, which is precisely what lets its result split two exact positions. The rank walk also advanced by a round's match count on the assumption that every match places its loser. That holds in single elimination and must be kept for undecided rounds — four semifinalists occupy positions 1-4 whether or not the games have been played — but a winners-bracket loss places nobody, since the loser drops into the elimination bracket and is ranked by whatever knocks them out later. Those rounds consumed positions they never filled, so a 20-team bracket ranked its last teams T23. Rounds whose losers are placed later now consume nothing, leaving LLWS at 1, T2, 3, 4, T5, T7, T9, T13, T17 for the full 20-team field. Single-elimination templates are unaffected; afl_10's sub-8th ranks were inflated the same way and are now correct too. Also from review: the llws_20 participant labels were 21-24 characters in an 80px fixed-width admin column and would have wrapped to three lines across all 20 rows. Shortened them and widened the column. Tests play a full 20-team tournament through the real advancement map and assert the resulting rank labels. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj --- app/components/scoring/PlayoffBracket.tsx | 51 ++++++- .../scoring/__tests__/PlayoffBracket.test.tsx | 136 ++++++++++++++++++ app/lib/bracket-templates.ts | 23 +-- app/models/__tests__/llws-20-bracket.test.ts | 30 +++- app/models/scoring-calculator.ts | 11 +- ...ts-seasons.$id.events.$eventId.bracket.tsx | 2 +- 6 files changed, 231 insertions(+), 22 deletions(-) diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index a3def30..17cfc40 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -196,16 +196,50 @@ export interface ConsolationRound { /** * Find the template's consolation round, if it has one. + * + * A consolation round must be TERMINAL — its winner plays no further game, which is + * what lets its result split two exact positions. `loserFeedsInto` alone is not + * enough: a double-elimination bracket (llws_20) uses it on every winners-bracket + * round to route losers into the elimination bracket, and those targets are ordinary + * rounds that feed onward. Picking the first `loserFeedsInto` there would mistake + * "Elimination Round 1" for a third-place game and corrupt the final rankings. + * * Exported for unit testing. */ export function findConsolationRound( template: BracketTemplate | undefined ): ConsolationRound | undefined { - const feeder = template?.rounds.find((r) => r.loserFeedsInto); + const isTerminal = (roundName: string) => + template?.rounds.find((r) => r.name === roundName)?.feedsInto === null; + + const feeder = template?.rounds.find( + (r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto) + ); if (!feeder?.loserFeedsInto) return undefined; return { round: feeder.loserFeedsInto, feederRound: feeder.name }; } +/** + * Round names whose losers are placed by some LATER round rather than finishing where + * they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into + * the elimination bracket. + * + * The consolation feeder is deliberately excluded: its losers do finish at that tier + * (the consolation game splits their two positions), so it still consumes them. + * + * Exported for unit testing. + */ +export function roundsWithLosersPlacedLater( + template: BracketTemplate | undefined, + consolation: ConsolationRound | undefined +): Set { + return new Set( + (template?.rounds ?? []) + .filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound) + .map((r) => r.name) + ); +} + /** * Build the ordered final-rankings list from completed matches. * @@ -228,7 +262,9 @@ export function computeRankedEntries( rounds: string[], matchesByRound: Map, consolation: ConsolationRound | undefined, - ownershipMap: Map + ownershipMap: Map, + /** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */ + losersPlacedLater: Set = new Set() ): EliminatedEntry[] { const eliminatedByRound = computeEliminatedByRound(matches, rounds); @@ -300,6 +336,14 @@ export function computeRankedEntries( // consumes none of its own. if (consolationActive && roundName === consolation?.round) continue; + // A round normally consumes one position per match — its losers finish here, + // whether or not the games have been played yet (four semifinalists occupy 1–4 + // regardless). But in a double-elimination bracket a winners-bracket loss places + // nobody: the loser drops into the elimination bracket and is ranked by whatever + // knocks them out later. Those rounds must consume nothing, or every position + // below inflates (a 20-team llws_20 bracket would end at "T23"). + if (losersPlacedLater.has(roundName)) continue; + nextRank += matchesByRound.get(roundName)?.length ?? 0; } @@ -371,7 +415,8 @@ export function PlayoffBracket({ rounds, matchesByRound, consolation, - ownershipMap + ownershipMap, + roundsWithLosersPlacedLater(template, consolation) ); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx index 42c3a07..a4df24f 100644 --- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -7,9 +7,11 @@ import { computeEliminatedByRound, computeRankedEntries, findConsolationRound, + roundsWithLosersPlacedLater, type Match, } from "../PlayoffBracket"; import { getBracketTemplate } from "~/lib/bracket-templates"; +import { resolveLLWSAdvancement } from "~/models/playoff-match"; // --------------------------------------------------------------------------- // Helpers @@ -426,6 +428,129 @@ function rankOf(entries: ReturnType, id: string) { return entries.find((e) => e.participant.id === id)?.rankLabel; } +// --------------------------------------------------------------------------- +// llws_20 — double elimination, where a winners-bracket loss places nobody +// --------------------------------------------------------------------------- + +/** Stable participant id for an llws_20 bracket slot. */ +function llwsTeam(i: number): string { + return `t${String(i).padStart(2, "0")}`; +} + +/** + * Play a full 20-team LLWS tournament, always advancing the lower-numbered + * participant id so the outcome is deterministic, and return every match. + * Routing comes from the real advancement map rather than being hand-listed. + */ +function llwsMatches(): Match[] { + const template = getBracketTemplate("llws_20"); + if (!template) throw new Error("llws_20 template missing"); + + // round → matchNumber → [p1, p2] + const slots = new Map>(); + for (const round of template.rounds) { + const byNumber = new Map(); + for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]); + slots.set(round.name, byNumber); + } + const put = (round: string, n: number, slot: 0 | 1, id: string) => { + const pair = slots.get(round)?.get(n); + if (pair) pair[slot] = id; + }; + + // Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket. + for (const [base, roundBase] of [[0, 1], [10, 5]] as const) { + for (let local = 0; local < 4; local++) { + put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2)); + put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1)); + } + } + put("Winners Round 2", 1, 0, llwsTeam(8)); + put("Winners Round 2", 2, 0, llwsTeam(9)); + put("Winners Round 2", 3, 0, llwsTeam(18)); + put("Winners Round 2", 4, 0, llwsTeam(19)); + + const matches: Match[] = []; + for (const round of template.rounds) { + for (let n = 1; n <= round.matchCount; n++) { + const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null]; + if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`); + // Deterministic: the lower id always wins. + const winnerId = p1 < p2 ? p1 : p2; + const loserId = p1 < p2 ? p2 : p1; + matches.push( + makeRankedMatch(round.name, n, winnerId, loserId, { + winnerSlot: winnerId === p1 ? 1 : 2, + }) + ); + const { winner, loser } = resolveLLWSAdvancement(round.name, n); + if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId); + if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId); + } + } + return matches; +} + +describe("computeRankedEntries — llws_20 double elimination", () => { + const template = getBracketTemplate("llws_20"); + const rounds = template?.rounds.map((r) => r.name) ?? []; + + function rankLlws() { + const matches = llwsMatches(); + const consolation = findConsolationRound(template); + return computeRankedEntries( + matches, + rounds, + groupMatchesByRound(matches), + consolation, + new Map(), + roundsWithLosersPlacedLater(template, consolation) + ); + } + + it("ranks all 19 non-champions exactly once", () => { + const entries = rankLlws(); + expect(entries).toHaveLength(19); + expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19); + }); + + it("gives the top 8 the positions the scoring tiers depend on", () => { + const entries = rankLlws(); + const labels = entries.map((e) => e.rankLabel); + // 2nd (World Championship loser), then 3rd and 4th decided by the consolation + // game, then the two 5–6 and two 7–8 tier teams. + expect(labels[0]).toBe("T2"); + expect(labels.filter((l) => l === "3")).toHaveLength(1); + expect(labels.filter((l) => l === "4")).toHaveLength(1); + expect(labels.filter((l) => l === "T5")).toHaveLength(2); + expect(labels.filter((l) => l === "T7")).toHaveLength(2); + }); + + it("does not inflate positions below the top 8", () => { + // Winners-bracket losses place nobody — those teams are ranked by the + // elimination-bracket game that actually knocks them out. If the winners + // rounds consumed positions, the last tier would read T23 in a 20-team field. + const entries = rankLlws(); + const labels = entries.map((e) => e.rankLabel); + expect(labels.filter((l) => l === "T9")).toHaveLength(4); + expect(labels.filter((l) => l === "T13")).toHaveLength(4); + expect(labels.filter((l) => l === "T17")).toHaveLength(4); + // 1 champion (not in the list) + 19 ranked = the full 20-team field. + expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false); + }); + + it("never ranks a winners-bracket loser at the round they first lost", () => { + const entries = rankLlws(); + // t00 wins every game it plays (lowest id), so take a team that loses in the + // winners bracket but survives: the Opening Round M1 loser, t01. + const t01 = entries.find((e) => e.participant.id === "t01"); + expect(t01).toBeDefined(); + // Losing the opening game must not park them in the bottom tier — they got a + // second life in the elimination bracket. + expect(t01?.rankLabel).not.toBe("T17"); + }); +}); + describe("findConsolationRound", () => { it("identifies the fifa_48 third place game and the round that feeds it", () => { expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({ @@ -441,6 +566,17 @@ describe("findConsolationRound", () => { it("returns undefined when there is no template", () => { expect(findConsolationRound(undefined)).toBeUndefined(); }); + + it("ignores double-elimination loser routing and finds the real consolation game", () => { + // llws_20 sets loserFeedsInto on every winners-bracket round to route losers + // into the elimination bracket. Only the Bracket Championship feeds a terminal + // round; taking the first loserFeedsInto instead would mistake "Elimination + // Round 1" for a third-place game and corrupt the final rankings. + expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({ + round: "Consolation Third Place", + feederRound: "Bracket Championship", + }); + }); }); describe("computeRankedEntries", () => { diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 1922d88..a687d32 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -1120,18 +1120,19 @@ export const LLWS_20: BracketTemplate = { }, ], // Region assignments rotate year to year (which region draws the bye changes), so - // these are positional slot labels rather than region names. + // these are positional slot labels rather than region names. Kept short — the admin + // form renders them in a narrow fixed-width column alongside each participant picker. participantLabels: [ - "U.S. Opening 1 — Home", "U.S. Opening 1 — Away", - "U.S. Opening 2 — Home", "U.S. Opening 2 — Away", - "U.S. Opening 3 — Home", "U.S. Opening 3 — Away", - "U.S. Opening 4 — Home", "U.S. Opening 4 — Away", - "U.S. Bye — Winners R2 G1", "U.S. Bye — Winners R2 G2", - "Intl Opening 1 — Home", "Intl Opening 1 — Away", - "Intl Opening 2 — Home", "Intl Opening 2 — Away", - "Intl Opening 3 — Home", "Intl Opening 3 — Away", - "Intl Opening 4 — Home", "Intl Opening 4 — Away", - "Intl Bye — Winners R2 G1", "Intl Bye — Winners R2 G2", + "US G1 Home", "US G1 Away", + "US G2 Home", "US G2 Away", + "US G3 Home", "US G3 Away", + "US G4 Home", "US G4 Away", + "US Bye 1", "US Bye 2", + "Intl G1 Home", "Intl G1 Away", + "Intl G2 Home", "Intl G2 Away", + "Intl G3 Home", "Intl G3 Away", + "Intl G4 Home", "Intl G4 Away", + "Intl Bye 1", "Intl Bye 2", ], phases: [ { diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts index 929b99a..d489ac3 100644 --- a/app/models/__tests__/llws-20-bracket.test.ts +++ b/app/models/__tests__/llws-20-bracket.test.ts @@ -17,7 +17,11 @@ import { llwsMatchNumber, llwsSideAndLocal, } from "~/lib/bracket-templates"; -import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match"; +import { + doesLoserAdvance, + generateBracketFromTemplate, + resolveLLWSAdvancement, +} from "../playoff-match"; import { calculateBracketPoints, calculateAveragedPoints, @@ -489,6 +493,30 @@ describe("LLWS 20 Bracket Template", () => { expect(required(intl.loser).slot).toBe("participant2Id"); }); + it("flags winners-bracket losers as advancing so they are not marked eliminated", () => { + // doesLoserAdvance is what stops the scoring engine writing a 0-point + // elimination (and announcing a knockout) for a team that is still alive. + // Winners Final and Bracket Championship are scoring rounds and are covered + // by loserIsPartial instead, so they are deliberately not listed here. + for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) { + expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true); + } + for (const round of [ + "Elimination Round 1", + "Elimination Round 2", + "Elimination Round 3", + "Elimination Round 4", + "Elimination Final", + ]) { + expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false); + } + }); + + it("does not apply LLWS loser routing to other templates", () => { + expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false); + expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false); + }); + it("advances nobody out of the two final games", () => { for (const round of ["Consolation Third Place", "World Championship"]) { expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null }); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index aa396d8..cf6ef02 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -305,11 +305,10 @@ export async function processPlayoffEvent( } if (!isScoring) { - // Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). - // Winners only bank a provisional T5–T8 floor if they're entering the first - // scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences - // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should - // receive floor points — R64 and R32 winners are not yet guaranteed top-8. + // Non-scoring round: losers are permanently eliminated (0 pts) unless they + // advance (double-elimination winners-bracket losers). Winners bank a + // provisional floor only when this round guarantees them one — see + // nonScoringWinnerFloorFor for how that is derived per template. const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId); for (const match of matches) { const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); @@ -377,7 +376,7 @@ export async function processPlayoffEvent( // Progressive floor scoring: assign guaranteed minimum points to winners. // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the // winner is already finalized as 1st above. For non-scoring rounds it also - // returns null (winners were given floor 5 inline above). + // returns null; those winners were given their floor inline above. const guaranteedMinimum = getGuaranteedMinimumPosition( round, event.bracketTemplateId, diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 5569fe0..154ba39 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -889,7 +889,7 @@ export default function EventBracket({ return ( // eslint-disable-next-line react/no-array-index-key
-