brackt/app/services/simulations/__tests__/darts-simulator.test.ts
Chris Parsons 8c3663e01b
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

455 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
it("calibration: 100-pt gap → ~55% per set (ELO_DIVISOR=500)", () => {
// 1 / (1 + e^(-100/500)) ≈ 0.5499
expect(setWinProb(1900, 1800)).toBeCloseTo(0.5499, 2);
});
});
// ─── 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);
// First 32 pairs: seeded vs unseeded
for (let i = 0; i < 32; i++) {
const [a, b] = pairs[i];
const aSeeded = seededSet.has(a);
const bSeeded = seededSet.has(b);
// One must be seeded, the other unseeded
expect(aSeeded !== bSeeded).toBe(true);
}
});
it("last 32 pairs: all unseeded vs unseeded", () => {
const pairs = buildR1Bracket(seeded, unseeded);
const seededSet = new Set(seeded);
for (let i = 32; i < 64; i++) {
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,
sourceElo: ELO_BASE - i * 2, // higher seed = higher Elo
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);
});
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/);
});
});