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
This commit is contained in:
Claude 2026-05-14 18:55:01 +00:00
parent 06339fd4b6
commit 4e0c4aa524
No known key found for this signature in database
9 changed files with 6996 additions and 0 deletions

View file

@ -0,0 +1,363 @@
import { describe, it, expect } from "vitest";
import {
mlsWinProbability,
simulateMlsKnockoutGame,
simulateMlsBestOfThree,
simulateMlsRegularSeason,
simulateMlsPlayoffs,
type MlsConferenceSeed,
type MlsTeamEntry,
} from "../mls-simulator";
import { SIMULATOR_TYPES } from "../registry";
import { SIMULATOR_MANIFEST } from "../manifest";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeSeed(
participantId: string,
seed: number,
conference: "Eastern" | "Western" = "Eastern",
elo = 1500
): MlsConferenceSeed {
return { participantId, elo, conference, seed };
}
function make15Teams(
conference: "Eastern" | "Western",
eloFn: (i: number) => number = () => 1500
): MlsTeamEntry[] {
return Array.from({ length: 15 }, (_, i) => ({
participantId: `${conference}-team-${i + 1}`,
elo: eloFn(i),
conference,
currentPoints: 0,
currentGoalsFor: 0,
currentGoalDifference: 0,
remainingGames: 34,
}));
}
function make9Seeds(conference: "Eastern" | "Western", eloFn: (i: number) => number = () => 1500): MlsConferenceSeed[] {
return Array.from({ length: 9 }, (_, i) =>
makeSeed(`${conference}-t${i + 1}`, i + 1, conference, eloFn(i))
);
}
// ─── mlsWinProbability ────────────────────────────────────────────────────────
describe("mlsWinProbability", () => {
it("returns 0.5 for equal Elo", () => {
expect(mlsWinProbability(1500, 1500)).toBeCloseTo(0.5);
});
it("favours the higher-Elo team", () => {
expect(mlsWinProbability(1600, 1400)).toBeGreaterThan(0.5);
});
it("returns a probability in (0, 1)", () => {
const p = mlsWinProbability(2000, 1000);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(1);
});
});
// ─── simulateMlsKnockoutGame ──────────────────────────────────────────────────
describe("simulateMlsKnockoutGame", () => {
it("returns one winner and one loser from the two teams", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsKnockoutGame(a, b);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("winner and loser are always different", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 200; i++) {
const result = simulateMlsKnockoutGame(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
}
});
it("over many trials, higher-Elo team wins more often", () => {
const strong = makeSeed("strong", 1, "Eastern", 1700);
const weak = makeSeed("weak", 2, "Eastern", 1300);
let wins = 0;
for (let i = 0; i < 10_000; i++) {
if (simulateMlsKnockoutGame(strong, weak).winner.participantId === "strong") wins++;
}
expect(wins).toBeGreaterThan(7000);
});
});
// ─── simulateMlsBestOfThree ────────────────────────────────────────────────────
describe("simulateMlsBestOfThree", () => {
it("returns one winner and one loser", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsBestOfThree(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("gamesPlayed is between 2 and 3", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 500; i++) {
const { gamesPlayed } = simulateMlsBestOfThree(a, b);
expect(gamesPlayed).toBeGreaterThanOrEqual(2);
expect(gamesPlayed).toBeLessThanOrEqual(3);
}
});
it("from a 1-1 state always plays exactly 1 more game", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
for (let i = 0; i < 500; i++) {
const { gamesPlayed } = simulateMlsBestOfThree(a, b, undefined, { winsHigher: 1, winsLower: 1 });
expect(gamesPlayed).toBe(1);
}
});
it("respects existing state: team with 2 wins is declared winner immediately", () => {
const a = makeSeed("A", 1);
const b = makeSeed("B", 2);
const result = simulateMlsBestOfThree(a, b, undefined, { winsHigher: 2, winsLower: 0 });
expect(result.winner.participantId).toBe("A");
expect(result.gamesPlayed).toBe(0);
});
it("higher-Elo team wins series more often", () => {
const strong = makeSeed("strong", 1, "Eastern", 1800);
const weak = makeSeed("weak", 2, "Eastern", 1200);
let wins = 0;
for (let i = 0; i < 5_000; i++) {
if (simulateMlsBestOfThree(strong, weak).winner.participantId === "strong") wins++;
}
expect(wins).toBeGreaterThan(4000);
});
});
// ─── simulateMlsRegularSeason ─────────────────────────────────────────────────
describe("simulateMlsRegularSeason", () => {
it("returns exactly 9 seeds per conference from a 30-team input", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
expect(east).toHaveLength(9);
expect(west).toHaveLength(9);
});
it("seeds are numbered 19 with no duplicates within each conference", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
const eastNums = east.map((s) => s.seed).toSorted((a, b) => a - b);
const westNums = west.map((s) => s.seed).toSorted((a, b) => a - b);
expect(eastNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(westNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("conference separation holds — no Eastern team appears in Western seeds", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const { east, west } = simulateMlsRegularSeason(teams);
for (const s of east) expect(s.participantId).toMatch(/^Eastern-/);
for (const s of west) expect(s.participantId).toMatch(/^Western-/);
});
it("all returned participant IDs are from the input teams", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
const ids = new Set(teams.map((t) => t.participantId));
const { east, west } = simulateMlsRegularSeason(teams);
for (const s of [...east, ...west]) {
expect(ids.has(s.participantId)).toBe(true);
}
});
it("higher-Elo Eastern team qualifies more often", () => {
const teams = [
...make15Teams("Eastern", (i) => i === 0 ? 1900 : 1400),
...make15Teams("Western"),
];
let qualified = 0;
for (let i = 0; i < 500; i++) {
const { east } = simulateMlsRegularSeason(teams);
if (east.some((s) => s.participantId === "Eastern-team-1")) qualified++;
}
expect(qualified).toBeGreaterThan(450);
});
it("works with a full preseason 0-0 state", () => {
const teams = [...make15Teams("Eastern"), ...make15Teams("Western")];
expect(() => simulateMlsRegularSeason(teams)).not.toThrow();
});
it("respects current points as a floor", () => {
const teams = [
...make15Teams("Eastern").map((t, i) =>
i === 0 ? { ...t, currentPoints: 90, remainingGames: 0 } : t
),
...make15Teams("Western"),
];
// A team with 90 points and 0 remaining games always tops the East
for (let i = 0; i < 50; i++) {
const { east } = simulateMlsRegularSeason(teams);
expect(east[0].participantId).toBe("Eastern-team-1");
}
});
});
// ─── simulateMlsPlayoffs ──────────────────────────────────────────────────────
describe("simulateMlsPlayoffs", () => {
const east = make9Seeds("Eastern");
const west = make9Seeds("Western");
it("returns exactly one champion", () => {
const result = simulateMlsPlayoffs(east, west);
expect(typeof result.champion).toBe("string");
});
it("returns exactly one finalist", () => {
const result = simulateMlsPlayoffs(east, west);
expect(typeof result.finalist).toBe("string");
});
it("champion and finalist are different", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.champion).not.toBe(result.finalist);
});
it("confFinalsLosers has exactly 2 unique entries", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.confFinalsLosers).toHaveLength(2);
expect(new Set(result.confFinalsLosers).size).toBe(2);
});
it("confSemiLosers has exactly 4 unique entries", () => {
const result = simulateMlsPlayoffs(east, west);
expect(result.confSemiLosers).toHaveLength(4);
expect(new Set(result.confSemiLosers).size).toBe(4);
});
it("champion comes from the playoff field", () => {
const allIds = new Set([...east, ...west].map((s) => s.participantId));
const result = simulateMlsPlayoffs(east, west);
expect(allIds.has(result.champion)).toBe(true);
});
it("MLS Cup never pits two teams from the same conference", () => {
const eastIds = new Set(east.map((s) => s.participantId));
const westIds = new Set(west.map((s) => s.participantId));
for (let i = 0; i < 100; i++) {
const { champion, finalist } = simulateMlsPlayoffs(east, west);
const champIsEast = eastIds.has(champion);
const champIsWest = westIds.has(champion);
const finalIsEast = eastIds.has(finalist);
const finalIsWest = westIds.has(finalist);
// Champion and finalist must be from opposite conferences
expect(champIsEast !== finalIsEast || champIsWest !== finalIsWest).toBe(true);
}
});
it("all 8 placed participants are unique", () => {
const result = simulateMlsPlayoffs(east, west);
const all = [
result.champion,
result.finalist,
...result.confFinalsLosers,
...result.confSemiLosers,
];
expect(new Set(all).size).toBe(8);
});
it("higher-Elo seed-1 East team wins MLS Cup more often than lower-Elo seed-9 East team", () => {
const strongEast = make9Seeds("Eastern", (i) => 1800 - i * 30);
let seed1Wins = 0;
for (let i = 0; i < 3_000; i++) {
if (simulateMlsPlayoffs(strongEast, west).champion === "Eastern-t1") seed1Wins++;
}
expect(seed1Wins).toBeGreaterThan(500);
});
});
// ─── Probability column sums ───────────────────────────────────────────────────
describe("simulateMlsPlayoffs probability column sums", () => {
const east = make9Seeds("Eastern");
const west = make9Seeds("Western");
const N = 1000;
it("probFirst sums to exactly N across all participants", () => {
const counts = new Map([...east, ...west].map((s) => [s.participantId, 0]));
for (let i = 0; i < N; i++) {
const r = simulateMlsPlayoffs(east, west);
counts.set(r.champion, (counts.get(r.champion) ?? 0) + 1);
}
const total = [...counts.values()].reduce((s, c) => s + c, 0);
expect(total).toBe(N);
});
it("confFinalsLosers: 2 per sim → total = 2*N", () => {
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateMlsPlayoffs(east, west).confFinalsLosers.length;
}
expect(total).toBe(2 * N);
});
it("confSemiLosers: 4 per sim → total = 4*N", () => {
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateMlsPlayoffs(east, west).confSemiLosers.length;
}
expect(total).toBe(4 * N);
});
});
// ─── Manifest, registry, and schema enum sync ────────────────────────────────
describe("mls_bracket simulator sync", () => {
it("mls_bracket is in SIMULATOR_TYPES", () => {
expect(SIMULATOR_TYPES).toContain("mls_bracket");
});
it("mls_bracket has a manifest profile", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"]).toBeDefined();
});
it("manifest profile requires sourceElo", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].requiredInputs).toContain("sourceElo");
});
it("manifest profile has projectedTablePoints as optional input", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].optionalInputs).toContain("projectedTablePoints");
});
it("manifest profile has region as optional input", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].optionalInputs).toContain("region");
});
it("manifest profile lists regularStandings and eloRatings setup sections", () => {
const sections = SIMULATOR_MANIFEST["mls_bracket"].setupSections;
expect(sections).toContain("regularStandings");
expect(sections).toContain("eloRatings");
});
it("manifest profile has sourceElo derivable from projectedTablePoints and sourceOdds", () => {
const derivable = SIMULATOR_MANIFEST["mls_bracket"].derivableInputs;
expect(derivable?.sourceElo).toContain("projectedTablePoints");
expect(derivable?.sourceElo).toContain("sourceOdds");
});
it("manifest defaultConfig has soccer-appropriate parameters", () => {
expect(SIMULATOR_MANIFEST["mls_bracket"].defaultConfig).toMatchObject({
seasonGames: 34,
parityFactor: 400,
baseDrawRate: 0.26,
});
});
});

View file

@ -216,6 +216,22 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
},
mls_bracket: {
defaultConfig: {
...BASE_CONFIG,
seasonGames: 34,
parityFactor: 400,
matchParityFactor: 400,
averageOpponentElo: 1500,
baseDrawRate: 0.26,
drawDecay: 0.002,
playoffTeamsPerConference: 9,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedTablePoints", "seed", "region"],
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings"],
},
};
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =

View file

@ -0,0 +1,480 @@
/**
* 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",
}));
}
}

View file

@ -30,6 +30,7 @@ import { LLWSSimulator } from "./llws-simulator";
import { CollegeHockeySimulator } from "./college-hockey-simulator";
import { BracktSimulator } from "./brackt-simulator";
import { NLLSimulator } from "./nll-simulator";
import { MLSSimulator } from "./mls-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -56,6 +57,7 @@ export const SIMULATOR_TYPES = [
"college_hockey_bracket",
"brackt",
"nll_bracket",
"mls_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -191,6 +193,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
},
create: () => new NLLSimulator(),
},
mls_bracket: {
info: {
name: "MLS Season + Playoffs Monte Carlo",
description: "Projects MLS regular season standings per conference via Elo (30 teams, 34 games) to determine the 18-team playoff field (top 9 per conference), then simulates the full bracket: Wild Card (single game + PKs) → Round 1 (best-of-3) → Conference Semis (single game) → Conference Finals (single game) → MLS Cup.",
},
create: () => new MLSSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -68,6 +68,12 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
parityFactor: 400,
averageOpponentElo: 1500,
},
mls_bracket: {
seasonGames: 34,
parityFactor: 400,
averageOpponentElo: 1500,
projectionInput: "tablePoints",
},
// Simulators below don't use projected-wins → Elo conversion.
// They can be populated later if needed.
f1_standings: null,

View file

@ -155,6 +155,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"college_hockey_bracket",
"brackt",
"nll_bracket",
"mls_bracket",
]);
export const tournamentStatusEnum = pgEnum("tournament_status", [

View file

@ -0,0 +1 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'mls_bracket';

File diff suppressed because it is too large Load diff

View file

@ -729,6 +729,13 @@
"when": 1778611567910,
"tag": "0103_salty_runaways",
"breakpoints": true
},
{
"idx": 104,
"version": "7",
"when": 1778784280606,
"tag": "0104_chief_boom_boom",
"breakpoints": true
}
]
}