/** * Snooker World Championship Simulator * * Monte Carlo simulation of the World Snooker Championship (Crucible, Sheffield). * The tournament is a 32-player single-elimination bracket with 5 rounds, using * best-of frame formats that increase in length each round. * * Algorithm: * 1. Load the bracket scoring event and all playoff matches from DB. * 2. Load Elo ratings from participantExpectedValues.sourceElo. * 3. Compute per-match win probability using the Bernoulli frame model: * p_frame = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR)) * P(win match) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2 * where F = frames to win, which varies by round. * 4. Two simulation paths: * a. Bracket populated: simulate from actual draw, respecting completed matches. * b. No bracket: simulate qualifying (ranks 17-48 play one round best-of-19), * randomly pair 16 winners vs top 16 seeds, then simulate 5-round bracket. * 5. Track integer placement counts per tier across 50,000 simulations. * 6. Convert to probability distributions using exact denominators (column sums = 1.0). * * Round format (Crucible main draw): * First Round (R32): best-of-19, first to 10 * Second Round (R16): best-of-25, first to 13 * Quarter-Finals (QF): best-of-25, first to 13 * Semi-Finals (SF): best-of-33, first to 17 * Final: best-of-35, first to 18 * * Placement bucketing (8-slot probability model): * probFirst → Champion * probSecond → Finalist * probThird/Fourth → SF losers (2/sim) * probFifth–Eighth → QF losers (4/sim) * R16 losers → all 0 * R32 losers → all 0 * * World rankings (for pre-bracket path): hardcoded below. Update annually. */ import { database } from "~/database/context"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50000; /** * Controls how much Elo gaps affect per-frame win probability. * Higher = softer probabilities (more randomness, less Elo dominance). * Lower = sharper probabilities (Elo differences matter more). * * Standard chess Elo uses 400. Snooker frames are more random than chess * games, so a higher value is appropriate. Tune this until tournament win * probabilities for the top seed feel realistic (~15% for a clear favourite * in a 32-player field). * * Reference calibration points at ELO_DIVISOR=700: * 100-pt gap → 53.5% per frame * 300-pt gap → 60.6% per frame * At ELO_DIVISOR=400 (standard chess): * 100-pt gap → 56.2% per frame * 300-pt gap → 67.9% per frame */ const ELO_DIVISOR = 700; /** * Frames needed to win per round, indexed by round order (most matches first). * Index 0 = R32 (16 matches, best-of-19, need 10) * Index 1 = R16 (8 matches, best-of-25, need 13) * Index 2 = QF (4 matches, best-of-25, need 13) * Index 3 = SF (2 matches, best-of-33, need 17) * Index 4 = Final (1 match, best-of-35, need 18) */ const FRAMES_TO_WIN = [10, 13, 13, 17, 18] as const; // ─── Tournament seedings (2025 World Snooker Championship) ──────────────────── // Seed 1 = defending champion; seeds 2-16 = world ranking order (skipping champion). // Seeds 17+ = qualifiers — only their relative order here matters (all > 16). // Update this map each year when a new snooker season is added. // Players not in this map default to seed 999 (treated as qualifiers). const SEEDINGS_2026: Record = { "Judd Trump": 1, "Kyren Wilson": 2, "Neil Robertson": 3, "Mark Williams": 4, "Zhao Xintong": 5, "John Higgins": 6, "Mark Selby": 7, "Shaun Murphy": 8, "Xiao Guodong": 9, "Ronnie O'Sullivan": 10, "Barry Hawkins": 11, "Wu Yize": 12, "Chris Wakelin": 13, "Mark Allen": 14, "Si Jiahui": 15, "Ding Junhui": 16, "Stuart Bingham": 17, "Jack Lisowski": 18, "Jak Jones": 19, "Zhang Anda": 20, "Elliot Slessor": 21, "Thepchaiya Un-Nooh": 22, "Ali Carter": 23, "Gary Wilson": 24, "Zhou Yuelong": 25, "David Gilbert": 26, "Stephen Maguire": 27, "Joe O'Connor": 28, "Pang Junxu": 29, "Lei Peifan": 30, "Yuan Sijun": 31, "Tom Ford": 32, "Hossein Vafaei": 33, "Jimmy Robertson": 34, "Ryan Day": 35, "Jackson Page": 36, "Matthew Selt": 37, "Xu Si": 38, "Ben Woollaston": 39, "Aaron Hill": 40, "Daniel Wells": 41, "Anthony McGill": 42, "Zak Surety": 43, "Stan Moody": 44, "Noppon Saengkham": 45, "Luca Brecel": 46, "He Guoqiang": 47, "Matthew Stevens": 48, }; /** * Standard seeded R32 bracket for 32-player single-elimination. * Each entry is [seedA, seedB] for one R32 match, in bracket order. * Consecutive pairs of R32 winners form R16 matches (bracket structure preserved). * Ensures seed 1 and seed 2 can only meet in the Final. * * Structure (seeds 1-16 are fixed; seeds 17-32 are randomly drawn qualifiers): * Top half: 1v32, 16v17, 9v24, 8v25, 5v28, 12v21, 13v20, 4v29 * Bottom half: 3v30, 14v19, 11v22, 6v27, 7v26, 10v23, 15v18, 2v31 */ const R32_BRACKET: Array<[number, number]> = [ [1, 32], [16, 17], [9, 24], [8, 25], [5, 28], [12, 21], [13, 20], [4, 29], [3, 30], [14, 19], [11, 22], [6, 27], [7, 26], [10, 23], [15, 18], [2, 31], ]; // ─── Name normalization ────────────────────────────────────────────────────── // Normalize unicode apostrophes/quotes so DB names (which may use curly quotes // like U+2019 ') match the ASCII apostrophes (U+0027 ') in SEEDINGS keys. function normalizeApostrophes(name: string): string { return name.replace(/[\u2018\u2019\u2032\u0060]/g, "'"); } const SEEDINGS_NORMALIZED = new Map( Object.entries(SEEDINGS_2026).map(([name, seed]) => [normalizeApostrophes(name), seed]) ); function getSeedingForName(name: string): number { return SEEDINGS_NORMALIZED.get(normalizeApostrophes(name)) ?? 999; } // ─── Math helpers ────────────────────────────────────────────────────────────── /** * Per-frame win probability for player 1 vs player 2 based on their Elo ratings. * * Uses the logistic sigmoid: p = 1 / (1 + e^(-(R1 - R2) / ELO_DIVISOR)) * * ELO_DIVISOR is set higher than standard chess (400) to reflect the greater * randomness of snooker frames vs chess games. At ELO_DIVISOR=700: * 100-pt gap → ~53.5% per frame * 300-pt gap → ~60.6% per frame * * Exported for unit testing. */ export function frameWinProb(elo1: number, elo2: number): number { return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR)); } /** * Match win probability for player 1 using the Bernoulli frame model. * * For a best-of-(2F-1) match (first to F frames wins): * P(win) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2 * * where p = per-frame win probability and F = framesToWin. * This sums over all winning score combinations (F-0, F-1, ..., F-(F-1)). * Exported for unit testing. */ export function matchWinProb(p: number, framesToWin: number): number { const F = framesToWin; let prob = 0; for (let w2 = 0; w2 < F; w2++) { prob += binomialCoeff(F - 1 + w2, w2) * Math.pow(p, F) * Math.pow(1 - p, w2); } return prob; } /** Binomial coefficient C(n, k) using iterative multiplication to avoid overflow. */ function binomialCoeff(n: number, k: number): number { if (k === 0) return 1; if (k > n - k) k = n - k; // Symmetry: C(n,k) = C(n, n-k) let result = 1; for (let i = 0; i < k; i++) { result = (result * (n - i)) / (i + 1); } return result; } /** 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; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class SnookerSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Find the bracket scoring event for this sports season. const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); // 2. Load playoff matches (may be empty if bracket hasn't been drawn yet). const allMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (m, { asc }) => [asc(m.matchNumber)], }) : []; // 3. Load Elo ratings from participantExpectedValues. const evRows = await db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); // Build Elo map; fall back to 1500 for any participant with no stored rating. const eloMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined) { eloMap.set(r.participantId, r.sourceElo); } else if (!eloMap.has(r.participantId)) { eloMap.set(r.participantId, 1500); } } // Determine which simulation path to take. const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); if (bracketPopulated) { return this.simulateBracket(allMatches, eloMap); } else { return this.simulatePreBracket(sportsSeasonId, eloMap, db); } } // ─── Path A: Full bracket simulation ──────────────────────────────────────── private async simulateBracket( allMatches: Awaited["query"]["playoffMatches"]["findMany"]>>, eloMap: Map ): Promise { // Group matches by round, sorted by match count descending (R32 first). const byRound = new Map(); for (const m of allMatches) { if (!byRound.has(m.round)) byRound.set(m.round, []); byRound.get(m.round)?.push(m); } const sortedRounds = [...byRound.values()] .toSorted((a, b) => b.length - a.length) .map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber)); if (sortedRounds.length !== 5) { throw new Error( `Expected 5 rounds for World Snooker Championship, found ${sortedRounds.length}. ` + `Rounds: ${[...byRound.keys()].join(", ")}` ); } const [r32Matches, r16Matches, qfMatches, sfMatches, finalMatches] = sortedRounds; if (r32Matches.length !== 16) { throw new Error( `Expected 16 First Round matches, found ${r32Matches.length}.` ); } // Collect all 32 participant IDs from R32. const participantIds: string[] = []; for (const m of r32Matches) { if (!m.participant1Id || !m.participant2Id) { throw new Error( `First Round match ${m.matchNumber} is missing participants. ` + `Assign all 32 players to the bracket before running simulation.` ); } participantIds.push(m.participant1Id, m.participant2Id); } // Build lookup maps by matchNumber for O(1) access in the hot loop. const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m])); const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m])); const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m])); const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m])); const finalMatch = finalMatches[0]; const fallbackElo = 1500; // Cache matchWinProb results — the Elo values and framesToWin are fixed across // all simulations, so the same (elo1, elo2, framesToWin) triple always yields // the same probability. Avoids recomputing the Bernoulli sum ~2M times per run. const matchProbCache = new Map(); const simMatch = (p1: string, p2: string, framesToWin: number): { winner: string; loser: string } => { const elo1 = eloMap.get(p1) ?? fallbackElo; const elo2 = eloMap.get(p2) ?? fallbackElo; const cacheKey = `${elo1},${elo2},${framesToWin}`; let winProb = matchProbCache.get(cacheKey); if (winProb === undefined) { winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin); matchProbCache.set(cacheKey, winProb); } const winner = Math.random() < winProb ? p1 : p2; return { winner, loser: winner === p1 ? p2 : p1 }; }; // Integer placement counts. const championCounts = new Map(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const qfLoserCounts = new Map(participantIds.map((id) => [id, 0])); for (let s = 0; s < NUM_SIMULATIONS; s++) { // ── First Round (R32) ────────────────────────────────────────────────── const r32Winners: string[] = []; for (let i = 1; i <= 16; i++) { const m = r32ByNum.get(i); if (!m) continue; if (m.isComplete && m.winnerId) { r32Winners.push(m.winnerId); } else { const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", FRAMES_TO_WIN[0]); r32Winners.push(winner); } } // ── Second Round (R16) ──────────────────────────────────────────────── // R16 losers score 0 — only the winner is tracked. const r16Winners: string[] = []; for (let i = 1; i <= 8; i++) { const dbMatch = r16ByNum.get(i); let winner: string; if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; } else { const p1 = r32Winners[(i - 1) * 2]; const p2 = r32Winners[(i - 1) * 2 + 1]; ({ winner } = simMatch(p1, p2, FRAMES_TO_WIN[1])); } r16Winners.push(winner); } // ── Quarter-Finals ──────────────────────────────────────────────────── const qfWinners: string[] = []; for (let i = 1; i <= 4; i++) { const dbMatch = qfByNum.get(i); let winner: string; let loser: string; if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { winner = dbMatch.winnerId; loser = dbMatch.loserId; } else { const p1 = r16Winners[(i - 1) * 2]; const p2 = r16Winners[(i - 1) * 2 + 1]; ({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[2])); } qfWinners.push(winner); qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1); } // ── Semi-Finals ─────────────────────────────────────────────────────── const sfWinners: string[] = []; for (let i = 1; i <= 2; i++) { const dbMatch = sfByNum.get(i); let winner: string; let loser: string; if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) { winner = dbMatch.winnerId; loser = dbMatch.loserId; } else { const p1 = qfWinners[(i - 1) * 2]; const p2 = qfWinners[(i - 1) * 2 + 1]; ({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[3])); } sfWinners.push(winner); sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1); } // ── Final ───────────────────────────────────────────────────────────── let champion: string; let finalist: string; if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) { champion = finalMatch.winnerId; finalist = finalMatch.loserId; } else { ({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4])); } championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); } return buildResults(participantIds, NUM_SIMULATIONS, { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, }); } // ─── Path B: Pre-bracket simulation ───────────────────────────────────────── // Players 17-48 haven't qualified yet. Each simulation: // 1. Randomly pairs the qualifiers (17-48) and simulates 16 best-of-19 matches. // 2. The 16 winners are shuffled and randomly drawn into seed slots 17-32. // 3. The full 32-player bracket is then simulated using the proper seeded structure. private async simulatePreBracket( sportsSeasonId: string, eloMap: Map, db: ReturnType ): Promise { const allParticipants = await db .select({ id: schema.participants.id, name: schema.participants.name }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); if (allParticipants.length < 17) { throw new Error( `Pre-bracket simulation requires at least 17 participants (got ${allParticipants.length}). ` + `Add players to this sports season first.` ); } // Sort by seeding. Unranked players default to 999 (treated as qualifiers). const ranked = allParticipants.toSorted((a, b) => { return getSeedingForName(a.name) - getSeedingForName(b.name); }); const topSeeds = ranked.slice(0, 16); // Fixed seed positions 1-16 const qualifiers = ranked.slice(16); // Randomly drawn into positions 17-32 const fallbackElo = 1500; const matchProbCache = new Map(); const simMatch = (p1Id: string, p2Id: string, framesToWin: number): string => { const elo1 = eloMap.get(p1Id) ?? fallbackElo; const elo2 = eloMap.get(p2Id) ?? fallbackElo; const cacheKey = `${elo1},${elo2},${framesToWin}`; let winProb = matchProbCache.get(cacheKey); if (winProb === undefined) { winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin); matchProbCache.set(cacheKey, winProb); } return Math.random() < winProb ? p1Id : p2Id; }; // Pre-extract qualifier IDs once — reused (with a fresh shuffle) each iteration. const qualifierIds = qualifiers.map((p) => p.id); const allParticipantIds = allParticipants.map((p) => p.id); const championCounts = new Map(allParticipantIds.map((id) => [id, 0])); const finalistCounts = new Map(allParticipantIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); // Whether qualifying needs to be simulated (>16 players competing for 16 bracket slots). // If exactly 16 qualifiers are already loaded (the common case when only 32 total players // are in the DB), skip the qualifying simulation and use them directly as seeds 17-32. const needsQualifying = qualifiers.length > 16; for (let s = 0; s < NUM_SIMULATIONS; s++) { // ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ── let drawnQualifiers: string[]; if (!needsQualifying) { // All 16 qualifiers go straight through — just shuffle the draw order. drawnQualifiers = shuffle([...qualifierIds]); } else { // Multiple qualifying rounds (best-of-19 each) to whittle down to 16. let pool = shuffle([...qualifierIds]); while (pool.length > 16) { const winners: string[] = []; for (let i = 0; i + 1 < pool.length; i += 2) { winners.push(simMatch(pool[i], pool[i + 1], FRAMES_TO_WIN[0])); } // Odd player out gets a bye. if (pool.length % 2 !== 0) { winners.push(pool[pool.length - 1]); } pool = shuffle(winners); } drawnQualifiers = pool; } // Seeds 5-16: randomly swap adjacent pairs to reflect uncertainty in // mid-tier seedings (top 4 are locked in). const midSeeds = topSeeds.slice(4).map((p) => p.id); for (let i = 0; i < midSeeds.length - 1; i++) { if (Math.random() < 0.5) { [midSeeds[i], midSeeds[i + 1]] = [midSeeds[i + 1], midSeeds[i]]; } } // Map seed number (1-indexed) → participant ID. const seedToId = new Map(); topSeeds.slice(0, 4).forEach((p, i) => seedToId.set(i + 1, p.id)); midSeeds.forEach((id, i) => seedToId.set(i + 5, id)); drawnQualifiers.forEach((id, i) => seedToId.set(i + 17, id)); // ── First Round (R32) — proper seeded bracket ───────────────────────── // R32_BRACKET defines which seeds meet in each match, in bracket order so // consecutive R32 winner pairs form the correct R16 matchups. const r32Winners: string[] = []; for (const [s1, s2] of R32_BRACKET) { const p1 = seedToId.get(s1) ?? ""; const p2 = seedToId.get(s2) ?? ""; r32Winners.push(simMatch(p1, p2, FRAMES_TO_WIN[0])); } // ── Second Round (R16) ──────────────────────────────────────────────── const r16Winners: string[] = []; for (let i = 0; i < r32Winners.length; i += 2) { r16Winners.push(simMatch(r32Winners[i], r32Winners[i + 1], FRAMES_TO_WIN[1])); } // ── Quarter-Finals ──────────────────────────────────────────────────── const qfWinners: string[] = []; for (let i = 0; i < r16Winners.length; i += 2) { const p1 = r16Winners[i], p2 = r16Winners[i + 1]; const winner = simMatch(p1, p2, FRAMES_TO_WIN[2]); qfWinners.push(winner); qfLoserCounts.set(winner === p1 ? p2 : p1, (qfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1); } // ── Semi-Finals ─────────────────────────────────────────────────────── const sfWinners: string[] = []; for (let i = 0; i < qfWinners.length; i += 2) { const p1 = qfWinners[i], p2 = qfWinners[i + 1]; const winner = simMatch(p1, p2, FRAMES_TO_WIN[3]); sfWinners.push(winner); sfLoserCounts.set(winner === p1 ? p2 : p1, (sfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1); } // ── Final ───────────────────────────────────────────────────────────── const champion = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]); const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0]; championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1); finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1); } return buildResults(allParticipantIds, NUM_SIMULATIONS, { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, }); } } // ─── Shared result builder ───────────────────────────────────────────────────── function buildResults( participantIds: string[], N: number, counts: { championCounts: Map; finalistCounts: Map; sfLoserCounts: Map; qfLoserCounts: Map; } ): SimulationResult[] { const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts; const results: SimulationResult[] = participantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const sf = sfLoserCounts.get(participantId) ?? 0; const qf = qfLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / N, probSecond: f / N, probThird: sf / (2 * N), probFourth: sf / (2 * N), probFifth: qf / (4 * N), probSixth: qf / (4 * N), probSeventh: qf / (4 * N), probEighth: qf / (4 * N), }, source: "snooker_world_championship_monte_carlo", }; }); // Per-position column normalization — ensures sums are exactly 1.0. const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ]; for (const key of positionKeys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); const residual = 1.0 - colSum; if (residual !== 0) { const maxResult = results.reduce((best, r) => r.probabilities[key] > best.probabilities[key] ? r : best ); maxResult.probabilities[key] += residual; } } return results; }