brackt/app/services/simulations/__tests__/mls-simulator.test.ts

364 lines
14 KiB
TypeScript
Raw Normal View History

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