brackt/app/services/simulations/__tests__/tennis-simulator.test.ts
Chris Parsons d7ba20d9ea
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.

New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.

Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)

Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00

281 lines
12 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 } from "vitest";
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator";
// ─── eloWinProb ───────────────────────────────────────────────────────────────
describe("eloWinProb", () => {
it("returns 0.5 for equal Elo", () => {
expect(eloWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
expect(eloWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
});
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
expect(eloWinProb(2000, 1600)).toBeGreaterThan(0.5);
expect(eloWinProb(1600, 2000)).toBeLessThan(0.5);
});
it("is symmetric: eloWinProb(a, b) + eloWinProb(b, a) = 1", () => {
expect(eloWinProb(2000, 1600) + eloWinProb(1600, 2000)).toBeCloseTo(1.0, 5);
expect(eloWinProb(1837, 1756) + eloWinProb(1756, 1837)).toBeCloseTo(1.0, 5);
});
it("returns value in (0, 1)", () => {
expect(eloWinProb(3000, 1000)).toBeLessThan(1);
expect(eloWinProb(3000, 1000)).toBeGreaterThan(0);
expect(eloWinProb(1000, 3000)).toBeLessThan(1);
expect(eloWinProb(1000, 3000)).toBeGreaterThan(0);
});
it("larger Elo gap → higher win probability (monotonic)", () => {
const p200 = eloWinProb(1900, 1700);
const p400 = eloWinProb(2100, 1700);
expect(p400).toBeGreaterThan(p200);
});
it("400-point gap gives ~91% win probability (standard Elo)", () => {
expect(eloWinProb(2200, 1800)).toBeCloseTo(0.909, 2);
});
});
// ─── buildDraw ────────────────────────────────────────────────────────────────
function makeIds(n: number): string[] {
return Array.from({ length: n }, (_, i) => `p${String(i + 1).padStart(3, "0")}`);
}
function makeEloMap(
ids: string[],
elos: number[],
rankings?: (number | null)[]
): Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }> {
return new Map(ids.map((id, i) => [id, {
worldRanking: rankings ? (rankings[i] ?? null) : i + 1, // default: rank by index (1-indexed)
eloHard: elos[i],
eloClay: elos[i],
eloGrass: elos[i],
}]));
}
describe("buildDraw", () => {
const IDS = makeIds(128);
it("returns exactly 128 slots", () => {
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
expect(buildDraw(IDS, eloMap)).toHaveLength(128);
});
it("contains every participant exactly once", () => {
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
const draw = buildDraw(IDS, eloMap);
expect(new Set(draw).size).toBe(128);
for (const id of IDS) {
expect(draw).toContain(id);
}
});
it("places seed 1 (highest Elo) in slot 0", () => {
const elos = IDS.map((_, i) => 2000 - i); // descending, p001 is best
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
expect(draw[0]).toBe("p001"); // seed 1 always goes to slot 0
});
it("places seed 2 (second-highest Elo) in slot 64", () => {
const elos = IDS.map((_, i) => 2000 - i);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
expect(draw[64]).toBe("p002"); // seed 2 always goes to slot 64
});
it("seeds 1 and 2 are in opposite halves (slots 063 and 64127)", () => {
const elos = IDS.map((_, i) => 2000 - i);
const eloMap = makeEloMap(IDS, elos);
// Run multiple times to confirm it's not random
for (let i = 0; i < 10; i++) {
const draw = buildDraw(IDS, eloMap);
const seed1Slot = draw.indexOf("p001");
const seed2Slot = draw.indexOf("p002");
const seed1InTopHalf = seed1Slot < 64;
const seed2InTopHalf = seed2Slot < 64;
expect(seed1InTopHalf).not.toBe(seed2InTopHalf);
}
});
it("seeds by worldRanking regardless of surface (ATP/WTA ranking used for all majors)", () => {
// p001 = world #2, p002 = world #1 — p002 should always be seed 1 regardless of surface Elo
const eloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
eloMap.set("p001", { worldRanking: 2, eloHard: 2500, eloClay: 2500, eloGrass: 2500 }); // world #2 (high Elo)
eloMap.set("p002", { worldRanking: 1, eloHard: 1400, eloClay: 1400, eloGrass: 1400 }); // world #1 (low Elo)
for (let i = 2; i < 128; i++) {
eloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 });
}
// p002 (world #1) should always be seed 1 → slot 0, regardless of Elo
expect(buildDraw(IDS, eloMap)[0]).toBe("p002");
});
it("falls back to Elo 1500 for players not in the Elo map", () => {
// Create a map with only 1 entry; remaining 127 get fallback 1500
const partialMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
partialMap.set("p001", { worldRanking: 1, eloHard: 2000, eloClay: null, eloGrass: null });
// Should not throw
expect(() => buildDraw(IDS, partialMap)).not.toThrow();
const draw = buildDraw(IDS, partialMap);
expect(draw).toHaveLength(128);
});
});
// ─── simulateMajor ────────────────────────────────────────────────────────────
describe("simulateMajor", () => {
const IDS = makeIds(128);
it("returns one QP result per participant in the draw", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results).toHaveLength(128);
const ids = results.map((r) => r.participantId);
expect(new Set(ids).size).toBe(128);
});
it("QP values are non-negative", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
for (const r of results) {
expect(r.qp).toBeGreaterThanOrEqual(0);
}
});
it("exactly one winner with 20 QP, one finalist with 14 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const winners = results.filter((r) => r.qp === 20);
const finalists = results.filter((r) => r.qp === 14);
expect(winners).toHaveLength(1);
expect(finalists).toHaveLength(1);
});
it("exactly two SF losers with 9 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 9)).toHaveLength(2);
});
it("exactly four QF losers with 4 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 4)).toHaveLength(4);
});
it("exactly eight R16 losers with 1.5 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 1.5)).toHaveLength(8);
});
it("remaining 112 players (R1R3 losers) earn 0 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 0)).toHaveLength(112);
});
it("with a dominant player (Elo gap 1000+), they win the title almost always", () => {
// Give p001 an overwhelming Elo advantage
const dominantEloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
dominantEloMap.set("p001", { worldRanking: 1, eloHard: 5000, eloClay: 5000, eloGrass: 5000 });
for (let i = 1; i < IDS.length; i++) {
dominantEloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
}
let wins = 0;
const TRIALS = 200;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, dominantEloMap);
const results = simulateMajor(draw, dominantEloMap, "hard");
const p001 = results.find((r) => r.participantId === "p001");
if (p001?.qp === 20) wins++;
}
// With Elo 5000 vs 1000, win probability per match ≈ 99.99% → should win almost all trials
expect(wins).toBeGreaterThan(190);
});
it("uses the correct surface Elo for match win probability", () => {
// p001 dominates on clay but is average on hard; p002 is the opposite.
// With a 2000-point Elo gap, the dominant player wins every match.
const surfaceMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
surfaceMap.set("p001", { worldRanking: 1, eloHard: 1500, eloClay: 3500, eloGrass: 1500 });
surfaceMap.set("p002", { worldRanking: 2, eloHard: 3500, eloClay: 1500, eloGrass: 1500 });
for (let i = 2; i < 128; i++) {
surfaceMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
}
let p001ClayWins = 0;
let p002HardWins = 0;
const TRIALS = 50;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, surfaceMap);
const clayResults = simulateMajor(draw, surfaceMap, "clay");
const hardResults = simulateMajor(draw, surfaceMap, "hard");
if (clayResults.find((r) => r.participantId === "p001")?.qp === 20) p001ClayWins++;
if (hardResults.find((r) => r.participantId === "p002")?.qp === 20) p002HardWins++;
}
// With Elo 3500 vs ≤1500, each player should win essentially every trial on their surface
expect(p001ClayWins).toBeGreaterThan(45); // p001 dominates clay
expect(p002HardWins).toBeGreaterThan(45); // p002 dominates hard
});
it("total QP distributed per major: 20+14+2*9+4*4+8*1.5 = 80 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const total = results.reduce((sum, r) => sum + r.qp, 0);
// 20 + 14 + 9*2 + 4*4 + 1.5*8 = 20 + 14 + 18 + 16 + 12 = 80
expect(total).toBeCloseTo(80, 5);
});
});
// ─── TennisSimulator column-sum property ──────────────────────────────────────
// Full integration test via the simulate() method would require DB mocking.
// The column-sum guarantee is verified by the math:
// - In each simulation, exactly one player holds each rank 18.
// - Therefore count[rank] sums to NUM_SIMULATIONS across all players.
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
// This is verified indirectly by the simulateMajor + buildDraw tests above.
describe("QP accumulation ranking (unit)", () => {
it("seed 1 wins more often than a random player across many trials", () => {
const IDS = makeIds(128);
const elos = IDS.map((_, i) => 2000 - i * 3); // descending by seeding
const eloMap = makeEloMap(IDS, elos);
let seed1Wins = 0;
const TRIALS = 500;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const seed1 = results.find((r) => r.participantId === "p001");
if (seed1?.qp === 20) seed1Wins++;
}
// Seed 1 has ~3 points advantage per player; against 127 opponents should win
// substantially more than 1/128 ≈ 0.78% of the time
const winRate = seed1Wins / TRIALS;
expect(winRate).toBeGreaterThan(0.03); // > 3% (well above random 0.78%)
});
});