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
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>
392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
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 A–L, 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 2–16 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);
|
||
});
|
||
});
|