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 { 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(); expect(() => resolveQfMatch(match, eloMap)).toThrow(); }); it("throws when match is undefined", () => { const eloMap = new Map(); 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(); 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"); }); });