brackt/app/services/simulations/darts-simulator.ts
Claude a92cd6da97
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
2026-03-31 18:21:04 +00:00

650 lines
25 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;
/**
* Standard seeded R128 bracket for the top 32 seeds.
* Each entry is [topSeedSlot, unseededOpponentIndex] where:
* - topSeedSlot is 1-indexed seed number (1 = world #1)
* - unseededOpponentIndex is a placeholder (0..31) indicating which randomly-drawn
* unseeded player fills this slot; the actual opponent is drawn at runtime.
*
* Structure preserves the property that seeds 1 and 2 can only meet in the Final,
* seeds 1/2/3/4 can only meet in the SFs or later, etc.
*
* Top half: 1v?, 16v?, 9v?, 8v?, 5v?, 12v?, 13v?, 4v?
* Bottom half: 3v?, 14v?, 11v?, 6v?, 7v?, 10v?, 15v?, 2v?
* (Each seeded position plays an unseeded opponent in R1)
*/
const SEEDED_R1_PAIRS: Array<[number, number]> = [
[1, 0], [16, 1],
[9, 2], [8, 3],
[5, 4], [12, 5],
[13, 6], [4, 7],
[3, 8], [14, 9],
[11, 10],[6, 11],
[7, 12], [10, 13],
[15, 14],[2, 15],
];
/**
* Unseeded R1 matchups: the 96 unseeded players pair off in 48 matches among
* themselves for the remaining 32 R1 slots. Seeds 33128 are randomly drawn.
* We model this by randomly pairing all 96 unseeded players in R1.
*/
// ─── 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:
* - 16 seeded-vs-unseeded matches (SEEDED_R1_PAIRS, one per seed 116)
* - 16 seeded-vs-unseeded matches (seeds 1732 each also get an unseeded opponent)
* - 32 unseeded-vs-unseeded matches (remaining 64 unseeded players)
*
* Wait — in the PDC World Championship, seeds 132 are all seeded and each plays
* an unseeded opponent in R1. That gives 32 seeded matches. The remaining 64
* unseeded players fill 32 R1 matches among themselves.
* Total: 32 + 32 = 64 R1 matches ✓ (128 players).
*
* 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].sort((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;
};
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Draw: shuffle the unseeded pool and build R1 bracket.
const drawnUnseeded = shuffle([...unseeded]);
// If fewer than 128 players total, pad with ghost entries using a low fallback Elo.
// (In practice the admin should always load 128 players.)
while (drawnUnseeded.length + topSeeds.length < 128) {
drawnUnseeded.push(`__bye_${drawnUnseeded.length}`);
}
const r1Pairs = buildR1Bracket(topSeeds, drawnUnseeded);
// 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;
}