/** * 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. 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 * 5. Track placement counts per scoring tier * 6. Convert counts to probability distributions * * 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, * the per-game win probability is blended: * P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb * where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)). * Normalized odds are vig-removed futures win probabilities. * ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as UCL simulator). * Falls back to Elo-only when no odds are stored. * * 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 * * 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"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { logger } from "~/lib/logger"; 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. */ const PARITY_FACTOR = 1000; /** * Blend weights for Elo vs. Vegas futures odds when sourceOdds are available. * Same calibration as the UCL simulator (0.7 / 0.3). */ const ELO_WEIGHT = 0.7; const ODDS_WEIGHT = 1 - ELO_WEIGHT; // ─── Team data (2025-26 season) ─────────────────────────────────────────────── // // 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. // // 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 // 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; } const TEAMS_DATA: Record = { // ── Eastern Conference — Atlantic ────────────────────────────────────────── "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 ───────────────────────────────────── "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 ─────────────────────────────────────────── "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 ─────────────────────────────────────────── "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) ─────────────────────────────── /** 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; 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; /** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */ resolvedElo: number; } /** Get Elo for a team entry. */ function elo(entry: TeamEntry): number { return entry.resolvedElo; } /** * 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. */ 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 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 ──────────────────────────────────────────────────────────────── export class NHLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // Check for a populated playoff bracket; if found, use bracket-aware simulation. const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); if (bracketEvent) { const allMatches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (m, { asc }) => [asc(m.matchNumber)], }); const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); if (bracketPopulated) { return this.simulateBracket(sportsSeasonId, allMatches); } } // 1. Load participants, standings, and sourceElo values in parallel. const [participantRows, standings, evRows] = await Promise.all([ db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)), getRegularSeasonStandings(sportsSeasonId), db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.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); // 2. Build standings lookup, sourceElo map, and construct team entries. const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const dbEloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) { dbEloMap.set(row.participantId, row.sourceElo); } } 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); const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; return { id: r.id, name: r.name, data, conference, division, currentPoints, remainingGames, winProb: eloWinProbability(resolvedElo, 1500), resolvedElo, }; }); // Warn about participants that don't match any hardcoded team. 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 use fallback Elo 1400: ` + unrecognized.map((t) => t.name).join(", ") ); } // Separate recognized teams by conference + division. 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"); if ( easternAtlantic.length < 3 || easternMetro.length < 3 || westernCentral.length < 3 || westernPacific.length < 3 ) { throw new Error( `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 ───────────────────────────────────────────────── const participantIdSet = new Set(participantIds); const oddsRows = evRows.filter( (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) ); const hasOdds = oddsRows.length > 0; const normalizedOddsMap = new Map(); if (hasOdds) { const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0) ); const normalized = normalizeProbabilities(rawProbs); oddsRows.forEach(({ participantId }, i) => { normalizedOddsMap.set(participantId, normalized[i]); }); } // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** * Blended per-game win probability for team A over team B. * When odds are available: 70% Elo + 30% vig-removed futures head-to-head. */ const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { const eloProb = eloWinProbability(elo(a), elo(b)); if (!hasOdds) return eloProb; const o1 = normalizedOddsMap.get(a.id) ?? 0; const o2 = normalizedOddsMap.get(b.id) ?? 0; const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; }; /** Simulate a best-of-7 series. Returns winner and loser. */ const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const winProb = gameWinProb(a, 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 }; }; /** * Build the 8-team playoff bracket for one conference. * * 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). * * 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[] ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => { if (divATeams.length < 3 || divBTeams.length < 3) return undefined; // 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 divASeeds = divAProjected.slice(0, 3); const divBSeeds = divBProjected.slice(0, 3); // 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)); 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] = divASeeds[0].pts >= divBSeeds[0].pts ? [divASeeds, divBSeeds] : [divBSeeds, divASeeds]; return [ [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 ]; }; // ─── 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 ─────────────────────────────────────────── let effectiveN = 0; for (let s = 0; s < NUM_SIMULATIONS; s++) { const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro); const westBracket = buildConferenceBracket(westernCentral, westernPacific); if (!eastBracket || !westBracket) continue; effectiveN++; // ── Eastern Conference ──────────────────────────────────────────────────── const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner); const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]); const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]); 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); 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 at least 3 participants with standings data." ); } // ─── Convert counts to probability distributions ─────────────────────────── 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 ──────────────────────────────────────────── 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; } // ── Mode: Bracket-Aware ─────────────────────────────────────────────────────── private async simulateBracket( sportsSeasonId: string, allMatches: Awaited["query"]["playoffMatches"]["findMany"]>> ): Promise { const db = database(); // Group matches by round, sorted by match count descending (R1 first → Final last). const byRound = new Map(); for (const m of allMatches) { if (!byRound.has(m.round)) byRound.set(m.round, []); byRound.get(m.round)?.push(m); } const sortedRoundMatches = [...byRound.values()] .toSorted((a, b) => b.length - a.length) .map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber)); if (sortedRoundMatches.length < 4) { throw new Error( `NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` + `Check the bracket template.` ); } const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card) const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional) const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals) const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup) if (r1Matches.length !== 8) { throw new Error( `Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.` ); } // Validate all R1 matches have participants before entering the hot loop. for (const m of r1Matches) { if (!m.participant1Id || !m.participant2Id) { throw new Error( `Round 1 match ${m.matchNumber} is missing participants. ` + `Seed all 16 teams into the bracket before running simulation.` ); } } // Load all participants and EV data in parallel. const [participantRows, evRows] = await Promise.all([ db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)), db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)), ]); const allParticipantIds = participantRows.map((r) => r.id); const dbEloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) { dbEloMap.set(row.participantId, row.sourceElo); } } // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400. const eloMap = new Map(); for (const r of participantRows) { eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400); } const participantIdSet = new Set(allParticipantIds); const oddsRows = evRows.filter( (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) ); const hasOdds = oddsRows.length > 0; const normalizedOddsMap = new Map(); if (hasOdds) { const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0)); const normalized = normalizeProbabilities(rawProbs); oddsRows.forEach(({ participantId }, i) => { normalizedOddsMap.set(participantId, normalized[i]); }); } // Per-round lookup maps for O(1) access. const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m])); const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m])); const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m])); const finalMatch = finalMatches[0]; // ── Helpers ────────────────────────────────────────────────────────────────── const gameWinProb = (aId: string, bId: string): number => { const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400); if (!hasOdds) return eloProb; const o1 = normalizedOddsMap.get(aId) ?? 0; const o2 = normalizedOddsMap.get(bId) ?? 0; const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; }; const simSeries = (aId: string, bId: string): { winner: string; loser: string } => { const winProb = gameWinProb(aId, bId); let wA = 0; let wB = 0; while (wA < 4 && wB < 4) { if (Math.random() < winProb) wA++; else wB++; } return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId }; }; const resolveSeries = ( match: (typeof allMatches)[0] | undefined, p1Fallback?: string, p2Fallback?: string ): { winner: string; loser: string } => { if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId }; } const p1 = match?.participant1Id ?? p1Fallback; const p2 = match?.participant2Id ?? p2Fallback; if (!p1 || !p2) { throw new Error( `Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` + `missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).` ); } return simSeries(p1, p2); }; // ── Placement count maps ────────────────────────────────────────────────────── const championCounts = new Map(allParticipantIds.map((id) => [id, 0])); const finalistCounts = new Map(allParticipantIds.map((id) => [id, 0])); const r3LoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); const r2LoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); // ── Monte Carlo simulation loop ─────────────────────────────────────────────── for (let s = 0; s < NUM_SIMULATIONS; s++) { // Round 1: R1 losers score 0 points — not tracked. const r1Winners: string[] = []; for (let i = 1; i <= 8; i++) { const { winner } = resolveSeries(r1ByNum.get(i)); r1Winners.push(winner); } // Round 2 (Quarterfinals / Divisional): losers → 5th–8th. const r2Winners: string[] = []; for (let i = 1; i <= 4; i++) { const { winner, loser } = resolveSeries( r2ByNum.get(i), r1Winners[(i - 1) * 2], r1Winners[(i - 1) * 2 + 1] ); r2Winners.push(winner); r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1); } // Round 3 (Semifinals / Conference Finals): losers → 3rd–4th. const r3Winners: string[] = []; for (let i = 1; i <= 2; i++) { const { winner, loser } = resolveSeries( r3ByNum.get(i), r2Winners[(i - 1) * 2], r2Winners[(i - 1) * 2 + 1] ); r3Winners.push(winner); r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1); } // Final (Stanley Cup): winner → 1st, loser → 2nd. const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]); championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1); finalistCounts.set(loser, (finalistCounts.get(loser) ?? 0) + 1); } // ── Convert counts to probability distributions ─────────────────────────────── const N = NUM_SIMULATIONS; const results: SimulationResult[] = allParticipantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const r3 = r3LoserCounts.get(participantId) ?? 0; const r2 = r2LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / N, probSecond: f / N, probThird: r3 / (2 * N), probFourth: r3 / (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. 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; } }