brackt/app/services/simulations/__tests__/nll-simulator.test.ts
Chris Parsons 5b261bf258
Add NLL box lacrosse simulator implementation plan and preseason-draftability guidance (#417)
* 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>
2026-05-12 14:22:34 -07:00

506 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 18)", () => {
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 18 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("probFifthEighth 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");
});
});