/** * NCAA Men's College Hockey Tournament Simulator * * Supports two modes with admin-editable inputs only: * 1. Pre-bracket mode: samples a 16-team tournament field from all season * participants using futures odds, NPI rank, and/or Elo entered in Admin. * 2. Bracket-aware mode: when a 16-team playoff bracket exists, simulates the * actual bracket and honors completed match results. * * No team ratings are hardcoded. Strength comes from season_participant_expected_values: * - sourceElo: optional direct Elo / admin power rating * - worldRanking: optional NPI rank (1 = best) * - sourceOdds: optional American championship futures odds */ import { database } from "~/database/context"; import { and, eq, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertAmericanOddsToProbability, convertFuturesToElo, eloWinProbabilityWithParity, } from "~/services/probability-engine"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; const DEFAULT_NUM_SIMULATIONS = 50_000; const BRACKET_SIZE = 16; const HOCKEY_PARITY_FACTOR = 850; const DEFAULT_ELO = 1500; const RANK_ELO_SCALE = 125; const RANK_ZIPF_EXPONENT = 1.1; const ODDS_ELO_WEIGHT = 0.25; const DIRECT_ELO_WEIGHT = 1 - ODDS_ELO_WEIGHT; const COLLEGE_HOCKEY_TEMPLATE_ID = "college_hockey_16"; const ROUND_OF_16_NAMES = new Set(["Round of 16", "Regional Semifinals"]); const QUARTERFINAL_NAMES = new Set(["Quarterfinals", "Regional Finals"]); const SEMIFINAL_NAMES = new Set(["Semifinals", "Frozen Four Semifinals"]); const FINAL_NAMES = new Set(["Finals", "National Championship"]); interface Team { participantId: string; elo: number; seedScore: number; oddsProb: number; selectionWeight: number; } interface EvInputRow { participantId: string; sourceElo: number | null; sourceOdds: number | null; worldRanking: number | null; } interface PlacementCounts { champion: number; finalist: number; semifinalLoser: number; quarterfinalLoser: number; } type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; export function npiRankToElo(rank: number, fieldSize: number): number { if (!Number.isFinite(rank) || rank <= 0) return DEFAULT_ELO; const safeFieldSize = Math.max(fieldSize, BRACKET_SIZE); return Math.round(DEFAULT_ELO + RANK_ELO_SCALE * Math.log(safeFieldSize / rank)); } export function npiRankSelectionWeight(rank: number | null): number { if (!rank || rank <= 0) return 0; return 1 / Math.pow(rank, RANK_ZIPF_EXPONENT); } function normalizeOdds(evRows: EvInputRow[]): Map { const raw = new Map(); for (const row of evRows) { if (row.sourceOdds !== null) { raw.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds)); } } const sum = [...raw.values()].reduce((acc, p) => acc + p, 0); const normalized = new Map(); if (sum <= 0) return normalized; for (const [id, prob] of raw) normalized.set(id, prob / sum); return normalized; } function buildOddsEloMap(evRows: EvInputRow[]): Map { const oddsInput = evRows .filter((row) => row.sourceOdds !== null) .map((row) => ({ participantId: row.participantId, odds: row.sourceOdds ?? 0 })); if (oddsInput.length < 2) return new Map(); return convertFuturesToElo(oddsInput, "american", { exponent: 0.33, eloMin: 1325, eloMax: 1725, }); } export function buildCollegeHockeyTeams( participantIds: string[], evRows: EvInputRow[] ): Team[] { const evById = new Map(evRows.map((row) => [row.participantId, row])); const normalizedOdds = normalizeOdds(evRows); const oddsEloMap = buildOddsEloMap(evRows); const hasOdds = normalizedOdds.size > 0; const hasAnyRank = evRows.some((row) => row.worldRanking !== null); const hasAnyDirectElo = evRows.some((row) => row.sourceElo !== null); const teams: Team[] = participantIds.map((participantId) => { const ev = evById.get(participantId); const rankElo = ev?.worldRanking ? npiRankToElo(ev.worldRanking, participantIds.length) : null; const baseElo = ev?.sourceElo ?? rankElo ?? oddsEloMap.get(participantId) ?? DEFAULT_ELO; const oddsElo = oddsEloMap.get(participantId); const elo = oddsElo !== undefined && ((ev?.sourceElo !== null && ev?.sourceElo !== undefined) || rankElo !== null) ? Math.round(DIRECT_ELO_WEIGHT * baseElo + ODDS_ELO_WEIGHT * oddsElo) : baseElo; return { participantId, elo, seedScore: rankElo ?? ev?.sourceElo ?? elo, oddsProb: normalizedOdds.get(participantId) ?? 0, selectionWeight: 0, }; }); const maxElo = Math.max(...teams.map((team) => team.elo)); const fallbackWeights = new Map(); for (const team of teams) { const rank = evById.get(team.participantId)?.worldRanking ?? null; const fallbackWeight = hasAnyRank ? npiRankSelectionWeight(rank) : hasAnyDirectElo ? Math.exp((team.elo - maxElo) / 140) : 1; fallbackWeights.set(team.participantId, fallbackWeight > 0 ? fallbackWeight : 0.01); } if (hasOdds) { const fallbackTotal = [...fallbackWeights.values()].reduce((sum, weight) => sum + weight, 0); for (const team of teams) { const fallbackShare = (fallbackWeights.get(team.participantId) ?? 0.01) / fallbackTotal; team.selectionWeight = team.oddsProb + 0.1 * fallbackShare; } } else { for (const team of teams) team.selectionWeight = fallbackWeights.get(team.participantId) ?? 1; } return teams; } function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } { const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor); return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 }; } function bump(counts: Map, id: string, key: keyof PlacementCounts): void { const entry = counts.get(id); if (entry) entry[key]++; } function sampleField(pool: Team[], n: number): Team[] { const remaining = [...pool]; const selected: Team[] = []; for (let i = 0; i < n; i++) { const totalWeight = remaining.reduce((sum, team) => sum + team.selectionWeight, 0); let r = Math.random() * totalWeight; let index = 0; for (; index < remaining.length - 1; index++) { r -= remaining[index].selectionWeight; if (r <= 0) break; } selected.push(remaining[index]); remaining.splice(index, 1); } return seedField(selected); } function seedField(teams: Team[]): Team[] { return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo); } function simulateSeeded16TeamBracket(teams: Team[], counts: Map, parityFactor = HOCKEY_PARITY_FACTOR): void { const r16Pairs: Array<[number, number]> = [ [0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14], ]; const game = (a: Team, b: Team) => simGame(a, b, parityFactor); const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner); const qfWinners: Team[] = []; for (let i = 0; i < 4; i++) { const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]); qfWinners.push(result.winner); bump(counts, result.loser.participantId, "quarterfinalLoser"); } const sf1 = game(qfWinners[0], qfWinners[1]); const sf2 = game(qfWinners[2], qfWinners[3]); bump(counts, sf1.loser.participantId, "semifinalLoser"); bump(counts, sf2.loser.participantId, "semifinalLoser"); const final = game(sf1.winner, sf2.winner); bump(counts, final.winner.participantId, "champion"); bump(counts, final.loser.participantId, "finalist"); } function findRound(matches: PlayoffMatch[], names: Set): PlayoffMatch[] { return matches .filter((match) => names.has(match.round) || (match.templateRound ? names.has(match.templateRound) : false)) .toSorted((a, b) => a.matchNumber - b.matchNumber); } function completedLoser(match: PlayoffMatch): string { if (match.loserId) return match.loserId; if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; throw new Error(`Completed ${match.round} match ${match.matchNumber} is missing a loser.`); } function getTeam(teamById: Map, participantId: string): Team { const team = teamById.get(participantId); if (!team) throw new Error(`Participant ${participantId} is missing a saved rating row.`); return team; } function simulateMatchFromIds( p1: string, p2: string, match: PlayoffMatch | undefined, teamById: Map, parityFactor = HOCKEY_PARITY_FACTOR ): { winner: Team; loser: Team } { if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) }; } return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor); } function simulateExistingBracket( rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }, teamById: Map, counts: Map, parityFactor = HOCKEY_PARITY_FACTOR ): void { const r16Winners: Team[] = []; for (let i = 0; i < 8; i++) { const match = rounds.r16[i]; if (!match.participant1Id || !match.participant2Id) { throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`); } r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner); } const qfWinners: Team[] = []; for (let i = 0; i < 4; i++) { const match = rounds.qf[i]; const p1 = r16Winners[i * 2]?.participantId; const p2 = r16Winners[i * 2 + 1]?.participantId; if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor); qfWinners.push(result.winner); bump(counts, result.loser.participantId, "quarterfinalLoser"); } const sfWinners: Team[] = []; for (let i = 0; i < 2; i++) { const match = rounds.sf[i]; const p1 = qfWinners[i * 2]?.participantId; const p2 = qfWinners[i * 2 + 1]?.participantId; if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`); const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor); sfWinners.push(result.winner); bump(counts, result.loser.participantId, "semifinalLoser"); } const p1 = sfWinners[0]?.participantId; const p2 = sfWinners[1]?.participantId; if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`); const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor); bump(counts, final.winner.participantId, "champion"); bump(counts, final.loser.participantId, "finalist"); } function toResults(participantIds: string[], counts: Map, numSimulations: number): SimulationResult[] { const results = participantIds.map((participantId) => { const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }; const sf = c.semifinalLoser / (2 * numSimulations); const qf = c.quarterfinalLoser / (4 * numSimulations); return { participantId, probabilities: { probFirst: c.champion / numSimulations, probSecond: c.finalist / numSimulations, probThird: sf, probFourth: sf, probFifth: qf, probSixth: qf, probSeventh: qf, probEighth: qf, }, source: "college_hockey_monte_carlo", }; }); for (const key of ["probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth"] as const) { const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0); const residual = 1 - colSum; if (Math.abs(residual) > 0 && Math.abs(residual) < 1e-9) { const maxResult = results.reduce((best, result) => result.probabilities[key] > best.probabilities[key] ? result : best ); maxResult.probabilities[key] += residual; } } return results; } export class CollegeHockeySimulator implements Simulator { async simulate(sportsSeasonId: string, config: Record = {}): Promise { const db = database(); const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR); const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); const bracketMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (m, { asc }) => [asc(m.matchNumber)], }) : []; const existingBracket = this.getExistingBracketRounds(bracketMatches); const participantIds = existingBracket ? this.getParticipantIdsFromBracket(existingBracket) : (await db .select({ id: schema.seasonParticipants.id }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId))).map((row) => row.id); if (participantIds.length < BRACKET_SIZE) { throw new Error( `College hockey simulator requires at least ${BRACKET_SIZE} participants, found ${participantIds.length}.` ); } const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, worldRanking: schema.seasonParticipantExpectedValues.worldRanking, }) .from(schema.seasonParticipantExpectedValues) .where(inArray(schema.seasonParticipantExpectedValues.participantId, participantIds)); const teams = buildCollegeHockeyTeams(participantIds, evRows); const teamById = new Map(teams.map((team) => [team.participantId, team])); const counts = new Map( participantIds.map((id) => [id, { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }]) ); if (existingBracket) { for (let i = 0; i < numSimulations; i++) { simulateExistingBracket(existingBracket, teamById, counts, parityFactor); } } else { const preBracketMode = participantIds.length > BRACKET_SIZE; const deterministicField = preBracketMode ? null : seedField(teams); for (let i = 0; i < numSimulations; i++) { const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? []; simulateSeeded16TeamBracket(field, counts, parityFactor); } } return toResults(participantIds, counts, numSimulations); } private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null { if (matches.length === 0) return null; const r16 = findRound(matches, ROUND_OF_16_NAMES); const qf = findRound(matches, QUARTERFINAL_NAMES); const sf = findRound(matches, SEMIFINAL_NAMES); const final = findRound(matches, FINAL_NAMES); if (r16.length === 0 && qf.length === 0 && sf.length === 0 && final.length === 0) return null; if (r16.length !== 8 || qf.length !== 4 || sf.length !== 2 || final.length !== 1) { throw new Error( `College hockey bracket must have 8 Round of 16, 4 Quarterfinal, 2 Semifinal, and 1 Final match. ` + `Found ${r16.length}/${qf.length}/${sf.length}/${final.length}.` ); } return { r16, qf, sf, final: final[0] }; } private getParticipantIdsFromBracket(rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }): string[] { const ids = new Set(); const allMatches = [...rounds.r16, ...rounds.qf, ...rounds.sf, rounds.final]; for (const match of allMatches) { if (match.participant1Id) ids.add(match.participant1Id); if (match.participant2Id) ids.add(match.participant2Id); if (match.winnerId) ids.add(match.winnerId); if (match.loserId) ids.add(match.loserId); } return [...ids]; } } export { COLLEGE_HOCKEY_TEMPLATE_ID };