* Fix NHL bracket-aware simulation and MLB sourceElo support NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude <noreply@anthropic.com>
801 lines
35 KiB
TypeScript
801 lines
35 KiB
TypeScript
/**
|
||
* 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. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division)
|
||
* 3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback)
|
||
* 4. For each simulation:
|
||
* a. For each team, simulate remaining regular season games (82 - gamesPlayed):
|
||
* - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500)
|
||
* - OT/SO loss (1 pt) at NHL_OT_RATE × (1 − winProb)
|
||
* - Regulation loss (0 pts) otherwise
|
||
* → projectedPoints = currentPoints + simulated extra points
|
||
* b. Per division: rank by projected points (random tiebreaker) → top 3 are division seeds
|
||
* c. Per conference: top 2 non-division-seed teams by projected points → WC1 and WC2
|
||
* (WC1 has more points than WC2)
|
||
* d. Build the 8-team bracket per conference:
|
||
* - Division winner with more points = 1st seed, faces WC2
|
||
* - Other division winner = 2nd seed, faces WC1
|
||
* - Each division's 2nd and 3rd seeds face each other
|
||
* e. Simulate 4 rounds of best-of-7 series:
|
||
* Round 1 (Wild Card): m0=1stSeed/WC2, m1=2ndSeed/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
|
||
* 5. Track placement counts per scoring tier
|
||
* 6. Convert counts to probability distributions
|
||
*
|
||
* Win probability (Elo, PARITY_FACTOR = 1000):
|
||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000))
|
||
* NHL uses a higher parity factor than the standard 400 to reflect the high
|
||
* variance of hockey.
|
||
*
|
||
* Futures blending:
|
||
* If sourceOdds are stored in participantExpectedValues for this season,
|
||
* the per-game win probability is blended:
|
||
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
|
||
* where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
|
||
* Normalized odds are vig-removed futures win probabilities.
|
||
* ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as UCL simulator).
|
||
* Falls back to Elo-only when no odds are stored.
|
||
*
|
||
* 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)
|
||
* probFifth–probEighth = Second Round losers (4 per sim)
|
||
* First Round losers → all 0 (score 0 points)
|
||
* Missed playoffs → all 0
|
||
*
|
||
* Elo ratings are hardcoded (2025-26 season data).
|
||
* Source: https://elo.harvitronix.com/nhl/2025-2026
|
||
* Conference and division are read from the standings table (synced from the
|
||
* NHL API); TEAMS_DATA values serve as fallbacks if standings are missing.
|
||
* Update Elo values 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 { logger } from "~/lib/logger";
|
||
import {
|
||
convertAmericanOddsToProbability,
|
||
normalizeProbabilities,
|
||
} from "~/services/probability-engine";
|
||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50_000;
|
||
|
||
/** NHL regular season games per team. */
|
||
const NHL_REGULAR_SEASON_GAMES = 82;
|
||
|
||
/**
|
||
* Fraction of NHL games that go to overtime / shootout.
|
||
* The losing team earns 1 point (instead of 0) in these games.
|
||
* Historical average: ~23% of games.
|
||
*/
|
||
const NHL_OT_RATE = 0.23;
|
||
|
||
/**
|
||
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
|
||
* the elevated game-to-game variance in hockey.
|
||
*/
|
||
const PARITY_FACTOR = 1000;
|
||
|
||
/**
|
||
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
|
||
* Same calibration as the UCL simulator (0.7 / 0.3).
|
||
*/
|
||
const ELO_WEIGHT = 0.7;
|
||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||
|
||
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
|
||
//
|
||
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
|
||
// Used for both regular-season game simulation (vs. avg opponent Elo 1500)
|
||
// and for all playoff matchup win probabilities.
|
||
//
|
||
// conference / division: Used as fallbacks when the standings table has no
|
||
// conference or division data for a participant. In normal operation these
|
||
// are read from regularSeasonStandings (synced from the NHL API).
|
||
//
|
||
// 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;
|
||
}
|
||
|
||
const TEAMS_DATA: Record<string, NhlTeamData> = {
|
||
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
|
||
"Tampa Bay Lightning": { conference: "Eastern", division: "Atlantic", elo: 1587 },
|
||
"Buffalo Sabres": { conference: "Eastern", division: "Atlantic", elo: 1572 },
|
||
"Montreal Canadiens": { conference: "Eastern", division: "Atlantic", elo: 1527 },
|
||
"Ottawa Senators": { conference: "Eastern", division: "Atlantic", elo: 1542 },
|
||
"Detroit Red Wings": { conference: "Eastern", division: "Atlantic", elo: 1506 },
|
||
"Boston Bruins": { conference: "Eastern", division: "Atlantic", elo: 1501 },
|
||
"Florida Panthers": { conference: "Eastern", division: "Atlantic", elo: 1505 },
|
||
"Toronto Maple Leafs": { conference: "Eastern", division: "Atlantic", elo: 1479 },
|
||
|
||
// ── Eastern Conference — Metropolitan ─────────────────────────────────────
|
||
"Carolina Hurricanes": { conference: "Eastern", division: "Metropolitan", elo: 1574 },
|
||
"Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan", elo: 1530 },
|
||
"Pittsburgh Penguins": { conference: "Eastern", division: "Metropolitan", elo: 1518 },
|
||
"New York Islanders": { conference: "Eastern", division: "Metropolitan", elo: 1513 },
|
||
"Philadelphia Flyers": { conference: "Eastern", division: "Metropolitan", elo: 1473 },
|
||
"Washington Capitals": { conference: "Eastern", division: "Metropolitan", elo: 1506 },
|
||
"New Jersey Devils": { conference: "Eastern", division: "Metropolitan", elo: 1488 },
|
||
"New York Rangers": { conference: "Eastern", division: "Metropolitan", elo: 1480 },
|
||
|
||
// ── Western Conference — Central ───────────────────────────────────────────
|
||
"Colorado Avalanche": { conference: "Western", division: "Central", elo: 1594 },
|
||
"Dallas Stars": { conference: "Western", division: "Central", elo: 1581 },
|
||
"Minnesota Wild": { conference: "Western", division: "Central", elo: 1548 },
|
||
"Utah Mammoth": { conference: "Western", division: "Central", elo: 1529 },
|
||
"Nashville Predators": { conference: "Western", division: "Central", elo: 1471 },
|
||
"Winnipeg Jets": { conference: "Western", division: "Central", elo: 1483 },
|
||
"St. Louis Blues": { conference: "Western", division: "Central", elo: 1473 },
|
||
"Chicago Blackhawks": { conference: "Western", division: "Central", elo: 1411 },
|
||
|
||
// ── Western Conference — Pacific ───────────────────────────────────────────
|
||
"Anaheim Ducks": { conference: "Western", division: "Pacific", elo: 1490 },
|
||
"Edmonton Oilers": { conference: "Western", division: "Pacific", elo: 1531 },
|
||
"Vegas Golden Knights": { conference: "Western", division: "Pacific", elo: 1519 },
|
||
"Los Angeles Kings": { conference: "Western", division: "Pacific", elo: 1482 },
|
||
"San Jose Sharks": { conference: "Western", division: "Pacific", elo: 1455 },
|
||
"Seattle Kraken": { conference: "Western", division: "Pacific", elo: 1469 },
|
||
"Calgary Flames": { conference: "Western", division: "Pacific", elo: 1436 },
|
||
"Vancouver Canucks": { conference: "Western", division: "Pacific", elo: 1403 },
|
||
};
|
||
|
||
// ─── 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;
|
||
conference: "Eastern" | "Western";
|
||
division: string;
|
||
/** Current regular season points: wins × 2 + OT losses × 1. */
|
||
currentPoints: number;
|
||
/** Remaining regular season games = 82 − gamesPlayed. */
|
||
remainingGames: number;
|
||
/** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
|
||
winProb: number;
|
||
/** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */
|
||
resolvedElo: number;
|
||
}
|
||
|
||
/** Get Elo for a team entry. */
|
||
function elo(entry: TeamEntry): number {
|
||
return entry.resolvedElo;
|
||
}
|
||
|
||
/**
|
||
* Simulate remaining regular season games for one team.
|
||
* Each game:
|
||
* - Win (2 pts) with probability winProb
|
||
* - OT/SO loss (1 pt) with probability (1 − winProb) × NHL_OT_RATE
|
||
* - Regulation loss (0 pts) otherwise
|
||
* Returns projected total points for the season.
|
||
*/
|
||
export function simulateProjectedPoints(entry: TeamEntry): number {
|
||
let extra = 0;
|
||
const { winProb, remainingGames } = entry;
|
||
const otlProb = (1 - winProb) * NHL_OT_RATE;
|
||
for (let g = 0; g < remainingGames; g++) {
|
||
const r = Math.random();
|
||
if (r < winProb) {
|
||
extra += 2;
|
||
} else if (r < winProb + otlProb) {
|
||
extra += 1;
|
||
}
|
||
}
|
||
return entry.currentPoints + extra;
|
||
}
|
||
|
||
// ─── Projected-points helpers (module-level for hot-loop efficiency) ──────────
|
||
|
||
interface ProjectedTeam {
|
||
team: TeamEntry;
|
||
pts: number;
|
||
tb: number;
|
||
}
|
||
|
||
/** Project one team's end-of-season point total with a random tiebreaker. */
|
||
function projectTeam(t: TeamEntry): ProjectedTeam {
|
||
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
|
||
}
|
||
|
||
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
|
||
function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
|
||
return arr.toSorted((a, b) => b.pts - a.pts || b.tb - a.tb);
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class NHLSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
|
||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||
where: and(
|
||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||
),
|
||
});
|
||
|
||
if (bracketEvent) {
|
||
const allMatches = await db.query.playoffMatches.findMany({
|
||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
||
});
|
||
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||
if (bracketPopulated) {
|
||
return this.simulateBracket(sportsSeasonId, allMatches);
|
||
}
|
||
}
|
||
|
||
// 1. Load participants, standings, and sourceElo values in parallel.
|
||
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,
|
||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||
})
|
||
.from(schema.participantExpectedValues)
|
||
.where(eq(schema.participantExpectedValues.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);
|
||
|
||
// 2. Build standings lookup, sourceElo map, and construct team entries.
|
||
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
|
||
|
||
const dbEloMap = new Map<string, number>();
|
||
for (const row of evRows) {
|
||
if (row.sourceElo !== null && row.sourceElo !== undefined) {
|
||
dbEloMap.set(row.participantId, row.sourceElo);
|
||
}
|
||
}
|
||
|
||
if (standings.length === 0) {
|
||
logger.warn(
|
||
`[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
|
||
`Simulation will use 0 current points and 82 remaining games for all teams. ` +
|
||
`Sync standings before running for accurate results.`
|
||
);
|
||
}
|
||
|
||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||
const standing = standingsMap.get(r.id);
|
||
const data = getTeamData(r.name);
|
||
|
||
// Conference and division: prefer standings table, fall back to TEAMS_DATA.
|
||
const rawConf = standing?.conference;
|
||
const conference: "Eastern" | "Western" =
|
||
rawConf === "Eastern" || rawConf === "Western"
|
||
? rawConf
|
||
: (data?.conference ?? "Eastern");
|
||
|
||
const division: string = standing?.division ?? data?.division ?? "";
|
||
|
||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||
const wins = standing?.wins ?? 0;
|
||
const otLosses = standing?.otLosses ?? 0;
|
||
const currentPoints = wins * 2 + otLosses;
|
||
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
|
||
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
|
||
|
||
return {
|
||
id: r.id,
|
||
name: r.name,
|
||
data,
|
||
conference,
|
||
division,
|
||
currentPoints,
|
||
remainingGames,
|
||
winProb: eloWinProbability(resolvedElo, 1500),
|
||
resolvedElo,
|
||
};
|
||
});
|
||
|
||
// Warn about participants that don't match any hardcoded team.
|
||
const unrecognized = teams.filter((t) => !t.data);
|
||
if (unrecognized.length > 0) {
|
||
logger.warn(
|
||
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will use fallback Elo 1400: ` +
|
||
unrecognized.map((t) => t.name).join(", ")
|
||
);
|
||
}
|
||
|
||
// Separate recognized teams by conference + division.
|
||
const easternAtlantic = teams.filter((t) => t.conference === "Eastern" && t.division === "Atlantic");
|
||
const easternMetro = teams.filter((t) => t.conference === "Eastern" && t.division === "Metropolitan");
|
||
const westernCentral = teams.filter((t) => t.conference === "Western" && t.division === "Central");
|
||
const westernPacific = teams.filter((t) => t.conference === "Western" && t.division === "Pacific");
|
||
|
||
if (
|
||
easternAtlantic.length < 3 ||
|
||
easternMetro.length < 3 ||
|
||
westernCentral.length < 3 ||
|
||
westernPacific.length < 3
|
||
) {
|
||
throw new Error(
|
||
`Each division needs at least 3 participants ` +
|
||
`(got Eastern/Atlantic: ${easternAtlantic.length}, Eastern/Metro: ${easternMetro.length}, ` +
|
||
`Western/Central: ${westernCentral.length}, Western/Pacific: ${westernPacific.length}). ` +
|
||
`Add all 32 NHL teams before running simulation.`
|
||
);
|
||
}
|
||
|
||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
||
|
||
const participantIdSet = new Set(participantIds);
|
||
const oddsRows = evRows.filter(
|
||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
||
);
|
||
const hasOdds = oddsRows.length > 0;
|
||
|
||
const normalizedOddsMap = new Map<string, number>();
|
||
if (hasOdds) {
|
||
const rawProbs = oddsRows.map((r) =>
|
||
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
|
||
);
|
||
const normalized = normalizeProbabilities(rawProbs);
|
||
oddsRows.forEach(({ participantId }, i) => {
|
||
normalizedOddsMap.set(participantId, normalized[i]);
|
||
});
|
||
}
|
||
|
||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||
|
||
/**
|
||
* Blended per-game win probability for team A over team B.
|
||
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
|
||
*/
|
||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
||
const eloProb = eloWinProbability(elo(a), elo(b));
|
||
if (!hasOdds) return eloProb;
|
||
|
||
const o1 = normalizedOddsMap.get(a.id) ?? 0;
|
||
const o2 = normalizedOddsMap.get(b.id) ?? 0;
|
||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
||
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
||
};
|
||
|
||
/** Simulate a best-of-7 series. Returns winner and loser. */
|
||
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
||
const winProb = gameWinProb(a, 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 };
|
||
};
|
||
|
||
/**
|
||
* Build the 8-team playoff bracket for one conference.
|
||
*
|
||
* For each sim iteration:
|
||
* 1. Simulate projected points for all teams in both divisions.
|
||
* 2. Top 3 per division (by projected pts + random tiebreaker) → division seeds.
|
||
* 3. Top 2 remaining conference teams → WC1 (more pts) and WC2 (fewer pts).
|
||
* 4. Division winner with more pts = 1st seed (faces WC2);
|
||
* other division winner = 2nd seed (faces WC1).
|
||
*
|
||
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed],
|
||
* or undefined if the conference doesn't have enough teams for a valid bracket.
|
||
*/
|
||
const buildConferenceBracket = (
|
||
divATeams: TeamEntry[],
|
||
divBTeams: TeamEntry[]
|
||
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
|
||
if (divATeams.length < 3 || divBTeams.length < 3) return undefined;
|
||
|
||
// Project all conference teams exactly once (single simulateProjectedPoints call per team).
|
||
const divASet = new Set(divATeams.map((t) => t.id));
|
||
const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
|
||
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
|
||
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
|
||
|
||
const divASeeds = divAProjected.slice(0, 3);
|
||
const divBSeeds = divBProjected.slice(0, 3);
|
||
|
||
// Wildcard pool: non-division-seed teams, already sorted by projected pts.
|
||
const divisionSeedIds = new Set([...divASeeds, ...divBSeeds].map((p) => p.team.id));
|
||
const wcPool = allProjected.filter((p) => !divisionSeedIds.has(p.team.id));
|
||
|
||
if (wcPool.length < 2) return undefined;
|
||
const [wc1, wc2] = wcPool; // wc1 has more pts → stronger wildcard
|
||
|
||
// Division winner with more projected points is the top seed (plays WC2).
|
||
const [topDiv, otherDiv] =
|
||
divASeeds[0].pts >= divBSeeds[0].pts
|
||
? [divASeeds, divBSeeds]
|
||
: [divBSeeds, divASeeds];
|
||
|
||
return [
|
||
[topDiv[0].team, wc2.team], // m0: 1st seed vs WC2
|
||
[otherDiv[0].team, wc1.team], // m1: 2nd seed vs WC1
|
||
[topDiv[1].team, topDiv[2].team], // m2: top div 2nd vs 3rd
|
||
[otherDiv[1].team, otherDiv[2].team], // m3: other div 2nd vs 3rd
|
||
];
|
||
};
|
||
|
||
// ─── 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 ───────────────────────────────────────────
|
||
|
||
let effectiveN = 0;
|
||
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
|
||
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
|
||
if (!eastBracket || !westBracket) continue;
|
||
effectiveN++;
|
||
|
||
// ── Eastern Conference ────────────────────────────────────────────────────
|
||
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
|
||
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
|
||
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
|
||
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);
|
||
|
||
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 at least 3 participants with standings data."
|
||
);
|
||
}
|
||
|
||
// ─── Convert counts to probability distributions ───────────────────────────
|
||
|
||
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 ────────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
|
||
// ── Mode: Bracket-Aware ───────────────────────────────────────────────────────
|
||
|
||
private async simulateBracket(
|
||
sportsSeasonId: string,
|
||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>
|
||
): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// Group matches by round, sorted by match count descending (R1 first → Final last).
|
||
const byRound = new Map<string, typeof allMatches>();
|
||
for (const m of allMatches) {
|
||
if (!byRound.has(m.round)) byRound.set(m.round, []);
|
||
byRound.get(m.round)?.push(m);
|
||
}
|
||
|
||
const sortedRoundMatches = [...byRound.values()]
|
||
.toSorted((a, b) => b.length - a.length)
|
||
.map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber));
|
||
|
||
if (sortedRoundMatches.length < 4) {
|
||
throw new Error(
|
||
`NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` +
|
||
`Check the bracket template.`
|
||
);
|
||
}
|
||
|
||
const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card)
|
||
const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional)
|
||
const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals)
|
||
const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup)
|
||
|
||
if (r1Matches.length !== 8) {
|
||
throw new Error(
|
||
`Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.`
|
||
);
|
||
}
|
||
|
||
// Validate all R1 matches have participants before entering the hot loop.
|
||
for (const m of r1Matches) {
|
||
if (!m.participant1Id || !m.participant2Id) {
|
||
throw new Error(
|
||
`Round 1 match ${m.matchNumber} is missing participants. ` +
|
||
`Seed all 16 teams into the bracket before running simulation.`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Load all participants and EV data 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,
|
||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.participantExpectedValues)
|
||
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||
]);
|
||
|
||
const allParticipantIds = participantRows.map((r) => r.id);
|
||
|
||
const dbEloMap = new Map<string, number>();
|
||
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<string, number>();
|
||
for (const r of participantRows) {
|
||
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
|
||
}
|
||
|
||
const participantIdSet = new Set(allParticipantIds);
|
||
const oddsRows = evRows.filter(
|
||
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
||
);
|
||
const hasOdds = oddsRows.length > 0;
|
||
|
||
const normalizedOddsMap = new Map<string, number>();
|
||
if (hasOdds) {
|
||
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
|
||
const normalized = normalizeProbabilities(rawProbs);
|
||
oddsRows.forEach(({ participantId }, i) => {
|
||
normalizedOddsMap.set(participantId, normalized[i]);
|
||
});
|
||
}
|
||
|
||
// Per-round lookup maps for O(1) access.
|
||
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
|
||
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
|
||
const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m]));
|
||
const finalMatch = finalMatches[0];
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
const gameWinProb = (aId: string, bId: string): number => {
|
||
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
|
||
if (!hasOdds) return eloProb;
|
||
const o1 = normalizedOddsMap.get(aId) ?? 0;
|
||
const o2 = normalizedOddsMap.get(bId) ?? 0;
|
||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
||
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
||
};
|
||
|
||
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
|
||
const winProb = gameWinProb(aId, bId);
|
||
let wA = 0;
|
||
let wB = 0;
|
||
while (wA < 4 && wB < 4) {
|
||
if (Math.random() < winProb) wA++; else wB++;
|
||
}
|
||
return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId };
|
||
};
|
||
|
||
const resolveSeries = (
|
||
match: (typeof allMatches)[0] | undefined,
|
||
p1Fallback?: string,
|
||
p2Fallback?: string
|
||
): { winner: string; loser: string } => {
|
||
if (match?.isComplete && match.winnerId && match.loserId) {
|
||
return { winner: match.winnerId, loser: match.loserId };
|
||
}
|
||
const p1 = match?.participant1Id ?? p1Fallback;
|
||
const p2 = match?.participant2Id ?? p2Fallback;
|
||
if (!p1 || !p2) {
|
||
throw new Error(
|
||
`Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` +
|
||
`missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).`
|
||
);
|
||
}
|
||
return simSeries(p1, p2);
|
||
};
|
||
|
||
// ── Placement count maps ──────────────────────────────────────────────────────
|
||
|
||
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const r3LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const r2LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
|
||
// ── Monte Carlo simulation loop ───────────────────────────────────────────────
|
||
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
// Round 1: R1 losers score 0 points — not tracked.
|
||
const r1Winners: string[] = [];
|
||
for (let i = 1; i <= 8; i++) {
|
||
const { winner } = resolveSeries(r1ByNum.get(i));
|
||
r1Winners.push(winner);
|
||
}
|
||
|
||
// Round 2 (Quarterfinals / Divisional): losers → 5th–8th.
|
||
const r2Winners: string[] = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const { winner, loser } = resolveSeries(
|
||
r2ByNum.get(i),
|
||
r1Winners[(i - 1) * 2],
|
||
r1Winners[(i - 1) * 2 + 1]
|
||
);
|
||
r2Winners.push(winner);
|
||
r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// Round 3 (Semifinals / Conference Finals): losers → 3rd–4th.
|
||
const r3Winners: string[] = [];
|
||
for (let i = 1; i <= 2; i++) {
|
||
const { winner, loser } = resolveSeries(
|
||
r3ByNum.get(i),
|
||
r2Winners[(i - 1) * 2],
|
||
r2Winners[(i - 1) * 2 + 1]
|
||
);
|
||
r3Winners.push(winner);
|
||
r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// Final (Stanley Cup): winner → 1st, loser → 2nd.
|
||
const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]);
|
||
championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1);
|
||
finalistCounts.set(loser, (finalistCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// ── Convert counts to probability distributions ───────────────────────────────
|
||
|
||
const N = NUM_SIMULATIONS;
|
||
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
|
||
const c = championCounts.get(participantId) ?? 0;
|
||
const f = finalistCounts.get(participantId) ?? 0;
|
||
const r3 = r3LoserCounts.get(participantId) ?? 0;
|
||
const r2 = r2LoserCounts.get(participantId) ?? 0;
|
||
return {
|
||
participantId,
|
||
probabilities: {
|
||
probFirst: c / N,
|
||
probSecond: f / N,
|
||
probThird: r3 / (2 * N),
|
||
probFourth: r3 / (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.
|
||
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;
|
||
}
|
||
}
|