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
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,
|
||
});
|
||
});
|
||
});
|