brackt/app/services/simulations/darts-simulator.ts
Chris Parsons 8c3663e01b
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123

* Add PDC World Darts Championship simulator (128-player bracket)

- New DartsSimulator: 128-player single-elimination, 7 rounds with
  PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
  ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
  Path A (bracket drawn) simulates from actual DB matches; Path B
  (pre-bracket) seeds top 32 by world ranking in fixed positions and
  randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
  (migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
  "Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
  matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.

https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz

* Review fixes: pre-compute hot-loop invariants, import normalizeName

- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
  simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
  removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally

https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz

* Fix lint failures: unused vars, eqeqeq, toSorted

- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)

https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz

* Fix TS2552: restore seededSet declaration removed during lint fix

https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00

629 lines
24 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";
// ─── 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 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]> = [];
// 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 3295)
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<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 {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
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<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);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db);
}
}
// ─── Path A: Bracket drawn ────────────────────────────────────────────────
private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, 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);
}
const fallbackElo = 1600;
// 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 < 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<string, number>,
rankingMap: Map<string, number>,
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
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 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 < 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]));
}
// 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, NUM_SIMULATIONS, {
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;
}