- 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>
466 lines
17 KiB
TypeScript
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/);
|
|
});
|
|
});
|