755 lines
28 KiB
TypeScript
755 lines
28 KiB
TypeScript
/**
|
||
* 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 { 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 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
|
||
);
|
||
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;
|
||
}
|
||
}
|