All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m53s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m6s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
The World Cup Monte Carlo simulator ignored the admin-populated knockout draw and re-paired its own simulated group output sequentially, so bracket paths from the Round of 32 onward did not match the real tournament. It also silently invented 12 synthetic groups when none were configured, discarding real group-stage results. Rework the simulator around three regimes: - draw set → simulate the real R32 matchups, advancing through the bracket tree via the canonical ceil(matchNumber/2) rule (matching advanceWinnerTemplate) so sim paths match the admin tooling. - groups only → simulate remaining group matches, then seed the R32 with the official 2026 group-position template + FIFA's exact Annex C best-third-place allocation (495-row published table). - neither → futures-seeded bracket fallback, flagged via result source. Completed group and knockout matches still lock in real results at every round. New app/lib/fifa-2026-bracket.ts encodes the R32 group-position template (official FIFA matches 73-88 mapped onto DB matchNumbers 1-16 so the tree reproduces the real halves) and assignThirdPlaceSlots(). The Annex C table is generated deterministically by scripts/gen-fifa-third-place.mjs into app/lib/fifa-2026-third-place-allocation.ts. Tests assert exact Annex C values, cross-check the template's eligible groups against all 495 combinations, and cover each regime including the partial-draw dedup and fully-drawn fast path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
796 lines
30 KiB
TypeScript
796 lines
30 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 { 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<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
|
||
): { 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<number, KnockoutMatch>,
|
||
completed: Map<string, { winnerId: string; loserId: string }>,
|
||
eloFn: (id: string) => number,
|
||
normalizedProb: (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, 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<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 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);
|
||
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;
|
||
}
|
||
}
|