brackt/app/services/simulations/mls-simulator.ts
Claude 4e0c4aa524
Add MLS sport simulator (mls_bracket)
Adds Major League Soccer as a draftable sport with a full-season Monte Carlo
simulator. Models both preseason regular-season projection (34 games across
Eastern and Western conferences) and the MLS Cup Playoffs bracket, including
the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference
Semis/Finals (single game), and MLS Cup.

P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference
Semifinals losers. Conference assignment reads from regularSeasonStandings.conference
or falls back to the region simulator input ("Eastern"/"Western").

Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation
chain to sourceElo via existing input-policy; sourceOdds as alternative.
No hardcoded team data — all inputs are admin-managed per season.

- database/schema.ts: add mls_bracket to simulatorTypeEnum
- drizzle/0104_chief_boom_boom.sql: migration for the new enum value
- mls-simulator.ts: MLSSimulator + exported pure helpers for testability
- registry.ts / manifest.ts / simulator-config.ts: register mls_bracket
- mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks

https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa
2026-05-14 18:55:01 +00:00

480 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (19 East, 19 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 34-game 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. 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)
* probFifthEighth = 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 } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import type { EloSoccerMatchOptions } from "./soccer-helpers";
// ─── Constants ────────────────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const MLS_REGULAR_SEASON_GAMES = 34;
const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;
const BASE_DRAW_RATE = 0.26;
const DRAW_DECAY = 0.002;
// ─── 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; // 19 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 = PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
const DEFAULT_MATCH_OPTIONS: EloSoccerMatchOptions = {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
parityFactor: 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 ?? 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
): { 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, AVERAGE_OPPONENT_ELO, 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[] {
return seedNums.map((n) => {
const entry = seeds.find((s) => s.seed === 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);
// Determine finalist (MLS Cup loser)
const cupLoser = cup.loser;
return {
champion: cup.winner.participantId,
finalist: cupLoser.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();
// Load participants
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// Load Elo ratings from EV table (materialized by prepareSimulatorInputsForRun)
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
}
}
if (eloMap.size === 0) {
throw new Error(
`No Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
);
}
// Load simulator inputs (seed + region)
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
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);
}
}
// Load regular season standings (for current points, GD, GF, conference)
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
// Resolve conference for every participant
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) ??
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(", ")}. ` +
`Set region to "Eastern" or "Western" via Admin → Simulator Inputs.`
);
}
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);
}
// 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) 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, MLS_REGULAR_SEASON_GAMES - gamesPlayed),
}];
});
if (teamsWithElo.length === 0) {
throw new Error(`No participants with Elo ratings found for season ${sportsSeasonId}.`);
}
return this.simulateRegularSeason(allIds, teamsWithElo);
}
// ── Mode 1: Known-Seed ──────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
east: MlsConferenceSeed[],
west: MlsConferenceSeed[]
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateMlsPlayoffs(east, west);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts);
}
// ── Mode 2: Regular-Season Projection ──────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: MlsTeamEntry[]
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const { east, west } = simulateMlsRegularSeason(teams);
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);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts);
}
// ── 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"]>
): SimulationResult[] {
const N = NUM_SIMULATIONS;
return 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 5th8th
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",
}));
}
}