brackt/app/services/simulations/nba-simulator.ts
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00

444 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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. Match participant names to hardcoded team data (Elo + seed probabilities)
* 3. For each simulation:
* a. Assign each team a seed based on its weighted probability distribution (p_1..p_10)
* Teams with no seed probabilities always miss the playoffs (seed = 11)
* b. Sort each conference by drawn seed + random tiebreaker
* → Top 6 lock in directly; seeds 710 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
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 400):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400))
*
* 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)
* probFifthEighth = Conference Semis losers (4 per sim)
* Round 1 losers → all 0 (score 0 points)
* Missed playoffs → all 0
*
* Elo ratings and seed probabilities are hardcoded below (March 2026 data).
* Source: Basketball-Reference Playoff Probabilities + Neil Paine Substack estimates.
* 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";
// ─── 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;
/** Seed probability keys — ordered p_1..p_10 for the drawSeed() inner loop. */
const SEED_KEYS = ["p_1", "p_2", "p_3", "p_4", "p_5", "p_6", "p_7", "p_8", "p_9", "p_10"] as const;
// ─── Team data (2025-26 season, as of March 6, 2026) ─────────────────────────
//
// elo: Estimated Elo rating (higher = stronger).
// p_1 through p_10: Probability of finishing at each conference seed.
// Sum of all p_X for a team = probability of making the playoffs.
// Teams with no p_X entries always miss the playoffs in simulation.
//
// Sources:
// - Elo: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight).
// This is the appropriate signal for simulating playoff matchups.
// - Seed probs: Basketball-Reference Playoff Probabilities Report (March 16, 2026).
//
// Seed probability methodology:
// BBRef reports conditional seed probabilities (summing to ~100% per team).
// We scale each team's values by their Playoffs% to get unconditional probabilities.
// The drawSeed() function then treats the remainder (1 - sum) as "miss playoffs".
// Formula: p_X = (BBRef_conditional_X / 100) × (Playoffs% / 100)
interface NbaTeamData {
conference: "Eastern" | "Western";
elo: number;
p_1?: number;
p_2?: number;
p_3?: number;
p_4?: number;
p_5?: number;
p_6?: number;
p_7?: number;
p_8?: number;
p_9?: number;
p_10?: number;
}
const TEAMS_DATA: Record<string, NbaTeamData> = {
// ── Eastern Conference ──────────────────────────────────────────────────────
// Elo = playoff rating (last 110 games, no regression, postseason games 3× weight)
// Seed probs = BBRef conditional × (Playoffs% / 100)
// Detroit (PO%=100%): locked as 1-seed
"Detroit Pistons": { conference: "Eastern", elo: 1558,
p_1: 0.982, p_2: 0.016, p_3: 0.001 },
// Boston (PO%=100%): clear 2/3 seed
"Boston Celtics": { conference: "Eastern", elo: 1699,
p_1: 0.014, p_2: 0.540, p_3: 0.389, p_4: 0.052, p_5: 0.004 },
// New York (PO%=100%)
"New York Knicks": { conference: "Eastern", elo: 1626,
p_1: 0.003, p_2: 0.423, p_3: 0.479, p_4: 0.086, p_5: 0.009, p_6: 0.001 },
// Cleveland (PO%=99.9%): mostly 4-seed, slight play-in risk
"Cleveland Cavaliers": { conference: "Eastern", elo: 1628,
p_2: 0.020, p_3: 0.117, p_4: 0.684, p_5: 0.137, p_6: 0.032, p_7: 0.009, p_8: 0.001 },
// Orlando (PO%=88.8%): 5/6-seed range, play-in risk
"Orlando Magic": { conference: "Eastern", elo: 1508,
p_3: 0.007, p_4: 0.075, p_5: 0.333, p_6: 0.182, p_7: 0.139, p_8: 0.091, p_9: 0.044, p_10: 0.018 },
// Miami (PO%=78.7%)
"Miami Heat": { conference: "Eastern", elo: 1530,
p_3: 0.004, p_4: 0.041, p_5: 0.212, p_6: 0.224, p_7: 0.255, p_8: 0.113, p_9: 0.037, p_10: 0.009 },
// Toronto (PO%=86.4%)
"Toronto Raptors": { conference: "Eastern", elo: 1467,
p_3: 0.001, p_4: 0.038, p_5: 0.165, p_6: 0.324, p_7: 0.192, p_8: 0.103, p_9: 0.041, p_10: 0.011 },
// Atlanta (PO%=46.7%): mostly play-in range
"Atlanta Hawks": { conference: "Eastern", elo: 1496,
p_4: 0.001, p_5: 0.011, p_6: 0.022, p_7: 0.058, p_8: 0.124, p_9: 0.134, p_10: 0.123 },
// Philadelphia (PO%=52.9%)
"Philadelphia 76ers": { conference: "Eastern", elo: 1471,
p_5: 0.011, p_6: 0.039, p_7: 0.079, p_8: 0.116, p_9: 0.134, p_10: 0.091 },
// Charlotte (PO%=47.1%): deep play-in territory
"Charlotte Hornets": { conference: "Eastern", elo: 1496,
p_5: 0.002, p_6: 0.004, p_7: 0.017, p_8: 0.058, p_9: 0.118, p_10: 0.201 },
// Milwaukee (PO%=0%)
"Milwaukee Bucks": { conference: "Eastern", elo: 1442 },
// Chicago (PO%=0%)
"Chicago Bulls": { conference: "Eastern", elo: 1381 },
// Brooklyn (PO%=0%)
"Brooklyn Nets": { conference: "Eastern", elo: 1334 },
// Indiana (PO%=0%)
"Indiana Pacers": { conference: "Eastern", elo: 1433 },
// Washington (PO%=0%)
"Washington Wizards": { conference: "Eastern", elo: 1255 },
// ── Western Conference ──────────────────────────────────────────────────────
// OKC (PO%=100%): dominant 1-seed
"Oklahoma City Thunder": { conference: "Western", elo: 1731,
p_1: 0.920, p_2: 0.081 },
// San Antonio (PO%=100%): locked as 2-seed
"San Antonio Spurs": { conference: "Western", elo: 1599,
p_1: 0.081, p_2: 0.919 },
// Houston (PO%=99.8%): 3/4 seed range
"Houston Rockets": { conference: "Western", elo: 1564,
p_3: 0.467, p_4: 0.258, p_5: 0.169, p_6: 0.092, p_7: 0.014, p_8: 0.001 },
// Denver (PO%=99.8%)
"Denver Nuggets": { conference: "Western", elo: 1618,
p_3: 0.222, p_4: 0.270, p_5: 0.277, p_6: 0.181, p_7: 0.043, p_8: 0.001 },
// LA Lakers (PO%=99.7%)
"Los Angeles Lakers": { conference: "Western", elo: 1569,
p_3: 0.219, p_4: 0.267, p_5: 0.242, p_6: 0.172, p_7: 0.079, p_8: 0.003 },
// Minnesota (PO%=96.2%)
"Minnesota Timberwolves": { conference: "Western", elo: 1603,
p_3: 0.072, p_4: 0.154, p_5: 0.222, p_6: 0.340, p_7: 0.173, p_8: 0.007 },
// Phoenix (PO%=83.4%): mostly 7-seed play-in entry
"Phoenix Suns": { conference: "Western", elo: 1500,
p_3: 0.011, p_4: 0.032, p_5: 0.065, p_6: 0.165, p_7: 0.517, p_8: 0.051, p_9: 0.005 },
// LA Clippers (PO%=71.1%): heavy play-in range
"LA Clippers": { conference: "Western", elo: 1573,
p_6: 0.042, p_7: 0.468, p_8: 0.112, p_9: 0.027 },
// Golden State (PO%=30.3%): longshot play-in
"Golden State Warriors": { conference: "Western", elo: 1530,
p_6: 0.003, p_7: 0.053, p_8: 0.160, p_9: 0.147 },
// Portland (PO%=19.5%): deep longshot
"Portland Trail Blazers": { conference: "Western", elo: 1426,
p_7: 0.013, p_8: 0.077, p_9: 0.111 },
// Dallas (PO%=0%)
"Dallas Mavericks": { conference: "Western", elo: 1473 },
// Memphis (PO%=0%)
"Memphis Grizzlies": { conference: "Western", elo: 1417 },
// New Orleans (PO%=0%)
"New Orleans Pelicans": { conference: "Western", elo: 1380 },
// Sacramento (PO%=0%)
"Sacramento Kings": { conference: "Western", elo: 1352 },
// Utah (PO%=0%)
"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;
}
/** 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;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
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 NBA teams as participants before running simulation.`
);
}
// 2. Match participant names to hardcoded team data.
// Teams with no match or no seed probabilities will always miss the playoffs.
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// Separate by conference for simulation (fall back to Eastern if no data found).
const easternTeams = teams.filter((t) => (t.data?.conference ?? "Eastern") === "Eastern");
const westernTeams = teams.filter((t) => t.data?.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) ─────────────────────────
/** Draw a conference seed (110) based on a team's probability distribution.
* Returns 11 if the draw falls outside all p_X values (team misses playoffs). */
const drawSeed = (entry: TeamEntry): number => {
const data = entry.data;
if (!data) return 11; // Unknown team — always misses playoffs
let r = Math.random();
for (let i = 0; i < SEED_KEYS.length; i++) {
const prob = data[SEED_KEYS[i]] ?? 0;
r -= prob;
if (r <= 0) return i + 1;
}
return 11; // Missed playoffs
};
/** 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 drawn probabilistically; positions 710 go through the Play-In. */
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
const seeded = confTeams.map((t) => ({
team: t,
seed: drawSeed(t),
tiebreaker: Math.random(),
}));
seeded.sort((a, b) => a.seed - b.seed || a.tiebreaker - b.tiebreaker);
const top6 = seeded.slice(0, 6).map((x) => x.team);
const playIn = seeded.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] };
};
// 3. Integer placement count maps — initialized to 0 for all participants.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 4. 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() is always defined) ───
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).
}
// 5. Convert integer counts to probability distributions.
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → N total (1 per sim)
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
// probFifthEighth → confSemiLoserCounts / (4*N) — 4 conf semi 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 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",
};
});
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 5.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"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;
}
}