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
|
|
|
|
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
setWinProb,
|
|
|
|
|
|
matchWinProb,
|
|
|
|
|
|
getSeededMatchOrder,
|
|
|
|
|
|
buildR1Bracket,
|
|
|
|
|
|
DartsSimulator,
|
|
|
|
|
|
} from "../darts-simulator";
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Pure math: setWinProb ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("setWinProb", () => {
|
|
|
|
|
|
it("returns 0.5 for equal Elo", () => {
|
|
|
|
|
|
expect(setWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
|
|
|
|
|
|
expect(setWinProb(2000, 2000)).toBeCloseTo(0.5, 5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
|
|
|
|
|
|
expect(setWinProb(2000, 1700)).toBeGreaterThan(0.5);
|
|
|
|
|
|
expect(setWinProb(1700, 2000)).toBeLessThan(0.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is symmetric: setWinProb(a, b) + setWinProb(b, a) = 1", () => {
|
|
|
|
|
|
expect(setWinProb(2000, 1700) + setWinProb(1700, 2000)).toBeCloseTo(1.0, 5);
|
|
|
|
|
|
expect(setWinProb(2099, 1952) + setWinProb(1952, 2099)).toBeCloseTo(1.0, 5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("larger Elo gap → higher win probability (monotonic)", () => {
|
|
|
|
|
|
const p100 = setWinProb(1900, 1800);
|
|
|
|
|
|
const p300 = setWinProb(2100, 1800);
|
|
|
|
|
|
const p500 = setWinProb(2300, 1800);
|
|
|
|
|
|
expect(p300).toBeGreaterThan(p100);
|
|
|
|
|
|
expect(p500).toBeGreaterThan(p300);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns value strictly in (0, 1) for any finite Elo", () => {
|
|
|
|
|
|
expect(setWinProb(3000, 1000)).toBeLessThan(1);
|
|
|
|
|
|
expect(setWinProb(3000, 1000)).toBeGreaterThan(0);
|
|
|
|
|
|
expect(setWinProb(1000, 3000)).toBeLessThan(1);
|
|
|
|
|
|
expect(setWinProb(1000, 3000)).toBeGreaterThan(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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>
2026-04-09 00:47:47 -04:00
|
|
|
|
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);
|
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
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Pure math: matchWinProb ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("matchWinProb", () => {
|
|
|
|
|
|
it("returns 0.5 for equal players (p=0.5) at any match length", () => {
|
|
|
|
|
|
// PDC formats: bo3 (S=2), bo5 (S=3), bo7 (S=4), bo11 (S=6), bo13 (S=7)
|
|
|
|
|
|
expect(matchWinProb(0.5, 2)).toBeCloseTo(0.5, 3);
|
|
|
|
|
|
expect(matchWinProb(0.5, 3)).toBeCloseTo(0.5, 3);
|
|
|
|
|
|
expect(matchWinProb(0.5, 4)).toBeCloseTo(0.5, 3);
|
|
|
|
|
|
expect(matchWinProb(0.5, 6)).toBeCloseTo(0.5, 3);
|
|
|
|
|
|
expect(matchWinProb(0.5, 7)).toBeCloseTo(0.5, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("approaches 1.0 as p → 1.0", () => {
|
|
|
|
|
|
expect(matchWinProb(0.9999, 2)).toBeCloseTo(1.0, 3);
|
|
|
|
|
|
expect(matchWinProb(0.9999, 7)).toBeCloseTo(1.0, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("approaches 0.0 as p → 0.0", () => {
|
|
|
|
|
|
expect(matchWinProb(0.0001, 2)).toBeCloseTo(0.0, 3);
|
|
|
|
|
|
expect(matchWinProb(0.0001, 7)).toBeCloseTo(0.0, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("longer matches amplify stronger player's advantage", () => {
|
|
|
|
|
|
// p=0.6: win prob should be higher in best-of-13 than best-of-3
|
|
|
|
|
|
const bo3 = matchWinProb(0.6, 2);
|
|
|
|
|
|
const bo13 = matchWinProb(0.6, 7);
|
|
|
|
|
|
expect(bo13).toBeGreaterThan(bo3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("match win prob + opponent win prob sums to 1.0", () => {
|
|
|
|
|
|
const p = 0.63;
|
|
|
|
|
|
expect(matchWinProb(p, 3) + matchWinProb(1 - p, 3)).toBeCloseTo(1.0, 5);
|
|
|
|
|
|
expect(matchWinProb(p, 6) + matchWinProb(1 - p, 6)).toBeCloseTo(1.0, 5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("best-of-3 (S=2): reference check for p=0.6 → ~0.648", () => {
|
|
|
|
|
|
// P(2-0) = 0.6^2 = 0.36; P(2-1) = 2 * 0.6^2 * 0.4 = 0.288; total = 0.648
|
|
|
|
|
|
expect(matchWinProb(0.6, 2)).toBeCloseTo(0.648, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Bracket structure: getSeededMatchOrder ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("getSeededMatchOrder", () => {
|
|
|
|
|
|
it("for n=2, returns [1, 2]", () => {
|
|
|
|
|
|
expect(getSeededMatchOrder(2)).toEqual([1, 2]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("for n=4, seed 1 vs 4 in top half, seed 2 vs 3 in bottom half", () => {
|
|
|
|
|
|
// Algorithm: start [1,2] → expand to [1,4,2,3]
|
|
|
|
|
|
expect(getSeededMatchOrder(4)).toEqual([1, 4, 2, 3]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("for n=8, seed 1 and seed 2 are in opposite halves", () => {
|
|
|
|
|
|
const order = getSeededMatchOrder(8);
|
|
|
|
|
|
expect(order).toHaveLength(8);
|
|
|
|
|
|
const idx1 = order.indexOf(1);
|
|
|
|
|
|
const idx2 = order.indexOf(2);
|
|
|
|
|
|
// Seeds 1 and 2 should be in different halves (0-3 vs 4-7)
|
|
|
|
|
|
expect(Math.floor(idx1 / 4)).not.toBe(Math.floor(idx2 / 4));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("for n=32, contains all seeds 1–32 exactly once", () => {
|
|
|
|
|
|
const order = getSeededMatchOrder(32);
|
|
|
|
|
|
expect(order).toHaveLength(32);
|
|
|
|
|
|
const sorted = [...order].toSorted((a, b) => a - b);
|
|
|
|
|
|
expect(sorted).toEqual(Array.from({ length: 32 }, (_, i) => i + 1));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("for n=32, seed 1 and seed 2 are in opposite halves", () => {
|
|
|
|
|
|
const order = getSeededMatchOrder(32);
|
|
|
|
|
|
const idx1 = order.indexOf(1);
|
|
|
|
|
|
const idx2 = order.indexOf(2);
|
|
|
|
|
|
// Opposite halves: one in [0,15], other in [16,31]
|
|
|
|
|
|
expect(Math.floor(idx1 / 16)).not.toBe(Math.floor(idx2 / 16));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Bracket structure: buildR1Bracket ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("buildR1Bracket", () => {
|
|
|
|
|
|
const seeded = Array.from({ length: 32 }, (_, i) => `seed-${i + 1}`);
|
|
|
|
|
|
const unseeded = Array.from({ length: 96 }, (_, i) => `unseed-${i}`);
|
|
|
|
|
|
|
|
|
|
|
|
it("returns exactly 64 pairs", () => {
|
|
|
|
|
|
const pairs = buildR1Bracket(seeded, unseeded);
|
|
|
|
|
|
expect(pairs).toHaveLength(64);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("covers all 128 players (32 seeded + 96 unseeded)", () => {
|
|
|
|
|
|
const pairs = buildR1Bracket(seeded, unseeded);
|
|
|
|
|
|
const allPlayers = pairs.flat();
|
|
|
|
|
|
expect(allPlayers).toHaveLength(128);
|
|
|
|
|
|
|
|
|
|
|
|
for (const [a, b] of pairs) {
|
|
|
|
|
|
expect(a).toBeTruthy();
|
|
|
|
|
|
expect(b).toBeTruthy();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// All seeded players appear exactly once
|
|
|
|
|
|
for (const s of seeded) {
|
|
|
|
|
|
expect(allPlayers.filter(p => p === s)).toHaveLength(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// All unseeded players appear exactly once
|
|
|
|
|
|
for (const u of unseeded) {
|
|
|
|
|
|
expect(allPlayers.filter(p => p === u)).toHaveLength(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("each of the 32 seeded players faces an unseeded opponent in their R1 match", () => {
|
|
|
|
|
|
const pairs = buildR1Bracket(seeded, unseeded);
|
|
|
|
|
|
const seededSet = new Set(seeded);
|
|
|
|
|
|
|
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>
2026-04-09 00:47:47 -04:00
|
|
|
|
// Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded
|
|
|
|
|
|
for (let i = 0; i < 64; i += 2) {
|
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
|
|
|
|
const [a, b] = pairs[i];
|
|
|
|
|
|
const aSeeded = seededSet.has(a);
|
|
|
|
|
|
const bSeeded = seededSet.has(b);
|
|
|
|
|
|
expect(aSeeded !== bSeeded).toBe(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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>
2026-04-09 00:47:47 -04:00
|
|
|
|
it("odd-indexed pairs are all unseeded vs unseeded (interleaved bracket structure)", () => {
|
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
|
|
|
|
const pairs = buildR1Bracket(seeded, unseeded);
|
|
|
|
|
|
const seededSet = new Set(seeded);
|
|
|
|
|
|
|
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>
2026-04-09 00:47:47 -04:00
|
|
|
|
// Odd-indexed pairs (1, 3, 5, ...) are unseeded vs unseeded
|
|
|
|
|
|
for (let i = 1; i < 64; i += 2) {
|
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
|
|
|
|
const [a, b] = pairs[i];
|
|
|
|
|
|
expect(seededSet.has(a)).toBe(false);
|
|
|
|
|
|
expect(seededSet.has(b)).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Integration tests with mocked DB ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const PARTICIPANT_IDS = Array.from({ length: 128 }, (_, i) => `player-${i + 1}`);
|
|
|
|
|
|
const ELO_BASE = 1750;
|
|
|
|
|
|
|
|
|
|
|
|
function makeMatch(
|
|
|
|
|
|
round: string,
|
|
|
|
|
|
matchNumber: number,
|
|
|
|
|
|
p1Index: number,
|
|
|
|
|
|
p2Index: number,
|
|
|
|
|
|
opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}
|
|
|
|
|
|
) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: `${round}-match-${matchNumber}`,
|
|
|
|
|
|
scoringEventId: "event-1",
|
|
|
|
|
|
round,
|
|
|
|
|
|
matchNumber,
|
|
|
|
|
|
participant1Id: PARTICIPANT_IDS[p1Index],
|
|
|
|
|
|
participant2Id: PARTICIPANT_IDS[p2Index],
|
|
|
|
|
|
winnerId: opts.winnerId ?? null,
|
|
|
|
|
|
loserId: opts.loserId ?? null,
|
|
|
|
|
|
isComplete: opts.isComplete ?? false,
|
|
|
|
|
|
isScoring: true,
|
|
|
|
|
|
templateRound: round,
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
participant1Score: null,
|
|
|
|
|
|
participant2Score: null,
|
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Build a full 7-round bracket (128 players, 127 matches). */
|
|
|
|
|
|
function makeFullBracket(): ReturnType<typeof makeMatch>[] {
|
|
|
|
|
|
const matches: ReturnType<typeof makeMatch>[] = [];
|
|
|
|
|
|
for (let i = 0; i < 64; i++) matches.push(makeMatch("Round 1", i + 1, i * 2, i * 2 + 1));
|
|
|
|
|
|
for (let i = 0; i < 32; i++) matches.push(makeMatch("Round 2", i + 1, 0, 1));
|
|
|
|
|
|
for (let i = 0; i < 16; i++) matches.push(makeMatch("Round 3", i + 1, 0, 1));
|
|
|
|
|
|
for (let i = 0; i < 8; i++) matches.push(makeMatch("Round 4", i + 1, 0, 1));
|
|
|
|
|
|
for (let i = 0; i < 4; i++) matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1));
|
|
|
|
|
|
for (let i = 0; i < 2; i++) matches.push(makeMatch("Semi-Finals", i + 1, 0, 1));
|
|
|
|
|
|
matches.push(makeMatch("Final", 1, 0, 1));
|
|
|
|
|
|
return matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeEVRows(eloOverride?: Map<string, number>) {
|
|
|
|
|
|
return PARTICIPANT_IDS.map((participantId) => ({
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sourceElo: eloOverride?.get(participantId) ?? ELO_BASE,
|
|
|
|
|
|
worldRanking: null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock("~/database/context", () => ({
|
|
|
|
|
|
database: vi.fn(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
describe("DartsSimulator.simulate() — Path A (bracket populated)", () => {
|
|
|
|
|
|
let mockDb: {
|
|
|
|
|
|
query: {
|
|
|
|
|
|
scoringEvents: { findFirst: MockInstance };
|
|
|
|
|
|
playoffMatches: { findMany: MockInstance };
|
|
|
|
|
|
};
|
|
|
|
|
|
select: MockInstance;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
|
const { database } = await import("~/database/context");
|
|
|
|
|
|
|
|
|
|
|
|
mockDb = {
|
|
|
|
|
|
query: {
|
|
|
|
|
|
scoringEvents: { findFirst: vi.fn() },
|
|
|
|
|
|
playoffMatches: { findMany: vi.fn() },
|
|
|
|
|
|
},
|
|
|
|
|
|
select: vi.fn().mockReturnValue({
|
|
|
|
|
|
from: vi.fn().mockReturnValue({
|
|
|
|
|
|
where: vi.fn().mockResolvedValue(makeEVRows()),
|
|
|
|
|
|
}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
|
|
|
|
|
|
|
|
|
|
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
|
|
|
|
|
|
id: "event-1",
|
|
|
|
|
|
sportsSeasonId: "season-1",
|
|
|
|
|
|
eventType: "playoff_game",
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws if bracket does not have exactly 7 rounds", async () => {
|
|
|
|
|
|
// Only 6 rounds (missing Final)
|
|
|
|
|
|
const matches = [
|
|
|
|
|
|
...Array.from({ length: 64 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)),
|
|
|
|
|
|
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
|
|
|
|
|
|
];
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 7 rounds/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws if R1 does not have 64 matches", async () => {
|
|
|
|
|
|
const matches = [
|
|
|
|
|
|
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 1", i + 1, i * 2, i * 2 + 1)),
|
|
|
|
|
|
...Array.from({ length: 32 }, (_, i) => makeMatch("Round 2", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 16 }, (_, i) => makeMatch("Round 3", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 8 }, (_, i) => makeMatch("Round 4", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
|
|
|
|
|
|
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
|
|
|
|
|
|
makeMatch("Final", 1, 0, 1),
|
|
|
|
|
|
];
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 64 R1 matches/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns 128 results with valid probability distributions", async () => {
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
expect(results).toHaveLength(128);
|
|
|
|
|
|
for (const r of results) {
|
|
|
|
|
|
const p = r.probabilities;
|
|
|
|
|
|
expect(p.probFirst).toBeGreaterThanOrEqual(0);
|
|
|
|
|
|
expect(p.probFirst).toBeLessThanOrEqual(1);
|
|
|
|
|
|
const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
|
|
|
|
|
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
|
|
|
|
|
|
expect(sum).toBeGreaterThanOrEqual(0);
|
|
|
|
|
|
expect(sum).toBeLessThanOrEqual(1.001);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("each probability column sums to 1.0 across all 128 participants", async () => {
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
const keys = [
|
|
|
|
|
|
"probFirst", "probSecond", "probThird", "probFourth",
|
|
|
|
|
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
|
|
|
|
|
expect(colSum).toBeCloseTo(1.0, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("labels results with darts_world_championship_monte_carlo source", async () => {
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
expect(results.every(r => r.source === "darts_world_championship_monte_carlo")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Path B: pre-bracket simulation ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("DartsSimulator.simulate() — Path B (pre-bracket)", () => {
|
|
|
|
|
|
let mockDb: {
|
|
|
|
|
|
query: {
|
|
|
|
|
|
scoringEvents: { findFirst: MockInstance };
|
|
|
|
|
|
playoffMatches: { findMany: MockInstance };
|
|
|
|
|
|
};
|
|
|
|
|
|
select: MockInstance;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 128 participants with world rankings
|
|
|
|
|
|
const SEASON_PARTICIPANTS = Array.from({ length: 128 }, (_, i) => ({
|
|
|
|
|
|
id: `player-${i + 1}`,
|
|
|
|
|
|
name: `Player ${i + 1}`,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
function makeEVRowsWithRankings() {
|
|
|
|
|
|
return SEASON_PARTICIPANTS.map((p, i) => ({
|
|
|
|
|
|
participantId: p.id,
|
2026-03-31 15:53:43 -07:00
|
|
|
|
sourceElo: ELO_BASE - i * 100, // higher seed = higher Elo; large gap needed for reliable MC ordering
|
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
|
|
|
|
worldRanking: i + 1,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
|
const { database } = await import("~/database/context");
|
|
|
|
|
|
|
|
|
|
|
|
const eloRows = makeEVRowsWithRankings();
|
|
|
|
|
|
const participantRows = SEASON_PARTICIPANTS;
|
|
|
|
|
|
|
|
|
|
|
|
mockDb = {
|
|
|
|
|
|
query: {
|
|
|
|
|
|
scoringEvents: { findFirst: vi.fn() },
|
|
|
|
|
|
playoffMatches: { findMany: vi.fn() },
|
|
|
|
|
|
},
|
|
|
|
|
|
select: vi.fn()
|
|
|
|
|
|
.mockReturnValueOnce({
|
|
|
|
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
|
|
|
|
|
|
})
|
|
|
|
|
|
.mockReturnValueOnce({
|
|
|
|
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
|
|
|
|
|
|
|
|
|
|
|
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
|
|
|
|
|
|
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns 128 results when bracket is not yet populated", async () => {
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
expect(results).toHaveLength(128);
|
2026-05-02 12:45:53 -07:00
|
|
|
|
}, 15_000);
|
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
|
|
|
|
|
|
|
|
|
|
it("each probability column sums to 1.0 across all 128 participants", async () => {
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
const keys = [
|
|
|
|
|
|
"probFirst", "probSecond", "probThird", "probFourth",
|
|
|
|
|
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
|
|
|
|
|
expect(colSum).toBeCloseTo(1.0, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("world #1 (highest Elo) has the highest probFirst", async () => {
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
const results = await sim.simulate("season-1");
|
|
|
|
|
|
|
|
|
|
|
|
const sorted = [...results].toSorted((a, b) => b.probabilities.probFirst - a.probabilities.probFirst);
|
|
|
|
|
|
// player-1 is world #1 with the highest Elo — should have highest win probability
|
|
|
|
|
|
expect(sorted[0].participantId).toBe("player-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws if fewer than 2 participants exist", async () => {
|
|
|
|
|
|
const { database } = await import("~/database/context");
|
|
|
|
|
|
const tooFew = [{ id: "player-1", name: "Solo" }];
|
|
|
|
|
|
const eloRowsFew = [{ participantId: "player-1", sourceElo: ELO_BASE, worldRanking: 1 }];
|
|
|
|
|
|
|
|
|
|
|
|
(database as unknown as MockInstance).mockReturnValue({
|
|
|
|
|
|
...mockDb,
|
|
|
|
|
|
query: {
|
|
|
|
|
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
|
|
|
|
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
|
|
|
|
|
},
|
|
|
|
|
|
select: vi.fn()
|
|
|
|
|
|
.mockReturnValueOnce({
|
|
|
|
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRowsFew) }),
|
|
|
|
|
|
})
|
|
|
|
|
|
.mockReturnValueOnce({
|
|
|
|
|
|
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const sim = new DartsSimulator();
|
|
|
|
|
|
await expect(sim.simulate("season-1")).rejects.toThrow(/at least 2 participants/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|