brackt/app/services/simulations/__tests__/snooker-simulator.test.ts

510 lines
19 KiB
TypeScript
Raw Normal View History

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,
worldRanking: null,
}));
}
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 — names don't drive seeding anymore; worldRanking in EV rows does.
const SEASON_PARTICIPANTS = Array.from({ length: 32 }, (_, i) => ({
id: `player-${i + 1}`,
name: `Player ${i + 1}`,
}));
// Build EV rows including worldRanking (rank 1 = player-1, rank 2 = player-2, etc.)
function makeEvRows(rankOverride?: Map<string, number | null>) {
return SEASON_PARTICIPANTS.map((p, i) => ({
participantId: p.id,
sourceElo: ELO_BASE,
worldRanking: rankOverride?.has(p.id) ? rankOverride.get(p.id) : i + 1,
}));
}
function setupMockDb(evRows: ReturnType<typeof makeEvRows>, participantRows = SEASON_PARTICIPANTS) {
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn()
// First select(): participantExpectedValues (Elo + worldRanking)
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(evRows) }),
})
// Second select(): participants (id + name)
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }),
}),
};
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
}
beforeEach(async () => {
const { database } = await import("~/database/context");
setupMockDb(makeEvRows());
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
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("top-seeded player (rank 1, highest Elo) has highest win probability", async () => {
const { database } = await import("~/database/context");
// Give the seed-1 player a big Elo advantage; everyone else gets low Elo
const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({
participantId: p.id,
sourceElo: i === 0 ? 3000 : 1500,
worldRanking: i + 1,
}));
setupMockDb(eloRows);
(database as unknown as MockInstance).mockReturnValue(mockDb);
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
const topSeedResult = results.find(r => r.participantId === "player-1");
const otherProbFirsts = results
.filter(r => r.participantId !== "player-1")
.map(r => r.probabilities.probFirst);
expect(topSeedResult).toBeDefined();
expect(topSeedResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts));
});
it("falls back to Elo order when no world rankings are stored", async () => {
const { database } = await import("~/database/context");
// No worldRanking on any row — simulator should use Elo order
const eloRows = SEASON_PARTICIPANTS.map((p, i) => ({
participantId: p.id,
sourceElo: i === 0 ? 3000 : 1500, // player-1 has highest Elo
worldRanking: null,
}));
setupMockDb(eloRows);
(database as unknown as MockInstance).mockReturnValue(mockDb);
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
// player-1 with the highest Elo should be treated as seed 1
const topEloResult = results.find(r => r.participantId === "player-1");
const otherProbFirsts = results
.filter(r => r.participantId !== "player-1")
.map(r => r.probabilities.probFirst);
expect(topEloResult).toBeDefined();
expect(topEloResult!.probabilities.probFirst).toBeGreaterThan(Math.max(...otherProbFirsts));
});
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, i) => ({ participantId: p.id, sourceElo: ELO_BASE, worldRanking: i + 1 }));
(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/);
});
});