Fix darts simulator: bracket bug, ELO calibration, fallback Elo (#284)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-09 00:47:47 -04:00 committed by GitHub
parent d5aa8a3de4
commit a6bb1330e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 24 deletions

View file

@ -40,9 +40,9 @@ describe("setWinProb", () => {
expect(setWinProb(1000, 3000)).toBeGreaterThan(0); expect(setWinProb(1000, 3000)).toBeGreaterThan(0);
}); });
it("calibration: 100-pt gap → ~55% per set (ELO_DIVISOR=500)", () => { it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => {
// 1 / (1 + e^(-100/500)) ≈ 0.5499 // 1 / (1 + e^(-100/200)) ≈ 0.6225
expect(setWinProb(1900, 1800)).toBeCloseTo(0.5499, 2); expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2);
}); });
}); });
@ -160,21 +160,21 @@ describe("buildR1Bracket", () => {
const pairs = buildR1Bracket(seeded, unseeded); const pairs = buildR1Bracket(seeded, unseeded);
const seededSet = new Set(seeded); const seededSet = new Set(seeded);
// First 32 pairs: seeded vs unseeded // Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded
for (let i = 0; i < 32; i++) { for (let i = 0; i < 64; i += 2) {
const [a, b] = pairs[i]; const [a, b] = pairs[i];
const aSeeded = seededSet.has(a); const aSeeded = seededSet.has(a);
const bSeeded = seededSet.has(b); const bSeeded = seededSet.has(b);
// One must be seeded, the other unseeded
expect(aSeeded !== bSeeded).toBe(true); 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 pairs = buildR1Bracket(seeded, unseeded);
const seededSet = new Set(seeded); 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]; const [a, b] = pairs[i];
expect(seededSet.has(a)).toBe(false); expect(seededSet.has(a)).toBe(false);
expect(seededSet.has(b)).toBe(false); expect(seededSet.has(b)).toBe(false);

View file

@ -59,12 +59,15 @@ const NUM_SIMULATIONS = 50000;
* Lower = sharper (Elo differences matter more). * Lower = sharper (Elo differences matter more).
* *
* Standard chess uses 400. Snooker (more random than chess) uses 700. * Standard chess uses 400. Snooker (more random than chess) uses 700.
* Darts is moderately volatile; 500 gives: * Darts at the elite level is highly skill-dominated; 200 gives:
* 100-pt gap ~55% per set * 100-pt gap ~62% per set
* 300-pt gap ~63% per set * 200-pt gap ~73% per set
* 500-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 = 500; const ELO_DIVISOR = 200;
/** /**
* Sets needed to win per round, in bracket order (R1 first, Final last). * 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]> = []; 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++) { for (let i = 0; i < 32; i++) {
const seedPos = seededMatchOrder[i] - 1; // 0-indexed const seedPos = seededMatchOrder[i] - 1; // 0-indexed
// Even slot: seed vs. unseeded[i]
pairs.push([seededIds[seedPos], unseededIds[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]]);
// 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; return pairs;
@ -289,7 +292,10 @@ export class DartsSimulator implements Simulator {
participantIds.push(m.participant1Id, m.participant2Id); 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. // Cache matchWinProb — Elo values are fixed across simulations.
const matchProbCache = new Map<string, number>(); const matchProbCache = new Map<string, number>();
@ -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 // Sort participants by world ranking (ascending). Fall back to Elo order (descending) for
// any without a ranking, then alphabetical as a final tiebreak. // any without a ranking, then alphabetical as a final tiebreak.
@ -510,12 +519,20 @@ export class DartsSimulator implements Simulator {
const drawnUnseeded = shuffle([...unseededPool]); const drawnUnseeded = shuffle([...unseededPool]);
// Build R1 pairs inline using pre-computed seeded slots. // 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]> = []; const r1Pairs: Array<[string, string]> = [];
for (let i = 0; i < TOP_SEEDS; i++) { for (let i = 0; i < TOP_SEEDS; i++) {
// Even slot: seeded player vs. their randomly drawn unseeded opponent
r1Pairs.push([seededSlots[i], drawnUnseeded[i]]); r1Pairs.push([seededSlots[i], drawnUnseeded[i]]);
} // Odd slot (adjacent): unseeded vs. unseeded pair that feeds into the
for (let i = TOP_SEEDS; i < drawnUnseeded.length; i += 2) { // same R2 match as the seeded slot above
r1Pairs.push([drawnUnseeded[i], drawnUnseeded[i + 1]]); r1Pairs.push([drawnUnseeded[TOP_SEEDS + i * 2], drawnUnseeded[TOP_SEEDS + i * 2 + 1]]);
} }
// R1 (64 matches) // R1 (64 matches)