brackt/app/services/simulations/__tests__/darts-simulator.test.ts
Claude a92cd6da97
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
2026-03-31 18:21:04 +00:00

460 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].sort((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);
const seededSet = new Set(seeded);
const unseededSet = new Set(unseeded);
for (const [a, b] of pairs) {
// Each pair has one seeded and one unseeded (first 32 pairs),
// or two unseeded (last 32 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 unseededSet = new Set(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].sort((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/);
});
});