/** * NBA Playoff Simulator * * Two modes, auto-detected at runtime: * * ── Mode 1: Bracket-Aware (preferred) ───────────────────────────────────────── * Used when a playoff_game scoring event with generated matches exists for the * sports season. Reads the actual bracket state from the DB and simulates only * the remaining rounds forward. * * Algorithm: * 1. Find the playoff_game scoring event; load all playoffMatches * 2. Group matches by round: Play-In Round 1/2, First Round, Conference * Semifinals, Conference Finals, NBA Finals * 3. Build Elo map from TEAMS_DATA (name-matched from participants table) * 4. For each of 50k simulations: * a. Play-In Round 1 (4 single games): use real result if isComplete, else sim * → derives E7/E8-candidate/E9-E10-winner and their West equivalents * b. Play-In Round 2 (2 single games): uses PIR1 results as participant * fallbacks when DB slots are still null * → derives E8 and W8 seeds * c. First Round (8 best-of-7 series): uses simmed seeds as participant * fallbacks for the 4 play-in slots; real results respected * d. Conference Semifinals → Conference Finals → NBA Finals: same * completed-vs-simulate pattern; standard ceil bracket path * 5. Track integer placement counts per tier (champion/finalist/CF loser/CS loser) * 6. Convert to probability distributions with exact denominators * * In-progress series: treated as a fresh best-of-7 (partial game scores are not * used). A series only locks once isComplete + winnerId + loserId are all set. * * ── Mode 2: Season Projection (fallback) ────────────────────────────────────── * Used when no bracket event / matches exist yet (pre-playoffs). Projects * remaining regular season games → seeds → play-in → playoffs from scratch. * See inline comments for details. * * Placement tiers (both modes): * probFirst = NBA champion * probSecond = NBA Finals loser * probThird/Fourth = Conference Finals losers (2 per sim) * probFifth–Eighth = Conference Semis losers (4 per sim) * First Round / Play-In losers → all 0 (score 0 points) * Missed playoffs (Mode 2 only) → all 0 * * Elo ratings are hardcoded (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, and } 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) ───────────────────────────── 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 ─────────────────────────────────────────────────────────── 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)) */ export function eloWinProbability(eloA: number, eloB: number): number { return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); } // ─── Per-position normalization ─────────────────────────────────────────────── const POSITION_KEYS = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ] as const; function normalizeColumns(results: SimulationResult[]): void { for (const key of POSITION_KEYS) { 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; } } } // ─── Bracket-aware helpers ──────────────────────────────────────────────────── type DbMatch = typeof schema.playoffMatches.$inferSelect; /** Simulate a single-game matchup. Returns winner and loser. */ function simGame( eloMap: Map, a: string, b: string ): { winner: string; loser: string } { const eloA = eloMap.get(a) ?? 1400; const eloB = eloMap.get(b) ?? 1400; const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b; return { winner, loser: winner === a ? b : a }; } /** Simulate a best-of-7 series. Returns winner and loser. */ function simSeries( eloMap: Map, a: string, b: string ): { winner: string; loser: string } { const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400); let wA = 0; let wB = 0; while (wA < 4 && wB < 4) { if (Math.random() < winProb) wA++; else wB++; } return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; } /** * Resolve a play-in single game. * Uses the real result if the match is complete; otherwise simulates. * p1Override / p2Override are used when the DB participant slot is still null * (i.e. filled in by the previous round's simulated result). */ function resolveGame( eloMap: Map, match: DbMatch | undefined, p1Override?: string, p2Override?: string ): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId }; } const p1 = match?.participant1Id ?? p1Override; const p2 = match?.participant2Id ?? p2Override; if (!p1 || !p2) { throw new Error( `Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` + `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` + `Ensure the bracket is fully generated before simulating.` ); } return simGame(eloMap, p1, p2); } /** * Resolve a playoff series. * Uses the real result if the match is complete; otherwise simulates best-of-7. * p1Override / p2Override fill null DB participant slots with the simulated seed. */ function resolveSeries( eloMap: Map, match: DbMatch | undefined, p1Override?: string, p2Override?: string ): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId }; } const p1 = match?.participant1Id ?? p1Override; const p2 = match?.participant2Id ?? p2Override; if (!p1 || !p2) { throw new Error( `Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` + `(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` + `Ensure the bracket is fully generated before simulating.` ); } return simSeries(eloMap, p1, p2); } // ─── Simulator ──────────────────────────────────────────────────────────────── export class NBASimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // ── Mode detection: bracket-aware if a playoff event with matches exists ── const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); if (bracketEvent) { const bracketMatches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (m, { asc }) => [asc(m.matchNumber)], }); if (bracketMatches.length > 0) { return this.simulateBracketAware(sportsSeasonId, bracketMatches); } } // ── Fall back to season-projection mode ─────────────────────────────────── return this.simulateSeasonProjection(sportsSeasonId); } // ── Mode 1: Bracket-Aware ────────────────────────────────────────────────── private async simulateBracketAware( sportsSeasonId: string, allMatches: DbMatch[] ): Promise { const db = database(); // Group matches by round. const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1") .toSorted((a, b) => a.matchNumber - b.matchNumber); const pir2 = allMatches.filter((m) => m.round === "Play-In Round 2") .toSorted((a, b) => a.matchNumber - b.matchNumber); const fr = allMatches.filter((m) => m.round === "First Round") .toSorted((a, b) => a.matchNumber - b.matchNumber); const cs = allMatches.filter((m) => m.round === "Conference Semifinals") .toSorted((a, b) => a.matchNumber - b.matchNumber); const cf = allMatches.filter((m) => m.round === "Conference Finals") .toSorted((a, b) => a.matchNumber - b.matchNumber); const finals = allMatches.filter((m) => m.round === "NBA Finals"); if ( pir1.length !== 4 || pir2.length !== 2 || fr.length !== 8 || cs.length !== 4 || cf.length !== 2 || finals.length !== 1 ) { throw new Error( `NBA bracket has unexpected structure. Expected PIR1×4, PIR2×2, FR×8, CS×4, CF×2, Finals×1. ` + `Got PIR1×${pir1.length}, PIR2×${pir2.length}, FR×${fr.length}, ` + `CS×${cs.length}, CF×${cf.length}, Finals×${finals.length}. ` + `Ensure the bracket was generated using the nba_20 template.` ); } // Collect all participant IDs from the bracket. const participantIdSet = new Set(); for (const m of allMatches) { if (m.participant1Id) participantIdSet.add(m.participant1Id); if (m.participant2Id) participantIdSet.add(m.participant2Id); if (m.winnerId) participantIdSet.add(m.winnerId); if (m.loserId) participantIdSet.add(m.loserId); } const participantIds = [...participantIdSet]; // Load participant names and sourceElo values in parallel. const [participantRows, evRows] = await Promise.all([ db .select({ id: schema.participants.id, name: schema.participants.name }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), ]); const nameById = new Map(participantRows.map((r) => [r.id, r.name])); 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 id of participantIds) { const name = nameById.get(id) ?? ""; eloMap.set(id, dbEloMap.get(id) ?? getTeamData(name)?.elo ?? 1400); } // Build per-round lookup maps for O(1) access in the hot loop. const pir1ByNum = new Map(pir1.map((m) => [m.matchNumber, m])); const pir2ByNum = new Map(pir2.map((m) => [m.matchNumber, m])); const frByNum = new Map(fr.map((m) => [m.matchNumber, m])); const csByNum = new Map(cs.map((m) => [m.matchNumber, m])); const cfByNum = new Map(cf.map((m) => [m.matchNumber, m])); const finalMatch = finals[0]; // Integer placement counts — avoids fractional accumulation error. const championCounts = new Map(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const cfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const csLoserCounts = new Map(participantIds.map((id) => [id, 0])); // ── Monte Carlo loop ────────────────────────────────────────────────────── for (let s = 0; s < NUM_SIMULATIONS; s++) { // ── Play-In Round 1 (4 single games) ───────────────────────────────── // M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1 // M2: East 9 vs 10 → winner enters PIR2 as p2; loser eliminated // M3: West 7 vs 8 → winner = W7 seed; loser enters PIR2 as p1 // M4: West 9 vs 10 → winner enters PIR2 as p2; loser eliminated const pir1M1 = resolveGame(eloMap, pir1ByNum.get(1)); const pir1M2 = resolveGame(eloMap, pir1ByNum.get(2)); const pir1M3 = resolveGame(eloMap, pir1ByNum.get(3)); const pir1M4 = resolveGame(eloMap, pir1ByNum.get(4)); const e7Seed = pir1M1.winner; const e8Cand = pir1M1.loser; // plays PIR2 M1 p1 const e9e10W = pir1M2.winner; // plays PIR2 M1 p2 const w7Seed = pir1M3.winner; const w8Cand = pir1M3.loser; // plays PIR2 M2 p1 const w9w10W = pir1M4.winner; // plays PIR2 M2 p2 // ── Play-In Round 2 (2 single games) ───────────────────────────────── // M1: E8 candidate vs E9/10 winner → winner = E8 seed // M2: W8 candidate vs W9/10 winner → winner = W8 seed // Use DB participant slots when already filled (after PIR1 locked in), // otherwise fall back to simulated values from above. const pir2M1 = resolveGame(eloMap, pir2ByNum.get(1), e8Cand, e9e10W); const pir2M2 = resolveGame(eloMap, pir2ByNum.get(2), w8Cand, w9w10W); const e8Seed = pir2M1.winner; const w8Seed = pir2M2.winner; // ── First Round (8 best-of-7 series) ───────────────────────────────── // Bracket order (matches correct Conference Semis pairings): // M1: E1 vs E8 M2: E4 vs E5 M3: E2 vs E7 M4: E3 vs E6 // M5: W1 vs W8 M6: W4 vs W5 M7: W2 vs W7 M8: W3 vs W6 // // Slots that stay null until play-in resolves use simmed seeds as fallback. // Once the play-in result is recorded, the DB slot is filled, so participant // coalescence (DB ?? simmed) always produces the correct team. const frM1 = resolveSeries(eloMap, frByNum.get(1), undefined, e8Seed); // E1(DB) vs E8 const frM2 = resolveSeries(eloMap, frByNum.get(2)); // E4(DB) vs E5(DB) const frM3 = resolveSeries(eloMap, frByNum.get(3), undefined, e7Seed); // E2(DB) vs E7 const frM4 = resolveSeries(eloMap, frByNum.get(4)); // E3(DB) vs E6(DB) const frM5 = resolveSeries(eloMap, frByNum.get(5), undefined, w8Seed); // W1(DB) vs W8 const frM6 = resolveSeries(eloMap, frByNum.get(6)); // W4(DB) vs W5(DB) const frM7 = resolveSeries(eloMap, frByNum.get(7), undefined, w7Seed); // W2(DB) vs W7 const frM8 = resolveSeries(eloMap, frByNum.get(8)); // W3(DB) vs W6(DB) // ── Conference Semifinals (4 best-of-7 series) ──────────────────────── // Standard ceil bracket path: // CS M1: FR M1/M2 winners (East upper: E1/8 vs E4/5) // CS M2: FR M3/M4 winners (East lower: E2/7 vs E3/6) // CS M3: FR M5/M6 winners (West upper: W1/8 vs W4/5) // CS M4: FR M7/M8 winners (West lower: W2/7 vs W3/6) const csM1 = resolveSeries(eloMap, csByNum.get(1), frM1.winner, frM2.winner); const csM2 = resolveSeries(eloMap, csByNum.get(2), frM3.winner, frM4.winner); const csM3 = resolveSeries(eloMap, csByNum.get(3), frM5.winner, frM6.winner); const csM4 = resolveSeries(eloMap, csByNum.get(4), frM7.winner, frM8.winner); csLoserCounts.set(csM1.loser, (csLoserCounts.get(csM1.loser) ?? 0) + 1); csLoserCounts.set(csM2.loser, (csLoserCounts.get(csM2.loser) ?? 0) + 1); csLoserCounts.set(csM3.loser, (csLoserCounts.get(csM3.loser) ?? 0) + 1); csLoserCounts.set(csM4.loser, (csLoserCounts.get(csM4.loser) ?? 0) + 1); // ── Conference Finals (2 best-of-7 series) ──────────────────────────── // CF M1: East champion (CS M1/M2 winners) // CF M2: West champion (CS M3/M4 winners) const cfM1 = resolveSeries(eloMap, cfByNum.get(1), csM1.winner, csM2.winner); const cfM2 = resolveSeries(eloMap, cfByNum.get(2), csM3.winner, csM4.winner); cfLoserCounts.set(cfM1.loser, (cfLoserCounts.get(cfM1.loser) ?? 0) + 1); cfLoserCounts.set(cfM2.loser, (cfLoserCounts.get(cfM2.loser) ?? 0) + 1); // ── NBA Finals ──────────────────────────────────────────────────────── const nbaFinals = resolveSeries(eloMap, finalMatch, cfM1.winner, cfM2.winner); championCounts.set(nbaFinals.winner, (championCounts.get(nbaFinals.winner) ?? 0) + 1); finalistCounts.set(nbaFinals.loser, (finalistCounts.get(nbaFinals.loser) ?? 0) + 1); } // ── Convert counts to probability distributions ─────────────────────────── // Exact denominators guarantee column sums of 1.0 by construction: // probFirst/Second → N total (1 per sim) // probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim // probFifth–Eighth → csLoserCounts / (4×N) — 4 CS losers per sim const N = NUM_SIMULATIONS; const results: SimulationResult[] = participantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const cfCount = cfLoserCounts.get(participantId) ?? 0; const csCount = csLoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / N, probSecond: f / N, probThird: cfCount / (2 * N), probFourth: cfCount / (2 * N), probFifth: csCount / (4 * N), probSixth: csCount / (4 * N), probSeventh: csCount / (4 * N), probEighth: csCount / (4 * N), }, source: "nba_bracket_monte_carlo", }; }); normalizeColumns(results); return results; } // ── Mode 2: Season Projection ────────────────────────────────────────────── private async simulateSeasonProjection(sportsSeasonId: string): Promise { const db = database(); const [participantRows, standings, evRows] = await Promise.all([ db .select({ id: schema.participants.id, name: schema.participants.name }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), getRegularSeasonStandings(sportsSeasonId), db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), ]); if (participantRows.length === 0) { throw new Error( `No participants found for sports season ${sportsSeasonId}. ` + `Add NBA teams as participants before running simulation.` ); } const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const participantIds = 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); } } interface TeamEntry { id: string; name: string; data: NbaTeamData | undefined; conference: "Eastern" | "Western"; currentWins: number; remainingGames: number; winProb: number; resolvedElo: number; } 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"); const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; return { id: r.id, name: r.name, data, conference, currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed), winProb: eloWinProbability(resolvedElo, 1500), resolvedElo, }; }); const easternTeams = teams.filter((t) => t.conference === "Eastern"); const westernTeams = teams.filter((t) => t.conference === "Western"); 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.` ); } const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry => Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b; const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b)); let wA = 0; let wB = 0; while (wA < 4 && wB < 4) { if (Math.random() < winProb) wA++; else wB++; } return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; }; const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => { const game1Winner = simTeamGame(s7, s8); const game1Loser = game1Winner === s7 ? s8 : s7; const game2Winner = simTeamGame(s9, s10); return [game1Winner, simTeamGame(game1Loser, game2Winner)]; }; const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => { const projected = confTeams.map((t) => ({ team: t, projectedWins: simulateProjectedWins(t), tiebreaker: Math.random(), })); const ranked = projected.toSorted((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker); const top6 = ranked.slice(0, 6).map((x) => x.team); const playIn = ranked.slice(6, 10).map((x) => x.team) as [TeamEntry, TeamEntry, TeamEntry, TeamEntry]; const [seed7, seed8] = simPlayIn(playIn); return [...top6, seed7, seed8]; }; const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [ simTeamSeries(s1, s8).winner, simTeamSeries(s4, s5).winner, simTeamSeries(s2, s7).winner, simTeamSeries(s3, s6).winner, ]; const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => { const m1 = simTeamSeries(w0, w1); const m2 = simTeamSeries(w2, w3); return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] }; }; 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])); for (let s = 0; s < NUM_SIMULATIONS; s++) { const eastBracket = buildConferenceBracket(easternTeams); const westBracket = buildConferenceBracket(westernTeams); const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket)); const { winner: eastChamp, loser: eastCFLoser } = simTeamSeries(eastR2Winners[0], eastR2Winners[1]); const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket)); const { winner: westChamp, loser: westCFLoser } = simTeamSeries(westR2Winners[0], westR2Winners[1]); const { winner: champion, loser: finalist } = simTeamSeries(eastChamp, westChamp); 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); } } const N = NUM_SIMULATIONS; 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 / N, probSecond: f / N, probThird: cf / (2 * N), probFourth: cf / (2 * N), probFifth: cs / (4 * N), probSixth: cs / (4 * N), probSeventh: cs / (4 * N), probEighth: cs / (4 * N), }, source: "nba_bracket_monte_carlo", }; }); normalizeColumns(results); return results; } } // ─── Season-projection helpers (used only in Mode 2) ───────────────────────── // TeamEntryBase is a structural subset of TeamEntry (the private interface defined inside // simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for // "consistent-function-scoping" (it doesn't close over any local variables). interface TeamEntryBase { resolvedElo: number; } function eloOfEntry(entry: TeamEntryBase): number { return entry.resolvedElo; } interface TeamForProjection { currentWins: number; remainingGames: number; winProb: number; } function simulateProjectedWins(entry: TeamForProjection): number { let extra = 0; for (let g = 0; g < entry.remainingGames; g++) { if (Math.random() < entry.winProb) extra++; } return entry.currentWins + extra; }