* refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema 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> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> 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;
|
||
}
|
||
}
|