/** * MLS Season + Playoffs Simulator * * Monte Carlo simulation of the MLS regular season and MLS Cup Playoffs. * * Two modes, auto-detected at runtime: * * ── Mode 1: Known-Seed ──────────────────────────────────────────────────────── * Used when all 18 playoff seeds (1–9 East, 1–9 West) are set via the `seed` * input and a `region` ("Eastern"/"Western") is present for every playoff team. * Simulates the playoff bracket directly from those seeds. * * ── Mode 2: Regular-Season Projection (default) ─────────────────────────────── * Simulates remaining regular-season games per team using Elo vs the average * opponent (1500), per conference. Top 9 teams per conference qualify. * Then simulates the MLS Cup Playoffs bracket. * * Conference assignment resolution order: * 1. regularSeasonStandings.conference (populated by standings sync) * 2. seasonParticipantSimulatorInputs.region ("Eastern" or "Western") * 3. seasonParticipant.externalId set to "Eastern" or "Western" (pre-sync bootstrap; * note: this prevents externalId-based matching in future syncs for that participant, * which falls back to slower name-matching — acceptable tradeoff for bootstrapping) * 4. Error — conference must be known for all teams * * MLS Cup Playoffs format (18 teams: 9 East + 9 West): * Wild Card : E8 vs E9, W8 vs W9 — single game (no draws; PKs if tied) * Round 1 (Conf Quarters): best-of-3 — E1 vs WC winner, E2 vs E7, E3 vs E6, E4 vs E5 * (same structure West) * Conference Semifinals : single game — 4 per conference → 2 per conference * Conference Finals : single game — 2 per conference → 1 per conference * MLS Cup : single game — East champion vs West champion * * Probability mapping: * probFirst = MLS Cup champion (1 per sim) * probSecond = MLS Cup finalist (1 per sim) * probThird/Fourth = Conference Finals losers (2 per sim — split evenly) * probFifth–Eighth = Conference Semifinals losers (4 per sim — split evenly) * Wild Card losers, Round 1 losers, non-qualifiers → all 0 */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from "~/models/simulator"; import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers"; import type { EloSoccerMatchOptions } from "./soccer-helpers"; import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { logger } from "~/lib/logger"; import { configNumber } from "./config-access"; // ─── Default constants (overridable via season simulator config) ─────────────── const DEFAULT_NUM_SIMULATIONS = 50_000; const DEFAULT_SEASON_GAMES = 34; const DEFAULT_PARITY_FACTOR = 400; const DEFAULT_AVERAGE_OPPONENT_ELO = 1500; const DEFAULT_BASE_DRAW_RATE = 0.26; const DEFAULT_DRAW_DECAY = 0.002; const MLS_PLAYOFF_TEAMS_PER_CONF = 9; /** * Infer MLS conference from seasonParticipant.externalId. * Admins can set externalId to "Eastern" or "Western" before the first standings * sync, mirroring how LLWS uses externalId to encode pool assignment. * * Side-effect: once externalId holds a conference name instead of an ESPN team ID, * the standings-sync write-back won't overwrite it (write-back only fires when * externalId is null). That participant will always match by name, never by ID. */ export function parseConferenceFromExternalId(externalId: string | null | undefined): MlsConference | null { if (!externalId) return null; const upper = externalId.toUpperCase(); if (upper === "EASTERN") return "Eastern"; if (upper === "WESTERN") return "Western"; return null; } // ─── Public types ───────────────────────────────────────────────────────────── export type MlsConference = "Eastern" | "Western"; export interface MlsTeamEntry { participantId: string; elo: number; conference: MlsConference; currentPoints: number; currentGoalsFor: number; currentGoalDifference: number; remainingGames: number; } export interface MlsConferenceSeed { participantId: string; elo: number; conference: MlsConference; seed: number; // 1–9 within conference } export interface MlsPlayoffResult { champion: string; finalist: string; confFinalsLosers: [string, string]; confSemiLosers: [string, string, string, string]; } // ─── Match helpers ──────────────────────────────────────────────────────────── export function mlsWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number { return eloWinProbabilityWithParity(eloA, eloB, parityFactor); } const DEFAULT_MATCH_OPTIONS: EloSoccerMatchOptions = { baseDrawRate: DEFAULT_BASE_DRAW_RATE, drawDecay: DEFAULT_DRAW_DECAY, parityFactor: DEFAULT_PARITY_FACTOR, }; /** * Simulate a single MLS knockout game (no draws allowed in result). * If the Elo match produces a draw, a penalty shootout is simulated * using the same Elo win probability. */ export function simulateMlsKnockoutGame( teamA: MlsConferenceSeed, teamB: MlsConferenceSeed, matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS ): { winner: MlsConferenceSeed; loser: MlsConferenceSeed } { const result = simulateEloSoccerMatch(teamA.elo, teamB.elo, matchOptions); if (result === "win") return { winner: teamA, loser: teamB }; if (result === "loss") return { winner: teamB, loser: teamA }; // Draw → penalty shootout (Elo-biased coin flip) const pA = mlsWinProbability(teamA.elo, teamB.elo, matchOptions.parityFactor ?? DEFAULT_PARITY_FACTOR); return Math.random() < pA ? { winner: teamA, loser: teamB } : { winner: teamB, loser: teamA }; } /** * Simulate a best-of-3 MLS Round 1 series. * Individual games use knockout rules (no draws; PKs decide ties). * Higher seed is home for games 1 and 3. */ export function simulateMlsBestOfThree( higherSeed: MlsConferenceSeed, lowerSeed: MlsConferenceSeed, matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS, existing: { winsHigher: number; winsLower: number } = { winsHigher: 0, winsLower: 0 } ): { winner: MlsConferenceSeed; loser: MlsConferenceSeed; gamesPlayed: number } { let wH = existing.winsHigher; let wL = existing.winsLower; let gamesPlayed = 0; while (wH < 2 && wL < 2) { const { winner } = simulateMlsKnockoutGame(higherSeed, lowerSeed, matchOptions); if (winner.participantId === higherSeed.participantId) wH++; else wL++; gamesPlayed++; } return wH === 2 ? { winner: higherSeed, loser: lowerSeed, gamesPlayed } : { winner: lowerSeed, loser: higherSeed, gamesPlayed }; } // ─── Regular-season simulation ──────────────────────────────────────────────── /** * Simulate remaining regular season games per conference and return the * top-9 seeds for each conference sorted by points → GD → GF → random tiebreak. */ export function simulateMlsRegularSeason( teams: MlsTeamEntry[], matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS, averageOpponentElo = DEFAULT_AVERAGE_OPPONENT_ELO ): { east: MlsConferenceSeed[]; west: MlsConferenceSeed[] } { const jitter = new Map(teams.map((t) => [t.participantId, Math.random()])); const rows = teams.map((t) => { let points = t.currentPoints; let gd = t.currentGoalDifference; let gf = t.currentGoalsFor; for (let g = 0; g < t.remainingGames; g++) { const result = simulateEloSoccerMatch(t.elo, averageOpponentElo, matchOptions); const goals = simulateSimpleSoccerGoals(result); gf += goals.gf; gd += goals.gf - goals.ga; if (result === "win") points += 3; else if (result === "draw") points += 1; } return { participantId: t.participantId, elo: t.elo, conference: t.conference, points, gd, gf }; }); function seedConference(conf: MlsConference): MlsConferenceSeed[] { const confRows = rows.filter((r) => r.conference === conf); const sorted = confRows.toSorted( (a, b) => b.points - a.points || b.gd - a.gd || b.gf - a.gf || (jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0) ); return sorted.slice(0, MLS_PLAYOFF_TEAMS_PER_CONF).map((r, i) => ({ participantId: r.participantId, elo: r.elo, conference: conf, seed: i + 1, })); } return { east: seedConference("Eastern"), west: seedConference("Western") }; } // ─── Playoff simulation ──────────────────────────────────────────────────────── function getBySeeds( seeds: MlsConferenceSeed[], ...seedNums: number[] ): MlsConferenceSeed[] { const byNum = new Map(seeds.map((s) => [s.seed, s])); return seedNums.map((n) => { const entry = byNum.get(n); if (!entry) throw new Error(`MLS playoff seed ${n} not found in conference bracket.`); return entry; }); } /** * Simulate the full MLS Cup Playoffs from 9+9 conference seeds. */ export function simulateMlsPlayoffs( east: MlsConferenceSeed[], west: MlsConferenceSeed[], matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS ): MlsPlayoffResult { function simConference(seeds: MlsConferenceSeed[]): { champion: MlsConferenceSeed; finalist: MlsConferenceSeed; confSemiLosers: [string, string]; } { const [s1, s2, s3, s4, s5, s6, s7, s8, s9] = getBySeeds(seeds, 1, 2, 3, 4, 5, 6, 7, 8, 9); // Wild Card: 8 vs 9 const wc = simulateMlsKnockoutGame(s8, s9, matchOptions); // Round 1 (best-of-3): 1 vs WC winner, 2 vs 7, 3 vs 6, 4 vs 5 const r1a = simulateMlsBestOfThree(s1, wc.winner, matchOptions); const r1b = simulateMlsBestOfThree(s2, s7, matchOptions); const r1c = simulateMlsBestOfThree(s3, s6, matchOptions); const r1d = simulateMlsBestOfThree(s4, s5, matchOptions); // Conference Semifinals: single game // Arm A: r1a winner vs r1d winner; Arm B: r1b winner vs r1c winner const sfA = simulateMlsKnockoutGame(r1a.winner, r1d.winner, matchOptions); const sfB = simulateMlsKnockoutGame(r1b.winner, r1c.winner, matchOptions); // Conference Finals: single game const confFinal = simulateMlsKnockoutGame(sfA.winner, sfB.winner, matchOptions); return { champion: confFinal.winner, finalist: confFinal.loser, confSemiLosers: [sfA.loser.participantId, sfB.loser.participantId], }; } const eastResult = simConference(east); const westResult = simConference(west); // MLS Cup: East champion vs West champion const cup = simulateMlsKnockoutGame(eastResult.champion, westResult.champion, matchOptions); return { champion: cup.winner.participantId, finalist: cup.loser.participantId, confFinalsLosers: [eastResult.finalist.participantId, westResult.finalist.participantId], confSemiLosers: [ ...eastResult.confSemiLosers, ...westResult.confSemiLosers, ] as [string, string, string, string], }; } // ─── Simulator class ────────────────────────────────────────────────────────── export class MLSSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); const [participants, evRows, standingsRows, simInputs, simulatorConfig] = await Promise.all([ db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId), }), db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), getRegularSeasonStandings(sportsSeasonId), getParticipantSimulatorInputs(sportsSeasonId), getSportsSeasonSimulatorConfig(sportsSeasonId), ]); if (participants.length === 0) { throw new Error(`No participants found for sports season ${sportsSeasonId}.`); } // Extract config values (admin-overridable; fall back to defaults) const config = simulatorConfig?.config; const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_SEASON_GAMES)); const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO); const matchOptions: EloSoccerMatchOptions = { baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE), drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY), parityFactor: configNumber(config, "matchParityFactor", DEFAULT_PARITY_FACTOR), }; // Build Elo map: prefer sourceElo; fall back to converting sourceOdds const eloMap = new Map(); const hasSourceElo = evRows.some((r) => r.sourceElo !== null); if (hasSourceElo) { for (const r of evRows) { if (r.sourceElo !== null) eloMap.set(r.participantId, r.sourceElo); } } else { const hasOdds = evRows.some((r) => r.sourceOdds !== null); if (!hasOdds) { throw new Error( `No Elo ratings or futures odds found for sports season ${sportsSeasonId}. ` + `Enter sourceElo, projectedTablePoints, or sourceOdds via Admin → Elo Ratings before simulating.` ); } const oddsInput = evRows .filter((r) => r.sourceOdds !== null) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds as number })); const converted = convertFuturesToElo(oddsInput); for (const [id, elo] of converted) eloMap.set(id, elo); } // Build simulator input maps (seed + region) const seedMap = new Map(); const regionMap = new Map(); for (const input of simInputs) { if (input.seed !== null && input.seed !== undefined) { seedMap.set(input.participantId, input.seed); } if (input.region !== null && input.region !== undefined) { regionMap.set(input.participantId, input.region); } } // Resolve conference for every participant. // Priority: (1) regularSeasonStandings.conference, (2) simulatorInputs.region, // (3) seasonParticipant.externalId set to "Eastern"/"Western" (pre-sync bootstrap). const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r])); const conferenceMap = new Map(); for (const p of participants) { const standing = standingsMap.get(p.id); const raw = (standing?.conference as string | null | undefined) ?? regionMap.get(p.id) ?? parseConferenceFromExternalId(p.externalId) ?? null; if (raw === "Eastern" || raw === "Western") { conferenceMap.set(p.id, raw); } } const missingConf = participants.filter((p) => !conferenceMap.has(p.id)); if (missingConf.length > 0) { throw new Error( `MLSSimulator: Conference assignment missing for ${missingConf.length} team(s): ` + `${missingConf.map((p) => p.name).join(", ")}. ` + `Options: sync standings (populates conference automatically), set region to "Eastern"/"Western" ` + `via Admin → Simulator Inputs, or set externalId to "Eastern"/"Western" on the participant.` ); } const allIds = participants.map((p) => p.id); // ── Mode detection ───────────────────────────────────────────────────────── // Mode 1: Known-seed — all 18 playoff seeds (1-9 East, 1-9 West) are explicitly set const eastSeeds: MlsConferenceSeed[] = []; const westSeeds: MlsConferenceSeed[] = []; for (const p of participants) { const seed = seedMap.get(p.id); const elo = eloMap.get(p.id); const conf = conferenceMap.get(p.id); if (seed === undefined || elo === undefined || conf === undefined) continue; if (seed < 1 || seed > MLS_PLAYOFF_TEAMS_PER_CONF) continue; if (conf === "Eastern") eastSeeds.push({ participantId: p.id, elo, conference: conf, seed }); else westSeeds.push({ participantId: p.id, elo, conference: conf, seed }); } const eastSeedSet = new Set(eastSeeds.map((s) => s.seed)); const westSeedSet = new Set(westSeeds.map((s) => s.seed)); const isKnownSeed = eastSeeds.length === MLS_PLAYOFF_TEAMS_PER_CONF && westSeeds.length === MLS_PLAYOFF_TEAMS_PER_CONF && eastSeedSet.size === MLS_PLAYOFF_TEAMS_PER_CONF && westSeedSet.size === MLS_PLAYOFF_TEAMS_PER_CONF; if (isKnownSeed) { return this.simulateKnownSeeds(allIds, eastSeeds, westSeeds, numSimulations, matchOptions); } // Mode 2: Regular-season projection (default, including preseason) const teamsWithElo = participants.flatMap((p): MlsTeamEntry[] => { const elo = eloMap.get(p.id); const conf = conferenceMap.get(p.id); if (elo === undefined || conf === undefined) { logger.warn( `MLSSimulator: no Elo rating for participant "${p.name}" (${p.id}). ` + `They will be excluded from simulation. Enter sourceElo or projectedTablePoints via Admin → Elo Ratings.` ); return []; } const standing = standingsMap.get(p.id); const wins = standing?.wins ?? 0; const draws = standing?.ties ?? 0; const gamesPlayed = standing?.gamesPlayed ?? wins + draws + (standing?.losses ?? 0); return [{ participantId: p.id, elo, conference: conf, currentPoints: standing?.tablePoints ?? wins * 3 + draws, currentGoalsFor: standing?.goalsFor ?? 0, currentGoalDifference: standing?.goalDifference ?? 0, remainingGames: Math.max(0, seasonGames - gamesPlayed), }]; }); if (teamsWithElo.length === 0) { throw new Error(`No participants with Elo ratings found for season ${sportsSeasonId}.`); } return this.simulateRegularSeason(allIds, teamsWithElo, numSimulations, matchOptions, averageOpponentElo); } // ── Mode 1: Known-Seed ────────────────────────────────────────────────────── private simulateKnownSeeds( allIds: string[], east: MlsConferenceSeed[], west: MlsConferenceSeed[], numSimulations: number, matchOptions: EloSoccerMatchOptions ): SimulationResult[] { const counts = this.zeroCounts(allIds); for (let s = 0; s < numSimulations; s++) { const result = simulateMlsPlayoffs(east, west, matchOptions); this.recordResult(counts, result); } return this.buildResults(allIds, counts, numSimulations); } // ── Mode 2: Regular-Season Projection ────────────────────────────────────── private simulateRegularSeason( allIds: string[], teams: MlsTeamEntry[], numSimulations: number, matchOptions: EloSoccerMatchOptions, averageOpponentElo: number ): SimulationResult[] { const counts = this.zeroCounts(allIds); for (let s = 0; s < numSimulations; s++) { const { east, west } = simulateMlsRegularSeason(teams, matchOptions, averageOpponentElo); if (east.length < MLS_PLAYOFF_TEAMS_PER_CONF || west.length < MLS_PLAYOFF_TEAMS_PER_CONF) { // Not enough teams in a conference — skip this iteration continue; } const result = simulateMlsPlayoffs(east, west, matchOptions); this.recordResult(counts, result); } return this.buildResults(allIds, counts, numSimulations); } // ── Helpers ───────────────────────────────────────────────────────────────── private zeroCounts(allIds: string[]) { return { champion: new Map(allIds.map((id) => [id, 0])), finalist: new Map(allIds.map((id) => [id, 0])), confFinalsLoser: new Map(allIds.map((id) => [id, 0])), confSemiLoser: new Map(allIds.map((id) => [id, 0])), }; } private recordResult( counts: ReturnType, result: MlsPlayoffResult ) { counts.champion.set(result.champion, (counts.champion.get(result.champion) ?? 0) + 1); counts.finalist.set(result.finalist, (counts.finalist.get(result.finalist) ?? 0) + 1); for (const id of result.confFinalsLosers) { counts.confFinalsLoser.set(id, (counts.confFinalsLoser.get(id) ?? 0) + 1); } for (const id of result.confSemiLosers) { counts.confSemiLoser.set(id, (counts.confSemiLoser.get(id) ?? 0) + 1); } } private buildResults( allIds: string[], counts: ReturnType, numSimulations: number ): SimulationResult[] { const N = numSimulations; const results: SimulationResult[] = allIds.map((id) => ({ participantId: id, probabilities: { probFirst: (counts.champion.get(id) ?? 0) / N, probSecond: (counts.finalist.get(id) ?? 0) / N, // 2 conf finals losers per sim — split evenly across 3rd/4th probThird: (counts.confFinalsLoser.get(id) ?? 0) / N / 2, probFourth: (counts.confFinalsLoser.get(id) ?? 0) / N / 2, // 4 conf semi losers per sim — split evenly across 5th–8th probFifth: (counts.confSemiLoser.get(id) ?? 0) / N / 4, probSixth: (counts.confSemiLoser.get(id) ?? 0) / N / 4, probSeventh: (counts.confSemiLoser.get(id) ?? 0) / N / 4, probEighth: (counts.confSemiLoser.get(id) ?? 0) / N / 4, }, source: "mls_bracket_monte_carlo", })); normalizeSimulationResultColumns(results); return results; } }