brackt/app/services/simulations/darts-simulator.ts
Claude 4142b21c55
Honor engine knobs across simulators, de-dupe odds, unify config UI
Two problems addressed:

1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied).
   - The NHL simulator hardcoded its parity factor (1000) and ignored the
     season config's parityFactor, so the knob meant to flatten the
     distribution did nothing. It also re-blended raw futures odds into every
     game on top of the odds->Elo conversion, double-counting the same signal.
   - NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config
     and no longer re-blends odds per game (odds enter once, via the central
     odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to
     ~13% title odds.

2. "Season Config" and "Input Policy" were two forms over the same stored
   object that didn't reflect each other, and the engine-knob half was inert
   for many simulators.
   - Every simulator now reads its engine knobs (iterations everywhere;
     parityFactor for all Elo-based sims) from the merged config, passed in by
     the runner via the Simulator interface. Defaults equal the former
     hardcoded constants, so behavior is unchanged unless a season overrides.
   - The admin simulator page is now a single "Simulator Configuration" card
     with structured Engine and Input-derivation sections (profile-driven, so
     each sport shows only the knobs it honors) plus an Advanced raw-JSON
     escape hatch — all writing the same config.

Also: centralized the duplicated configNumber helpers into config-access.ts;
the central odds->Elo resolver now maps onto the configured Elo floor/ceiling
so those bounds set the odds-derived spread (a real flattening dial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf
2026-06-30 22:00:33 +00:00

656 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
* probFifthEighth → 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";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters ────────────────────────────────────────────────────
/**
* 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 at the elite level is highly skill-dominated; 200 gives:
* 100-pt gap → ~62% per set
* 200-pt gap → ~73% per set
* 300-pt gap → ~83% per set
*
* With 200, a real PDC field (top players 18001970 Elo, unseeded at 1400)
* produces EV ≈ 6570 for the world #1 (≈2080 Elo) in a 128-player bracket.
*/
const ELO_DIVISOR = 200;
/**
* 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 132 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]> = [];
// Interleave seeded and unseeded pairs so each seed's R1 match is immediately
// followed by an unseeded-vs-unseeded match. This ensures the two types of
// match converge in R2 rather than running as separate sub-brackets until the Final.
for (let i = 0; i < 32; i++) {
const seedPos = seededMatchOrder[i] - 1; // 0-indexed
// Even slot: seed vs. unseeded[i]
pairs.push([seededIds[seedPos], unseededIds[i]]);
// Odd slot: unseeded vs. unseeded (indices 32 + 2i and 32 + 2i + 1)
pairs.push([unseededIds[32 + i * 2], unseededIds[32 + i * 2 + 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<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 DartsSimulator implements Simulator {
private readonly numSimulations: number;
constructor(numSimulations = 10_000) {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
// 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.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
const rankingMap = new Map<string, number>();
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, numSimulations);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db, numSimulations);
}
}
// ─── Path A: Bracket drawn ────────────────────────────────────────────────
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 (R1 first = most matches).
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 !== 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);
}
// 1400 reflects the typical strength of unseeded PDC World Championship
// qualifiers (regional/Q-School players), who are significantly weaker than
// the seeded tour players.
const fallbackElo = 1400;
// Cache matchWinProb — Elo values are fixed across simulations.
const matchProbCache = new Map<string, number>();
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<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++) {
// 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, numSimulations, {
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<string, number>,
rankingMap: 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 < 2) {
throw new Error(
`Pre-bracket simulation requires at least 2 participants (got ${allParticipants.length}). ` +
`Add players to this sports season first.`
);
}
// 1400 reflects the typical strength of unseeded PDC World Championship
// qualifiers (regional/Q-School players), who are significantly weaker than
// the seeded tour players.
const fallbackElo = 1400;
// 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 132
const unseeded = sorted.slice(TOP_SEEDS).map((p) => p.id); // remaining players
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]));
// Cache set-level probabilities — fixed across all simulations.
const matchProbCache = new Map<string, number>();
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 < numSimulations; s++) {
// Draw: shuffle the unseeded pool — seeded positions are pre-computed.
const drawnUnseeded = shuffle([...unseededPool]);
// Build R1 pairs inline using pre-computed seeded slots.
// IMPORTANT: interleave each seeded match with its adjacent unseeded match.
// Without interleaving, all 32 seeded matches come first (pairs 031) and
// all 32 unseeded matches come last (pairs 3263). Because R2 pairs adjacent
// R1 winners, this creates two completely separate sub-brackets (seeded vs.
// unseeded) that only converge at the Final — producing absurd results like
// unseeded 1800-Elo players having a 20% finalist probability.
// Interleaving ensures seeded and unseeded regions mix from R2 onwards.
const r1Pairs: Array<[string, string]> = [];
for (let i = 0; i < TOP_SEEDS; i++) {
// Even slot: seeded player vs. their randomly drawn unseeded opponent
r1Pairs.push([seededSlots[i], drawnUnseeded[i]]);
// Odd slot (adjacent): unseeded vs. unseeded pair that feeds into the
// same R2 match as the seeded slot above
r1Pairs.push([drawnUnseeded[TOP_SEEDS + i * 2], drawnUnseeded[TOP_SEEDS + i * 2 + 1]]);
}
// R1 (64 matches)
const r1Winners: string[] = [];
for (const [p1, p2] of r1Pairs) {
r1Winners.push(simMatch(p1, p2, SETS_TO_WIN[0]));
}
// R2R4 (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, 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: "darts_world_championship_monte_carlo",
};
});
// Per-position column normalisation — 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;
}