/** * Little League World Series (LLWS) Bracket Simulator * * Monte Carlo simulation of the LLWS (20-team format, 2022–present). * * 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. 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. * * 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. * * 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) * * 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" * 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"; // ─── Simulation parameters ──────────────────────────────────────────────────── 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 ──────────────────────────────────────────────────────────────────── 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; } interface PlacementCounts { champion: number; finalist: number; thirdPlace: number; fourthPlace: number; bracketLoser: number; } // ─── Helpers ───────────────────────────────────────────────────────────────── 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; } /** * 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. * * Seeds (pool results): * poolA1 = Pool A winner, poolA2 = Pool A runner-up * poolB1 = Pool B winner, poolB2 = Pool B runner-up * * 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) * * Returns: { sideChampion, sideLoser } * bracketLosers (2) are bumped into counts directly. */ function simulateSideBracket( poolA1: Team, poolA2: Team, poolB1: Team, poolB2: Team, bump: (id: string, key: keyof PlacementCounts) => void ): { sideChampion: Team; sideLoser: Team } { // Winners bracket const g1 = simGame(poolA1, poolB2); const g2 = simGame(poolB1, poolA2); const g3 = simGame(g1.winner, g2.winner); // WB Final // Losers bracket const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated bump(g4.loser.participantId, "bracketLoser"); const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated bump(g5.loser.participantId, "bracketLoser"); // Side championship const g6 = simGame(g3.winner, g5.winner); return { sideChampion: g6.winner, sideLoser: g6.loser }; } // ─── Validation helpers ─────────────────────────────────────────────────────── type PoolSuffix = "A" | "B" | null; function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | 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" }; 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"; } /** * 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 { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants. const participants = await db .select({ id: schema.participants.id, name: schema.participants.name, externalId: schema.participants.externalId }) .from(schema.participants) .where(eq(schema.participants.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.participantExpectedValues.participantId, sourceOdds: schema.participantExpectedValues.sourceOdds, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.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 side and fixed pool. 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", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".` ); } teams.push({ participantId: p.id, side: parsed.side, fixedPool: parsed.pool, 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}.`); } // 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 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 < NUM_SIMULATIONS; 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. const { sideChampion: usChamp, sideLoser: usLose } = simulateSideBracket(usA1, usA2, usB1, usB2, bump); const { sideChampion: intlChamp, sideLoser: intlLose } = simulateSideBracket(intlA1, intlA2, intlB1, intlB2, 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. 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 * NUM_SIMULATIONS; const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }; return allIds.map((id) => { const c = counts.get(id) ?? zeroCounts; const bracketProb = c.bracketLoser / bracketDivisor; return { participantId: id, probabilities: { probFirst: c.champion / NUM_SIMULATIONS, probSecond: c.finalist / NUM_SIMULATIONS, probThird: c.thirdPlace / NUM_SIMULATIONS, probFourth: c.fourthPlace / NUM_SIMULATIONS, probFifth: bracketProb, probSixth: bracketProb, probSeventh: bracketProb, probEighth: bracketProb, }, source: "llws_monte_carlo", }; }); } }