brackt/app/services/simulations/__tests__/snooker-simulator.test.ts
Chris Parsons 1bdd5c3372 Add snooker bracket simulator and Elo ratings admin UI, fixes #119
- SnookerSimulator: Monte Carlo simulation of the 32-player World
  Championship bracket using per-frame Bernoulli win probabilities
  derived from Elo ratings (ELO_DIVISOR=700). Two paths: bracket-
  populated (respects completed matches) and pre-bracket (simulates
  qualifying, then seeds full draw).
- Admin route /sports-seasons/:id/elo-ratings: bulk-import and per-
  player Elo entry with fuzzy name matching; saves ratings and auto-
  runs the simulation in one action.
- schema: add snooker_bracket simulator type, source_elo column on
  participant_expected_values, unique index on (participant_id,
  sports_season_id).
- Fix batchSaveSourceElos to use INSERT ... ON CONFLICT DO UPDATE
  instead of N+1 SELECT+UPDATE loop.
- Fix existingElos returned as plain Record (Map doesn't survive JSON
  serialization through useLoaderData).
- Fix "How It Works" formula to show correct divisor (700, not 400).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:24:28 -07:00

466 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { frameWinProb, matchWinProb, SnookerSimulator } from "../snooker-simulator";
// ─── Pure math: frameWinProb ──────────────────────────────────────────────────
describe("frameWinProb", () => {
it("returns 0.5 for equal Elo", () => {
expect(frameWinProb(2400, 2400)).toBeCloseTo(0.5, 5);
expect(frameWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
});
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
expect(frameWinProb(2600, 2300)).toBeGreaterThan(0.5);
expect(frameWinProb(2300, 2600)).toBeLessThan(0.5);
});
it("larger Elo gap → higher win probability (monotonic)", () => {
const p300 = frameWinProb(2600, 2300);
const p400 = frameWinProb(2700, 2300);
const p200 = frameWinProb(2500, 2300);
expect(p400).toBeGreaterThan(p300);
expect(p300).toBeGreaterThan(p200);
});
it("is symmetric: frameWinProb(a, b) + frameWinProb(b, a) = 1", () => {
expect(frameWinProb(2600, 2300) + frameWinProb(2300, 2600)).toBeCloseTo(1.0, 5);
expect(frameWinProb(2837, 2756) + frameWinProb(2756, 2837)).toBeCloseTo(1.0, 5);
});
it("returns value in [0, 1]", () => {
expect(frameWinProb(3000, 1000)).toBeLessThan(1);
expect(frameWinProb(3000, 1000)).toBeGreaterThan(0);
expect(frameWinProb(1000, 3000)).toBeLessThan(1);
expect(frameWinProb(1000, 3000)).toBeGreaterThan(0);
});
it("is symmetric: p1 vs p2 win probs sum to 1", () => {
expect(frameWinProb(2500, 2200) + frameWinProb(2200, 2500)).toBeCloseTo(1.0, 5);
});
});
// ─── Pure math: matchWinProb ──────────────────────────────────────────────────
describe("matchWinProb", () => {
it("returns 0.5 for equal players (p=0.5) at any match length", () => {
// Best-of-19 (F=10), best-of-25 (F=13), best-of-33 (F=17), best-of-35 (F=18)
expect(matchWinProb(0.5, 10)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 13)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 17)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 18)).toBeCloseTo(0.5, 3);
});
it("returns ~0.758 for p=0.68, best-of-3 (F=2) — matches reference example", () => {
// Site example: 68% per-frame win rate → 75.8% match win for best-of-3
expect(matchWinProb(0.68, 2)).toBeCloseTo(0.758, 2);
});
it("approaches 1.0 as p → 1.0", () => {
expect(matchWinProb(0.9999, 10)).toBeCloseTo(1.0, 3);
expect(matchWinProb(0.9999, 18)).toBeCloseTo(1.0, 3);
});
it("approaches 0.0 as p → 0.0", () => {
expect(matchWinProb(0.0001, 10)).toBeCloseTo(0.0, 3);
expect(matchWinProb(0.0001, 18)).toBeCloseTo(0.0, 3);
});
it("longer matches amplify the stronger player's advantage", () => {
// p=0.6: win prob should be higher in best-of-35 than best-of-3
const bo3 = matchWinProb(0.6, 2);
const bo35 = matchWinProb(0.6, 18);
expect(bo35).toBeGreaterThan(bo3);
});
it("match win prob + opponent win prob sums to 1.0", () => {
// p = 0.65, F = 13
const p = 0.65;
const winProb = matchWinProb(p, 13);
const lossProb = matchWinProb(1 - p, 13);
expect(winProb + lossProb).toBeCloseTo(1.0, 5);
});
});
// ─── Integration tests with mocked DB ────────────────────────────────────────
const PARTICIPANT_IDS = Array.from({ length: 32 }, (_, i) => `player-${i + 1}`);
const ELO_BASE = 2400;
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 5-round bracket (32 players, 31 matches total). */
function makeFullBracket(): ReturnType<typeof makeMatch>[] {
const matches: ReturnType<typeof makeMatch>[] = [];
// R32: 16 matches, pairs: (0,1), (2,3), ..., (30,31)
for (let i = 0; i < 16; i++) {
matches.push(makeMatch("First Round", i + 1, i * 2, i * 2 + 1));
}
// R16: 8 matches
for (let i = 0; i < 8; i++) {
matches.push(makeMatch("Second Round", i + 1, 0, 1)); // participants filled in by bracket
}
// QF: 4 matches
for (let i = 0; i < 4; i++) {
matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1));
}
// SF: 2 matches
for (let i = 0; i < 2; i++) {
matches.push(makeMatch("Semi-Finals", i + 1, 0, 1));
}
// Final: 1 match
matches.push(makeMatch("Final", 1, 0, 1));
return matches;
}
/** Build EV rows with uniform Elo for all 32 participants. */
function makeEVRows(eloOverride?: Map<string, number>) {
return PARTICIPANT_IDS.map((participantId) => ({
participantId,
sourceElo: eloOverride?.get(participantId) ?? ELO_BASE,
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
describe("SnookerSimulator.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 has wrong number of rounds", async () => {
// Only 4 rounds instead of 5 (missing Final)
const matches = [
...Array.from({ length: 16 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 5 rounds/);
});
it("throws if First Round match count is wrong", async () => {
// Only 8 R32 matches instead of 16
const matches = [
...Array.from({ length: 8 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", 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 SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 16 First Round/);
});
it("throws if a First Round match is missing participants", async () => {
const matches = makeFullBracket();
// Remove participant2Id from First Round match 5
const r32Match5 = matches.find(m => m.round === "First Round" && m.matchNumber === 5);
if (!r32Match5) throw new Error("match not found");
Object.assign(r32Match5, { participant2Id: null });
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
});
it("returns 32 results with valid probability distributions", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(32);
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probSecond).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 32 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
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("completed tournament: champion has probFirst≈1, all other players have probFirst=0", async () => {
const champion = PARTICIPANT_IDS[0]; // player-1
const finalist = PARTICIPANT_IDS[2]; // player-3
// Build a complete bracket where player-1 wins everything
const r32Matches = Array.from({ length: 16 }, (_, i) => {
const p1 = PARTICIPANT_IDS[i * 2];
const p2 = PARTICIPANT_IDS[i * 2 + 1];
const winner = i === 0 ? p1 : p2; // match 1: p1 wins; rest: p2 wins
return makeMatch("First Round", i + 1, i * 2, i * 2 + 1, {
winnerId: winner,
loserId: winner === p1 ? p2 : p1,
isComplete: true,
});
});
// R16: player-1 vs player-4 (p2 winners from R32 were p2s)
// For simplicity, mark QF/SF/Final all complete with player-1 winning
const r16Matches = Array.from({ length: 8 }, (_, i) => ({
...makeMatch("Second Round", i + 1, 0, 1),
winnerId: i === 0 ? champion : PARTICIPANT_IDS[3],
loserId: i === 0 ? PARTICIPANT_IDS[3] : champion,
isComplete: true,
}));
const qfMatches = Array.from({ length: 4 }, (_, i) => ({
...makeMatch("Quarter-Finals", i + 1, 0, 1),
winnerId: i === 0 ? champion : PARTICIPANT_IDS[5],
loserId: i === 0 ? PARTICIPANT_IDS[5] : champion,
isComplete: true,
}));
const sfMatches = Array.from({ length: 2 }, (_, i) => ({
...makeMatch("Semi-Finals", i + 1, 0, 1),
winnerId: i === 0 ? champion : finalist,
loserId: i === 0 ? finalist : champion,
isComplete: true,
}));
const finalMatch = {
...makeMatch("Final", 1, 0, 1),
winnerId: champion,
loserId: finalist,
isComplete: true,
};
mockDb.query.playoffMatches.findMany.mockResolvedValue([
...r32Matches, ...r16Matches, ...qfMatches, ...sfMatches, finalMatch,
]);
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
const championResult = results.find(r => r.participantId === champion);
expect(championResult?.probabilities.probFirst).toBeCloseTo(1.0, 2);
const finalistResult = results.find(r => r.participantId === finalist);
expect(finalistResult?.probabilities.probSecond).toBeCloseTo(1.0, 2);
// R32 losers (the p2s from matches 2-16) should have all-zero probs
const r32Loser = PARTICIPANT_IDS[3]; // p2 of match 2, loses in R32
const loserResult = results.find(r => r.participantId === r32Loser);
if (!loserResult) throw new Error("loserResult not found");
const lp = loserResult.probabilities;
expect(lp.probFirst).toBe(0);
expect(lp.probSecond).toBe(0);
const loserSum = lp.probFirst + lp.probSecond + lp.probThird + lp.probFourth +
lp.probFifth + lp.probSixth + lp.probSeventh + lp.probEighth;
expect(loserSum).toBe(0);
});
it("falls back to equal Elo (50/50) when no sourceElo stored", async () => {
// Return empty EV rows — no Elo stored
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
// With equal Elo, probFirst should be roughly equal across all participants
expect(results).toHaveLength(32);
// Each player should have a roughly fair shot — not zero for all
const hasNonZero = results.some(r => r.probabilities.probFirst > 0);
expect(hasNonZero).toBe(true);
});
it("labels results with snooker_world_championship_monte_carlo source", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results.every(r => r.source === "snooker_world_championship_monte_carlo")).toBe(true);
});
});
// ─── Path B: pre-bracket simulation ──────────────────────────────────────────
describe("SnookerSimulator.simulate() — Path B (pre-bracket)", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
// 32 participants: use names that match the hardcoded rankings
const RANKED_NAMES = [
"Judd Trump", "Kyren Wilson", "Mark Selby", "Ronnie O'Sullivan",
"Mark Allen", "Luca Brecel", "Neil Robertson", "John Higgins",
"Ryan Day", "Zhao Xintong", "Jak Jones", "Si Jiahui",
"Barry Hawkins", "Ding Junhui", "Pang Junxu", "Zhang Anda",
"Xiao Guodong", "Jack Lisowski", "Shaun Murphy", "David Gilbert",
"Anthony McGill", "Tom Ford", "Stuart Bingham", "Andrew Higginson",
"Chris Wakelin", "Gary Wilson", "Matthew Selt", "Gerard Greene",
"Ben Woollaston", "Jamie Jones", "Robbie Williams", "Mark Williams",
];
const SEASON_PARTICIPANTS = RANKED_NAMES.map((name, i) => ({
id: `player-${i + 1}`,
name,
}));
beforeEach(async () => {
const { database } = await import("~/database/context");
// select() is called twice in Path B:
// 1. for participantExpectedValues (EV/Elo rows)
// 2. for participants (all players in the season)
const eloRows = SEASON_PARTICIPANTS.map(p => ({ participantId: p.id, sourceElo: ELO_BASE }));
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({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
// No matches → triggers Path B
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
});
it("returns 32 results when bracket is not yet populated", async () => {
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(32);
});
it("each probability column sums to 1.0 across all 32 participants", async () => {
const sim = new SnookerSimulator();
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("throws if fewer than 17 participants exist", async () => {
const { database } = await import("~/database/context");
const tooFew = SEASON_PARTICIPANTS.slice(0, 10);
const eloRows = tooFew.map(p => ({ participantId: p.id, sourceElo: ELO_BASE }));
(database as unknown as MockInstance).mockReturnValue({
...mockDb,
select: vi.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }),
}),
});
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/at least 17 participants/);
});
});