/** * NHL Playoff Simulator * * Monte Carlo simulation of the NHL playoffs including seeding projection * for the current season (2025-26). * * 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, * 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 * * Win probability (Elo, PARITY_FACTOR = 800): * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 800)) * NHL uses a higher parity factor than the standard 400 to dampen the Elo * spread and reflect the high variance of hockey. * * Placement tiers → SimulationProbabilities mapping: * probFirst = Stanley Cup champion (1 per sim) * probSecond = Stanley Cup Final loser (1 per sim) * probThird / probFourth = Conference Final losers (2 per sim — East + West) * probFifth–probEighth = Second Round losers (4 per sim) * 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. */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { logger } from "~/lib/logger"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; /** * Elo parity factor. NHL uses 800 (double the standard 400) to reflect the * high game-to-game variance in hockey vs other sports. * An 800-point Elo difference → ~90.9% win probability per game. */ const PARITY_FACTOR = 800; // ─── Team data (2025-26 season, as of March 18, 2026) ──────────────────────── // // 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). // // These are weights used in sequential weighted sampling (not strict distributions). // Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site. // // Divisions: // Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR // Eastern — Metropolitan: CAR, CBJ, NJD, NYI, NYR, PHI, PIT, WSH // Western — Central: CHI, COL, DAL, MIN, NSH, STL, UTA, WPG // Western — Pacific: ANA, CGY, EDM, LAK, SJS, SEA, VAN, VGK 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 }, // ── 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 }, // ── 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 }, // ── 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 }, }; // ─── Public helpers (exported for unit testing) ─────────────────────────────── /** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */ export function normalizeTeamName(name: string): string { return name.toLowerCase().trim().replace(/\s+/g, " "); } /** Look up team data by participant name (case-insensitive). */ export function getTeamData(name: string): NhlTeamData | undefined { const normalized = normalizeTeamName(name); for (const [teamName, data] of Object.entries(TEAMS_DATA)) { if (normalizeTeamName(teamName) === normalized) return data; } return undefined; } /** * Elo win probability for team A over team B. * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) * Exported for unit testing. */ export function eloWinProbability(eloA: number, eloB: number): number { return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); } // ─── Internal types ─────────────────────────────────────────────────────────── interface TeamEntry { id: string; name: string; data: NhlTeamData | undefined; } 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. */ 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]; } return eligible[eligible.length - 1]; } // ─── Simulator ──────────────────────────────────────────────────────────────── 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)); if (participantRows.length === 0) { throw new Error( `No participants found for sports season ${sportsSeasonId}. ` + `Add NHL teams as participants before running simulation.` ); } const participantIds = participantRows.map((r) => r.id); const teams: TeamEntry[] = participantRows.map((r) => ({ id: r.id, name: r.name, data: getTeamData(r.name), })); // 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: ` + 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"); // 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) { throw new Error( `Each conference needs at least 8 recognized participants ` + `(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` + `Add all 32 NHL teams before running simulation.` ); } // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** Simulate a best-of-7 series. Returns winner and loser. */ const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const winProb = eloWinProbability(elo(a), elo(b)); let winsA = 0; let winsB = 0; while (winsA < 4 && winsB < 4) { if (Math.random() < winProb) winsA++; else winsB++; } 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 * * "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. */ const buildConferenceBracket = ( divATeams: TeamEntry[], divBTeams: TeamEntry[], allConfTeams: TeamEntry[] ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => { const divA = drawDivisionSeedings(divATeams); const divB = drawDivisionSeedings(divBTeams); if (!divA || !divB) 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(); const wc1 = weightedPick(wcPool, "p_wc1", wcTaken); if (!wc1) return undefined; wcTaken.add(wc1); const wc2 = weightedPick(wcPool, "p_wc2", wcTaken); if (!wc2) return undefined; // Higher-Elo division winner faces WC2 (the weaker wildcard). const [topDiv, otherDiv] = elo(divA.first) >= elo(divB.first) ? [divA, divB] : [divB, divA]; return [ [topDiv.first, wc2], // m0 [otherDiv.first, wc1], // m1 [topDiv.second, topDiv.third], // m2 [otherDiv.second, otherDiv.third], // m3 ]; }; // ─── Placement count maps ────────────────────────────────────────────────── const championCounts = new Map(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const confFinalLoserCounts = new Map(participantIds.map((id) => [id, 0])); const r2LoserCounts = new Map(participantIds.map((id) => [id, 0])); // ─── 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 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); // ── Western Conference ──────────────────────────────────────────────────── const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner); const westR2m1 = simSeries(westR1Winners[0], westR1Winners[2]); const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]); const westCF = simSeries(westR2m1.winner, westR2m2.winner); const westChamp = westCF.winner; confFinalLoserCounts.set(westCF.loser.id, (confFinalLoserCounts.get(westCF.loser.id) ?? 0) + 1); // ── Stanley Cup Final ───────────────────────────────────────────────────── const final = simSeries(eastChamp, westChamp); 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); } } if (effectiveN === 0) { throw new Error( "All simulations produced degenerate brackets. " + "Check that each division has teams with positive seeding probabilities." ); } // ─── 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; const f = finalistCounts.get(participantId) ?? 0; const cf = confFinalLoserCounts.get(participantId) ?? 0; const r2 = r2LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / N, probSecond: f / N, probThird: cf / (2 * N), probFourth: cf / (2 * N), probFifth: r2 / (4 * N), probSixth: r2 / (4 * N), probSeventh: r2 / (4 * N), probEighth: r2 / (4 * N), }, source: "nhl_bracket_monte_carlo", }; }); // ─── 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", ]; for (const key of positionKeys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); const residual = 1.0 - colSum; if (residual !== 0) { const maxResult = results.reduce((best, r) => r.probabilities[key] > best.probabilities[key] ? r : best ); maxResult.probabilities[key] += residual; } } return results; } }