647 lines
26 KiB
TypeScript
647 lines
26 KiB
TypeScript
/**
|
||
* 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";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_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<string, number> = {
|
||
"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<T>(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, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
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.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
// Build Elo map; fall back to 1500 for any participant with no stored rating.
|
||
const eloMap = new Map<string, number>();
|
||
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, numSimulations);
|
||
} else {
|
||
return this.simulatePreBracket(sportsSeasonId, eloMap, db, numSimulations);
|
||
}
|
||
}
|
||
|
||
// ─── Path A: Full bracket simulation ────────────────────────────────────────
|
||
|
||
private async simulateBracket(
|
||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
||
eloMap: Map<string, number>,
|
||
numSimulations: number
|
||
): Promise<SimulationResult[]> {
|
||
// Group matches by round, sorted by match count descending (R32 first).
|
||
const byRound = new Map<string, typeof allMatches>();
|
||
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<string, number>();
|
||
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<string, number>(participantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
|
||
for (let s = 0; s < numSimulations; 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, numSimulations, {
|
||
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<string, number>,
|
||
db: ReturnType<typeof database>,
|
||
numSimulations: number
|
||
): Promise<SimulationResult[]> {
|
||
const allParticipants = await db
|
||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.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<string, number>();
|
||
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<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const sfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const qfLoserCounts = new Map<string, number>(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 < numSimulations; 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<number, string>();
|
||
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, numSimulations, {
|
||
championCounts,
|
||
finalistCounts,
|
||
sfLoserCounts,
|
||
qfLoserCounts,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Shared result builder ─────────────────────────────────────────────────────
|
||
|
||
function buildResults(
|
||
participantIds: string[],
|
||
N: number,
|
||
counts: {
|
||
championCounts: Map<string, number>;
|
||
finalistCounts: Map<string, number>;
|
||
sfLoserCounts: Map<string, number>;
|
||
qfLoserCounts: Map<string, number>;
|
||
}
|
||
): 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<keyof typeof results[0]["probabilities"]> = [
|
||
"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;
|
||
}
|