/** * Little League World Series (LLWS) Bracket Simulator * * 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 * (must be exactly 10 US + 10 International, identified by externalId) * 2. Load championship futures odds from participantExpectedValues.sourceOdds * (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. 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. * * 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 (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 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 */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertAmericanOddsToProbability } from "~/services/probability-engine"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; const US_TEAM_COUNT = 10; const INTL_TEAM_COUNT = 10; // ─── Types ──────────────────────────────────────────────────────────────────── type Side = "US" | "Intl"; interface Team { participantId: string; side: Side; /** Normalized championship win probability (0–1, vig removed). */ oddsProb: number; } interface PlacementCounts { champion: number; finalist: number; thirdPlace: number; fourthPlace: 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) // because oddsProb=0 would otherwise give the unknown team a 0% win rate. let p1Win: number; if (t1.oddsProb === 0 || t2.oddsProb === 0) { p1Win = 0.5; } else { p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb); } return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } /** * Fisher-Yates shuffle (in-place). */ function shuffle(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * Simulate one side's 10-team double-elimination bracket. * * `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. * * 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 * * 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( 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]); const wr21 = simGame(slots[8], op1.winner); const wr22 = simGame(slots[9], op2.winner); const wsf1 = simGame(op3.winner, wr21.winner); const wsf2 = simGame(wr22.winner, op4.winner); const wf = simGame(wsf1.winner, wsf2.winner); // ── Elimination bracket ──────────────────────────────────────────────────── const er11 = simGame(op2.loser, op3.loser); const er12 = simGame(op1.loser, op4.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 ─────────────────────────────────────────────────────── /** * 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 side = raw.toUpperCase().split(":")[0]; if (side === "US") return { side: "US" }; if (side === "INTL") return { side: "Intl" }; return null; } /** * Infer an externalId from a participant name when none is stored. * Teams whose name is exactly "US" or starts with "US " (case-insensitive) * are assigned to the US side; all others are assigned to Intl. * The inferred value never has a pool suffix, so pools will be randomized. */ function inferExternalIdFromName(name: string): string { const upper = name.trim().toUpperCase(); return upper === "US" || upper.startsWith("US ") ? "US" : "Intl"; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class LLWSSimulator implements Simulator { constructor(private numSimulations = NUM_SIMULATIONS) {} async simulate(sportsSeasonId: string, config: Record = {}): Promise { const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations)); const db = database(); // 1. Load all participants. const participants = await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name, externalId: schema.seasonParticipants.externalId }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); if (participants.length !== US_TEAM_COUNT + INTL_TEAM_COUNT) { throw new Error( `LLWS simulator requires exactly ${US_TEAM_COUNT + INTL_TEAM_COUNT} participants, ` + `found ${participants.length}.` ); } // 2. Load championship futures odds. const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); const rawOddsMap = new Map(); for (const row of evRows) { if (row.sourceOdds !== null) { rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds)); } } // 3. Normalize odds (remove vig) to get championship probability per team. const normalizedOddsMap = new Map(); if (rawOddsMap.size > 0) { const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0); for (const [id, prob] of rawOddsMap) { normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0); } } // 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); const parsed = parseExternalId(raw); if (!parsed) { throw new Error( `Participant ${p.id} has invalid externalId "${p.externalId}". ` + `Expected: "US" or "Intl".` ); } teams.push({ participantId: p.id, side: parsed.side, oddsProb: normalizedOddsMap.get(p.id) ?? 0, }); } // Validate team counts per side. const usTeams = teams.filter((t) => t.side === "US"); const intlTeams = teams.filter((t) => t.side === "Intl"); if (usTeams.length !== US_TEAM_COUNT) { throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`); } if (intlTeams.length !== INTL_TEAM_COUNT) { throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`); } // 5. Initialise placement count accumulators for all participants. const allIds = participants.map((p) => p.id); 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]++; }; // 6. Run Monte Carlo simulations. for (let s = 0; s < numSimulations; s++) { // 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(shuffle([...usTeams]), bump); const { sideChampion: intlChamp, sideLoser: intlLose } = 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 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. // 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 empty = zeroCounts(); return allIds.map((id) => { const c = counts.get(id) ?? empty; const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th return { participantId: id, probabilities: { probFirst: c.champion / numSimulations, probSecond: c.finalist / numSimulations, probThird: c.thirdPlace / numSimulations, probFourth: c.fourthPlace / numSimulations, probFifth: upperTier, probSixth: upperTier, probSeventh: lowerTier, probEighth: lowerTier, }, source: "llws_monte_carlo", }; }); } }