* 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
* Fix MLS simulator: config loading, sourceOdds fallback, normalization
Three issues found in code review comparing against EPL and NFL simulators:
1. Load simulator config from DB via getSportsSeasonSimulatorConfig so
admins can override iterations, seasonGames, parityFactor, drawRates
per season without code changes. Previously all constants were hardcoded.
2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is
present. EPL and NFL both do this; MLS was throwing immediately instead
of attempting the odds conversion that the manifest declares as optional.
3. Call normalizeSimulationResultColumns before returning results, matching
EPL's local normalization pattern for consistency (runner also normalizes
globally, but EPL calls it locally too).
https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa
* Polish MLS simulator: configNumber zero, logger warning, Map lookup
Three small fixes from secondary code review:
1. configNumber: allow value >= 0 (not just > 0) so admins can
legitimately set baseDrawRate or drawDecay to 0 without the value
being silently discarded and replaced with the default.
2. Add logger.warn when a participant is excluded from simulation due
to a missing Elo rating, matching the NLL bracket-aware pattern.
Gives admins a visible signal instead of a silent exclusion.
3. getBySeeds: build a Map once instead of calling Array.find() per
seed. Eliminates 4M linear scans across 50k iterations for a 9-
element array — trivially fast either way, but Map is the right tool.
https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa
---------
Co-authored-by: Claude <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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,
|
||
});
|
||
});
|
||
});
|