/** * 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) * probFifth–probEighth = 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 { convertFuturesToElo, 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; const ELO_WEIGHT = 0.7; const ODDS_WEIGHT = 1 - ELO_WEIGHT; /** 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 = { "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( 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(); 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, normalizedProb: (id: string) => number ): { winner: string; loser: string } { const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB)); const oddsProb = normalizedProb(teamA); const blended = ELO_WEIGHT * eloProb + ODDS_WEIGHT * (0.5 + (oddsProb - 0.5)); const winner = Math.random() < blended ? 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, completed: Map, eloFn: (id: string) => number, normalizedProb: (id: string) => number ): { winnersByNumber: Map; losersByNumber: Map; nextRound: Map; } { const winnersByNumber = new Map(); const losersByNumber = new Map(); const nextRound = new Map(); 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, normalizedProb); 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, qualifyingThirdGroups: string[] ): Map { 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(); 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 { 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(); 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 { 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 stored ratings (sourceElo from Elo page, sourceOdds from futures page) const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); const evMap = new Map(evRows.map((r) => [r.participantId, r])); const participantSet = new Set(participantIds); // 3. Build Elo map — priority: sourceElo (direct) > sourceOdds (converted) > hardcoded const sourceEloMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined && participantSet.has(r.participantId)) { sourceEloMap.set(r.participantId, r.sourceElo); } } const hasOdds = evRows.some( (r) => r.sourceOdds !== null && participantSet.has(r.participantId) ); let eloFromOdds: Map; if (hasOdds) { const oddsInput = evRows .filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId)) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); eloFromOdds = convertFuturesToElo(oddsInput, "american"); } else { eloFromOdds = new Map(); } const eloFn = (id: string): number => { if (sourceEloMap.has(id)) return sourceEloMap.get(id) ?? 1500; if (eloFromOdds.has(id)) return eloFromOdds.get(id) ?? 1500; const name = participantNames.get(id) ?? ""; return getTeamElo(name, 1500); }; // 4. Build normalized futures win-probability map (vig removed) const rawProbs = new Map(); for (const id of participantIds) { const ev = evMap.get(id); if (ev?.sourceOdds !== null && ev?.sourceOdds !== undefined) { rawProbs.set(id, Math.abs(ev.sourceOdds) > 0 ? ev.sourceOdds > 0 ? 100 / (ev.sourceOdds + 100) : Math.abs(ev.sourceOdds) / (Math.abs(ev.sourceOdds) + 100) : 1 / participantIds.length); } else { rawProbs.set(id, 1 / participantIds.length); } } const totalRawProb = [...rawProbs.values()].reduce((s, p) => s + p, 0); const normalizedProb = (id: string): number => totalRawProb > 0 ? (rawProbs.get(id) ?? 0) / totalRawProb : 1 / participantIds.length; // 5. 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(); const r32Drawn = new Map(); 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(), runnerUp: new Map(), thirdPlace: new Map(), fourthPlace: new Map(), qfLoser: new Map(), }; 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 => { 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(); 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(); 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(); 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, normalizedProb); const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb); // QF losers (placements 5–8) 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, normalizedProb ); 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, normalizedProb ); 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; } }