Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
609 lines
22 KiB
TypeScript
609 lines
22 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 { 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<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" {
|
||
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<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 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<keyof SimulationProbabilities>
|
||
): 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<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. 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<string, { winnerId: string; loserId: string }>();
|
||
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<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);
|
||
}
|
||
|
||
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<keyof SimulationProbabilities> = [
|
||
"probFirst", "probSecond", "probThird", "probFourth",
|
||
];
|
||
normalizeProbabilities(results, positionKeys);
|
||
|
||
return results;
|
||
}
|
||
}
|