/** * 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 { logger } from "~/lib/logger"; import type { Simulator, SimulationResult, SimulationProbabilities } 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 = 50_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" { const eloDiff = eloA - eloB; const pDraw = BASE_DRAW_RATE * Math.exp(-DRAW_DECAY * Math.abs(eloDiff)); const eloWin = eloWinProbability(eloA, eloB); const pWin = (1 - pDraw) * eloWin; const rand = Math.random(); if (rand < pWin) return "win"; if (rand < pWin + pDraw) return "draw"; return "loss"; } 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 key = [m.participant1Id, m.participant2Id].toSorted().join(":"); completedPairs.add(key); } } // 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].toSorted().join(":"); 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 }; } // ─── Probability normalisation ──────────────────────────────────────────────── function normalizeProbabilities( results: SimulationResult[], positionKeys: Array ): void { for (const key of positionKeys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); const residual = 1.0 - colSum; if (residual !== 0 && Math.abs(residual) < 1e-9) { const maxResult = results.reduce((best, r) => r.probabilities[key] > best.probabilities[key] ? r : best ); maxResult.probabilities[key] += residual; } } } // ─── 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.participants.findMany({ where: eq(schema.participants.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.participantExpectedValues.participantId, sourceOdds: schema.participantExpectedValues.sourceOdds, sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.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. Load group stage data from DB const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); const tournamentGroups = bracketEvent ? await db.query.tournamentGroups.findMany({ where: eq(schema.tournamentGroups.scoringEventId, bracketEvent.id), with: { members: true, matches: true, }, }) : []; // Build group definitions: list of teams + their completed match data per group. // If no groups set up yet, distribute all participants into 12 synthetic groups of 4. const groupDefs: Array<{ groupName: string; teamIds: string[]; completedMatches: GroupMatchResult[]; }> = []; if (tournamentGroups.length > 0) { for (const group of tournamentGroups) { const teamIds = group.members.map((m) => m.participantId); const completedMatches: GroupMatchResult[] = group.matches .filter((m) => m.isComplete) .map((m) => ({ participant1Id: m.participant1Id, participant2Id: m.participant2Id, participant1Score: m.participant1Score, participant2Score: m.participant2Score, isComplete: m.isComplete, })); groupDefs.push({ groupName: group.groupName, teamIds, completedMatches }); } } else { // No groups set up — distribute participants into 12 synthetic groups of 4 const chunkSize = 4; const labels = ["A","B","C","D","E","F","G","H","I","J","K","L"]; for (let i = 0; i < Math.min(12, labels.length); i++) { const teamIds = participantIds.slice(i * chunkSize, (i + 1) * chunkSize); if (teamIds.length > 0) groupDefs.push({ groupName: labels[i], teamIds, completedMatches: [] }); } } // 6. Load completed knockout matches (to fix results in simulation) const completedKnockoutMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: and( eq(schema.playoffMatches.scoringEventId, bracketEvent.id), eq(schema.playoffMatches.isComplete, true) ), }) : []; const completedByRoundAndNumber = new Map(); for (const m of completedKnockoutMatches) { if (m.winnerId && m.loserId) { completedByRoundAndNumber.set(`${m.round}:${m.matchNumber}`, { winnerId: m.winnerId, loserId: m.loserId, }); } } // 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); } for (let sim = 0; sim < this.numSimulations; sim++) { // ── Group stage ────────────────────────────────────────────── const advancingFromGroup: string[] = []; // group winners + runners-up (24 teams) const thirdPlaceTeams: TeamStats[] = []; // 12 third-place teams for (const group of groupDefs) { if (group.teamIds.length < 3) continue; const sorted = simGroup(group.teamIds, eloFn, group.completedMatches); advancingFromGroup.push(sorted[0].id, sorted[1].id); // 1st and 2nd if (sorted[2]) thirdPlaceTeams.push(sorted[2]); // 3rd place } // Pick best 8 third-place teams const best8Third = thirdPlaceTeams .toSorted(sortTeams) .slice(0, 8) .map((t) => t.id); // Build R32 pool: 24 group advancers + 8 best 3rd-place = 32 teams const r32Pool = [...advancingFromGroup, ...best8Third]; // ── Knockout rounds ────────────────────────────────────────── // R32 → R16 → QF → SF → 3PG + Final // // SEEDING NOTE: We pair teams sequentially (1v2, 3v4, …) in arrival order // (group A winner, group A runner-up, group B winner, …, best-8 3rd-place teams). // Real FIFA uses a pre-determined bracket path (e.g. Group A winner vs Group B // runner-up), which varies by edition. This simplified pairing produces correct // aggregate probabilities for fantasy purposes even though simulated bracket paths // may not match the actual draw. // // Completed knockout matches are honoured by round+matchNumber, so as the real // bracket plays out the simulation locks in actual results automatically. function runKnockoutRound( teams: string[], roundName: string ): { winners: string[]; losers: string[] } { const winners: string[] = []; const losers: string[] = []; for (let i = 0; i < teams.length; i += 2) { const a = teams[i]; const b = teams[i + 1]; if (!a || !b) continue; const matchNum = Math.floor(i / 2) + 1; const fixed = completedByRoundAndNumber.get(`${roundName}:${matchNum}`); if (fixed) { winners.push(fixed.winnerId); losers.push(fixed.loserId); } else { const { winner, loser } = simKnockout(a, b, eloFn, normalizedProb); winners.push(winner); losers.push(loser); } } return { winners, losers }; } const r32 = runKnockoutRound(r32Pool, "Round of 32"); const r16 = runKnockoutRound(r32.winners, "Round of 16"); const qf = runKnockoutRound(r16.winners, "Quarterfinals"); const sf = runKnockoutRound(qf.winners, "Semifinals"); // QF losers for (const id of qf.losers) { counts.qfLoser.set(id, (counts.qfLoser.get(id) ?? 0) + 1); } // Third place game (SF losers) const [sf1Loser, sf2Loser] = sf.losers; 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, sfWinner2] = sf.winners; if (sfWinner1 && sfWinner2) { const fixedFinal = completedByRoundAndNumber.get("Finals: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); } } } // 8. Convert counts to probabilities const N = this.numSimulations; const numQfLosers = 4; // 4 QF losers per sim 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: "World Cup Monte Carlo (group stage + knockout)", }; }); // Normalise floating-point residuals per position const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", ]; normalizeProbabilities(results, positionKeys); return results; } }