* docs: plan NLL preseason simulator * Add NLL Season + Playoffs Monte Carlo simulator 14-team regular season (18 games) projects top-8 via Elo + decaying projectedWins prior (adjusts for games already played). Playoff bracket: QF single-game (1v8, 2v7, 3v6, 4v5), SF and Finals best-of-3. Three runtime modes: bracket-aware → known-seed → regular-season projection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import {
|
||
nllGameWinProbability,
|
||
simulateNllGame,
|
||
simulateBestOfThree,
|
||
buildNllBracketFromSeeds,
|
||
simulateNllPlayoffs,
|
||
simulateRegularSeasonSeeds,
|
||
resolveQfMatch,
|
||
resolveBo3Match,
|
||
type SeedEntry,
|
||
type SimTeam,
|
||
type MatchSnapshot,
|
||
type GameSnapshot,
|
||
} from "../nll-simulator";
|
||
import { SIMULATOR_TYPES } from "../registry";
|
||
import { SIMULATOR_MANIFEST } from "../manifest";
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function makeSeed(participantId: string, seed: number, elo = 1500): SeedEntry {
|
||
return { participantId, elo, seed };
|
||
}
|
||
|
||
function make14Teams(eloFn = (_i: number) => 1500) {
|
||
return Array.from({ length: 14 }, (_, i) => ({
|
||
participantId: `team-${i + 1}`,
|
||
elo: eloFn(i),
|
||
currentWins: 0,
|
||
gamesPlayed: 0,
|
||
projectedWins: null as number | null,
|
||
}));
|
||
}
|
||
|
||
// ─── nllGameWinProbability ─────────────────────────────────────────────────────
|
||
|
||
describe("nllGameWinProbability", () => {
|
||
it("returns 0.5 for equal Elo", () => {
|
||
expect(nllGameWinProbability(1500, 1500)).toBeCloseTo(0.5);
|
||
});
|
||
|
||
it("favours the higher-Elo team", () => {
|
||
expect(nllGameWinProbability(1600, 1400)).toBeGreaterThan(0.5);
|
||
});
|
||
|
||
it("returns a probability in [0, 1]", () => {
|
||
const p = nllGameWinProbability(2000, 1000);
|
||
expect(p).toBeGreaterThan(0);
|
||
expect(p).toBeLessThan(1);
|
||
});
|
||
|
||
it("uses the supplied parityFactor", () => {
|
||
const pLoose = nllGameWinProbability(1600, 1400, 1000);
|
||
const pTight = nllGameWinProbability(1600, 1400, 200);
|
||
expect(pLoose).toBeLessThan(pTight);
|
||
});
|
||
});
|
||
|
||
// ─── simulateNllGame ──────────────────────────────────────────────────────────
|
||
|
||
describe("simulateNllGame", () => {
|
||
it("returns one winner and one loser from the two teams", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
const result = simulateNllGame(a, b);
|
||
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
|
||
expect(ids).toEqual(["A", "B"]);
|
||
});
|
||
|
||
it("winner and loser are different", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
const result = simulateNllGame(a, b);
|
||
expect(result.winner.participantId).not.toBe(result.loser.participantId);
|
||
});
|
||
|
||
it("over many trials, higher-Elo team wins more often", () => {
|
||
const strong: SimTeam = { participantId: "strong", elo: 1700 };
|
||
const weak: SimTeam = { participantId: "weak", elo: 1300 };
|
||
let wins = 0;
|
||
for (let i = 0; i < 10_000; i++) {
|
||
if (simulateNllGame(strong, weak).winner.participantId === "strong") wins++;
|
||
}
|
||
expect(wins).toBeGreaterThan(7000);
|
||
});
|
||
});
|
||
|
||
// ─── simulateBestOfThree ──────────────────────────────────────────────────────
|
||
|
||
describe("simulateBestOfThree", () => {
|
||
it("returns one winner and one loser", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
const result = simulateBestOfThree(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: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
for (let i = 0; i < 1000; i++) {
|
||
const { gamesPlayed } = simulateBestOfThree(a, b);
|
||
expect(gamesPlayed).toBeGreaterThanOrEqual(2);
|
||
expect(gamesPlayed).toBeLessThanOrEqual(3);
|
||
}
|
||
});
|
||
|
||
it("from a 1-1 state always plays exactly 1 more game", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
for (let i = 0; i < 1000; i++) {
|
||
const { gamesPlayed } = simulateBestOfThree(a, b, 400, { winsA: 1, winsB: 1 });
|
||
expect(gamesPlayed).toBe(1);
|
||
}
|
||
});
|
||
|
||
it("respects existing series state: team with 2 wins is declared winner immediately", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1500 };
|
||
const b: SimTeam = { participantId: "B", elo: 1500 };
|
||
const result = simulateBestOfThree(a, b, 400, { winsA: 2, winsB: 0 });
|
||
expect(result.winner.participantId).toBe("A");
|
||
});
|
||
|
||
it("simulates from a 1-0 state correctly", () => {
|
||
const a: SimTeam = { participantId: "A", elo: 1900 };
|
||
const b: SimTeam = { participantId: "B", elo: 1100 };
|
||
let aWins = 0;
|
||
for (let i = 0; i < 5_000; i++) {
|
||
const r = simulateBestOfThree(a, b, 400, { winsA: 1, winsB: 0 });
|
||
if (r.winner.participantId === "A") aWins++;
|
||
}
|
||
// A is heavily favoured and already leads 1-0
|
||
expect(aWins).toBeGreaterThan(4000);
|
||
});
|
||
});
|
||
|
||
// ─── buildNllBracketFromSeeds ─────────────────────────────────────────────────
|
||
|
||
describe("buildNllBracketFromSeeds", () => {
|
||
const seeds = Array.from({ length: 8 }, (_, i) => makeSeed(`t${i + 1}`, i + 1));
|
||
|
||
it("produces 4 matchups", () => {
|
||
const bracket = buildNllBracketFromSeeds(seeds);
|
||
expect(bracket).toHaveLength(4);
|
||
});
|
||
|
||
it("QF1 is seed 1 vs seed 8", () => {
|
||
const [[a, b]] = buildNllBracketFromSeeds(seeds);
|
||
expect(a.seed).toBe(1);
|
||
expect(b.seed).toBe(8);
|
||
});
|
||
|
||
it("QF2 is seed 4 vs seed 5", () => {
|
||
const [, [a, b]] = buildNllBracketFromSeeds(seeds);
|
||
expect(a.seed).toBe(4);
|
||
expect(b.seed).toBe(5);
|
||
});
|
||
|
||
it("QF3 is seed 2 vs seed 7", () => {
|
||
const [, , [a, b]] = buildNllBracketFromSeeds(seeds);
|
||
expect(a.seed).toBe(2);
|
||
expect(b.seed).toBe(7);
|
||
});
|
||
|
||
it("QF4 is seed 3 vs seed 6", () => {
|
||
const [, , , [a, b]] = buildNllBracketFromSeeds(seeds);
|
||
expect(a.seed).toBe(3);
|
||
expect(b.seed).toBe(6);
|
||
});
|
||
|
||
it("throws if a required seed is missing", () => {
|
||
const incomplete = seeds.slice(0, 7); // missing seed 8
|
||
expect(() => buildNllBracketFromSeeds(incomplete)).toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── simulateNllPlayoffs ──────────────────────────────────────────────────────
|
||
|
||
describe("simulateNllPlayoffs", () => {
|
||
const seeds = Array.from({ length: 8 }, (_, i) => makeSeed(`t${i + 1}`, i + 1));
|
||
|
||
it("returns exactly one champion", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(typeof result.champion).toBe("string");
|
||
});
|
||
|
||
it("returns exactly one finalist", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(typeof result.finalist).toBe("string");
|
||
});
|
||
|
||
it("champion and finalist are different", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(result.champion).not.toBe(result.finalist);
|
||
});
|
||
|
||
it("returns 2 SF losers", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(result.sfLosers).toHaveLength(2);
|
||
});
|
||
|
||
it("returns 4 QF losers", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(result.qfLosers).toHaveLength(4);
|
||
});
|
||
|
||
it("champion comes from the playoff field (seeds 1–8)", () => {
|
||
const ids = new Set(seeds.map((s) => s.participantId));
|
||
const result = simulateNllPlayoffs(seeds);
|
||
expect(ids.has(result.champion)).toBe(true);
|
||
});
|
||
|
||
it("all 8 participants appear exactly once across all placements", () => {
|
||
const result = simulateNllPlayoffs(seeds);
|
||
const all = [
|
||
result.champion,
|
||
result.finalist,
|
||
...result.sfLosers,
|
||
...result.qfLosers,
|
||
];
|
||
expect(all).toHaveLength(8);
|
||
expect(new Set(all).size).toBe(8);
|
||
});
|
||
|
||
it("higher-Elo team (seed 1) wins more often than lower-Elo team (seed 8)", () => {
|
||
const strongSeeds = Array.from({ length: 8 }, (_, i) =>
|
||
makeSeed(`t${i + 1}`, i + 1, 1800 - i * 50)
|
||
);
|
||
let seed1Wins = 0;
|
||
for (let i = 0; i < 5_000; i++) {
|
||
if (simulateNllPlayoffs(strongSeeds).champion === "t1") seed1Wins++;
|
||
}
|
||
expect(seed1Wins).toBeGreaterThan(1500);
|
||
});
|
||
});
|
||
|
||
// ─── simulateRegularSeasonSeeds ───────────────────────────────────────────────
|
||
|
||
describe("simulateRegularSeasonSeeds", () => {
|
||
it("returns exactly 8 seeds from a 14-team field", () => {
|
||
const teams = make14Teams();
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
expect(seeds).toHaveLength(8);
|
||
});
|
||
|
||
it("seeds are numbered 1–8 with no duplicates", () => {
|
||
const teams = make14Teams();
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
const seedNums = seeds.map((s) => s.seed).toSorted((a, b) => a - b);
|
||
expect(seedNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||
});
|
||
|
||
it("all returned participant IDs are from the input teams", () => {
|
||
const teams = make14Teams();
|
||
const ids = new Set(teams.map((t) => t.participantId));
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
for (const s of seeds) {
|
||
expect(ids.has(s.participantId)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("higher-Elo teams qualify for playoffs more often", () => {
|
||
const teams = make14Teams((i) => i === 0 ? 1900 : 1400);
|
||
let qualifiedCount = 0;
|
||
for (let i = 0; i < 1000; i++) {
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
if (seeds.some((s) => s.participantId === "team-1")) qualifiedCount++;
|
||
}
|
||
expect(qualifiedCount).toBeGreaterThan(900);
|
||
});
|
||
|
||
it("team with higher projectedWins qualifies more often", () => {
|
||
const base = make14Teams(() => 1500);
|
||
base[0] = { ...base[0], projectedWins: 17 }; // 17/18 projected wins
|
||
base[1] = { ...base[1], projectedWins: 2 }; // 2/18 projected wins
|
||
|
||
let t1Qualifies = 0;
|
||
let t2Qualifies = 0;
|
||
for (let i = 0; i < 1000; i++) {
|
||
const seeds = simulateRegularSeasonSeeds(base);
|
||
const ids = seeds.map((s) => s.participantId);
|
||
if (ids.includes("team-1")) t1Qualifies++;
|
||
if (ids.includes("team-2")) t2Qualifies++;
|
||
}
|
||
expect(t1Qualifies).toBeGreaterThan(t2Qualifies);
|
||
});
|
||
|
||
it("mid-season: prior adjusts for remaining wins — team with more projected wins still ahead qualifies more", () => {
|
||
// All teams are at the halfway point with 0 currentWins so currentWins don't confound.
|
||
// team-1 has a very high remaining projection; team-2 has a very low one.
|
||
// With equal Elo, the prior blend should make team-1 qualify more.
|
||
const teams = make14Teams(() => 1500).map((t) => ({
|
||
...t, currentWins: 0, gamesPlayed: 9,
|
||
}));
|
||
teams[0] = { ...teams[0], projectedWins: 17 }; // priorRate = min(1, 17/9) = 1.0
|
||
teams[1] = { ...teams[1], projectedWins: 2 }; // priorRate = 2/9 ≈ 0.22
|
||
|
||
let t1Qualifies = 0;
|
||
let t2Qualifies = 0;
|
||
for (let i = 0; i < 1000; i++) {
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
const ids = seeds.map((s) => s.participantId);
|
||
if (ids.includes("team-1")) t1Qualifies++;
|
||
if (ids.includes("team-2")) t2Qualifies++;
|
||
}
|
||
expect(t1Qualifies).toBeGreaterThan(t2Qualifies);
|
||
});
|
||
|
||
it("works with a full pre-season 0-0 state", () => {
|
||
const teams = make14Teams(() => 1500);
|
||
expect(() => simulateRegularSeasonSeeds(teams)).not.toThrow();
|
||
});
|
||
|
||
it("respects existing wins as a floor (current wins carry over)", () => {
|
||
// Team with 16 wins already can't go below 16.
|
||
const teams = make14Teams(() => 1500);
|
||
teams[0] = { ...teams[0], currentWins: 16, gamesPlayed: 17 };
|
||
for (let i = 0; i < 100; i++) {
|
||
const seeds = simulateRegularSeasonSeeds(teams);
|
||
// team-1 must be seeded (has ≥16 wins, max 18 total)
|
||
expect(seeds.some((s) => s.participantId === "team-1")).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Output probability column sums ──────────────────────────────────────────
|
||
|
||
describe("simulateNllPlayoffs probability column sums", () => {
|
||
it("probFirst sums to 1 across all participants", () => {
|
||
const N = 1000;
|
||
const seeds = Array.from({ length: 8 }, (_, i) =>
|
||
makeSeed(`t${i + 1}`, i + 1, 1500)
|
||
);
|
||
const counts = new Map(seeds.map((s) => [s.participantId, 0]));
|
||
for (let i = 0; i < N; i++) {
|
||
const r = simulateNllPlayoffs(seeds);
|
||
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("probThird+Fourth sums: 2 SF losers per sim → total = 2*N", () => {
|
||
const N = 1000;
|
||
const seeds = Array.from({ length: 8 }, (_, i) =>
|
||
makeSeed(`t${i + 1}`, i + 1, 1500)
|
||
);
|
||
let total = 0;
|
||
for (let i = 0; i < N; i++) {
|
||
total += simulateNllPlayoffs(seeds).sfLosers.length;
|
||
}
|
||
expect(total).toBe(2 * N);
|
||
});
|
||
|
||
it("probFifth–Eighth sums: 4 QF losers per sim → total = 4*N", () => {
|
||
const N = 1000;
|
||
const seeds = Array.from({ length: 8 }, (_, i) =>
|
||
makeSeed(`t${i + 1}`, i + 1, 1500)
|
||
);
|
||
let total = 0;
|
||
for (let i = 0; i < N; i++) {
|
||
total += simulateNllPlayoffs(seeds).qfLosers.length;
|
||
}
|
||
expect(total).toBe(4 * N);
|
||
});
|
||
});
|
||
|
||
// ─── resolveQfMatch ───────────────────────────────────────────────────────────
|
||
|
||
function makeMatch(overrides: Partial<MatchSnapshot> = {}): MatchSnapshot {
|
||
return {
|
||
id: "match-1",
|
||
isComplete: false,
|
||
winnerId: null,
|
||
loserId: null,
|
||
participant1Id: "A",
|
||
participant2Id: "B",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("resolveQfMatch", () => {
|
||
it("returns stored result deterministically when match is complete", () => {
|
||
const match = makeMatch({ isComplete: true, winnerId: "A", loserId: "B" });
|
||
const eloMap = new Map([["A", 1500], ["B", 1500]]);
|
||
for (let i = 0; i < 100; i++) {
|
||
const result = resolveQfMatch(match, eloMap);
|
||
expect(result.winner).toBe("A");
|
||
expect(result.loser).toBe("B");
|
||
}
|
||
});
|
||
|
||
it("simulates a game when match is incomplete", () => {
|
||
const match = makeMatch();
|
||
const eloMap = new Map([["A", 1500], ["B", 1500]]);
|
||
const result = resolveQfMatch(match, eloMap);
|
||
const ids = [result.winner, result.loser].toSorted();
|
||
expect(ids).toEqual(["A", "B"]);
|
||
});
|
||
|
||
it("throws when participant slots are missing", () => {
|
||
const match = makeMatch({ participant1Id: null });
|
||
const eloMap = new Map<string, number>();
|
||
expect(() => resolveQfMatch(match, eloMap)).toThrow();
|
||
});
|
||
|
||
it("throws when match is undefined", () => {
|
||
const eloMap = new Map<string, number>();
|
||
expect(() => resolveQfMatch(undefined, eloMap)).toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── resolveBo3Match ──────────────────────────────────────────────────────────
|
||
|
||
function noGames(): GameSnapshot[] {
|
||
return [];
|
||
}
|
||
|
||
describe("resolveBo3Match", () => {
|
||
it("returns stored result deterministically when match is complete", () => {
|
||
const match = makeMatch({ isComplete: true, winnerId: "B", loserId: "A" });
|
||
const eloMap = new Map([["A", 1500], ["B", 1500]]);
|
||
for (let i = 0; i < 100; i++) {
|
||
const result = resolveBo3Match(match, noGames(), eloMap);
|
||
expect(result.winner).toBe("B");
|
||
expect(result.loser).toBe("A");
|
||
}
|
||
});
|
||
|
||
it("simulates from scratch when no games played", () => {
|
||
const match = makeMatch();
|
||
const eloMap = new Map([["A", 1500], ["B", 1500]]);
|
||
const result = resolveBo3Match(match, noGames(), eloMap);
|
||
const ids = [result.winner, result.loser].toSorted();
|
||
expect(ids).toEqual(["A", "B"]);
|
||
});
|
||
|
||
it("returns winner deterministically when game rows show 2 wins for p1", () => {
|
||
const match = makeMatch();
|
||
const games: GameSnapshot[] = [{ winnerId: "A" }, { winnerId: "A" }];
|
||
const eloMap = new Map([["A", 1500], ["B", 1500]]);
|
||
for (let i = 0; i < 100; i++) {
|
||
const result = resolveBo3Match(match, games, eloMap);
|
||
expect(result.winner).toBe("A");
|
||
expect(result.loser).toBe("B");
|
||
}
|
||
});
|
||
|
||
it("simulates correctly from a 1-1 game state", () => {
|
||
const match = makeMatch();
|
||
const games: GameSnapshot[] = [{ winnerId: "A" }, { winnerId: "B" }];
|
||
const eloMap = new Map([["A", 1900], ["B", 1100]]);
|
||
let aWins = 0;
|
||
for (let i = 0; i < 5_000; i++) {
|
||
if (resolveBo3Match(match, games, eloMap).winner === "A") aWins++;
|
||
}
|
||
expect(aWins).toBeGreaterThan(4000);
|
||
});
|
||
|
||
it("accepts p1/p2 overrides when match slots are null", () => {
|
||
const match = makeMatch({ participant1Id: null, participant2Id: null });
|
||
const eloMap = new Map([["X", 1500], ["Y", 1500]]);
|
||
const result = resolveBo3Match(match, noGames(), eloMap, "X", "Y");
|
||
const ids = [result.winner, result.loser].toSorted();
|
||
expect(ids).toEqual(["X", "Y"]);
|
||
});
|
||
|
||
it("throws when participant slots are missing and no overrides given", () => {
|
||
const match = makeMatch({ participant1Id: null });
|
||
const eloMap = new Map<string, number>();
|
||
expect(() => resolveBo3Match(match, noGames(), eloMap)).toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── Manifest, registry, and schema enum sync ────────────────────────────────
|
||
|
||
describe("nll_bracket simulator sync", () => {
|
||
it("nll_bracket is in SIMULATOR_TYPES", () => {
|
||
expect(SIMULATOR_TYPES).toContain("nll_bracket");
|
||
});
|
||
|
||
it("nll_bracket has a manifest profile", () => {
|
||
expect(SIMULATOR_MANIFEST["nll_bracket"]).toBeDefined();
|
||
});
|
||
|
||
it("manifest profile requires sourceElo", () => {
|
||
expect(SIMULATOR_MANIFEST["nll_bracket"].requiredInputs).toContain("sourceElo");
|
||
});
|
||
|
||
it("manifest profile has projectedWins as optional input", () => {
|
||
expect(SIMULATOR_MANIFEST["nll_bracket"].optionalInputs).toContain("projectedWins");
|
||
});
|
||
|
||
it("manifest profile lists regularStandings and bracket setup sections", () => {
|
||
const sections = SIMULATOR_MANIFEST["nll_bracket"].setupSections;
|
||
expect(sections).toContain("regularStandings");
|
||
expect(sections).toContain("bracket");
|
||
});
|
||
|
||
it("manifest profile has sourceElo derivable from projectedWins", () => {
|
||
const derivable = SIMULATOR_MANIFEST["nll_bracket"].derivableInputs;
|
||
expect(derivable?.sourceElo).toContain("projectedWins");
|
||
});
|
||
});
|