brackt/app/services/simulations/__tests__/world-cup-simulator.test.ts
Chris Parsons 6772079e86
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Reflect group stage in World Cup sim; seed R32 from real 2026 bracket
The World Cup Monte Carlo simulator ignored the admin-populated knockout
draw and re-paired its own simulated group output sequentially, so bracket
paths from the Round of 32 onward did not match the real tournament. It
also silently invented 12 synthetic groups when none were configured,
discarding real group-stage results.

Rework the simulator around three regimes:
- draw set     → simulate the real R32 matchups, advancing through the
  bracket tree via the canonical ceil(matchNumber/2) rule (matching
  advanceWinnerTemplate) so sim paths match the admin tooling.
- groups only  → simulate remaining group matches, then seed the R32 with
  the official 2026 group-position template + FIFA's exact Annex C
  best-third-place allocation (495-row published table).
- neither      → futures-seeded bracket fallback, flagged via result source.

Completed group and knockout matches still lock in real results at every
round.

New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template
(official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree
reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table
is generated deterministically by scripts/gen-fifa-third-place.mjs into
app/lib/fifa-2026-third-place-allocation.ts.

Tests assert exact Annex C values, cross-check the template's eligible
groups against all 495 combinations, and cover each regime including the
partial-draw dedup and fully-drawn fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:15:45 -07:00

392 lines
16 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, vi, beforeEach } from "vitest";
import { simGroupMatch, WorldCupSimulator } from "../world-cup-simulator";
vi.mock("~/lib/logger", () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ─── 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",
}));
}
/** 12 canonical groups AL, 4 members each (p0..p47), no completed matches. */
function makeCanonicalGroups() {
return Array.from({ length: 12 }, (_, gi) => ({
id: `group-${gi}`,
groupName: String.fromCharCode(65 + gi), // A..L
scoringEventId: "event-1",
members: [0, 1, 2, 3].map((s) => ({ participantId: `p${gi * 4 + s}` })),
matches: [],
}));
}
/** 16 Round-of-32 matches with both slots filled (p0..p31), seq pairing. */
function makeFullR32Draw() {
return Array.from({ length: 16 }, (_, i) => ({
round: "Round of 32",
matchNumber: i + 1,
participant1Id: `p${i * 2}`,
participant2Id: `p${i * 2 + 1}`,
winnerId: null as string | null,
loserId: null as string | null,
isComplete: false,
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
const mockDb = {
query: {
seasonParticipants: { findMany: vi.fn() },
scoringEvents: { findMany: vi.fn() },
tournamentGroups: { findMany: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
/** Set the resolved bracket scoring event (or null for none). */
function mockBracketEvent(event: Record<string, unknown> | null) {
mockDb.query.scoringEvents.findMany.mockResolvedValue(event ? [event] : []);
}
beforeEach(() => {
vi.mocked(database).mockReturnValue(mockDb as never);
mockDb.query.seasonParticipants.findMany.mockReset();
mockDb.query.scoringEvents.findMany.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.seasonParticipants.findMany.mockResolvedValue([]);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(20);
await expect(sim.simulate("season-1")).rejects.toThrow("No participants found");
});
it("returns one result per participant", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(20);
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.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(20);
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("SF losers land in 3rd or 4th, never 1st or 2nd", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(50);
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.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ 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(20);
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.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(20);
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);
}
});
// ─── Regime selection & bracket honoring ──────────────────────────────────
it("regime=draw: honors the real R32 draw and flags source as real bracket", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]); // no groups, but draw exists
const r32 = makeFullR32Draw();
// Lock R32 match 1: p0 beats p1, so p1 is eliminated in the Round of 32.
r32[0] = { ...r32[0], winnerId: "p0", loserId: "p1", isComplete: true };
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
const sim = new WorldCupSimulator(50);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("real bracket");
// p1 lost in the R32 → never earns any placement (scoring starts at QF).
const p1 = results.find((r) => r.participantId === "p1")?.probabilities;
const p1Mass =
(p1?.probFirst ?? 0) + (p1?.probSecond ?? 0) + (p1?.probThird ?? 0) + (p1?.probFourth ?? 0) +
(p1?.probFifth ?? 0) + (p1?.probSixth ?? 0) + (p1?.probSeventh ?? 0) + (p1?.probEighth ?? 0);
expect(p1Mass).toBe(0);
// Teams p32..p47 are not in the 32-team draw → also zero.
const p40 = results.find((r) => r.participantId === "p40")?.probabilities;
expect((p40?.probFirst ?? 0) + (p40?.probFifth ?? 0)).toBe(0);
// Champion mass still normalizes to ~1.
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("partial R32 draw never places an already-drawn team into an empty slot twice", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
// Real, canonical groups so the empty slots get filled from group seeding.
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
// Partial draw: only match 1 is populated (p0 vs p1); matches 216 are empty,
// so the seeded group results fill them — but must not re-place p0 or p1.
const r32 = makeFullR32Draw().map((m, i) =>
i === 0 ? m : { ...m, participant1Id: null, loserId: null, participant2Id: null }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(r32);
const sim = new WorldCupSimulator(40);
const results = await sim.simulate("season-1");
// A team can finish at most one placement per sim, so its total probability
// mass is ≤ 1. A duplicated team could exceed that (e.g. champion + QF-loser
// in the same iteration). Assert no team's mass exceeds 1.
for (const r of results) {
const p = r.probabilities;
const mass =
p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
expect(mass).toBeLessThanOrEqual(1 + 1e-9);
}
// Champion mass still normalizes to ~1 (exactly one champion per sim).
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("completed Final locks in the champion and runner-up", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([
{ round: "Finals", matchNumber: 1, participant1Id: "p5", participant2Id: "p6", winnerId: "p5", loserId: "p6", isComplete: true },
]);
const sim = new WorldCupSimulator(30);
const results = await sim.simulate("season-1");
expect(results.find((r) => r.participantId === "p5")?.probabilities.probFirst).toBe(1);
expect(results.find((r) => r.participantId === "p6")?.probabilities.probSecond).toBe(1);
});
it("regime=groups: real 12-group stage uses the 2026 bracket seeding source", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent({ id: "event-1", bracketTemplateId: "fifa_48" });
mockDb.query.tournamentGroups.findMany.mockResolvedValue(makeCanonicalGroups());
mockDb.query.playoffMatches.findMany.mockResolvedValue([]); // no draw yet
const sim = new WorldCupSimulator(40);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("2026 bracket seeding");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
it("regime=futures: no groups and no draw falls back with a flagged source", async () => {
const participants = makeParticipants(48);
mockDb.query.seasonParticipants.findMany.mockResolvedValue(participants);
mockBracketEvent(null);
mockDb.query.tournamentGroups.findMany.mockResolvedValue([]);
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
const sim = new WorldCupSimulator(30);
const results = await sim.simulate("season-1");
expect(results[0].source).toContain("futures fallback");
const sumFirst = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(sumFirst).toBeCloseTo(1.0, 2);
});
});