brackt/app/services/simulations/world-cup-simulator.ts
Claude dc4efe87cf
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.

Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
  projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
  on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
  manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.

Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
  parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
  resolve each participant by the configured priority instead of a fixed order.
  Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
  college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
  batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
  interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
  from the season policy; defaults preserve prior calibration when no context is
  passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
  Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
  link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
  parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
  context-blend behavioral test, and an updated non-destructive save test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00

801 lines
30 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 { 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, SimulationContext } 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<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,
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): { winner: string; loser: string } {
const eloProb = eloWinProbability(eloFn(teamA), eloFn(teamB));
const oddsProb = normalizedProb(teamA);
const blended = (1 - oddsWeight) * eloProb + oddsWeight * (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<number, KnockoutMatch>,
completed: Map<string, { winnerId: string; loserId: string }>,
eloFn: (id: string) => number,
normalizedProb: (id: string) => number,
oddsWeight: number = ODDS_WEIGHT
): {
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, normalizedProb, oddsWeight);
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, context?: SimulationContext): Promise<SimulationResult[]> {
// Elo-vs-odds blend weight for knockout matches: configurable via the season
// input policy, defaulting to the historical 70/30 split without context.
const oddsWeight = context?.oddsWeight ?? ODDS_WEIGHT;
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<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 hasOdds = evRows.some(
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
);
let eloFromOdds: Map<string, number>;
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<string, number>();
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<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, normalizedProb, oddsWeight);
const r16 = runKnockoutRoundByNumber(R16, r32.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
const qf = runKnockoutRoundByNumber(QF, r16.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
const sf = runKnockoutRoundByNumber(SF, qf.nextRound, completedByRoundAndNumber, eloFn, normalizedProb, oddsWeight);
// 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, normalizedProb, oddsWeight
);
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, oddsWeight
);
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;
}
}