/** * NBA Playoff Simulator * * Monte Carlo simulation of the NBA 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, gamesPlayed, conference) * 3. Match participant names to hardcoded team data (Elo ratings) * 4. For each simulation: * a. For each team, simulate remaining regular season games (82 - gamesPlayed) * using Elo win probability vs. an average opponent (Elo 1500) * → projectedWins = currentWins + simulatedRemainingWins * b. Sort each conference by projected wins (desc) + random tiebreaker → seeds 1–10 * → Top 6 lock in directly; seeds 7–10 enter the Play-In tournament * c. Simulate Play-In (single game each): * - Game 1: seed 7 vs seed 8 → winner becomes 7th playoff seed * - Game 2: seed 9 vs seed 10 → winner advances * - Game 3: Game 1 loser vs Game 2 winner → winner becomes 8th playoff seed * d. Simulate NBA playoff bracket (best-of-7 series each round): * Round 1: 1v8, 4v5, 2v7, 3v6 (per conference) * Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6) * Round 3: Conference Finals * NBA Finals: East champion vs West champion * 5. Track placement counts per scoring tier * 6. Convert counts to probability distributions * * Win probability (Elo, PARITY_FACTOR = 400): * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400)) * * Regular season projection: * Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent. * If no standings exist in DB, defaults to 0 wins / 82 remaining games (seeding by Elo only). * Conference is read from standings table; falls back to TEAMS_DATA if missing. * * Placement tiers → SimulationProbabilities mapping: * probFirst = NBA champion (1 per sim) * probSecond = NBA Finals loser (1 per sim) * probThird/Fourth = Conference Finals losers (2 per sim — East + West) * probFifth–Eighth = Conference Semis losers (4 per sim) * Round 1 losers → all 0 (score 0 points) * Missed playoffs → all 0 * * Elo ratings are hardcoded below (March 2026 data). * Source: Neil Paine Substack playoff Elo estimates (last 110 games, no regression, * postseason games 3× weight). 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 { normalizeTeamName } from "~/lib/normalize-team-name"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; /** * Elo parity factor. NBA uses 400 (standard formula). * A 400-point Elo difference → ~90.9% win probability per game. */ const PARITY_FACTOR = 400; /** NBA regular season games per team. */ const NBA_REGULAR_SEASON_GAMES = 82; // ─── Team data (2025-26 season, as of March 2026) ───────────────────────────── // // elo: Estimated Elo rating (higher = stronger). // Source: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight). // This is the appropriate signal for simulating both regular season win probability // (vs. average opponent) and playoff matchups. // // conference: Used as a fallback when the standings table has no conference data. interface NbaTeamData { conference: "Eastern" | "Western"; elo: number; } const TEAMS_DATA: Record = { // ── Eastern Conference ────────────────────────────────────────────────────── "Detroit Pistons": { conference: "Eastern", elo: 1558 }, "Boston Celtics": { conference: "Eastern", elo: 1699 }, "New York Knicks": { conference: "Eastern", elo: 1626 }, "Cleveland Cavaliers": { conference: "Eastern", elo: 1628 }, "Orlando Magic": { conference: "Eastern", elo: 1508 }, "Miami Heat": { conference: "Eastern", elo: 1530 }, "Toronto Raptors": { conference: "Eastern", elo: 1467 }, "Atlanta Hawks": { conference: "Eastern", elo: 1496 }, "Philadelphia 76ers": { conference: "Eastern", elo: 1471 }, "Charlotte Hornets": { conference: "Eastern", elo: 1496 }, "Milwaukee Bucks": { conference: "Eastern", elo: 1442 }, "Chicago Bulls": { conference: "Eastern", elo: 1381 }, "Brooklyn Nets": { conference: "Eastern", elo: 1334 }, "Indiana Pacers": { conference: "Eastern", elo: 1433 }, "Washington Wizards": { conference: "Eastern", elo: 1255 }, // ── Western Conference ────────────────────────────────────────────────────── "Oklahoma City Thunder": { conference: "Western", elo: 1731 }, "San Antonio Spurs": { conference: "Western", elo: 1599 }, "Houston Rockets": { conference: "Western", elo: 1564 }, "Denver Nuggets": { conference: "Western", elo: 1618 }, "LA Lakers": { conference: "Western", elo: 1569 }, "Minnesota Timberwolves":{ conference: "Western", elo: 1603 }, "Phoenix Suns": { conference: "Western", elo: 1500 }, "LA Clippers": { conference: "Western", elo: 1573 }, "Golden State Warriors": { conference: "Western", elo: 1530 }, "Portland Trail Blazers":{ conference: "Western", elo: 1426 }, "Dallas Mavericks": { conference: "Western", elo: 1473 }, "Memphis Grizzlies": { conference: "Western", elo: 1417 }, "New Orleans Pelicans": { conference: "Western", elo: 1380 }, "Sacramento Kings": { conference: "Western", elo: 1352 }, "Utah Jazz": { conference: "Western", elo: 1334 }, }; // ─── Public helpers (exported for unit testing) ─────────────────────────────── export { normalizeTeamName }; /** Look up team data by participant name (case-insensitive). */ export function getTeamData(name: string): NbaTeamData | 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: NbaTeamData | undefined; conference: "Eastern" | "Western"; /** Actual wins from the standings table (0 if no standings loaded). */ currentWins: number; /** Remaining regular season games = 82 - gamesPlayed (0 if season is complete). */ remainingGames: number; /** Elo win probability vs. average opponent (1500) — constant per team. */ winProb: number; } /** Get Elo for a team entry. * Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */ function elo(entry: TeamEntry): number { return entry.data?.elo ?? 1400; } /** Simulate remaining regular season games for a team. * Uses the pre-computed per-team winProb (Elo vs. average opponent). * Returns projected total wins for the season. */ function simulateProjectedWins(entry: TeamEntry): number { let extra = 0; for (let g = 0; g < entry.remainingGames; g++) { if (Math.random() < entry.winProb) extra++; } return entry.currentWins + extra; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class NBASimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 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( `No participants found for sports season ${sportsSeasonId}. ` + `Add NBA teams as participants before running simulation.` ); } // 2. Build standings lookup and construct team entries. // Conference, currentWins, remainingGames, and per-game winProb are all // resolved once here so nothing is recomputed inside the hot loop. const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const participantIds = participantRows.map((r) => r.id); const teams: TeamEntry[] = participantRows.map((r) => { const standing = standingsMap.get(r.id); const data = getTeamData(r.name); const gamesPlayed = standing?.gamesPlayed ?? 0; const conf = standing?.conference; const conference: "Eastern" | "Western" = conf === "Eastern" || conf === "Western" ? conf : (data?.conference ?? "Eastern"); return { id: r.id, name: r.name, data, conference, currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed), winProb: eloWinProbability(data?.elo ?? 1400, 1500), }; }); // 3. Separate by conference for simulation. const easternTeams = teams.filter((t) => t.conference === "Eastern"); const westernTeams = teams.filter((t) => t.conference === "Western"); // Validate: each conference needs at least 10 teams to fill the bracket + play-in. if (easternTeams.length < 10 || westernTeams.length < 10) { throw new Error( `Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` + `West: ${westernTeams.length}). Add all 30 NBA teams before running simulation.` ); } // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** Simulate a single playoff game. Returns the winner. */ const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b; /** 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 }; }; /** Simulate the Play-In tournament. * @param candidates 4 teams sorted by seeding position [7th, 8th, 9th, 10th] * @returns [7th playoff seed, 8th playoff seed] */ const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => { // Game 1: 7 vs 8 — winner locks up the 7th seed const game1Winner = simGame(s7, s8); const game1Loser = game1Winner === s7 ? s8 : s7; // Game 2: 9 vs 10 — winner advances to the final play-in game const game2Winner = simGame(s9, s10); // Game 3: loser of Game 1 vs winner of Game 2 — winner gets the 8th seed return [game1Winner, simGame(game1Loser, game2Winner)]; }; /** Build an 8-team conference bracket [s1..s8] for one simulation iteration. * Seeds are determined by simulated projected wins; positions 7–10 go through the Play-In. */ const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => { const projected = confTeams.map((t) => ({ team: t, projectedWins: simulateProjectedWins(t), tiebreaker: Math.random(), })); // Higher projected wins = better seed (sort descending; random tiebreaker for ties). projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker); const top6 = projected.slice(0, 6).map((x) => x.team); const playIn = projected.slice(6, 10).map((x) => x.team) as [TeamEntry, TeamEntry, TeamEntry, TeamEntry]; const [seed7, seed8] = simPlayIn(playIn); return [...top6, seed7, seed8]; }; /** Round 1: 1v8, 4v5, 2v7, 3v6. Returns 4 winners. */ const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [ simSeries(s1, s8).winner, simSeries(s4, s5).winner, simSeries(s2, s7).winner, simSeries(s3, s6).winner, ]; /** Conference Semis: winner(1v8) vs winner(4v5), winner(2v7) vs winner(3v6). */ const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => { const m1 = simSeries(w0, w1); const m2 = simSeries(w2, w3); return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] }; }; // 4. Integer placement count maps — initialized to 0 for all participants. 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 confSemiLoserCounts = new Map(participantIds.map((id) => [id, 0])); // 5. Monte Carlo simulation loop. for (let s = 0; s < NUM_SIMULATIONS; s++) { // ── Build brackets ─────────────────────────────────────────────────────── const eastBracket = buildConferenceBracket(easternTeams); const westBracket = buildConferenceBracket(westernTeams); // ── Simulate rounds ────────────────────────────────────────────────────── const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket)); const { winner: eastChamp, loser: eastCFLoser } = simSeries(eastR2Winners[0], eastR2Winners[1]); const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket)); const { winner: westChamp, loser: westCFLoser } = simSeries(westR2Winners[0], westR2Winners[1]); const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp); // ── Record counts (maps are pre-populated so .get() always returns a number) ─── championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1); finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1); confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1); confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1); for (const loser of [...eastR2Losers, ...westR2Losers]) { confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1); } // Round 1 losers are not counted (0 points per scoring rules). } // 6. Convert integer counts to probability distributions. // Exact denominators guarantee column sums of 1.0 by construction: // probFirst/Second → NUM_SIMULATIONS total (1 per sim) // probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim // probFifth–Eighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim 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 cs = confSemiLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / NUM_SIMULATIONS, probSecond: f / NUM_SIMULATIONS, probThird: cf / (2 * NUM_SIMULATIONS), probFourth: cf / (2 * NUM_SIMULATIONS), probFifth: cs / (4 * NUM_SIMULATIONS), probSixth: cs / (4 * NUM_SIMULATIONS), probSeventh: cs / (4 * NUM_SIMULATIONS), probEighth: cs / (4 * NUM_SIMULATIONS), }, source: "nba_bracket_monte_carlo", }; }); // 7. Per-position normalization — belt-and-suspenders guard against floating-point // division residuals. Columns are already near-exactly 1.0 after step 6. 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; } }