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

459 lines
16 KiB
TypeScript
Raw Normal View History

Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358) * Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import {
normalizeTeamName,
getTeamData,
eloWinProbability,
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
simulateProjectedPoints,
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358) * Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
NHLSimulator,
} from "../nhl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Colorado Avalanche ")).toBe("colorado avalanche");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("Tampa Bay Lightning")).toBe("tampa bay lightning");
});
it("is already-normalized identity", () => {
expect(normalizeTeamName("dallas stars")).toBe("dallas stars");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Colorado Avalanche");
expect(d).toBeDefined();
expect(d?.conference).toBe("Western");
expect(d?.division).toBe("Central");
expect(d?.elo).toBeGreaterThan(1500);
});
it("is case-insensitive", () => {
expect(getTeamData("colorado avalanche")).toEqual(getTeamData("Colorado Avalanche"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Ice Hounds")).toBeUndefined();
});
it("all 32 teams are present", () => {
const allTeams = [
// Eastern — Atlantic
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
// Eastern — Metropolitan
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
"New Jersey Devils", "New York Rangers",
// Western — Central
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
// Western — Pacific
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("all 32 teams have a valid conference and division", () => {
const validConferences = new Set(["Eastern", "Western"]);
const validDivisions = new Set(["Atlantic", "Metropolitan", "Central", "Pacific"]);
const allTeams = [
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
"New Jersey Devils", "New York Rangers",
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
];
for (const name of allTeams) {
const data = getTeamData(name);
expect(validConferences.has(data?.conference ?? ""), `${name}: invalid conference`).toBe(true);
expect(validDivisions.has(data?.division ?? ""), `${name}: invalid division`).toBe(true);
}
});
it("conference/division assignments are correct for spot-checked teams", () => {
expect(getTeamData("Carolina Hurricanes")?.division).toBe("Metropolitan");
expect(getTeamData("Colorado Avalanche")?.division).toBe("Central");
expect(getTeamData("Vegas Golden Knights")?.division).toBe("Pacific");
expect(getTeamData("Buffalo Sabres")?.conference).toBe("Eastern");
expect(getTeamData("Edmonton Oilers")?.conference).toBe("Western");
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1594, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1500, 1594)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1580, 1520);
expect(p + eloWinProbability(1520, 1580)).toBeCloseTo(1.0, 10);
});
it("a 100-pt gap gives ~55.7% win prob per game", () => {
const p = eloWinProbability(1600, 1500);
expect(p).toBeCloseTo(0.557, 2);
});
it("a 200-pt gap gives ~61.3% win prob per game", () => {
const p = eloWinProbability(1700, 1500);
expect(p).toBeCloseTo(0.613, 2);
});
it("a higher parity factor flattens the per-game win probability toward 0.5", () => {
const standard = eloWinProbability(1700, 1500); // default parity 1000
const flatter = eloWinProbability(1700, 1500, 2500); // season config override
expect(flatter).toBeLessThan(standard);
expect(flatter).toBeGreaterThan(0.5);
});
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
const makeEntry = (overrides: Partial<{
currentPoints: number;
remainingGames: number;
winProb: number;
}> = {}) => ({
id: "test",
name: "Test Team",
data: undefined,
conference: "Western" as const,
division: "Central",
currentPoints: overrides.currentPoints ?? 80,
remainingGames: overrides.remainingGames ?? 10,
winProb: overrides.winProb ?? 0.55,
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358) * Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
resolvedElo: 1500,
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
});
describe("simulateProjectedPoints", () => {
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("returns exactly currentPoints when no games remain", () => {
const entry = makeEntry({ currentPoints: 95, remainingGames: 0 });
expect(simulateProjectedPoints(entry)).toBe(95);
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("returns at least currentPoints (points never decrease)", () => {
const entry = makeEntry({ currentPoints: 70, remainingGames: 20, winProb: 0.5 });
for (let i = 0; i < 100; i++) {
expect(simulateProjectedPoints(entry)).toBeGreaterThanOrEqual(70);
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => {
const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 });
for (let i = 0; i < 100; i++) {
expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2);
}
});
Fix NHL simulator seeding bugs and code quality issues (#281) * Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:33:33 -04:00
it("higher winProb produces higher projected points on average", () => {
const base = makeEntry({ currentPoints: 0, remainingGames: 82 });
const runs = 2000;
const avg = (winProb: number) => {
let total = 0;
for (let i = 0; i < runs; i++) {
total += simulateProjectedPoints({ ...base, winProb });
}
return total / runs;
};
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
});
});
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358) * Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
// ─── NHLSimulator — bracket-aware mode ───────────────────────────────────────
const TEAM_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
function makeMatch(
round: string,
matchNumber: number,
opts: {
p1?: string | null;
p2?: string | null;
winnerId?: string | null;
loserId?: string | null;
isComplete?: boolean;
} = {}
) {
return {
id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`,
scoringEventId: "event-1",
round,
matchNumber,
participant1Id: opts.p1 ?? null,
participant2Id: opts.p2 ?? null,
winnerId: opts.winnerId ?? null,
loserId: opts.loserId ?? null,
isComplete: opts.isComplete ?? false,
isScoring: false,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/**
* Builds a full 16-team NHL bracket fixture:
* R1: 8 matches (Wild Card, matchNumbers 18)
* R2: 4 matches (Divisional, matchNumbers 14) participants null (filled from R1 winners)
* R3: 2 matches (Conference Finals, matchNumbers 12)
* Final: 1 match (Stanley Cup, matchNumber 1)
*/
function buildFullNHLBracket() {
const t = TEAM_IDS;
return [
makeMatch("Wild Card", 1, { p1: t[0], p2: t[15] }),
makeMatch("Wild Card", 2, { p1: t[1], p2: t[14] }),
makeMatch("Wild Card", 3, { p1: t[2], p2: t[13] }),
makeMatch("Wild Card", 4, { p1: t[3], p2: t[12] }),
makeMatch("Wild Card", 5, { p1: t[4], p2: t[11] }),
makeMatch("Wild Card", 6, { p1: t[5], p2: t[10] }),
makeMatch("Wild Card", 7, { p1: t[6], p2: t[9] }),
makeMatch("Wild Card", 8, { p1: t[7], p2: t[8] }),
makeMatch("Divisional", 1),
makeMatch("Divisional", 2),
makeMatch("Divisional", 3),
makeMatch("Divisional", 4),
makeMatch("Conference Finals", 1),
makeMatch("Conference Finals", 2),
makeMatch("Stanley Cup", 1),
];
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
}));
describe("NHLSimulator — bracket-aware mode", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
beforeEach(async () => {
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(
TEAM_IDS.map((id) => ({ id, name: id }))
),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
});
it("falls back to season-projection when no bracket event exists", async () => {
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
const sim = new NHLSimulator();
// Season-projection fails: unknown team IDs → no division data → division check throws
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
});
it("falls back to season-projection when bracket matches have no participants", async () => {
const emptyMatches = buildFullNHLBracket().map((m) => ({
...m,
participant1Id: null,
participant2Id: null,
}));
mockDb.query.playoffMatches.findMany.mockResolvedValue(emptyMatches);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/);
});
it("throws if bracket has fewer than 4 rounds", async () => {
const oneRound = Array.from({ length: 8 }, (_, i) =>
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 8] ?? TEAM_IDS[0] })
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(oneRound);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
});
it("throws if R1 has wrong match count", async () => {
const shortR1 = [
...Array.from({ length: 4 }, (_, i) =>
makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 4] })
),
makeMatch("Divisional", 1),
makeMatch("Divisional", 2),
makeMatch("Conference Finals", 1),
makeMatch("Stanley Cup", 1),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(shortR1);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 8 first-round matches/);
});
it("throws if an R1 participant slot is missing", async () => {
const matches = buildFullNHLBracket();
const r1m3 = matches.find((m) => m.round === "Wild Card" && m.matchNumber === 3);
if (!r1m3) throw new Error("R1 M3 not found in test fixture");
r1m3.participant2Id = null;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NHLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
});
it("returns results for all 16 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(16);
});
it("each probability column sums to 1.0 across all 16 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1);
}
});
it("all probabilities are non-negative", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const v of Object.values(r.probabilities)) {
expect(v).toBeGreaterThanOrEqual(0);
}
}
});
it("champion has probFirst=1 when bracket is fully decided", async () => {
const t = TEAM_IDS;
const matches = buildFullNHLBracket();
// R1: p1 always wins
const r1 = matches.filter((m) => m.round === "Wild Card");
for (const m of r1) {
m.winnerId = m.participant1Id;
m.loserId = m.participant2Id;
m.isComplete = true;
}
const r1Winners = r1.map((m) => m.winnerId ?? "");
// R2: fill from R1 winners, p1 always wins
const r2 = matches.filter((m) => m.round === "Divisional");
for (let i = 0; i < 4; i++) {
r2[i].participant1Id = r1Winners[i * 2];
r2[i].participant2Id = r1Winners[i * 2 + 1];
r2[i].winnerId = r2[i].participant1Id;
r2[i].loserId = r2[i].participant2Id;
r2[i].isComplete = true;
}
const r2Winners = r2.map((m) => m.winnerId ?? "");
// R3: fill from R2 winners, p1 always wins
const r3 = matches.filter((m) => m.round === "Conference Finals");
for (let i = 0; i < 2; i++) {
r3[i].participant1Id = r2Winners[i * 2];
r3[i].participant2Id = r2Winners[i * 2 + 1];
r3[i].winnerId = r3[i].participant1Id;
r3[i].loserId = r3[i].participant2Id;
r3[i].isComplete = true;
}
const r3Winners = r3.map((m) => m.winnerId ?? "");
// Final: t[0] vs t[1], t[0] wins
const champion = t[0];
const final = matches.find((m) => m.round === "Stanley Cup");
if (!final) throw new Error("Stanley Cup match not found in test fixture");
final.participant1Id = r3Winners[0];
final.participant2Id = r3Winners[1];
final.winnerId = champion;
final.loserId = r3Winners[1];
final.isComplete = true;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
const champResult = results.find((r) => r.participantId === champion);
expect(champResult, "champion result should exist").toBeDefined();
expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3);
for (const r of results) {
if (r.participantId !== champion) {
expect(r.probabilities.probFirst).toBeCloseTo(0, 3);
}
}
});
it("uses source 'nhl_bracket_monte_carlo' on all results", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket());
const sim = new NHLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("nhl_bracket_monte_carlo");
}
});
});