diff --git a/app/services/simulations/__tests__/nhl-simulator.test.ts b/app/services/simulations/__tests__/nhl-simulator.test.ts index 4cfcf72..80e2029 100644 --- a/app/services/simulations/__tests__/nhl-simulator.test.ts +++ b/app/services/simulations/__tests__/nhl-simulator.test.ts @@ -3,6 +3,7 @@ import { normalizeTeamName, getTeamData, eloWinProbability, + simulateProjectedPoints, } from "../nhl-simulator"; // ─── normalizeTeamName ──────────────────────────────────────────────────────── @@ -60,6 +61,35 @@ describe("getTeamData", () => { expect(getTeamData(name), `missing team: ${name}`).toBeDefined(); } }); + + 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 ──────────────────────────────────────────────────────── @@ -80,57 +110,64 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => { }); it("a 100-pt gap gives ~55.7% win prob per game", () => { - // At 1000: P = 1 / (1 + 10^(-100/1000)) ≈ 0.557 const p = eloWinProbability(1600, 1500); expect(p).toBeCloseTo(0.557, 2); }); it("a 200-pt gap gives ~61.3% win prob per game", () => { - // At 1000: P = 1 / (1 + 10^(-200/1000)) ≈ 0.613 const p = eloWinProbability(1700, 1500); expect(p).toBeCloseTo(0.613, 2); }); }); -// ─── Seeding probabilities sanity checks ───────────────────────────────────── +// ─── simulateProjectedPoints ────────────────────────────────────────────────── -describe("team seeding probabilities", () => { - it("playoff probability sums to ≤ 1.0 per team", () => { - const teamNames = [ - "Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", - "Carolina Hurricanes", "Buffalo Sabres", "Tampa Bay Lightning", - ]; - for (const name of teamNames) { - const d = getTeamData(name); - expect(d).toBeDefined(); - const total = - (d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) + - (d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0); - expect(total, `${name} playoff prob > 1`).toBeLessThanOrEqual(1.001); +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, +}); + +describe("simulateProjectedPoints", () => { + + it("returns exactly currentPoints when no games remain", () => { + const entry = makeEntry({ currentPoints: 95, remainingGames: 0 }); + expect(simulateProjectedPoints(entry)).toBe(95); + }); + + 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); } }); - it("eliminated teams have no seeding probability keys", () => { - const eliminated = ["Toronto Maple Leafs", "New York Rangers", "Chicago Blackhawks", "Vancouver Canucks"]; - for (const name of eliminated) { - const d = getTeamData(name); - expect(d).toBeDefined(); - const total = - (d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) + - (d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0); - expect(total, `${name} should have 0 playoff probability`).toBe(0); + 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); } }); - it("division leaders have the highest p_div1 in their division", () => { - // COL should dominate Central div1 - const col = getTeamData("Colorado Avalanche"); - const dal = getTeamData("Dallas Stars"); - expect((col?.p_div1 ?? 0)).toBeGreaterThan(dal?.p_div1 ?? 0); - - // CAR should dominate Metro div1 - const car = getTeamData("Carolina Hurricanes"); - const cbj = getTeamData("Columbus Blue Jackets"); - expect((car?.p_div1 ?? 0)).toBeGreaterThan(cbj?.p_div1 ?? 0); + 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)); }); }); diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index 2470ba6..a79e7a9 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -6,28 +6,34 @@ * * Algorithm: * 1. Load all participants for the sports season from DB - * 2. Match participant names to hardcoded team data (Elo + seeding probabilities) - * 3. For each simulation: - * a. For each conference, draw division placements (1st/2nd/3rd per division) - * and two wildcard teams using weighted probability distributions - * b. Build the NHL bracket: - * - Higher-Elo division winner faces WC2; other division winner faces WC1 - * - Each division's 2nd vs 3rd seeds play each other - * c. Simulate 4 rounds of best-of-7 series: - * Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1, + * 2. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division) + * 3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback) + * 4. For each simulation: + * a. For each team, simulate remaining regular season games (82 - gamesPlayed): + * - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500) + * - OT/SO loss (1 pt) at NHL_OT_RATE × (1 − winProb) + * - Regulation loss (0 pts) otherwise + * → projectedPoints = currentPoints + simulated extra points + * b. Per division: rank by projected points (random tiebreaker) → top 3 are division seeds + * c. Per conference: top 2 non-division-seed teams by projected points → WC1 and WC2 + * (WC1 has more points than WC2) + * d. Build the 8-team bracket per conference: + * - Division winner with more points = 1st seed, faces WC2 + * - Other division winner = 2nd seed, faces WC1 + * - Each division's 2nd and 3rd seeds face each other + * e. Simulate 4 rounds of best-of-7 series: + * Round 1 (Wild Card): m0=1stSeed/WC2, m1=2ndSeed/WC1, * m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd * Round 2 (Div Finals): m0w vs m2w, m1w vs m3w * Round 3 (Conf Finals): two division-final winners per conference * Stanley Cup Final: East champ vs West champ - * 4. Track placement counts per scoring tier - * 5. Convert counts to probability distributions + * 5. Track placement counts per scoring tier + * 6. Convert counts to probability distributions * - * Win probability (Elo, PARITY_FACTOR = 550): - * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 550)) - * NHL uses a higher parity factor than the standard 400 to dampen the Elo - * spread and reflect the high variance of hockey. 550 (down from an original - * 800) was chosen to better match Vegas championship implied probabilities — - * 800 compressed favorites too far toward 50/50 per game. + * Win probability (Elo, PARITY_FACTOR = 1000): + * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000)) + * NHL uses a higher parity factor than the standard 400 to reflect the high + * variance of hockey. * * Futures blending: * If sourceOdds are stored in participantExpectedValues for this season, @@ -46,19 +52,11 @@ * First Round losers → all 0 (score 0 points) * Missed playoffs → all 0 * - * Seeding probability keys per team: - * p_div1 = prob of winning own division - * p_div2 = prob of finishing 2nd in own division - * p_div3 = prob of finishing 3rd in own division - * p_wc1 = prob of being conference wildcard 1 (higher-ranked WC) - * p_wc2 = prob of being conference wildcard 2 (lower-ranked WC) - * Sum of all p_* ≈ team's overall playoff probability. - * Teams with no p_* entries always miss the playoffs. - * - * Elo ratings and seeding probabilities are hardcoded below (March 2026 data). - * Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration). - * Seeding probabilities are hand-derived from current standings as of March 18, 2026. - * Update at the start of each season. + * Elo ratings are hardcoded (2025-26 season data). + * Source: https://elo.harvitronix.com/nhl/2025-2026 + * Conference and division are read from the standings table (synced from the + * NHL API); TEAMS_DATA values serve as fallbacks if standings are missing. + * Update Elo values at the start of each season. */ import { database } from "~/database/context"; @@ -70,11 +68,22 @@ import { convertAmericanOddsToProbability, normalizeProbabilities, } from "~/services/probability-engine"; +import { getRegularSeasonStandings } from "~/models/regular-season-standings"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; +/** NHL regular season games per team. */ +const NHL_REGULAR_SEASON_GAMES = 82; + +/** + * Fraction of NHL games that go to overtime / shootout. + * The losing team earns 1 point (instead of 0) in these games. + * Historical average: ~23% of games. + */ +const NHL_OT_RATE = 0.23; + /** * Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect * the elevated game-to-game variance in hockey. @@ -88,17 +97,15 @@ const PARITY_FACTOR = 1000; const ELO_WEIGHT = 0.7; const ODDS_WEIGHT = 1 - ELO_WEIGHT; -// ─── Team data (2025-26 season, as of March 18, 2026) ──────────────────────── +// ─── Team data (2025-26 season) ─────────────────────────────────────────────── // -// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026. -// p_div1: Probability of finishing 1st in own division. -// p_div2: Probability of finishing 2nd in own division. -// p_div3: Probability of finishing 3rd in own division. -// p_wc1: Probability of being conference wildcard 1 (higher seed). -// p_wc2: Probability of being conference wildcard 2 (lower seed). +// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026. +// Used for both regular-season game simulation (vs. avg opponent Elo 1500) +// and for all playoff matchup win probabilities. // -// These are weights used in sequential weighted sampling (not strict distributions). -// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site. +// conference / division: Used as fallbacks when the standings table has no +// conference or division data for a participant. In normal operation these +// are read from regularSeasonStandings (synced from the NHL API). // // Divisions: // Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR @@ -110,168 +117,48 @@ interface NhlTeamData { conference: "Eastern" | "Western"; division: "Atlantic" | "Metropolitan" | "Central" | "Pacific"; elo: number; - p_div1?: number; - p_div2?: number; - p_div3?: number; - p_wc1?: number; - p_wc2?: number; } const TEAMS_DATA: Record = { // ── Eastern Conference — Atlantic ────────────────────────────────────────── - // BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs. - // MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC. - // OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble. - // FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated. - - "Tampa Bay Lightning": { - conference: "Eastern", division: "Atlantic", elo: 1587, - p_div1: 0.50, p_div2: 0.40, p_div3: 0.06, - }, - "Buffalo Sabres": { - conference: "Eastern", division: "Atlantic", elo: 1572, - p_div1: 0.45, p_div2: 0.42, p_div3: 0.07, - }, - "Montreal Canadiens": { - conference: "Eastern", division: "Atlantic", elo: 1527, - p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06, - }, - "Ottawa Senators": { - conference: "Eastern", division: "Atlantic", elo: 1542, - p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19, - }, - "Detroit Red Wings": { - conference: "Eastern", division: "Atlantic", elo: 1506, - p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22, - }, - "Boston Bruins": { - conference: "Eastern", division: "Atlantic", elo: 1501, - p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19, - }, - "Florida Panthers": { - conference: "Eastern", division: "Atlantic", elo: 1505, - p_wc2: 0.005, - }, - "Toronto Maple Leafs": { - conference: "Eastern", division: "Atlantic", elo: 1479, - // 0% playoff probability — always miss - }, + "Tampa Bay Lightning": { conference: "Eastern", division: "Atlantic", elo: 1587 }, + "Buffalo Sabres": { conference: "Eastern", division: "Atlantic", elo: 1572 }, + "Montreal Canadiens": { conference: "Eastern", division: "Atlantic", elo: 1527 }, + "Ottawa Senators": { conference: "Eastern", division: "Atlantic", elo: 1542 }, + "Detroit Red Wings": { conference: "Eastern", division: "Atlantic", elo: 1506 }, + "Boston Bruins": { conference: "Eastern", division: "Atlantic", elo: 1501 }, + "Florida Panthers": { conference: "Eastern", division: "Atlantic", elo: 1505 }, + "Toronto Maple Leafs": { conference: "Eastern", division: "Atlantic", elo: 1479 }, // ── Eastern Conference — Metropolitan ───────────────────────────────────── - // CAR 109 pts (99.9%) — clear 1st seed. - // CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race. - // WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots. - // NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated. - - "Carolina Hurricanes": { - conference: "Eastern", division: "Metropolitan", elo: 1574, - p_div1: 0.98, p_div2: 0.01, - }, - "Columbus Blue Jackets": { - conference: "Eastern", division: "Metropolitan", elo: 1530, - p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04, - }, - "Pittsburgh Penguins": { - conference: "Eastern", division: "Metropolitan", elo: 1518, - p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07, - }, - "New York Islanders": { - conference: "Eastern", division: "Metropolitan", elo: 1513, - p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07, - }, - "Philadelphia Flyers": { - conference: "Eastern", division: "Metropolitan", elo: 1473, - p_wc1: 0.02, p_wc2: 0.05, - }, - "Washington Capitals": { - conference: "Eastern", division: "Metropolitan", elo: 1506, - p_wc1: 0.01, p_wc2: 0.03, - }, - "New Jersey Devils": { - conference: "Eastern", division: "Metropolitan", elo: 1488, - p_wc2: 0.005, - }, - "New York Rangers": { - conference: "Eastern", division: "Metropolitan", elo: 1480, - // 0% playoff probability — always miss - }, + "Carolina Hurricanes": { conference: "Eastern", division: "Metropolitan", elo: 1574 }, + "Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan", elo: 1530 }, + "Pittsburgh Penguins": { conference: "Eastern", division: "Metropolitan", elo: 1518 }, + "New York Islanders": { conference: "Eastern", division: "Metropolitan", elo: 1513 }, + "Philadelphia Flyers": { conference: "Eastern", division: "Metropolitan", elo: 1473 }, + "Washington Capitals": { conference: "Eastern", division: "Metropolitan", elo: 1506 }, + "New Jersey Devils": { conference: "Eastern", division: "Metropolitan", elo: 1488 }, + "New York Rangers": { conference: "Eastern", division: "Metropolitan", elo: 1480 }, // ── Western Conference — Central ─────────────────────────────────────────── - // COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3. - // UTA 93 pts (92.8%) — leading WC candidate from Central. - // NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots. - // CHI 75 pts (0%) — eliminated. - - "Colorado Avalanche": { - conference: "Western", division: "Central", elo: 1594, - p_div1: 0.90, p_div2: 0.09, p_div3: 0.01, - }, - "Dallas Stars": { - conference: "Western", division: "Central", elo: 1581, - p_div1: 0.08, p_div2: 0.80, p_div3: 0.11, - }, - "Minnesota Wild": { - conference: "Western", division: "Central", elo: 1548, - p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01, - }, - "Utah Mammoth": { - conference: "Western", division: "Central", elo: 1529, - p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33, - }, - "Nashville Predators": { - conference: "Western", division: "Central", elo: 1471, - p_wc1: 0.04, p_wc2: 0.17, - }, - "Winnipeg Jets": { - conference: "Western", division: "Central", elo: 1483, - p_wc1: 0.01, p_wc2: 0.05, - }, - "St. Louis Blues": { - conference: "Western", division: "Central", elo: 1473, - p_wc2: 0.02, - }, - "Chicago Blackhawks": { - conference: "Western", division: "Central", elo: 1411, - // 0% playoff probability — always miss - }, + "Colorado Avalanche": { conference: "Western", division: "Central", elo: 1594 }, + "Dallas Stars": { conference: "Western", division: "Central", elo: 1581 }, + "Minnesota Wild": { conference: "Western", division: "Central", elo: 1548 }, + "Utah Mammoth": { conference: "Western", division: "Central", elo: 1529 }, + "Nashville Predators": { conference: "Western", division: "Central", elo: 1471 }, + "Winnipeg Jets": { conference: "Western", division: "Central", elo: 1483 }, + "St. Louis Blues": { conference: "Western", division: "Central", elo: 1473 }, + "Chicago Blackhawks": { conference: "Western", division: "Central", elo: 1411 }, // ── Western Conference — Pacific ─────────────────────────────────────────── - // ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds. - // LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble. - // CGY 73 pts (0%), VAN 63 pts (0%) — eliminated. - - "Anaheim Ducks": { - conference: "Western", division: "Pacific", elo: 1490, - p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01, - }, - "Edmonton Oilers": { - conference: "Western", division: "Pacific", elo: 1531, - p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04, - }, - "Vegas Golden Knights": { - conference: "Western", division: "Pacific", elo: 1519, - p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06, - }, - "Los Angeles Kings": { - conference: "Western", division: "Pacific", elo: 1482, - p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12, - }, - "San Jose Sharks": { - conference: "Western", division: "Pacific", elo: 1455, - p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12, - }, - "Seattle Kraken": { - conference: "Western", division: "Pacific", elo: 1469, - p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10, - }, - "Calgary Flames": { - conference: "Western", division: "Pacific", elo: 1436, - // 0% playoff probability — always miss - }, - "Vancouver Canucks": { - conference: "Western", division: "Pacific", elo: 1403, - // 0% playoff probability — always miss - }, + "Anaheim Ducks": { conference: "Western", division: "Pacific", elo: 1490 }, + "Edmonton Oilers": { conference: "Western", division: "Pacific", elo: 1531 }, + "Vegas Golden Knights": { conference: "Western", division: "Pacific", elo: 1519 }, + "Los Angeles Kings": { conference: "Western", division: "Pacific", elo: 1482 }, + "San Jose Sharks": { conference: "Western", division: "Pacific", elo: 1455 }, + "Seattle Kraken": { conference: "Western", division: "Pacific", elo: 1469 }, + "Calgary Flames": { conference: "Western", division: "Pacific", elo: 1436 }, + "Vancouver Canucks": { conference: "Western", division: "Pacific", elo: 1403 }, }; // ─── Public helpers (exported for unit testing) ─────────────────────────────── @@ -305,43 +192,60 @@ interface TeamEntry { id: string; name: string; data: NhlTeamData | undefined; + conference: "Eastern" | "Western"; + division: string; + /** Current regular season points: wins × 2 + OT losses × 1. */ + currentPoints: number; + /** Remaining regular season games = 82 − gamesPlayed. */ + remainingGames: number; + /** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */ + winProb: number; } -type DivisionSeedings = { - first: TeamEntry; - second: TeamEntry; - third: TeamEntry; -}; - /** Get Elo for a team entry. Fallback 1400 for unknown teams. */ function elo(entry: TeamEntry): number { return entry.data?.elo ?? 1400; } /** - * Weighted pick without replacement. - * Draws one team from `pool` using the given seeding weight key, excluding `excluded`. - * Returns undefined if no eligible team has positive weight — the caller should - * treat that as a degenerate draw and skip the iteration. + * Simulate remaining regular season games for one team. + * Each game: + * - Win (2 pts) with probability winProb + * - OT/SO loss (1 pt) with probability (1 − winProb) × NHL_OT_RATE + * - Regulation loss (0 pts) otherwise + * Returns projected total points for the season. */ -function weightedPick( - pool: TeamEntry[], - weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2", - excluded: Set -): TeamEntry | undefined { - const eligible = pool.filter((t) => !excluded.has(t)); - if (eligible.length === 0) return undefined; - - const weights = eligible.map((t) => t.data?.[weightKey] ?? 0); - const total = weights.reduce((s, w) => s + w, 0); - if (total === 0) return undefined; - - let r = Math.random() * total; - for (let i = 0; i < eligible.length; i++) { - r -= weights[i]; - if (r <= 0) return eligible[i]; +export function simulateProjectedPoints(entry: TeamEntry): number { + let extra = 0; + const { winProb, remainingGames } = entry; + const otlProb = (1 - winProb) * NHL_OT_RATE; + for (let g = 0; g < remainingGames; g++) { + const r = Math.random(); + if (r < winProb) { + extra += 2; + } else if (r < winProb + otlProb) { + extra += 1; + } } - return eligible[eligible.length - 1]; + return entry.currentPoints + extra; +} + +// ─── Projected-points helpers (module-level for hot-loop efficiency) ────────── + +interface ProjectedTeam { + team: TeamEntry; + pts: number; + tb: number; +} + +/** Project one team's end-of-season point total with a random tiebreaker. */ +function projectTeam(t: TeamEntry): ProjectedTeam { + return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() }; +} + +/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */ +function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] { + return arr.toSorted((a, b) => b.pts - a.pts || b.tb - a.tb); } // ─── Simulator ──────────────────────────────────────────────────────────────── @@ -350,11 +254,14 @@ export class NHLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); - // 1. Load all participants for this sports season. - const participantRows = await db - .select({ id: schema.participants.id, name: schema.participants.name }) - .from(schema.participants) - .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); + // 1. Load participants and standings in parallel. + const [participantRows, standings] = await Promise.all([ + db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), + getRegularSeasonStandings(sportsSeasonId), + ]); if (participantRows.length === 0) { throw new Error( @@ -365,44 +272,78 @@ export class NHLSimulator implements Simulator { const participantIds = participantRows.map((r) => r.id); - const teams: TeamEntry[] = participantRows.map((r) => ({ - id: r.id, - name: r.name, - data: getTeamData(r.name), - })); + // 2. Build standings lookup and construct team entries. + const standingsMap = new Map(standings.map((s) => [s.participantId, s])); + + if (standings.length === 0) { + logger.warn( + `[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` + + `Simulation will use 0 current points and 82 remaining games for all teams. ` + + `Sync standings before running for accurate results.` + ); + } + + const teams: TeamEntry[] = participantRows.map((r) => { + const standing = standingsMap.get(r.id); + const data = getTeamData(r.name); + + // Conference and division: prefer standings table, fall back to TEAMS_DATA. + const rawConf = standing?.conference; + const conference: "Eastern" | "Western" = + rawConf === "Eastern" || rawConf === "Western" + ? rawConf + : (data?.conference ?? "Eastern"); + + const division: string = standing?.division ?? data?.division ?? ""; + + const gamesPlayed = standing?.gamesPlayed ?? 0; + const wins = standing?.wins ?? 0; + const otLosses = standing?.otLosses ?? 0; + const currentPoints = wins * 2 + otLosses; + const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed); + + return { + id: r.id, + name: r.name, + data, + conference, + division, + currentPoints, + remainingGames, + winProb: eloWinProbability(data?.elo ?? 1400, 1500), + }; + }); // Warn about participants that don't match any hardcoded team. - // These are excluded entirely rather than silently landing in the wildcard pool. const unrecognized = teams.filter((t) => !t.data); if (unrecognized.length > 0) { logger.warn( - `[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` + + `[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will use fallback Elo 1400: ` + unrecognized.map((t) => t.name).join(", ") ); } // Separate recognized teams by conference + division. - const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic"); - const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan"); - const westernCentral = teams.filter((t) => t.data?.division === "Central"); - const westernPacific = teams.filter((t) => t.data?.division === "Pacific"); + const easternAtlantic = teams.filter((t) => t.conference === "Eastern" && t.division === "Atlantic"); + const easternMetro = teams.filter((t) => t.conference === "Eastern" && t.division === "Metropolitan"); + const westernCentral = teams.filter((t) => t.conference === "Western" && t.division === "Central"); + const westernPacific = teams.filter((t) => t.conference === "Western" && t.division === "Pacific"); - // Only recognized teams enter the conference wildcard pools. - const easternTeams = teams.filter((t) => t.data?.conference === "Eastern"); - const westernTeams = teams.filter((t) => t.data?.conference === "Western"); - - if (easternTeams.length < 8 || westernTeams.length < 8) { + if ( + easternAtlantic.length < 3 || + easternMetro.length < 3 || + westernCentral.length < 3 || + westernPacific.length < 3 + ) { throw new Error( - `Each conference needs at least 8 recognized participants ` + - `(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` + + `Each division needs at least 3 participants ` + + `(got Eastern/Atlantic: ${easternAtlantic.length}, Eastern/Metro: ${easternMetro.length}, ` + + `Western/Central: ${westernCentral.length}, Western/Pacific: ${westernPacific.length}). ` + `Add all 32 NHL teams before running simulation.` ); } // ─── Futures odds blending ───────────────────────────────────────────────── - // Load sourceOdds (American format) from participantExpectedValues. - // If any odds are present, blend them with Elo for per-game win probability. - // Falls back to Elo-only when no odds are stored. const evRows = await db .select({ @@ -418,7 +359,6 @@ export class NHLSimulator implements Simulator { ); const hasOdds = oddsRows.length > 0; - // Build vig-removed win-probability map keyed by participant ID. const normalizedOddsMap = new Map(); if (hasOdds) { const rawProbs = oddsRows.map((r) => @@ -435,7 +375,6 @@ export class NHLSimulator implements Simulator { /** * Blended per-game win probability for team A over team B. * When odds are available: 70% Elo + 30% vig-removed futures head-to-head. - * Falls back to pure Elo when no odds are stored. */ const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { const eloProb = eloWinProbability(elo(a), elo(b)); @@ -458,73 +397,52 @@ export class NHLSimulator implements Simulator { return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; }; - /** - * Draw the top-3 playoff seeds for a single division. - * Returns undefined for each position if no team has weight for that slot - * (shouldn't happen with correct data, but handled gracefully). - */ - const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => { - const taken = new Set(); - - const first = weightedPick(divTeams, "p_div1", taken); - if (!first) return undefined; - taken.add(first); - - const second = weightedPick(divTeams, "p_div2", taken); - if (!second) return undefined; - taken.add(second); - - const third = weightedPick(divTeams, "p_div3", taken); - if (!third) return undefined; - - return { first, second, third }; - }; - /** * Build the 8-team playoff bracket for one conference. - * Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed]. * - * Bracket: - * m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard) - * m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard) - * m2: topDiv_2nd vs topDiv_3rd - * m3: otherDiv_2nd vs otherDiv_3rd + * For each sim iteration: + * 1. Simulate projected points for all teams in both divisions. + * 2. Top 3 per division (by projected pts + random tiebreaker) → division seeds. + * 3. Top 2 remaining conference teams → WC1 (more pts) and WC2 (fewer pts). + * 4. Division winner with more pts = 1st seed (faces WC2); + * other division winner = 2nd seed (faces WC1). * - * "Top division" = whichever division winner has higher Elo. The actual NHL rule - * uses regular season points, but Elo is a reasonable per-simulation proxy since - * we don't simulate the regular season — it approximates the same ordering. + * Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed], + * or undefined if the conference doesn't have enough teams for a valid bracket. */ const buildConferenceBracket = ( divATeams: TeamEntry[], - divBTeams: TeamEntry[], - allConfTeams: TeamEntry[] + divBTeams: TeamEntry[] ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => { - const divA = drawDivisionSeedings(divATeams); - const divB = drawDivisionSeedings(divBTeams); - if (!divA || !divB) return undefined; + if (divATeams.length < 3 || divBTeams.length < 3) return undefined; - const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]); - const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t)); - const wcTaken = new Set(); + // Project all conference teams exactly once (single simulateProjectedPoints call per team). + const divASet = new Set(divATeams.map((t) => t.id)); + const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam)); + const divAProjected = allProjected.filter((p) => divASet.has(p.team.id)); + const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id)); - const wc1 = weightedPick(wcPool, "p_wc1", wcTaken); - if (!wc1) return undefined; - wcTaken.add(wc1); + const divASeeds = divAProjected.slice(0, 3); + const divBSeeds = divBProjected.slice(0, 3); - const wc2 = weightedPick(wcPool, "p_wc2", wcTaken); - if (!wc2) return undefined; + // Wildcard pool: non-division-seed teams, already sorted by projected pts. + const divisionSeedIds = new Set([...divASeeds, ...divBSeeds].map((p) => p.team.id)); + const wcPool = allProjected.filter((p) => !divisionSeedIds.has(p.team.id)); - // Higher-Elo division winner faces WC2 (the weaker wildcard). + if (wcPool.length < 2) return undefined; + const [wc1, wc2] = wcPool; // wc1 has more pts → stronger wildcard + + // Division winner with more projected points is the top seed (plays WC2). const [topDiv, otherDiv] = - elo(divA.first) >= elo(divB.first) - ? [divA, divB] - : [divB, divA]; + divASeeds[0].pts >= divBSeeds[0].pts + ? [divASeeds, divBSeeds] + : [divBSeeds, divASeeds]; return [ - [topDiv.first, wc2], // m0 - [otherDiv.first, wc1], // m1 - [topDiv.second, topDiv.third], // m2 - [otherDiv.second, otherDiv.third], // m3 + [topDiv[0].team, wc2.team], // m0: 1st seed vs WC2 + [otherDiv[0].team, wc1.team], // m1: 2nd seed vs WC1 + [topDiv[1].team, topDiv[2].team], // m2: top div 2nd vs 3rd + [otherDiv[1].team, otherDiv[2].team], // m3: other div 2nd vs 3rd ]; }; @@ -537,28 +455,18 @@ export class NHLSimulator implements Simulator { // ─── Monte Carlo simulation loop ─────────────────────────────────────────── - // Track effective iterations separately: degenerate bracket draws are skipped - // and must not count toward the probability denominators. let effectiveN = 0; for (let s = 0; s < NUM_SIMULATIONS; s++) { - // Build conference brackets. - const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams); - const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams); - if (!eastBracket || !westBracket) continue; // degenerate draw — do not count + const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro); + const westBracket = buildConferenceBracket(westernCentral, westernPacific); + if (!eastBracket || !westBracket) continue; effectiveN++; // ── Eastern Conference ──────────────────────────────────────────────────── - // Round 1: 4 series (Wild Card round). const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner); - - // Round 2 (Division Finals): - // m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner) - // m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner) const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]); const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]); - - // Conference Final. const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner); const eastChamp = eastCF.winner; confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1); @@ -576,7 +484,6 @@ export class NHLSimulator implements Simulator { championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); - // Round 2 losers (4 per sim). Round 1 losers are not counted (0 points). for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) { r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1); } @@ -585,16 +492,12 @@ export class NHLSimulator implements Simulator { if (effectiveN === 0) { throw new Error( "All simulations produced degenerate brackets. " + - "Check that each division has teams with positive seeding probabilities." + "Check that each division has at least 3 participants with standings data." ); } // ─── Convert counts to probability distributions ─────────────────────────── - // - // probFirst/Second → count / N (1 team per sim) - // probThird/Fourth → count / (2 * N) (2 conf final losers per sim) - // probFifth–Eighth → count / (4 * N) (4 R2 losers per sim) - // + const N = effectiveN; const results: SimulationResult[] = participantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; @@ -618,10 +521,7 @@ export class NHLSimulator implements Simulator { }); // ─── Per-position normalization ──────────────────────────────────────────── - // - // Belt-and-suspenders guard against floating-point residuals. - // Columns are already near-exactly 1.0 by construction above. - // + const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth",