brackt/app/services/simulations/nhl-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

574 lines
24 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.

/**
* NHL Playoff Simulator
*
* Monte Carlo simulation of the NHL playoffs including seeding projection
* for the current season (2025-26).
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities)
* 3. For each simulation:
* a. For each conference, draw division placements (1st/2nd/3rd per division)
* and two wildcard teams using weighted probability distributions
* b. Build the NHL bracket:
* - Higher-Elo division winner faces WC2; other division winner faces WC1
* - Each division's 2nd vs 3rd seeds play each other
* c. Simulate 4 rounds of best-of-7 series:
* Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1,
* m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd
* Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
* Round 3 (Conf Finals): two division-final winners per conference
* Stanley Cup Final: East champ vs West champ
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 800):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 800))
* NHL uses a higher parity factor than the standard 400 to dampen the Elo
* spread and reflect the high variance of hockey.
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = Stanley Cup champion (1 per sim)
* probSecond = Stanley Cup Final loser (1 per sim)
* probThird / probFourth = Conference Final losers (2 per sim — East + West)
* probFifthprobEighth = Second Round losers (4 per sim)
* First Round losers → all 0 (score 0 points)
* Missed playoffs → all 0
*
* Seeding probability keys per team:
* p_div1 = prob of winning own division
* p_div2 = prob of finishing 2nd in own division
* p_div3 = prob of finishing 3rd in own division
* p_wc1 = prob of being conference wildcard 1 (higher-ranked WC)
* p_wc2 = prob of being conference wildcard 2 (lower-ranked WC)
* Sum of all p_* ≈ team's overall playoff probability.
* Teams with no p_* entries always miss the playoffs.
*
* Elo ratings and seeding probabilities are hardcoded below (March 2026 data).
* Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration).
* Seeding probabilities are hand-derived from current standings as of March 18, 2026.
* 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";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
/**
* Elo parity factor. NHL uses 800 (double the standard 400) to reflect the
* high game-to-game variance in hockey vs other sports.
* An 800-point Elo difference → ~90.9% win probability per game.
*/
const PARITY_FACTOR = 800;
// ─── Team data (2025-26 season, as of March 18, 2026) ────────────────────────
//
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
// p_div1: Probability of finishing 1st in own division.
// p_div2: Probability of finishing 2nd in own division.
// p_div3: Probability of finishing 3rd in own division.
// p_wc1: Probability of being conference wildcard 1 (higher seed).
// p_wc2: Probability of being conference wildcard 2 (lower seed).
//
// These are weights used in sequential weighted sampling (not strict distributions).
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site.
//
// Divisions:
// Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
// Eastern — Metropolitan: CAR, CBJ, NJD, NYI, NYR, PHI, PIT, WSH
// Western — Central: CHI, COL, DAL, MIN, NSH, STL, UTA, WPG
// Western — Pacific: ANA, CGY, EDM, LAK, SJS, SEA, VAN, VGK
interface NhlTeamData {
conference: "Eastern" | "Western";
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
elo: number;
p_div1?: number;
p_div2?: number;
p_div3?: number;
p_wc1?: number;
p_wc2?: number;
}
const TEAMS_DATA: Record<string, NhlTeamData> = {
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
// BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs.
// MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC.
// OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble.
// FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated.
"Tampa Bay Lightning": {
conference: "Eastern", division: "Atlantic", elo: 1587,
p_div1: 0.50, p_div2: 0.40, p_div3: 0.06,
},
"Buffalo Sabres": {
conference: "Eastern", division: "Atlantic", elo: 1572,
p_div1: 0.45, p_div2: 0.42, p_div3: 0.07,
},
"Montreal Canadiens": {
conference: "Eastern", division: "Atlantic", elo: 1527,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06,
},
"Ottawa Senators": {
conference: "Eastern", division: "Atlantic", elo: 1542,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Detroit Red Wings": {
conference: "Eastern", division: "Atlantic", elo: 1506,
p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22,
},
"Boston Bruins": {
conference: "Eastern", division: "Atlantic", elo: 1501,
p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Florida Panthers": {
conference: "Eastern", division: "Atlantic", elo: 1505,
p_wc2: 0.005,
},
"Toronto Maple Leafs": {
conference: "Eastern", division: "Atlantic", elo: 1479,
// 0% playoff probability — always miss
},
// ── Eastern Conference — Metropolitan ─────────────────────────────────────
// CAR 109 pts (99.9%) — clear 1st seed.
// CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race.
// WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots.
// NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated.
"Carolina Hurricanes": {
conference: "Eastern", division: "Metropolitan", elo: 1574,
p_div1: 0.98, p_div2: 0.01,
},
"Columbus Blue Jackets": {
conference: "Eastern", division: "Metropolitan", elo: 1530,
p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04,
},
"Pittsburgh Penguins": {
conference: "Eastern", division: "Metropolitan", elo: 1518,
p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"New York Islanders": {
conference: "Eastern", division: "Metropolitan", elo: 1513,
p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"Philadelphia Flyers": {
conference: "Eastern", division: "Metropolitan", elo: 1473,
p_wc1: 0.02, p_wc2: 0.05,
},
"Washington Capitals": {
conference: "Eastern", division: "Metropolitan", elo: 1506,
p_wc1: 0.01, p_wc2: 0.03,
},
"New Jersey Devils": {
conference: "Eastern", division: "Metropolitan", elo: 1488,
p_wc2: 0.005,
},
"New York Rangers": {
conference: "Eastern", division: "Metropolitan", elo: 1480,
// 0% playoff probability — always miss
},
// ── Western Conference — Central ───────────────────────────────────────────
// COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3.
// UTA 93 pts (92.8%) — leading WC candidate from Central.
// NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots.
// CHI 75 pts (0%) — eliminated.
"Colorado Avalanche": {
conference: "Western", division: "Central", elo: 1594,
p_div1: 0.90, p_div2: 0.09, p_div3: 0.01,
},
"Dallas Stars": {
conference: "Western", division: "Central", elo: 1581,
p_div1: 0.08, p_div2: 0.80, p_div3: 0.11,
},
"Minnesota Wild": {
conference: "Western", division: "Central", elo: 1548,
p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01,
},
"Utah Mammoth": {
conference: "Western", division: "Central", elo: 1529,
p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33,
},
"Nashville Predators": {
conference: "Western", division: "Central", elo: 1471,
p_wc1: 0.04, p_wc2: 0.17,
},
"Winnipeg Jets": {
conference: "Western", division: "Central", elo: 1483,
p_wc1: 0.01, p_wc2: 0.05,
},
"St. Louis Blues": {
conference: "Western", division: "Central", elo: 1473,
p_wc2: 0.02,
},
"Chicago Blackhawks": {
conference: "Western", division: "Central", elo: 1411,
// 0% playoff probability — always miss
},
// ── Western Conference — Pacific ───────────────────────────────────────────
// ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds.
// LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble.
// CGY 73 pts (0%), VAN 63 pts (0%) — eliminated.
"Anaheim Ducks": {
conference: "Western", division: "Pacific", elo: 1490,
p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01,
},
"Edmonton Oilers": {
conference: "Western", division: "Pacific", elo: 1531,
p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04,
},
"Vegas Golden Knights": {
conference: "Western", division: "Pacific", elo: 1519,
p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06,
},
"Los Angeles Kings": {
conference: "Western", division: "Pacific", elo: 1482,
p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12,
},
"San Jose Sharks": {
conference: "Western", division: "Pacific", elo: 1455,
p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12,
},
"Seattle Kraken": {
conference: "Western", division: "Pacific", elo: 1469,
p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10,
},
"Calgary Flames": {
conference: "Western", division: "Pacific", elo: 1436,
// 0% playoff probability — always miss
},
"Vancouver Canucks": {
conference: "Western", division: "Pacific", elo: 1403,
// 0% playoff probability — always miss
},
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
export function normalizeTeamName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, " ");
}
/** Look up team data by participant name (case-insensitive). */
export function getTeamData(name: string): NhlTeamData | undefined {
const normalized = normalizeTeamName(name);
for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
if (normalizeTeamName(teamName) === normalized) return data;
}
return undefined;
}
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: NhlTeamData | undefined;
}
type DivisionSeedings = {
first: TeamEntry;
second: TeamEntry;
third: TeamEntry;
};
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/**
* Weighted pick without replacement.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
* Returns undefined if no eligible team has positive weight — the caller should
* treat that as a degenerate draw and skip the iteration.
*/
function weightedPick(
pool: TeamEntry[],
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
excluded: Set<TeamEntry>
): TeamEntry | undefined {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
const total = weights.reduce((s, w) => s + w, 0);
if (total === 0) return undefined;
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
}
return eligible[eligible.length - 1];
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NHLSimulator 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 NHL teams as participants before running simulation.`
);
}
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// Warn about participants that don't match any hardcoded team.
// These are excluded entirely rather than silently landing in the wildcard pool.
const unrecognized = teams.filter((t) => !t.data);
if (unrecognized.length > 0) {
console.warn(
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
unrecognized.map((t) => t.name).join(", ")
);
}
// Separate recognized teams by conference + division.
const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic");
const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan");
const westernCentral = teams.filter((t) => t.data?.division === "Central");
const westernPacific = teams.filter((t) => t.data?.division === "Pacific");
// Only recognized teams enter the conference wildcard pools.
const easternTeams = teams.filter((t) => t.data?.conference === "Eastern");
const westernTeams = teams.filter((t) => t.data?.conference === "Western");
if (easternTeams.length < 8 || westernTeams.length < 8) {
throw new Error(
`Each conference needs at least 8 recognized participants ` +
`(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` +
`Add all 32 NHL teams before running simulation.`
);
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** 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 };
};
/**
* Draw the top-3 playoff seeds for a single division.
* Returns undefined for each position if no team has weight for that slot
* (shouldn't happen with correct data, but handled gracefully).
*/
const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => {
const taken = new Set<TeamEntry>();
const first = weightedPick(divTeams, "p_div1", taken);
if (!first) return undefined;
taken.add(first);
const second = weightedPick(divTeams, "p_div2", taken);
if (!second) return undefined;
taken.add(second);
const third = weightedPick(divTeams, "p_div3", taken);
if (!third) return undefined;
return { first, second, third };
};
/**
* Build the 8-team playoff bracket for one conference.
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
*
* Bracket:
* m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard)
* m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard)
* m2: topDiv_2nd vs topDiv_3rd
* m3: otherDiv_2nd vs otherDiv_3rd
*
* "Top division" = whichever division winner has higher Elo. The actual NHL rule
* uses regular season points, but Elo is a reasonable per-simulation proxy since
* we don't simulate the regular season — it approximates the same ordering.
*/
const buildConferenceBracket = (
divATeams: TeamEntry[],
divBTeams: TeamEntry[],
allConfTeams: TeamEntry[]
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
const divA = drawDivisionSeedings(divATeams);
const divB = drawDivisionSeedings(divBTeams);
if (!divA || !divB) return undefined;
const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]);
const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t));
const wcTaken = new Set<TeamEntry>();
const wc1 = weightedPick(wcPool, "p_wc1", wcTaken);
if (!wc1) return undefined;
wcTaken.add(wc1);
const wc2 = weightedPick(wcPool, "p_wc2", wcTaken);
if (!wc2) return undefined;
// Higher-Elo division winner faces WC2 (the weaker wildcard).
const [topDiv, otherDiv] =
elo(divA.first) >= elo(divB.first)
? [divA, divB]
: [divB, divA];
return [
[topDiv.first, wc2], // m0
[otherDiv.first, wc1], // m1
[topDiv.second, topDiv.third], // m2
[otherDiv.second, otherDiv.third], // m3
];
};
// ─── Placement count maps ──────────────────────────────────────────────────
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 r2LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
// Track effective iterations separately: degenerate bracket draws are skipped
// and must not count toward the probability denominators.
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Build conference brackets.
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams);
const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams);
if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
effectiveN++;
// ── Eastern Conference ────────────────────────────────────────────────────
// Round 1: 4 series (Wild Card round).
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
// Round 2 (Division Finals):
// m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner)
// m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner)
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
// Conference Final.
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.winner;
confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1);
// ── Western Conference ────────────────────────────────────────────────────
const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner);
const westR2m1 = simSeries(westR1Winners[0], westR1Winners[2]);
const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]);
const westCF = simSeries(westR2m1.winner, westR2m2.winner);
const westChamp = westCF.winner;
confFinalLoserCounts.set(westCF.loser.id, (confFinalLoserCounts.get(westCF.loser.id) ?? 0) + 1);
// ── Stanley Cup Final ─────────────────────────────────────────────────────
const final = simSeries(eastChamp, westChamp);
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
// Round 2 losers (4 per sim). Round 1 losers are not counted (0 points).
for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
}
}
if (effectiveN === 0) {
throw new Error(
"All simulations produced degenerate brackets. " +
"Check that each division has teams with positive seeding probabilities."
);
}
// ─── Convert counts to probability distributions ───────────────────────────
//
// probFirst/Second → count / N (1 team per sim)
// probThird/Fourth → count / (2 * N) (2 conf final losers per sim)
// probFifthEighth → count / (4 * N) (4 R2 losers per sim)
//
const N = effectiveN;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const cf = confFinalLoserCounts.get(participantId) ?? 0;
const r2 = r2LoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: cf / (2 * N),
probFourth: cf / (2 * N),
probFifth: r2 / (4 * N),
probSixth: r2 / (4 * N),
probSeventh: r2 / (4 * N),
probEighth: r2 / (4 * N),
},
source: "nhl_bracket_monte_carlo",
};
});
// ─── Per-position normalization ────────────────────────────────────────────
//
// Belt-and-suspenders guard against floating-point residuals.
// Columns are already near-exactly 1.0 by construction above.
//
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;
}
}