brackt/app/services/simulations/__tests__/tennis-simulator.test.ts
Chris Parsons 2e2d8db777
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121

- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
  (weighted by p_div) and 3 WC teams per league, then simulating
  WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
  Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
  mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
  winner per division section + Wild Card section with 3-spot playoff line,
  headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
  (divisionSpots + wcSpots params); conferenceLabel now flows through group
  objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix flaky tennis simulator test: increase trials and lower threshold

500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00

282 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 = 2000;
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 Elo points advantage per seed; should win substantially
// more than 1/128 ≈ 0.78% of the time. Threshold at 0.02 is still well
// above random; 2000 trials keeps std dev small enough to be non-flaky.
const winRate = seed1Wins / TRIALS;
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
});
});