brackt/app/services/simulations/world-cup-simulator.ts
chrisp ee099c64cd
Some checks failed
🚀 Deploy / 🧪 Test (push) Successful in 3m7s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Failing after 50s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped
claude/clever-archimedes-dvye42 (#110)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #110
2026-06-26 05:16:54 +00:00

755 lines
28 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.

/**
* FIFA World Cup Simulator (2026+ 48-team format)
*
* Monte Carlo simulation covering both the group stage and knockout bracket.
*
* Algorithm:
* 1. Load participants, futures odds, and bracket/group data from DB
* 2. Build per-team Elo: from futures odds if available (blended 70/30 Elo+odds),
* otherwise from hardcoded 2026 national team Elo ratings, fallback 1500.
* 3. For each of NUM_SIMULATIONS iterations:
* a. GROUP STAGE (12 groups × 6 matches each = 72 matches)
* - Each match: compute P(win)/P(draw)/P(loss) from Elo difference
* - Track pts / GD / GF per team; sort to determine group positions
* - Group winner (pos 1) and runner-up (pos 2) always advance
* - 3rd-place teams ranked across all 12 groups; top 8 also advance
* b. KNOCKOUT (R32 → R16 → QF → SF)
* - Standard single-elimination using Elo win probabilities
* - Completed knockout matches use actual results
* c. THIRD PLACE GAME
* - SF loser 1 vs SF loser 2; winner = 3rd, loser = 4th
* d. FINAL
* - SF winner 1 vs SF winner 2; winner = 1st, loser = 2nd
* 4. Accumulate integer placement counts; convert to probabilities.
* Placement buckets → SimulationProbabilities mapping:
* probFirst = P(champion)
* probSecond = P(runner-up)
* probThird = P(3rd place game winner)
* probFourth = P(3rd place game loser)
* probFifthprobEighth = P(QF elimination) / 4
* R32/R16 losers → 0 (scoringStartsAtRound = "Quarterfinals")
*
* Group stage W/D/L probabilities:
* pDraw = 0.28 × exp(0.002 × |eloDiff|) (≈28% when equal, decreases with skill gap)
* pWin = (1 pDraw) × eloWinProb(eloA, eloB)
* pLoss = 1 pWin pDraw
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { eloWinProbability } from "~/services/probability-engine";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import { FIFA_48 } from "~/lib/bracket-templates";
import { FIFA_2026_R32_TEMPLATE, assignThirdPlaceSlots } from "~/lib/fifa-2026-bracket";
import type { Simulator, SimulationResult } from "./types";
// ─── Name normalisation (same logic as elo-ratings bulk-import fuzzy match) ──
function normalizeTeamName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " ").trim();
}
// ─── Parameters ──────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
/** Base draw rate when teams are evenly matched. Decays with Elo difference. */
const BASE_DRAW_RATE = 0.28;
const DRAW_DECAY = 0.002;
// ─── National team Elo ratings (eloratings.net, pre-2026 World Cup) ──────────
// These are used when no futures odds are stored for a team.
// Update before the tournament starts.
const NATIONAL_TEAM_ELO: Record<string, number> = {
"france": 2083,
"england": 2047,
"brazil": 2038,
"spain": 2031,
"belgium": 2003,
"argentina": 1997,
"portugal": 1993,
"netherlands": 1988,
"germany": 1978,
"italy": 1966,
"croatia": 1961,
"uruguay": 1955,
"colombia": 1950,
"mexico": 1945,
"usa": 1935,
"united states": 1935,
"senegal": 1930,
"denmark": 1928,
"switzerland": 1925,
"austria": 1918,
"morocco": 1915,
"japan": 1912,
"south korea": 1905,
"ecuador": 1900,
"poland": 1898,
"australia": 1893,
"nigeria": 1888,
"iran": 1883,
"cameroon": 1878,
"ghana": 1875,
"canada": 1870,
"peru": 1865,
"chile": 1862,
"venezuela": 1858,
"costa rica": 1853,
"cote d'ivoire": 1850,
"ivory coast": 1850,
"egypt": 1848,
"hungary": 1845,
"turkey": 1842,
"ukraine": 1838,
"serbia": 1835,
"czech republic": 1830,
"romania": 1825,
"slovakia": 1820,
"new zealand": 1790,
"saudi arabia": 1785,
"qatar": 1780,
"honduras": 1778,
"panama": 1775,
"el salvador": 1770,
"guatemala": 1765,
"cuba": 1760,
};
/**
* Look up a national team's Elo rating using fuzzy name matching.
* Priority:
* 1. Exact normalized match
* 2. Substring containment (either direction)
* 3. Word-overlap (≥50% shared significant words)
* Logs a warning when no match is found so mismatches are visible in server logs.
*/
function getTeamElo(name: string, fallback = 1500): number {
const normalized = normalizeTeamName(name);
const entries = Object.entries(NATIONAL_TEAM_ELO);
// 1. Exact match
const exact = entries.find(([k]) => k === normalized);
if (exact) return exact[1];
// 2. Substring containment (handles "Ivory Coast" ↔ "Cote d'Ivoire" aliases already in map,
// and things like "United States" matching "usa")
const contains = entries.find(([k]) => k.includes(normalized) || normalized.includes(k));
if (contains) return contains[1];
// 3. Word-overlap (≥50% shared words longer than 2 chars)
const inputWords = normalized.split(" ").filter((w) => w.length > 2);
if (inputWords.length > 0) {
const overlap = entries.find(([k]) => {
const kWords = k.split(" ").filter((w) => w.length > 2);
const shared = inputWords.filter((w) => kWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, kWords.length) * 0.5;
});
if (overlap) return overlap[1];
}
logger.warn(`[WorldCupSimulator] No Elo found for team "${name}" (normalized: "${normalized}") — using fallback ${fallback}`);
return fallback;
}
// ─── Group stage helpers ──────────────────────────────────────────────────────
/**
* Simulate a single group stage match.
* Returns "win" (team A wins), "draw", or "loss" (team B wins).
*/
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
});
}
interface TeamStats {
id: string;
pts: number;
gd: number;
gf: number;
}
interface GroupMatchResult {
participant1Id: string;
participant2Id: string;
participant1Score: number | null;
participant2Score: number | null;
isComplete: boolean;
}
/**
* Simulate a full round-robin group of 4 teams.
* Completed matches (isComplete=true with real scores) are replayed with their
* actual results; remaining matches are simulated via Elo.
* This correctly handles partial group completion (e.g. 4/6 matches done).
*/
function simGroup(
teamIds: string[],
eloFn: (id: string) => number,
completedMatches?: GroupMatchResult[]
): TeamStats[] {
const stats = new Map<string, TeamStats>(
teamIds.map((id) => [id, { id, pts: 0, gd: 0, gf: 0 }])
);
// Build set of completed pairings keyed "minId:maxId" so we can skip them
const completedPairs = new Set<string>();
if (completedMatches) {
for (const m of completedMatches) {
if (!m.isComplete || m.participant1Score === null || m.participant2Score === null) continue;
const sa = stats.get(m.participant1Id);
const sb = stats.get(m.participant2Id);
if (!sa || !sb) continue;
const s1 = m.participant1Score;
const s2 = m.participant2Score;
sa.gf += s1; sa.gd += s1 - s2;
sb.gf += s2; sb.gd += s2 - s1;
if (s1 > s2) { sa.pts += 3; }
else if (s2 > s1) { sb.pts += 3; }
else { sa.pts += 1; sb.pts += 1; }
// Mark this pairing as done so we don't re-simulate it
const [p1, p2] = m.participant1Id < m.participant2Id
? [m.participant1Id, m.participant2Id]
: [m.participant2Id, m.participant1Id];
completedPairs.add(`${p1}:${p2}`);
}
}
// Simulate remaining (not-yet-played) matches
for (let i = 0; i < teamIds.length; i++) {
for (let j = i + 1; j < teamIds.length; j++) {
const a = teamIds[i];
const b = teamIds[j];
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (completedPairs.has(key)) continue; // already applied above
const result = simGroupMatch(eloFn(a), eloFn(b));
const sa = stats.get(a);
const sb = stats.get(b);
if (!sa || !sb) continue;
// Simple goal simulation: winner scores 1-2, loser 0-1, draw both 1
let ga: number, gb: number;
if (result === "win") {
ga = Math.random() < 0.5 ? 2 : 1;
gb = ga === 2 && Math.random() < 0.3 ? 1 : 0;
sa.pts += 3;
} else if (result === "loss") {
gb = Math.random() < 0.5 ? 2 : 1;
ga = gb === 2 && Math.random() < 0.3 ? 1 : 0;
sb.pts += 3;
} else {
ga = gb = Math.random() < 0.5 ? 1 : 0;
sa.pts += 1;
sb.pts += 1;
}
sa.gf += ga; sa.gd += ga - gb;
sb.gf += gb; sb.gd += gb - ga;
}
}
return [...stats.values()].toSorted(sortTeams);
}
function sortTeams(a: TeamStats, b: TeamStats): number {
if (b.pts !== a.pts) return b.pts - a.pts;
if (b.gd !== a.gd) return b.gd - a.gd;
return b.gf - a.gf;
}
// ─── Knockout helpers ─────────────────────────────────────────────────────────
function simKnockout(
teamA: string,
teamB: string,
eloFn: (id: string) => number
): { winner: string; loser: string } {
const winProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const winner = Math.random() < winProb ? teamA : teamB;
return { winner, loser: winner === teamA ? teamB : teamA };
}
/** A knockout matchup; either slot may be empty (TBD) before it's drawn. */
interface KnockoutMatch {
p1: string | null;
p2: string | null;
}
/**
* Ordered knockout round names derived from the template's `feedsInto` chain,
* starting at `Round of 32`. Keeping this template-driven avoids hardcoded
* round-name strings drifting from the bracket definition.
*/
function knockoutRoundChain(startName: string): string[] {
const names: string[] = [];
let current = FIFA_48.rounds.find((r) => r.name === startName);
while (current) {
names.push(current.name);
const feedsInto = current.feedsInto;
current = feedsInto ? FIFA_48.rounds.find((r) => r.name === feedsInto) : undefined;
}
return names;
}
/**
* Run one knockout round keyed by matchNumber and advance winners into the next
* round using the canonical tree rule (`nextMatchNumber = ceil(n/2)`, odd → p1,
* even → p2) — identical to `advanceWinnerTemplate` so simulated paths match the
* bracket the admin tooling produces. Completed matches (from `completed`) lock
* in their real winner/loser; a half-filled match (a bye / partial draw)
* advances the present team.
*/
function runKnockoutRoundByNumber(
roundName: string,
matches: Map<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number
): {
winnersByNumber: Map<number, string>;
losersByNumber: Map<number, string>;
nextRound: Map<number, KnockoutMatch>;
} {
const winnersByNumber = new Map<number, string>();
const losersByNumber = new Map<number, string>();
const nextRound = new Map<number, KnockoutMatch>();
const placeIntoNext = (matchNumber: number, teamId: string): void => {
const nextNumber = Math.ceil(matchNumber / 2);
const existing = nextRound.get(nextNumber) ?? { p1: null, p2: null };
if (matchNumber % 2 === 1) existing.p1 = teamId;
else existing.p2 = teamId;
nextRound.set(nextNumber, existing);
};
for (const matchNumber of [...matches.keys()].toSorted((a, b) => a - b)) {
const m = matches.get(matchNumber);
if (!m) continue;
let winnerId: string | null = null;
let loserId: string | null = null;
const fixed = completed.get(`${roundName}:${matchNumber}`);
if (fixed) {
winnerId = fixed.winnerId;
loserId = fixed.loserId;
} else if (m.p1 && m.p2) {
const result = simKnockout(m.p1, m.p2, eloFn);
winnerId = result.winner;
loserId = result.loser;
} else if (m.p1 || m.p2) {
winnerId = m.p1 ?? m.p2; // bye / partially-drawn match
} else {
continue; // empty match — nothing to advance
}
if (winnerId) {
winnersByNumber.set(matchNumber, winnerId);
placeIntoNext(matchNumber, winnerId);
}
if (loserId) losersByNumber.set(matchNumber, loserId);
}
return { winnersByNumber, losersByNumber, nextRound };
}
/**
* Seed the Round of 32 from simulated group results using the official 2026
* group-position template. `groupResults` maps group letter → finishing order
* (index 0 = winner, 1 = runner-up, 2 = third). `qualifyingThirdGroups` are the
* eight groups whose third-place team advanced; their slot assignment comes from
* `assignThirdPlaceSlots`.
*/
function seedR32FromTemplate(
groupResults: Map<string, TeamStats[]>,
qualifyingThirdGroups: string[]
): Map<number, KnockoutMatch> {
const thirdAssignment = assignThirdPlaceSlots(qualifyingThirdGroups);
const resolveSlot = (slot: (typeof FIFA_2026_R32_TEMPLATE)[number]["slot1"]): string | null => {
if (slot.kind === "winner") return groupResults.get(slot.group)?.[0]?.id ?? null;
if (slot.kind === "runnerUp") return groupResults.get(slot.group)?.[1]?.id ?? null;
const group = thirdAssignment.get(slot.thirdSlotId);
return group ? groupResults.get(group)?.[2]?.id ?? null : null;
};
const r32 = new Map<number, KnockoutMatch>();
for (const spec of FIFA_2026_R32_TEMPLATE) {
r32.set(spec.dbMatchNumber, { p1: resolveSlot(spec.slot1), p2: resolveSlot(spec.slot2) });
}
return r32;
}
/**
* Standard single-elimination seed order for a bracket of `size` (a power of 2):
* returns 1-based seed numbers in bracket position order so that seed 1 and 2
* can only meet in the final. Used by the no-groups futures fallback.
*/
function standardSeedOrder(size: number): number[] {
let order = [1];
while (order.length < size) {
const next: number[] = [];
const rounds = order.length * 2 + 1;
for (const seed of order) {
next.push(seed, rounds - seed);
}
order = next;
}
return order;
}
/** Normalize a stored group label to a bare letter (e.g. "Group A" → "A"). */
function normalizeGroupLabel(name: string): string {
return name.trim().toUpperCase().replace(/^GROUP\s+/, "");
}
/**
* No-groups fallback field: the top 32 participants by Elo, standard-seeded into
* the 16 Round-of-32 matches so top seeds are spread across the bracket. The
* field is fixed; only match outcomes vary between simulations.
*/
function buildFuturesR32(
participantIds: string[],
eloFn: (id: string) => number
): Map<number, KnockoutMatch> {
const ranked = [...participantIds].toSorted((a, b) => eloFn(b) - eloFn(a)).slice(0, 32);
const order = standardSeedOrder(32); // 1-based seeds in bracket position order
const r32 = new Map<number, KnockoutMatch>();
for (let i = 0; i < 16; i++) {
const p1 = ranked[order[i * 2] - 1] ?? null;
const p2 = ranked[order[i * 2 + 1] - 1] ?? null;
r32.set(i + 1, { p1, p2 });
}
return r32;
}
// ─── Main simulator ───────────────────────────────────────────────────────────
export class WorldCupSimulator implements Simulator {
private readonly numSimulations: number;
constructor(numSimulations = NUM_SIMULATIONS) {
this.numSimulations = numSimulations;
}
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this season
const participantRows = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participantRows.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}`);
}
const participantIds = participantRows.map((p) => p.id);
const participantNames = new Map(participantRows.map((p) => [p.id, p.name]));
// 2. Load the resolved single Elo (the input policy already blended any raw
// Elo and futures odds into sourceElo before the run).
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantSet = new Set(participantIds);
// 3. Build the Elo map — resolved sourceElo, else hardcoded national-team Elo.
const sourceEloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) {
sourceEloMap.set(r.participantId, r.sourceElo);
}
}
const eloFn = (id: string): number => {
if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500;
const name = participantNames.get(id) ?? "";
return getTeamElo(name, 1500);
};
// 4. Resolve the bracket scoring event. Prefer a fifa_48 playoff event; if
// several playoff_game events exist, take the most recent so a re-created
// event wins over a stale one.
const playoffEvents = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
const bracketEvent =
playoffEvents.find((e) => e.bracketTemplateId === FIFA_48.id) ??
playoffEvents.toSorted(
(a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0)
)[0] ??
null;
// 6. Load group stage data for the bracket event (real groups only — no
// synthetic fallback; misconfiguration is handled by regime selection).
const tournamentGroups = bracketEvent
? await db.query.tournamentGroups.findMany({
where: eq(schema.tournamentGroups.scoringEventId, bracketEvent.id),
with: {
members: true,
matches: true,
},
})
: [];
const groupDefs: Array<{
groupName: string;
teamIds: string[];
completedMatches: GroupMatchResult[];
}> = tournamentGroups.map((group) => ({
groupName: normalizeGroupLabel(group.groupName),
teamIds: group.members.map((m) => m.participantId),
completedMatches: group.matches
.filter((m) => m.isComplete)
.map((m) => ({
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
participant1Score: m.participant1Score,
participant2Score: m.participant2Score,
isComplete: m.isComplete,
})),
}));
// 7. Load all knockout matches: completed ones lock in real results; the
// Round of 32 participant slots give us the real draw when populated.
const knockoutMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
})
: [];
const completedByRoundAndNumber = new Map<string, { winnerId: string; loserId: string }>();
const r32Drawn = new Map<number, KnockoutMatch>();
for (const m of knockoutMatches) {
if (m.isComplete && m.winnerId && m.loserId) {
completedByRoundAndNumber.set(`${m.round}:${m.matchNumber}`, {
winnerId: m.winnerId,
loserId: m.loserId,
});
}
if (m.round === "Round of 32") {
r32Drawn.set(m.matchNumber, { p1: m.participant1Id, p2: m.participant2Id });
}
}
const r32IsDrawn = [...r32Drawn.values()].some((m) => m.p1 && m.p2);
// Fully drawn = every template R32 match has both slots populated. When true
// the group stage is irrelevant to the sim and can be skipped entirely.
const r32IsFullyDrawn = FIFA_2026_R32_TEMPLATE.every((spec) => {
const m = r32Drawn.get(spec.dbMatchNumber);
return Boolean(m?.p1 && m?.p2);
});
// 8. Choose simulation regime:
// A — R32 draw populated → simulate the real bracket.
// B — real groups present → simulate remaining group matches, then seed
// the R32 with the official 2026 template.
// C — neither → futures-seeded bracket fallback ("like other
// sports"), flagged via the result source.
const groupLabels = FIFA_48.groupStage?.groupLabels ?? [];
const canSeedFromTemplate =
groupDefs.length === 12 &&
groupDefs.every((g) => g.teamIds.length >= 3) &&
groupLabels.every((label) => groupDefs.some((g) => g.groupName === label));
const regime: "draw" | "groups" | "futures" = r32IsDrawn
? "draw"
: canSeedFromTemplate
? "groups"
: "futures";
if (regime === "futures") {
logger.warn(
`[WorldCupSimulator] No R32 draw and no canonical 12-group stage for season ${sportsSeasonId} — falling back to a futures-seeded bracket.`
);
}
// Futures fallback field: top 32 participants by Elo, standard-seeded once.
const futuresR32 = buildFuturesR32(participantIds, eloFn);
// 7. Monte Carlo simulation
const counts = {
champion: new Map<string, number>(),
runnerUp: new Map<string, number>(),
thirdPlace: new Map<string, number>(),
fourthPlace: new Map<string, number>(),
qfLoser: new Map<string, number>(),
};
for (const id of participantIds) {
counts.champion.set(id, 0);
counts.runnerUp.set(id, 0);
counts.thirdPlace.set(id, 0);
counts.fourthPlace.set(id, 0);
counts.qfLoser.set(id, 0);
}
// Knockout round names, derived from the bracket template (no magic strings).
const [R32, R16, QF, SF] = knockoutRoundChain("Round of 32");
const THIRD_PLACE_GAME =
FIFA_48.rounds.find((r) => r.name === SF)?.loserFeedsInto ?? "Third Place Game";
const FINAL = FIFA_48.rounds.find((r) => r.name === SF)?.feedsInto ?? "Finals";
/**
* Build the Round-of-32 matchup map for one simulation iteration, per regime.
* In regimes that need group results, simGroup is run per group (replaying
* completed matches, simulating the rest) and the official 2026 template
* seeds the advancers into the real bracket positions.
*/
const buildR32 = (): Map<number, KnockoutMatch> => {
if (regime === "futures") {
return new Map([...futuresR32].map(([k, v]) => [k, { ...v }]));
}
// Fully-drawn bracket: the real draw is the whole answer — no group sim.
if (regime === "draw" && r32IsFullyDrawn) {
return new Map(
FIFA_2026_R32_TEMPLATE.map((spec) => {
const drawn = r32Drawn.get(spec.dbMatchNumber);
return [spec.dbMatchNumber, { p1: drawn?.p1 ?? null, p2: drawn?.p2 ?? null }];
})
);
}
// Simulate every group and capture finishing order per group letter.
const groupResults = new Map<string, TeamStats[]>();
const thirdPlace: Array<{ group: string; stats: TeamStats }> = [];
for (const group of groupDefs) {
if (group.teamIds.length < 3) continue;
const sorted = simGroup(group.teamIds, eloFn, group.completedMatches);
groupResults.set(group.groupName, sorted);
if (sorted[2]) thirdPlace.push({ group: group.groupName, stats: sorted[2] });
}
const qualifyingThirdGroups = thirdPlace
.toSorted((a, b) => sortTeams(a.stats, b.stats))
.slice(0, 8)
.map((t) => t.group);
const seeded = seedR32FromTemplate(groupResults, qualifyingThirdGroups);
if (regime === "draw") {
// Partially-drawn bracket: honor the real draw and fill only the empty
// slots from the seeded teams — skipping any participant already placed
// by the real draw so no team appears in the R32 twice.
const alreadyDrawn = new Set<string>();
for (const m of r32Drawn.values()) {
if (m.p1) alreadyDrawn.add(m.p1);
if (m.p2) alreadyDrawn.add(m.p2);
}
const fillSlot = (drawn: string | null | undefined, seededTeam: string | null | undefined) => {
if (drawn) return drawn;
return seededTeam && !alreadyDrawn.has(seededTeam) ? seededTeam : null;
};
const merged = new Map<number, KnockoutMatch>();
for (const spec of FIFA_2026_R32_TEMPLATE) {
const drawn = r32Drawn.get(spec.dbMatchNumber);
const fallback = seeded.get(spec.dbMatchNumber);
merged.set(spec.dbMatchNumber, {
p1: fillSlot(drawn?.p1, fallback?.p1),
p2: fillSlot(drawn?.p2, fallback?.p2),
});
}
return merged;
}
return seeded;
};
for (let sim = 0; sim < this.numSimulations; sim++) {
// ── Knockout rounds ──────────────────────────────────────────
// R32 → R16 → QF → SF → Third Place Game + Final, advancing via the
// canonical ceil(n/2) tree. Completed matches lock in real results.
const r32 = runKnockoutRoundByNumber(R32, buildR32(), completedByRoundAndNumber, eloFn);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn);
// QF losers (placements 58)
for (const id of qf.losersByNumber.values()) {
counts.qfLoser.set(id, (counts.qfLoser.get(id) ?? 0) + 1);
}
// Third place game (SF losers, ordered by SF match number)
const sf1Loser = sf.losersByNumber.get(1);
const sf2Loser = sf.losersByNumber.get(2);
if (sf1Loser && sf2Loser) {
const fixed3pg = completedByRoundAndNumber.get(`${THIRD_PLACE_GAME}:1`);
if (fixed3pg) {
counts.thirdPlace.set(fixed3pg.winnerId, (counts.thirdPlace.get(fixed3pg.winnerId) ?? 0) + 1);
counts.fourthPlace.set(fixed3pg.loserId, (counts.fourthPlace.get(fixed3pg.loserId) ?? 0) + 1);
} else {
const { winner: thirdWinner, loser: thirdLoser } = simKnockout(
sf1Loser, sf2Loser, eloFn
);
counts.thirdPlace.set(thirdWinner, (counts.thirdPlace.get(thirdWinner) ?? 0) + 1);
counts.fourthPlace.set(thirdLoser, (counts.fourthPlace.get(thirdLoser) ?? 0) + 1);
}
}
// Final (SF winners)
const sfWinner1 = sf.winnersByNumber.get(1);
const sfWinner2 = sf.winnersByNumber.get(2);
if (sfWinner1 && sfWinner2) {
const fixedFinal = completedByRoundAndNumber.get(`${FINAL}:1`);
if (fixedFinal) {
counts.champion.set(fixedFinal.winnerId, (counts.champion.get(fixedFinal.winnerId) ?? 0) + 1);
counts.runnerUp.set(fixedFinal.loserId, (counts.runnerUp.get(fixedFinal.loserId) ?? 0) + 1);
} else {
const { winner: champion, loser: runnerUp } = simKnockout(
sfWinner1, sfWinner2, eloFn
);
counts.champion.set(champion, (counts.champion.get(champion) ?? 0) + 1);
counts.runnerUp.set(runnerUp, (counts.runnerUp.get(runnerUp) ?? 0) + 1);
}
}
}
// 9. Convert counts to probabilities
const N = this.numSimulations;
const numQfLosers = 4; // 4 QF losers per sim
const source =
regime === "draw"
? "World Cup Monte Carlo (real bracket)"
: regime === "groups"
? "World Cup Monte Carlo (group stage + 2026 bracket seeding)"
: "World Cup Monte Carlo (futures fallback — no groups)";
const results: SimulationResult[] = participantIds.map((id) => {
const qfProb = (counts.qfLoser.get(id) ?? 0) / (numQfLosers * N);
return {
participantId: id,
probabilities: {
probFirst: (counts.champion.get(id) ?? 0) / N,
probSecond: (counts.runnerUp.get(id) ?? 0) / N,
probThird: (counts.thirdPlace.get(id) ?? 0) / N,
probFourth: (counts.fourthPlace.get(id) ?? 0) / N,
probFifth: qfProb,
probSixth: qfProb,
probSeventh: qfProb,
probEighth: qfProb,
},
source,
};
});
normalizeSimulationResultColumns(results);
return results;
}
}