brackt/app/services/simulations/__tests__/world-cup-simulator.test.ts
Chris Parsons 338979e0a8
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127

- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
  - Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
  - Partial group completion: completed matches replayed with real scores, remaining matches simulated
  - Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)

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

* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds

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

* Increase Node heap to 4GB for unit tests in CI to prevent OOM

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

* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests

Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00

268 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator";
// ─── Pure math: simGroupMatch ─────────────────────────────────────────────────
describe("simGroupMatch", () => {
it("returns win, draw, or loss", () => {
const results = new Set<string>();
for (let i = 0; i < 300; i++) {
results.add(simGroupMatch(1800, 1600));
}
// All three outcomes should appear in 300 trials
expect(results.has("win")).toBe(true);
expect(results.has("draw")).toBe(true);
expect(results.has("loss")).toBe(true);
});
it("equal teams draw ≈28% of the time", () => {
let draws = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
if (simGroupMatch(1500, 1500) === "draw") draws++;
}
const drawRate = draws / N;
// BASE_DRAW_RATE = 0.28 at eloDiff=0; accept ±5% from sampling noise at N=5k
expect(drawRate).toBeGreaterThan(0.23);
expect(drawRate).toBeLessThan(0.33);
});
it("draw rate decays with large Elo gap", () => {
let draws = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
if (simGroupMatch(2000, 1400) === "draw") draws++;
}
const drawRate = draws / N;
// eloDiff = 600 → pDraw ≈ 0.28 * exp(-1.2) ≈ 0.084; accept ±5%
expect(drawRate).toBeLessThan(0.15);
});
it("strong favorite wins more often than underdog", () => {
let wins = 0;
let losses = 0;
const N = 5_000;
for (let i = 0; i < N; i++) {
const r = simGroupMatch(1900, 1600);
if (r === "win") wins++;
if (r === "loss") losses++;
}
expect(wins).toBeGreaterThan(losses);
});
it("results sum to 1.0 (no probability mass lost)", () => {
let wins = 0, draws = 0, losses = 0;
const N = 10_000;
for (let i = 0; i < N; i++) {
const r = simGroupMatch(1700, 1700);
if (r === "win") wins++;
else if (r === "draw") draws++;
else losses++;
}
expect(wins + draws + losses).toBe(N);
});
});
// ─── WorldCupSimulator (with mocked DB) ───────────────────────────────────────
// Build 48 mock participants (ids p0..p47)
function makeParticipants(count = 48) {
return Array.from({ length: count }, (_, i) => ({
id: `p${i}`,
name: `Team${i}`,
sportsSeasonId: "season-1",
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
const mockDb = {
query: {
participants: { findMany: vi.fn() },
scoringEvents: { findFirst: vi.fn() },
tournamentGroups: { findMany: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
beforeEach(() => {
vi.mocked(database).mockReturnValue(mockDb as never);
mockDb.query.participants.findMany.mockReset();
mockDb.query.scoringEvents.findFirst.mockReset();
mockDb.query.tournamentGroups.findMany.mockReset();
mockDb.query.playoffMatches.findMany.mockReset();
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockResolvedValue([]);
});
describe("WorldCupSimulator", () => {
it("throws when no participants are found", async () => {
mockDb.query.participants.findMany.mockResolvedValue([]);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
});
it("returns one result per participant", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
expect(results).toHaveLength(48);
const ids = new Set(results.map((r) => r.participantId));
for (const p of participants) {
expect(ids.has(p.id)).toBe(true);
}
});
it("column sums for champion, runner-up, 3rd, 4th are each ≈1.0", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
const sumSecond = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
const sumThird = results.reduce((s, r) => s + r.probabilities.probThird, 0);
const sumFourth = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
expect(sumSecond).toBeCloseTo(1.0, 2);
expect(sumThird).toBeCloseTo(1.0, 2);
expect(sumFourth).toBeCloseTo(1.0, 2);
});
it("probabilities are all non-negative", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
for (const r of results) {
const { probFirst, probSecond, probThird, probFourth, probFifth } = r.probabilities;
expect(probFirst).toBeGreaterThanOrEqual(0);
expect(probSecond).toBeGreaterThanOrEqual(0);
expect(probThird).toBeGreaterThanOrEqual(0);
expect(probFourth).toBeGreaterThanOrEqual(0);
expect(probFifth).toBeGreaterThanOrEqual(0);
}
});
it("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
// Set up 8 participants (small bracket, 2 groups of 4)
const participants = makeParticipants(8);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
// probFirst + probSecond + probThird + probFourth should cover all probability mass
// No team should have probThird or probFourth < 0
for (const r of results) {
expect(r.probabilities.probFirst).toBeGreaterThanOrEqual(0);
expect(r.probabilities.probThird).toBeGreaterThanOrEqual(0);
expect(r.probabilities.probFourth).toBeGreaterThanOrEqual(0);
// A champion should have 0 chance at 3rd/4th AND vice versa
// (not guaranteed in aggregate but probFirst + probThird can't both be 1)
expect(r.probabilities.probFirst + r.probabilities.probThird).toBeLessThanOrEqual(1.01);
}
});
it("a team with pre-completed group stage result is fixed in simulation", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
// One group fully complete: p0 wins everything, p3 loses everything
const group = {
id: "group-a",
groupName: "A",
scoringEventId: "event-1",
members: [
{ participantId: "p0" }, { participantId: "p1" },
{ participantId: "p2" }, { participantId: "p3" },
],
matches: [
{ participant1Id: "p0", participant2Id: "p1", participant1Score: 3, participant2Score: 0, isComplete: true, matchday: 1 },
{ participant1Id: "p2", participant2Id: "p3", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 1 },
{ participant1Id: "p0", participant2Id: "p2", participant1Score: 2, participant2Score: 0, isComplete: true, matchday: 2 },
{ participant1Id: "p1", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 2 },
{ participant1Id: "p0", participant2Id: "p3", participant1Score: 1, participant2Score: 0, isComplete: true, matchday: 3 },
{ participant1Id: "p1", participant2Id: "p2", participant1Score: 1, participant2Score: 1, isComplete: true, matchday: 3 },
],
};
// Remaining 44 participants in 11 synthetic groups
const remainingGroups = Array.from({ length: 11 }, (_, gi) => ({
id: `group-${gi + 2}`,
groupName: String.fromCharCode(66 + gi),
scoringEventId: "event-1",
members: [
{ participantId: `p${(gi + 1) * 4 + 0}` },
{ participantId: `p${(gi + 1) * 4 + 1}` },
{ participantId: `p${(gi + 1) * 4 + 2}` },
{ participantId: `p${(gi + 1) * 4 + 3}` },
],
matches: [],
}));
mockDb.query.tournamentGroups.findMany.mockResolvedValue([group, ...remainingGroups]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
const p3Result = results.find((r) => r.participantId === "p3");
expect(p3Result).toBeDefined();
// p3 lost all 3 group games (0 pts) — always last in group, never advances
// → probFirst = probSecond = probThird = probFourth = 0
expect(p3Result?.probabilities.probFirst).toBe(0);
expect(p3Result?.probabilities.probSecond).toBe(0);
expect(p3Result?.probabilities.probThird).toBe(0);
expect(p3Result?.probabilities.probFourth).toBe(0);
});
it("probFifth through probEighth are equal for each participant (QF losers split evenly)", async () => {
const participants = makeParticipants(48);
mockDb.query.participants.findMany.mockResolvedValue(participants);
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(500);
const results = await sim.simulate("season-1");
for (const r of results) {
const { probFifth, probSixth, probSeventh, probEighth } = r.probabilities;
expect(probFifth).toBeCloseTo(probSixth, 10);
expect(probSixth).toBeCloseTo(probSeventh, 10);
expect(probSeventh).toBeCloseTo(probEighth, 10);
}
});
});