2026-03-16 21:43:39 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-03-21 23:37:20 -07:00
|
|
|
|
* 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
|
2026-03-16 21:43:39 -07:00
|
|
|
|
* → 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
|
2026-03-21 23:37:20 -07:00
|
|
|
|
* 5. Track placement counts per scoring tier
|
|
|
|
|
|
* 6. Convert counts to probability distributions
|
2026-03-16 21:43:39 -07:00
|
|
|
|
*
|
|
|
|
|
|
* Win probability (Elo, PARITY_FACTOR = 400):
|
|
|
|
|
|
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400))
|
|
|
|
|
|
*
|
2026-03-21 23:37:20 -07:00
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-03-16 21:43:39 -07:00
|
|
|
|
* 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
|
|
|
|
|
|
*
|
2026-03-21 23:37:20 -07:00
|
|
|
|
* 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.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import { eq } from "drizzle-orm";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
import type { Simulator, SimulationResult } from "./types";
|
2026-03-21 00:12:01 -07:00
|
|
|
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
2026-03-21 23:37:20 -07:00
|
|
|
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── 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;
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
/** NBA regular season games per team. */
|
|
|
|
|
|
const NBA_REGULAR_SEASON_GAMES = 82;
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
|
2026-03-16 21:43:39 -07:00
|
|
|
|
//
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 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.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
//
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// conference: Used as a fallback when the standings table has no conference data.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
interface NbaTeamData {
|
|
|
|
|
|
conference: "Eastern" | "Western";
|
|
|
|
|
|
elo: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const TEAMS_DATA: Record<string, NbaTeamData> = {
|
|
|
|
|
|
// ── Eastern Conference ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
"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 },
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
// ── Western Conference ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
"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 },
|
2026-03-16 21:43:39 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
|
|
|
|
|
|
2026-03-21 00:12:01 -07:00
|
|
|
|
export { normalizeTeamName };
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
/** 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;
|
2026-03-21 23:37:20 -07:00
|
|
|
|
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;
|
2026-03-16 21:43:39 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:43:39 -07:00
|
|
|
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export class NBASimulator implements Simulator {
|
|
|
|
|
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 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),
|
|
|
|
|
|
]);
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
if (participantRows.length === 0) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`No participants found for sports season ${sportsSeasonId}. ` +
|
|
|
|
|
|
`Add NBA teams as participants before running simulation.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 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]));
|
2026-03-16 21:43:39 -07:00
|
|
|
|
const participantIds = participantRows.map((r) => r.id);
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 3. Separate by conference for simulation.
|
|
|
|
|
|
const easternTeams = teams.filter((t) => t.conference === "Eastern");
|
|
|
|
|
|
const westernTeams = teams.filter((t) => t.conference === "Western");
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-03-21 23:37:20 -07:00
|
|
|
|
* Seeds are determined by simulated projected wins; positions 7–10 go through the Play-In. */
|
2026-03-16 21:43:39 -07:00
|
|
|
|
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
|
2026-03-21 23:37:20 -07:00
|
|
|
|
const projected = confTeams.map((t) => ({
|
2026-03-16 21:43:39 -07:00
|
|
|
|
team: t,
|
2026-03-21 23:37:20 -07:00
|
|
|
|
projectedWins: simulateProjectedWins(t),
|
2026-03-16 21:43:39 -07:00
|
|
|
|
tiebreaker: Math.random(),
|
|
|
|
|
|
}));
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// Higher projected wins = better seed (sort descending; random tiebreaker for ties).
|
|
|
|
|
|
projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
|
2026-03-16 21:43:39 -07:00
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
const top6 = projected.slice(0, 6).map((x) => x.team);
|
|
|
|
|
|
const playIn = projected.slice(6, 10).map((x) => x.team) as
|
2026-03-16 21:43:39 -07:00
|
|
|
|
[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] };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 4. Integer placement count maps — initialized to 0 for all participants.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
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]));
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 5. Monte Carlo simulation loop.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// ── 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);
|
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
|
|
|
|
confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
|
|
|
|
|
|
confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1);
|
2026-03-16 21:43:39 -07:00
|
|
|
|
for (const loser of [...eastR2Losers, ...westR2Losers]) {
|
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
|
|
|
|
confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1);
|
2026-03-16 21:43:39 -07:00
|
|
|
|
}
|
|
|
|
|
|
// Round 1 losers are not counted (0 points per scoring rules).
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 6. Convert integer counts to probability distributions.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
// Exact denominators guarantee column sums of 1.0 by construction:
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 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
|
2026-03-16 21:43:39 -07:00
|
|
|
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
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
|
|
|
|
const c = championCounts.get(participantId) ?? 0;
|
|
|
|
|
|
const f = finalistCounts.get(participantId) ?? 0;
|
|
|
|
|
|
const cf = confFinalLoserCounts.get(participantId) ?? 0;
|
|
|
|
|
|
const cs = confSemiLoserCounts.get(participantId) ?? 0;
|
2026-03-16 21:43:39 -07:00
|
|
|
|
return {
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
probabilities: {
|
2026-03-21 23:37:20 -07:00
|
|
|
|
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),
|
2026-03-16 21:43:39 -07:00
|
|
|
|
},
|
|
|
|
|
|
source: "nba_bracket_monte_carlo",
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-21 23:37:20 -07:00
|
|
|
|
// 7. Per-position normalization — belt-and-suspenders guard against floating-point
|
|
|
|
|
|
// division residuals. Columns are already near-exactly 1.0 after step 6.
|
2026-03-16 21:43:39 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|