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:
parent
06339fd4b6
commit
4e0c4aa524
9 changed files with 6996 additions and 0 deletions
363
app/services/simulations/__tests__/mls-simulator.test.ts
Normal file
363
app/services/simulations/__tests__/mls-simulator.test.ts
Normal 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 1–9 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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> =
|
||||
|
|
|
|||
480
app/services/simulations/mls-simulator.ts
Normal file
480
app/services/simulations/mls-simulator.ts
Normal 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 (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 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)
|
||||
* 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 } 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; // 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 = 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 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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"college_hockey_bracket",
|
||||
"brackt",
|
||||
"nll_bracket",
|
||||
"mls_bracket",
|
||||
]);
|
||||
|
||||
export const tournamentStatusEnum = pgEnum("tournament_status", [
|
||||
|
|
|
|||
1
drizzle/0104_chief_boom_boom.sql
Normal file
1
drizzle/0104_chief_boom_boom.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'mls_bracket';
|
||||
6113
drizzle/meta/0104_snapshot.json
Normal file
6113
drizzle/meta/0104_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue