/** * PDC World Darts Championship Simulator * * Monte Carlo simulation of the PDC World Darts Championship. * The tournament is a 128-player single-elimination bracket with 7 rounds, * using best-of-sets formats that increase in length each round. * * Algorithm: * 1. Load all participants and their Elo + world ranking from participantExpectedValues. * 2. Two simulation paths: * a. Bracket populated: simulate from actual draw, respecting completed matches. * b. Pre-bracket: top 32 seeds placed into fixed balanced bracket positions; * remaining 96 players randomly drawn into unseeded slots each simulation. * 3. Compute per-set win probability using the logistic sigmoid: * p_set = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR)) * 4. Compute match win probability using the Bernoulli sets model: * P(win) = sum_{w2=0}^{S-1} C(S-1+w2, w2) * p^S * (1-p)^w2 * where S = sets to win, which varies by round. * 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 (PDC World Championship): * R1 (R128): best-of-3 sets, first to 2 * R2 (R64): best-of-5 sets, first to 3 * R3 (R32): best-of-5 sets, first to 3 * R4 (R16): best-of-7 sets, first to 4 * QF: best-of-7 sets, first to 4 * SF: best-of-11 sets, first to 6 * Final: best-of-13 sets, first to 7 * * Seeding (pre-bracket path): * Top 32 players (by world ranking) are seeded into fixed bracket positions * using the standard balanced bracket structure: * R1 seeds: 1v32, 16v17, 9v24, 8v25 (top half) + 5v28, 12v21, 13v20, 4v29 * 3v30, 14v19, 11v22, 6v27 (bottom half) + 7v26, 10v23, 15v18, 2v31 * Each seed's unseeded opponent slot is randomly filled from the 96 unseeded players * in each simulation run — spreading the draw uncertainty across all simulations. * * Placement bucketing (8-slot probability model): * probFirst → Champion * probSecond → Finalist * probThird/Fourth → SF losers (2/sim) * probFifth–Eighth → QF losers (4/sim) * Earlier rounds → all 0 */ 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-set win probability. * Higher = softer probabilities (more randomness). * Lower = sharper (Elo differences matter more). * * Standard chess uses 400. Snooker (more random than chess) uses 700. * Darts is moderately volatile; 500 gives: * 100-pt gap → ~55% per set * 300-pt gap → ~63% per set * 500-pt gap → ~73% per set */ const ELO_DIVISOR = 500; /** * Sets needed to win per round, in bracket order (R1 first, Final last). * Index 0 = R1/R128 (64 matches, best-of-3, need 2) * Index 1 = R2/R64 (32 matches, best-of-5, need 3) * Index 2 = R3/R32 (16 matches, best-of-5, need 3) * Index 3 = R4/R16 (8 matches, best-of-7, need 4) * Index 4 = QF (4 matches, best-of-7, need 4) * Index 5 = SF (2 matches, best-of-11, need 6) * Index 6 = Final (1 match, best-of-13, need 7) */ const SETS_TO_WIN = [2, 3, 3, 4, 4, 6, 7] as const; /** * Number of seeds that get fixed bracket positions. * The remaining (128 - TOP_SEEDS) players are randomly drawn. */ const TOP_SEEDS = 32; // ─── Math helpers ────────────────────────────────────────────────────────────── /** * Per-set win probability for player 1 vs player 2 based on Elo. * Exported for unit testing. */ export function setWinProb(elo1: number, elo2: number): number { return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR)); } /** * Match win probability for player 1 using the Bernoulli sets model. * For a best-of-(2S-1) match (first to S sets): * P(win) = sum_{w2=0}^{S-1} C(S-1+w2, w2) * p^S * (1-p)^w2 * Exported for unit testing. */ export function matchWinProb(p: number, setsToWin: number): number { const S = setsToWin; let prob = 0; for (let w2 = 0; w2 < S; w2++) { prob += binomialCoeff(S - 1 + w2, w2) * Math.pow(p, S) * Math.pow(1 - p, w2); } return prob; } /** Binomial coefficient C(n, k) via iterative multiplication. */ function binomialCoeff(n: number, k: number): number { if (k === 0) return 1; if (k > n - k) k = n - k; let result = 1; for (let i = 0; i < k; i++) { result = (result * (n - i)) / (i + 1); } return result; } /** * Returns the 128-player seeded bracket R1 pair list. * Each entry is [participantIdA, participantIdB]. * Top 32 seeded players fill fixed positions; 96 unseeded players are randomly * shuffled and assigned to the remaining slots. * * Structure: * - 32 seeded-vs-unseeded matches (seeds 1–32 each face a randomly drawn unseeded opponent) * - 32 unseeded-vs-unseeded matches (remaining 64 unseeded players paired randomly) * Total: 64 R1 matches ✓ (128 players) * * Note: in the hot simulation loop, seeded positions are pre-computed via getSeededMatchOrder * and inlined directly — this function is used for testing and bracket-draw path only. * * Exported for unit testing. */ export function buildR1Bracket( seededIds: string[], // exactly 32, index 0 = seed 1 unseededIds: string[] // exactly 96, shuffled ): Array<[string, string]> { // The 32 seeded players each face one of the first 32 unseeded opponents. // Seeds are arranged in bracket order using the standard balanced structure // for 32 seeds (same algorithm as snooker's R32_BRACKET but generalised). const seededMatchOrder = getSeededMatchOrder(32); // returns 32 seed positions in bracket order const pairs: Array<[string, string]> = []; // 32 seeded-vs-unseeded R1 matches for (let i = 0; i < 32; i++) { const seedPos = seededMatchOrder[i] - 1; // 0-indexed pairs.push([seededIds[seedPos], unseededIds[i]]); } // 32 unseeded-vs-unseeded R1 matches (players 32–95) for (let i = 32; i < 96; i += 2) { pairs.push([unseededIds[i], unseededIds[i + 1]]); } return pairs; } /** * Returns seed positions in standard balanced bracket order for N seeds. * Guarantees seed 1 and seed 2 can only meet in the Final. * E.g. for N=4: [1, 4, 3, 2] → match order 1v4, 3v2 in the top/bottom halves. * * Algorithm: start with [1, 2], repeatedly interleave (n+1 - seed) complements. * Exported for unit testing. */ export function getSeededMatchOrder(n: number): number[] { let order = [1, 2]; while (order.length < n) { const size = order.length; const newOrder: number[] = []; for (const seed of order) { newOrder.push(seed); newOrder.push(2 * size + 1 - seed); } order = newOrder; } return order; } /** Fisher-Yates shuffle (in-place, returns array). */ 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 DartsSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Find the bracket scoring event (if it exists). const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); // 2. Load playoff matches (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 and world rankings. const evRows = await db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, worldRanking: schema.participantExpectedValues.worldRanking, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); const eloMap = new Map(); const rankingMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined) { eloMap.set(r.participantId, r.sourceElo); } if (r.worldRanking !== null && r.worldRanking !== undefined) { rankingMap.set(r.participantId, r.worldRanking); } } // Determine simulation path. const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); if (bracketPopulated) { return this.simulateBracket(allMatches, eloMap); } else { return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db); } } // ─── Path A: Bracket drawn ──────────────────────────────────────────────── private async simulateBracket( allMatches: Awaited["query"]["playoffMatches"]["findMany"]>>, eloMap: Map ): Promise { // Group matches by round, sorted by match count descending (R1 first = most matches). 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 !== 7) { throw new Error( `Expected 7 rounds for PDC World Darts Championship, found ${sortedRounds.length}. ` + `Rounds: ${[...byRound.keys()].join(", ")}` ); } const [r1Matches, r2Matches, r3Matches, r4Matches, qfMatches, sfMatches, finalMatches] = sortedRounds; if (r1Matches.length !== 64) { throw new Error( `Expected 64 R1 matches (128-player bracket), found ${r1Matches.length}.` ); } // Collect all 128 participant IDs from R1. const participantIds: string[] = []; for (const m of r1Matches) { if (!m.participant1Id || !m.participant2Id) { throw new Error( `R1 match ${m.matchNumber} is missing participants. ` + `Assign all 128 players to the bracket before running simulation.` ); } participantIds.push(m.participant1Id, m.participant2Id); } const fallbackElo = 1600; // Cache matchWinProb — Elo values are fixed across simulations. const matchProbCache = new Map(); const simMatch = (p1: string, p2: string, setsToWin: number): { winner: string; loser: string } => { const elo1 = eloMap.get(p1) ?? fallbackElo; const elo2 = eloMap.get(p2) ?? fallbackElo; const cacheKey = `${elo1},${elo2},${setsToWin}`; let winProb = matchProbCache.get(cacheKey); if (winProb === undefined) { winProb = matchWinProb(setWinProb(elo1, elo2), setsToWin); matchProbCache.set(cacheKey, winProb); } const winner = Math.random() < winProb ? p1 : p2; return { winner, loser: winner === p1 ? p2 : p1 }; }; const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m])); const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m])); const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m])); const r4ByNum = new Map(r4Matches.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 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++) { // R1 (64 matches) const r1Winners: string[] = []; for (let i = 1; i <= 64; i++) { const m = r1ByNum.get(i); if (!m) continue; if (m.isComplete && m.winnerId) { r1Winners.push(m.winnerId); } else { const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", SETS_TO_WIN[0]); r1Winners.push(winner); } } // R2 (32 matches) const r2Winners: string[] = []; for (let i = 1; i <= 32; i++) { const dbMatch = r2ByNum.get(i); let winner: string; if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; } else { const p1 = r1Winners[(i - 1) * 2]; const p2 = r1Winners[(i - 1) * 2 + 1]; ({ winner } = simMatch(p1, p2, SETS_TO_WIN[1])); } r2Winners.push(winner); } // R3 (16 matches) const r3Winners: string[] = []; for (let i = 1; i <= 16; i++) { const dbMatch = r3ByNum.get(i); let winner: string; if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; } else { const p1 = r2Winners[(i - 1) * 2]; const p2 = r2Winners[(i - 1) * 2 + 1]; ({ winner } = simMatch(p1, p2, SETS_TO_WIN[2])); } r3Winners.push(winner); } // R4 (8 matches) const r4Winners: string[] = []; for (let i = 1; i <= 8; i++) { const dbMatch = r4ByNum.get(i); let winner: string; if (dbMatch?.isComplete && dbMatch.winnerId) { winner = dbMatch.winnerId; } else { const p1 = r3Winners[(i - 1) * 2]; const p2 = r3Winners[(i - 1) * 2 + 1]; ({ winner } = simMatch(p1, p2, SETS_TO_WIN[3])); } r4Winners.push(winner); } // QF (4 matches) 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 = r4Winners[(i - 1) * 2]; const p2 = r4Winners[(i - 1) * 2 + 1]; ({ winner, loser } = simMatch(p1, p2, SETS_TO_WIN[4])); } qfWinners.push(winner); qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1); } // SF (2 matches) 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, SETS_TO_WIN[5])); } 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], SETS_TO_WIN[6])); } 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 ────────────────────────────────────────── // Top 32 seeds are placed into fixed bracket positions. // Remaining 96 players are randomly drawn into unseeded slots each simulation. private async simulatePreBracket( sportsSeasonId: string, eloMap: Map, rankingMap: 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 < 2) { throw new Error( `Pre-bracket simulation requires at least 2 participants (got ${allParticipants.length}). ` + `Add players to this sports season first.` ); } const fallbackElo = 1600; // Sort participants by world ranking (ascending). Fall back to Elo order (descending) for // any without a ranking, then alphabetical as a final tiebreak. const sorted = [...allParticipants].toSorted((a, b) => { const rankA = rankingMap.get(a.id); const rankB = rankingMap.get(b.id); if (rankA !== undefined && rankB !== undefined) return rankA - rankB; if (rankA !== undefined) return -1; // ranked before unranked if (rankB !== undefined) return 1; // Both unranked — sort by Elo descending return (eloMap.get(b.id) ?? fallbackElo) - (eloMap.get(a.id) ?? fallbackElo); }); const topSeeds = sorted.slice(0, TOP_SEEDS).map((p) => p.id); // seeds 1–32 const unseeded = sorted.slice(TOP_SEEDS).map((p) => p.id); // remaining players 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])); // Cache set-level probabilities — fixed across all simulations. const matchProbCache = new Map(); const simMatch = (p1Id: string, p2Id: string, setsToWin: number): string => { const elo1 = eloMap.get(p1Id) ?? fallbackElo; const elo2 = eloMap.get(p2Id) ?? fallbackElo; const cacheKey = `${elo1},${elo2},${setsToWin}`; let winProb = matchProbCache.get(cacheKey); if (winProb === undefined) { winProb = matchWinProb(setWinProb(elo1, elo2), setsToWin); matchProbCache.set(cacheKey, winProb); } return Math.random() < winProb ? p1Id : p2Id; }; // Pre-compute fixed seeded bracket positions once — only the unseeded draw changes per sim. const seededMatchOrder = getSeededMatchOrder(TOP_SEEDS); const seededSlots = seededMatchOrder.map(seed => topSeeds[seed - 1]); // Pad unseeded pool to 96 once before the loop. // In practice the admin should always load 128 players; this guards against edge cases. const unseededPool = [...unseeded]; while (unseededPool.length + topSeeds.length < 128) { unseededPool.push(`__bye_${unseededPool.length}`); } for (let s = 0; s < NUM_SIMULATIONS; s++) { // Draw: shuffle the unseeded pool — seeded positions are pre-computed. const drawnUnseeded = shuffle([...unseededPool]); // Build R1 pairs inline using pre-computed seeded slots. const r1Pairs: Array<[string, string]> = []; for (let i = 0; i < TOP_SEEDS; i++) { r1Pairs.push([seededSlots[i], drawnUnseeded[i]]); } for (let i = TOP_SEEDS; i < drawnUnseeded.length; i += 2) { r1Pairs.push([drawnUnseeded[i], drawnUnseeded[i + 1]]); } // R1 (64 matches) const r1Winners: string[] = []; for (const [p1, p2] of r1Pairs) { r1Winners.push(simMatch(p1, p2, SETS_TO_WIN[0])); } // R2–R4 (32 / 16 / 8 matches) const r2Winners: string[] = []; for (let i = 0; i < r1Winners.length; i += 2) { r2Winners.push(simMatch(r1Winners[i], r1Winners[i + 1], SETS_TO_WIN[1])); } const r3Winners: string[] = []; for (let i = 0; i < r2Winners.length; i += 2) { r3Winners.push(simMatch(r2Winners[i], r2Winners[i + 1], SETS_TO_WIN[2])); } const r4Winners: string[] = []; for (let i = 0; i < r3Winners.length; i += 2) { r4Winners.push(simMatch(r3Winners[i], r3Winners[i + 1], SETS_TO_WIN[3])); } // QF (4 matches) const qfWinners: string[] = []; for (let i = 0; i < r4Winners.length; i += 2) { const p1 = r4Winners[i], p2 = r4Winners[i + 1]; const winner = simMatch(p1, p2, SETS_TO_WIN[4]); const loser = winner === p1 ? p2 : p1; qfWinners.push(winner); qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1); } // SF (2 matches) 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, SETS_TO_WIN[5]); const loser = winner === p1 ? p2 : p1; sfWinners.push(winner); sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1); } // Final const champion = simMatch(sfWinners[0], sfWinners[1], SETS_TO_WIN[6]); 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: "darts_world_championship_monte_carlo", }; }); // Per-position column normalisation — 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; }