From a6bb1330e6d389e4cf5d9dfef6ea7132f8412e07 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:47:47 -0400 Subject: [PATCH] Fix darts simulator: bracket bug, ELO calibration, fallback Elo (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: lower darts ELO_DIVISOR to 400 and fallback Elo to 1400 ELO_DIVISOR 500 → 400: elite darts is more skill-dominated than the previous setting implied. A 400-point gap now gives ~78% per-set win probability (vs ~73% before). fallbackElo 1600 → 1400: unseeded PDC World Championship entrants (regional qualifiers, Q-School players) are materially weaker than seeded tour professionals. 1400 better reflects that gap. Combined effect: a dominant player (e.g. 2000 Elo vs 1400-1600 field) now produces EV ≈ 65 instead of ≈ 30, which better matches expectations for a top-ranked player in a 128-player bracket. Co-Authored-By: Claude Sonnet 4.6 * fix: lower darts ELO_DIVISOR to 200 for realistic EV distribution With the actual PDC field (top players clustered 1800–1970 Elo, Littler at ~2080), ELO_DIVISOR=400 made the gap between world #1 and the top 8 too soft — EV for Littler came out ~37 instead of the expected 65–70. ELO_DIVISOR=200 calibrates correctly for this field: a 100-pt gap now gives ~62% per-set win probability (vs ~56% at 400), sharply rewarding the best players while still allowing upsets. Littler at 2084 produces EV ≈ 65–70 against the real PDC tour roster. Co-Authored-By: Claude Sonnet 4.6 * fix: seed darts bracket by Elo instead of world ranking World ranking (PDC Order of Merit) is prize-money-based and can diverge from current skill — e.g. Kevin Doets (1818 Elo) was ranked 35th while Joe Cullen (1667 Elo) held the 32nd seed. Seeding the weaker player and leaving the stronger one unseeded contradicts the Elo-based match model. Seeding by Elo descending ensures the 32 strongest players (by the same metric used to compute match probabilities) get protected bracket positions. Co-Authored-By: Claude Sonnet 4.6 * revert: restore world-ranking-based seeding for darts bracket Seeding should follow the PDC Order of Merit (world ranking), not Elo. Reverts the previous Elo-seeding commit. Co-Authored-By: Claude Sonnet 4.6 * fix: interleave seeded and unseeded R1 pairs in darts bracket Previously, all 32 seeded-vs-unseeded matches were added to r1Pairs first (indices 0–31) and all 32 unseeded-vs-unseeded matches last (indices 32–63). Since R2 pairs adjacent R1 winners, this created two completely separate sub-brackets that only converged at the Final: - Seeded sub-bracket: top players eliminating each other early - Unseeded sub-bracket: high-Elo unseeded players (e.g. Doets 1818, Zonneveld 1806) steamrolling weak opponents and making the Final ~20% of the time Fix: interleave each seeded match with its adjacent unseeded-vs-unseeded match. Seeded pair i is at r1Pairs[2i], unseeded pair i is at r1Pairs[2i+1]. From R2 onwards, winners from seeded and unseeded regions now merge correctly as in a real single-elimination bracket. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/darts-simulator.test.ts | 16 +++--- app/services/simulations/darts-simulator.ts | 49 +++++++++++++------ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/services/simulations/__tests__/darts-simulator.test.ts b/app/services/simulations/__tests__/darts-simulator.test.ts index 1df59e7..6ad9b5e 100644 --- a/app/services/simulations/__tests__/darts-simulator.test.ts +++ b/app/services/simulations/__tests__/darts-simulator.test.ts @@ -40,9 +40,9 @@ describe("setWinProb", () => { expect(setWinProb(1000, 3000)).toBeGreaterThan(0); }); - it("calibration: 100-pt gap → ~55% per set (ELO_DIVISOR=500)", () => { - // 1 / (1 + e^(-100/500)) ≈ 0.5499 - expect(setWinProb(1900, 1800)).toBeCloseTo(0.5499, 2); + it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => { + // 1 / (1 + e^(-100/200)) ≈ 0.6225 + expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2); }); }); @@ -160,21 +160,21 @@ describe("buildR1Bracket", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); - // First 32 pairs: seeded vs unseeded - for (let i = 0; i < 32; i++) { + // Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded + for (let i = 0; i < 64; i += 2) { const [a, b] = pairs[i]; const aSeeded = seededSet.has(a); const bSeeded = seededSet.has(b); - // One must be seeded, the other unseeded expect(aSeeded !== bSeeded).toBe(true); } }); - it("last 32 pairs: all unseeded vs unseeded", () => { + it("odd-indexed pairs are all unseeded vs unseeded (interleaved bracket structure)", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); - for (let i = 32; i < 64; i++) { + // Odd-indexed pairs (1, 3, 5, ...) are unseeded vs unseeded + for (let i = 1; i < 64; i += 2) { const [a, b] = pairs[i]; expect(seededSet.has(a)).toBe(false); expect(seededSet.has(b)).toBe(false); diff --git a/app/services/simulations/darts-simulator.ts b/app/services/simulations/darts-simulator.ts index 922e6ba..207d4e0 100644 --- a/app/services/simulations/darts-simulator.ts +++ b/app/services/simulations/darts-simulator.ts @@ -59,12 +59,15 @@ const NUM_SIMULATIONS = 50000; * 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 + * 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 1800–1970 Elo, unseeded at 1400) + * produces EV ≈ 65–70 for the world #1 (≈2080 Elo) in a 128-player bracket. */ -const ELO_DIVISOR = 500; +const ELO_DIVISOR = 200; /** * Sets needed to win per round, in bracket order (R1 first, Final last). @@ -147,15 +150,15 @@ export function buildR1Bracket( const pairs: Array<[string, string]> = []; - // 32 seeded-vs-unseeded R1 matches + // 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]]); - } - - // 32 unseeded-vs-unseeded R1 matches (players 32–95) - for (let i = 32; i < 96; i += 2) { - pairs.push([unseededIds[i], unseededIds[i + 1]]); + // 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; @@ -289,7 +292,10 @@ export class DartsSimulator implements Simulator { participantIds.push(m.participant1Id, m.participant2Id); } - const fallbackElo = 1600; + // 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(); @@ -457,7 +463,10 @@ export class DartsSimulator implements Simulator { ); } - const fallbackElo = 1600; + // 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. @@ -510,12 +519,20 @@ export class DartsSimulator implements Simulator { 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 0–31) and + // all 32 unseeded matches come last (pairs 32–63). 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]]); - } - for (let i = TOP_SEEDS; i < drawnUnseeded.length; i += 2) { - r1Pairs.push([drawnUnseeded[i], drawnUnseeded[i + 1]]); + // 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)