brackt/app/services/simulations/__tests__/darts-simulator.test.ts

456 lines
16 KiB
TypeScript
Raw Normal View History

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 132 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);
}, 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/);
});
});