/** * NLL Season + Playoffs Simulator * * Monte Carlo simulation of the NLL regular season and playoffs. * * Three modes, auto-detected at runtime: * * ── Mode 1: Bracket-Aware ───────────────────────────────────────────────────── * Used when a playoff_game scoring event with generated matches exists. * Reads completed QF/SF/Finals results; simulates remaining games and series. * Best-of-3 series respect completed playoff_match_games rows. * * ── Mode 2: Known-Seed ──────────────────────────────────────────────────────── * Used when no bracket exists but all 8 seeds 1-8 are set via the seed input. * Simulates the playoff bracket directly from those seeds. * * ── Mode 3: Regular-Season Projection (default) ─────────────────────────────── * Simulates remaining regular season games per team using Elo vs average (1500), * blending with projectedWins when provided. Top 8 teams qualify. Then simulates * the NLL playoff bracket. * * NLL playoff bracket format: * Quarterfinals (single game): 1v8, 2v7, 3v6, 4v5 * Semifinal arms: (1/8 winner vs 4/5 winner), (2/7 winner vs 3/6 winner) * Semifinals (best-of-3) and Finals (best-of-3) * * Probability mapping: * probFirst = NLL champion (1 per sim) * probSecond = Finals loser (1 per sim) * probThird/Fourth = Semifinal losers (2 per sim — split evenly) * probFifth–Eighth = Quarterfinal losers (4 per sim — split evenly) * Missed playoffs → all 0 * * Round name constants (ROUND_*) must match the bracket template used to * generate playoff matches. If the template changes round names, update these. */ import { database } from "~/database/context"; import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { getParticipantSimulatorInputs } from "~/models/simulator"; import { logger } from "~/lib/logger"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; const NLL_REGULAR_SEASON_GAMES = 18; const NLL_PLAYOFF_TEAMS = 8; const PARITY_FACTOR = 400; const AVERAGE_OPPONENT_ELO = 1500; /** Maximum preseason weight given to projectedWins in the blend. Decays to 0 by season end. */ const PROJECTED_WINS_WEIGHT = 0.65; /** Fallback Elo used when a bracket participant has no sourceElo on record. */ const FALLBACK_ELO = 1400; // ─── Round name constants ───────────────────────────────────────────────────── // These must match the round strings generated by the nll_bracket bracket template. export const ROUND_QUARTERFINALS = "Quarterfinals" as const; export const ROUND_SEMIFINALS = "Semifinals" as const; export const ROUND_FINALS = "Finals" as const; // ─── Structural types used by resolve helpers ───────────────────────────────── /** Minimal match fields needed by the resolve helpers. */ export interface MatchSnapshot { id: string; isComplete: boolean; winnerId: string | null; loserId: string | null; participant1Id: string | null; participant2Id: string | null; } /** Minimal game result needed by the resolve helpers. */ export interface GameSnapshot { winnerId: string | null; } // ─── Public helpers (exported for testability) ──────────────────────────────── export function nllGameWinProbability( eloA: number, eloB: number, parityFactor = PARITY_FACTOR ): number { return eloWinProbabilityWithParity(eloA, eloB, parityFactor); } export interface SimTeam { participantId: string; elo: number; seed?: number; } export function simulateNllGame( teamA: SimTeam, teamB: SimTeam, parityFactor = PARITY_FACTOR ): { winner: SimTeam; loser: SimTeam } { const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor); if (Math.random() < pA) return { winner: teamA, loser: teamB }; return { winner: teamB, loser: teamA }; } export interface BestOfThreeState { winsA: number; winsB: number; } /** Simulate a best-of-3 series from an optional existing state. First to 2 wins. */ export function simulateBestOfThree( teamA: SimTeam, teamB: SimTeam, parityFactor = PARITY_FACTOR, existing: BestOfThreeState = { winsA: 0, winsB: 0 } ): { winner: SimTeam; loser: SimTeam; gamesPlayed: number } { let wA = existing.winsA; let wB = existing.winsB; let gamesPlayed = 0; const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor); while (wA < 2 && wB < 2) { if (Math.random() < pA) wA++; else wB++; gamesPlayed++; } return wA === 2 ? { winner: teamA, loser: teamB, gamesPlayed } : { winner: teamB, loser: teamA, gamesPlayed }; } export interface SeedEntry { participantId: string; elo: number; seed: number; } /** * Build the 4 NLL Quarterfinal matchups from seeds 1-8. * Bracket arms are fixed: * QF1: 1 vs 8 → SF arm A * QF2: 4 vs 5 → SF arm A * QF3: 2 vs 7 → SF arm B * QF4: 3 vs 6 → SF arm B */ export function buildNllBracketFromSeeds(seeds: SeedEntry[]): [ [SeedEntry, SeedEntry], // QF1: 1 vs 8 [SeedEntry, SeedEntry], // QF2: 4 vs 5 [SeedEntry, SeedEntry], // QF3: 2 vs 7 [SeedEntry, SeedEntry], // QF4: 3 vs 6 ] { const bySeeds = new Map(seeds.map((s) => [s.seed, s])); const s = (n: number) => { const entry = bySeeds.get(n); if (!entry) throw new Error(`Seed ${n} not found in bracket seeds.`); return entry; }; return [ [s(1), s(8)], [s(4), s(5)], [s(2), s(7)], [s(3), s(6)], ]; } export interface NllPlayoffResult { champion: string; finalist: string; sfLosers: [string, string]; qfLosers: [string, string, string, string]; } /** Simulate the full NLL playoff bracket from 8 seeded teams. */ export function simulateNllPlayoffs( seeds: SeedEntry[], parityFactor = PARITY_FACTOR ): NllPlayoffResult { const [[qf1a, qf1b], [qf2a, qf2b], [qf3a, qf3b], [qf4a, qf4b]] = buildNllBracketFromSeeds(seeds); const qf1 = simulateNllGame(qf1a, qf1b, parityFactor); const qf2 = simulateNllGame(qf2a, qf2b, parityFactor); const qf3 = simulateNllGame(qf3a, qf3b, parityFactor); const qf4 = simulateNllGame(qf4a, qf4b, parityFactor); // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5) const sfA = simulateBestOfThree(qf1.winner, qf2.winner, parityFactor); // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6) const sfB = simulateBestOfThree(qf3.winner, qf4.winner, parityFactor); const final = simulateBestOfThree(sfA.winner, sfB.winner, parityFactor); return { champion: final.winner.participantId, finalist: final.loser.participantId, sfLosers: [sfA.loser.participantId, sfB.loser.participantId], qfLosers: [ qf1.loser.participantId, qf2.loser.participantId, qf3.loser.participantId, qf4.loser.participantId, ], }; } // ─── Bracket-aware resolve helpers (exported for testability) ───────────────── /** * Resolve a single-game QF match. * Returns the stored result deterministically when the match is complete; * simulates a single game otherwise. */ export function resolveQfMatch( match: MatchSnapshot | undefined, eloMap: Map ): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId }; } const p1 = match?.participant1Id; const p2 = match?.participant2Id; if (!p1 || !p2) { throw new Error( `NLL QF match ${match?.id ?? "(undefined)"} is missing participant slots. ` + `Ensure the bracket is fully generated before simulating.` ); } const result = simulateNllGame( { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO } ); return { winner: result.winner.participantId, loser: result.loser.participantId }; } /** * Resolve a best-of-3 SF or Finals match. * Returns the stored result deterministically when the match is complete. * Counts completed game rows (GameSnapshot[]) for partial series state; if a * team already has 2 wins in the game rows, treats the series as done even if * playoffMatches.isComplete has not been toggled yet. * Simulates remaining games from the current series score otherwise. */ export function resolveBo3Match( match: MatchSnapshot | undefined, games: GameSnapshot[], eloMap: Map, p1Override?: string, p2Override?: string ): { winner: string; loser: string } { if (match?.isComplete && match.winnerId && match.loserId) { return { winner: match.winnerId, loser: match.loserId }; } const p1 = match?.participant1Id ?? p1Override; const p2 = match?.participant2Id ?? p2Override; if (!p1 || !p2) { throw new Error( `NLL series match ${match?.id ?? "(undefined)"} is missing participant slots. ` + `Ensure the bracket is fully generated before simulating.` ); } let winsP1 = 0; let winsP2 = 0; for (const g of games) { if (g.winnerId === null) continue; if (g.winnerId === p1) winsP1++; else if (g.winnerId === p2) winsP2++; } // A team with 2 game wins has clinched the series. if (winsP1 >= 2) return { winner: p1, loser: p2 }; if (winsP2 >= 2) return { winner: p2, loser: p1 }; const { winner, loser } = simulateBestOfThree( { participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO }, { participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }, PARITY_FACTOR, { winsA: winsP1, winsB: winsP2 } ); return { winner: winner.participantId, loser: loser.participantId }; } // ─── Regular-season projection helper ──────────────────────────────────────── export interface TeamProjection { participantId: string; elo: number; currentWins: number; gamesPlayed: number; projectedWins: number | null; } /** Simulate remaining regular season games and return the top-8 seeds. */ export function simulateRegularSeasonSeeds( teams: TeamProjection[], parityFactor = PARITY_FACTOR ): SeedEntry[] { // Pre-compute tiebreaker jitter once per simulation for sort stability. const jitter = new Map(teams.map((t) => [t.participantId, Math.random()])); const projected = teams.map((t) => { const remaining = Math.max(0, NLL_REGULAR_SEASON_GAMES - t.gamesPlayed); const eloRate = eloWinProbabilityWithParity(t.elo, AVERAGE_OPPONENT_ELO, parityFactor); // Decay the preseason projectedWins prior linearly as the season progresses. // At gamesPlayed=0 it carries PROJECTED_WINS_WEIGHT (65%); by gamesPlayed=18 // it carries 0% so late-season projections rely purely on Elo. This prevents // a stale preseason estimate from dominating when actual standings are available. // // The prior rate is computed over *remaining* games (not the full season) so // that wins already accumulated are subtracted from the preseason expectation. // If currentWins already meets or exceeds the projection, the prior is clamped // to 0 and Elo takes over fully. const completionFraction = t.gamesPlayed / NLL_REGULAR_SEASON_GAMES; const projectedBlend = t.projectedWins !== null ? PROJECTED_WINS_WEIGHT * (1 - completionFraction) : 0; const priorRate = t.projectedWins !== null && remaining > 0 ? Math.min(1, Math.max(0, t.projectedWins - t.currentWins) / remaining) : 0; const winRate = projectedBlend > 0 && priorRate > 0 ? projectedBlend * priorRate + (1 - projectedBlend) * eloRate : eloRate; let additionalWins = 0; for (let g = 0; g < remaining; g++) { if (Math.random() < winRate) additionalWins++; } return { participantId: t.participantId, elo: t.elo, finalWins: t.currentWins + additionalWins, }; }); const sorted = projected.toSorted( (a, b) => b.finalWins - a.finalWins || (jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0) ); return sorted.slice(0, NLL_PLAYOFF_TEAMS).map((t, i) => ({ participantId: t.participantId, elo: t.elo, seed: i + 1, })); } // ─── Simulator class ────────────────────────────────────────────────────────── export class NLLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // ── Load participants ───────────────────────────────────────────────────── const participants = await db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId), }); if (participants.length === 0) { throw new Error(`No participants found for sports season ${sportsSeasonId}.`); } // ── Load Elo ratings from EV table (populated by prepareSimulatorInputsForRun) ── const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); const eloMap = new Map(); for (const r of evRows) { if (r.sourceElo !== null && r.sourceElo !== undefined) { eloMap.set(r.participantId, r.sourceElo); } } if (eloMap.size === 0) { throw new Error( `No Elo ratings found for sports season ${sportsSeasonId}. ` + `Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.` ); } // ── Load simulator inputs (projectedWins + seed) ────────────────────────── const simInputs = await getParticipantSimulatorInputs(sportsSeasonId); const projectedWinsMap = new Map(); const seedMap = new Map(); for (const input of simInputs) { projectedWinsMap.set(input.participantId, input.projectedWins); if (input.seed !== null && input.seed !== undefined) { seedMap.set(input.participantId, input.seed); } } // ── All participant IDs for counting ───────────────────────────────────── const allIds = participants.map((p) => p.id); // ── Mode detection ──────────────────────────────────────────────────────── // Mode 1: bracket-aware — a playoff_game scoring event with matches exists. const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); if (bracketEvent) { const bracketMatches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (m, { asc }) => [asc(m.matchNumber)], }); if (bracketMatches.length > 0) { return this.simulateBracketAware(allIds, eloMap, bracketMatches); } } // Mode 2: known-seed — all 8 seeds 1-8 are explicitly set. const knownSeeds: SeedEntry[] = []; for (const p of participants) { const elo = eloMap.get(p.id); const seed = seedMap.get(p.id); if (elo !== undefined && seed !== undefined && seed >= 1 && seed <= 8) { knownSeeds.push({ participantId: p.id, elo, seed }); } } if ( knownSeeds.length === NLL_PLAYOFF_TEAMS && new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS ) { return this.simulateKnownSeeds(allIds, knownSeeds); } // Mode 3: regular-season projection (default, including preseason). const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db); const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r])); const teams: TeamProjection[] = []; for (const p of participants) { const elo = eloMap.get(p.id); if (elo === undefined) continue; const standing = standingsMap.get(p.id); teams.push({ participantId: p.id, elo, currentWins: standing?.wins ?? 0, gamesPlayed: standing?.gamesPlayed ?? 0, projectedWins: projectedWinsMap.get(p.id) ?? null, }); } if (teams.length === 0) { throw new Error( `No participants with Elo ratings found for season ${sportsSeasonId}.` ); } return this.simulateRegularSeason(allIds, teams); } // ── Mode 1: Bracket-Aware ───────────────────────────────────────────────── private async simulateBracketAware( allIds: string[], eloMap: Map, allMatches: typeof schema.playoffMatches.$inferSelect[] ): Promise { const db = database(); const qfMatches = allMatches .filter((m) => m.round === ROUND_QUARTERFINALS) .toSorted((a, b) => a.matchNumber - b.matchNumber); const sfMatches = allMatches .filter((m) => m.round === ROUND_SEMIFINALS) .toSorted((a, b) => a.matchNumber - b.matchNumber); const finalMatches = allMatches.filter((m) => m.round === ROUND_FINALS); if (qfMatches.length !== 4 || sfMatches.length !== 2 || finalMatches.length !== 1) { throw new Error( `NLL bracket has unexpected structure. Expected QF×4, SF×2, Finals×1. ` + `Got QF×${qfMatches.length}, SF×${sfMatches.length}, Finals×${finalMatches.length}. ` + `Ensure the bracket was generated with the nll_bracket template.` ); } // Warn about any bracket participants without Elo ratings before the hot loop. const bracketParticipantIds = new Set(); for (const m of allMatches) { if (m.participant1Id) bracketParticipantIds.add(m.participant1Id); if (m.participant2Id) bracketParticipantIds.add(m.participant2Id); if (m.winnerId) bracketParticipantIds.add(m.winnerId); if (m.loserId) bracketParticipantIds.add(m.loserId); } for (const id of bracketParticipantIds) { if (!eloMap.has(id)) { logger.warn( `NLL bracket simulator: no Elo rating for participant ${id}. ` + `Using fallback Elo ${FALLBACK_ELO}. Enter sourceElo via Admin → Elo Ratings.` ); } } // Load playoff_match_games for best-of-3 series state. const sfAndFinalIds = [...sfMatches, ...finalMatches].map((m) => m.id); const allGames = sfAndFinalIds.length > 0 ? await db.query.playoffMatchGames.findMany({ where: (g, { inArray }) => inArray(g.playoffMatchId, sfAndFinalIds), }) : []; const gamesByMatch = new Map(); for (const game of allGames) { const list = gamesByMatch.get(game.playoffMatchId) ?? []; list.push(game); gamesByMatch.set(game.playoffMatchId, list); } const championCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); const [qf1, qf2, qf3, qf4] = qfMatches; const [sf1, sf2] = sfMatches; const finalMatch = finalMatches[0]; for (let s = 0; s < NUM_SIMULATIONS; s++) { const r_qf1 = resolveQfMatch(qf1, eloMap); const r_qf2 = resolveQfMatch(qf2, eloMap); const r_qf3 = resolveQfMatch(qf3, eloMap); const r_qf4 = resolveQfMatch(qf4, eloMap); // SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm) const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner); // SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm) const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner); const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner); championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1); finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1); sfLoserCounts.set(r_sf1.loser, (sfLoserCounts.get(r_sf1.loser) ?? 0) + 1); sfLoserCounts.set(r_sf2.loser, (sfLoserCounts.get(r_sf2.loser) ?? 0) + 1); qfLoserCounts.set(r_qf1.loser, (qfLoserCounts.get(r_qf1.loser) ?? 0) + 1); qfLoserCounts.set(r_qf2.loser, (qfLoserCounts.get(r_qf2.loser) ?? 0) + 1); qfLoserCounts.set(r_qf3.loser, (qfLoserCounts.get(r_qf3.loser) ?? 0) + 1); qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1); } return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); } // ── Mode 2: Known-Seed ──────────────────────────────────────────────────── private simulateKnownSeeds( allIds: string[], seeds: SeedEntry[] ): SimulationResult[] { const championCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); for (let s = 0; s < NUM_SIMULATIONS; s++) { const result = simulateNllPlayoffs(seeds); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); for (const id of result.sfLosers) { sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1); } for (const id of result.qfLosers) { qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1); } } return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); } // ── Mode 3: Regular-Season Projection ──────────────────────────────────── private simulateRegularSeason( allIds: string[], teams: TeamProjection[] ): SimulationResult[] { const championCounts = new Map(allIds.map((id) => [id, 0])); const finalistCounts = new Map(allIds.map((id) => [id, 0])); const sfLoserCounts = new Map(allIds.map((id) => [id, 0])); const qfLoserCounts = new Map(allIds.map((id) => [id, 0])); for (let s = 0; s < NUM_SIMULATIONS; s++) { const seeds = simulateRegularSeasonSeeds(teams); const result = simulateNllPlayoffs(seeds); championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1); finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1); for (const id of result.sfLosers) { sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1); } for (const id of result.qfLosers) { qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1); } } return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts); } // ── Shared result builder ───────────────────────────────────────────────── private buildResults( allIds: string[], championCounts: Map, finalistCounts: Map, sfLoserCounts: Map, qfLoserCounts: Map ): SimulationResult[] { const N = NUM_SIMULATIONS; return allIds.map((id) => ({ participantId: id, probabilities: { probFirst: (championCounts.get(id) ?? 0) / N, probSecond: (finalistCounts.get(id) ?? 0) / N, // 2 SF losers per sim — split evenly across 3rd/4th probThird: (sfLoserCounts.get(id) ?? 0) / N / 2, probFourth: (sfLoserCounts.get(id) ?? 0) / N / 2, // 4 QF losers per sim — split evenly across 5th–8th probFifth: (qfLoserCounts.get(id) ?? 0) / N / 4, probSixth: (qfLoserCounts.get(id) ?? 0) / N / 4, probSeventh: (qfLoserCounts.get(id) ?? 0) / N / 4, probEighth: (qfLoserCounts.get(id) ?? 0) / N / 4, }, source: "nll_bracket_monte_carlo", })); } }