* Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
544 lines
23 KiB
TypeScript
544 lines
23 KiB
TypeScript
/**
|
||
* 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";
|
||
|
||
// ─── 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;
|
||
|
||
// ─── Config helpers ────────────────────────────────────────────────────────────
|
||
|
||
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
|
||
const value = config?.[key];
|
||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
||
}
|
||
|
||
/**
|
||
* 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<SimulationResult[]> {
|
||
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<string, number>();
|
||
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<string, number>();
|
||
const regionMap = new Map<string, string>();
|
||
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<string, MlsConference>();
|
||
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<MLSSimulator["zeroCounts"]>,
|
||
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<MLSSimulator["zeroCounts"]>,
|
||
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;
|
||
}
|
||
}
|