/** * NFL Season + Playoffs Simulator * * Monte Carlo simulation of the NFL regular season and playoffs. * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load Elo ratings from participantExpectedValues.sourceElo (user-maintained) * Falls back to sourceOdds → convertFuturesToElo() if no sourceElo set. * 3. Validate that all 8 NFL divisions have at least one Elo-rated team. * 4. Load current regular season standings (wins, gamesPlayed, conference, division) * If no standings in DB → pre-season mode (all teams start 0-0). * 5. For each simulation: * a. Simulate remaining games (17 total) for each team using Elo vs average (1500) * → projectedWins = currentWins + Binomial(remainingGames, winProb) * b. Per conference (AFC, NFC): * - Division winners: best projectedWins in each of 4 divisions (random tiebreak) * - Wildcards: top 3 non-division-winners by projectedWins * - Seed 1: best division winner (gets bye) * - Seeds 2-4: remaining division winners, sorted by projectedWins desc * - Seeds 5-7: wildcards, sorted by projectedWins desc * c. Simulate Wild Card (seed 1 has bye): 2v7, 3v6, 4v5 per conference * Higher seed has home-field advantage (+48 Elo ≈ 57% baseline win rate). * d. Simulate Divisional: seed 1 vs lowest remaining, seed 2 vs next * (higher seed is home) * e. Simulate Conference Championships (higher remaining seed is home) * f. Simulate Super Bowl at a neutral site (no home-field adjustment) * 6. Track placements: * - probFirst / probSecond: Super Bowl winner / loser * - probThird / probFourth: Conference Championship losers (1 per conf, split evenly) * - probFifth–probEighth: Divisional losers (4 total, split evenly) * - Wild Card losers and missed playoffs → 0 * * Elo ratings are maintained by the admin via the sourceElo field on * participantExpectedValues. Update before each simulation run. * Source: nfelo.app (nfelo ratings system) * * Home-field advantage: * NFL playoff home field is worth ~+48 Elo points (≈57% baseline win rate for * the higher seed). This applies to Wild Card, Divisional, and Conference * Championship rounds. The Super Bowl is played at a neutral site. */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { normalizeTeamName } from "~/lib/normalize-team-name"; import { eloWinProbability, convertFuturesToElo } from "~/services/probability-engine"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; /** NFL regular season games per team. */ const NFL_REGULAR_SEASON_GAMES = 17; /** Average opponent Elo for regular season game projection. */ const AVG_OPPONENT_ELO = 1500; /** * Home-field Elo advantage for NFL playoff games. * Adds to the higher seed's Elo before computing win probability. * +48 Elo ≈ 57% baseline win rate for the home team, consistent with * NFL regular-season home-field data (~2.5 point spread advantage). */ const NFL_HOME_FIELD_ELO_ADVANTAGE = 48; type Conference = "AFC" | "NFC"; type Division = "East" | "North" | "South" | "West"; const CONFERENCES: Conference[] = ["AFC", "NFC"]; const DIVISIONS: Division[] = ["East", "North", "South", "West"]; // ─── Team data ──────────────────────────────────────────────────────────────── interface NflTeamData { names: string[]; conference: Conference; division: Division; } const TEAMS_DATA: NflTeamData[] = [ // AFC East { names: ["Buffalo Bills", "Bills"], conference: "AFC", division: "East" }, { names: ["Miami Dolphins", "Dolphins"], conference: "AFC", division: "East" }, { names: ["New England Patriots", "Patriots"], conference: "AFC", division: "East" }, { names: ["New York Jets", "Jets"], conference: "AFC", division: "East" }, // AFC North { names: ["Baltimore Ravens", "Ravens"], conference: "AFC", division: "North" }, { names: ["Cincinnati Bengals", "Bengals"], conference: "AFC", division: "North" }, { names: ["Cleveland Browns", "Browns"], conference: "AFC", division: "North" }, { names: ["Pittsburgh Steelers", "Steelers"], conference: "AFC", division: "North" }, // AFC South { names: ["Houston Texans", "Texans"], conference: "AFC", division: "South" }, { names: ["Indianapolis Colts", "Colts"], conference: "AFC", division: "South" }, { names: ["Jacksonville Jaguars", "Jaguars"], conference: "AFC", division: "South" }, { names: ["Tennessee Titans", "Titans"], conference: "AFC", division: "South" }, // AFC West { names: ["Denver Broncos", "Broncos"], conference: "AFC", division: "West" }, { names: ["Kansas City Chiefs", "Chiefs"], conference: "AFC", division: "West" }, { names: ["Las Vegas Raiders", "Raiders"], conference: "AFC", division: "West" }, { names: ["Los Angeles Chargers", "Chargers"], conference: "AFC", division: "West" }, // NFC East { names: ["Dallas Cowboys", "Cowboys"], conference: "NFC", division: "East" }, { names: ["New York Giants", "Giants"], conference: "NFC", division: "East" }, { names: ["Philadelphia Eagles", "Eagles"], conference: "NFC", division: "East" }, { names: ["Washington Commanders", "Commanders"], conference: "NFC", division: "East" }, // NFC North { names: ["Chicago Bears", "Bears"], conference: "NFC", division: "North" }, { names: ["Detroit Lions", "Lions"], conference: "NFC", division: "North" }, { names: ["Green Bay Packers", "Packers"], conference: "NFC", division: "North" }, { names: ["Minnesota Vikings", "Vikings"], conference: "NFC", division: "North" }, // NFC South { names: ["Atlanta Falcons", "Falcons"], conference: "NFC", division: "South" }, { names: ["Carolina Panthers", "Panthers"], conference: "NFC", division: "South" }, { names: ["New Orleans Saints", "Saints"], conference: "NFC", division: "South" }, { names: ["Tampa Bay Buccaneers", "Buccaneers"], conference: "NFC", division: "South" }, // NFC West { names: ["Arizona Cardinals", "Cardinals"], conference: "NFC", division: "West" }, { names: ["Los Angeles Rams", "Rams"], conference: "NFC", division: "West" }, { names: ["San Francisco 49ers", "49ers"], conference: "NFC", division: "West" }, { names: ["Seattle Seahawks", "Seahawks"], conference: "NFC", division: "West" }, ]; // ─── Internal types ─────────────────────────────────────────────────────────── interface TeamState { participantId: string; conference: Conference; division: Division; elo: number; currentWins: number; gamesPlayed: number; } interface SeededTeam { participantId: string; elo: number; seed: number; } interface ConferenceBracketResult { finalist: SeededTeam; confChampLoser: SeededTeam; divisionalLosers: [SeededTeam, SeededTeam]; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Look up team data by participant name. */ export function getTeamData(name: string): NflTeamData | undefined { const normalized = normalizeTeamName(name); return TEAMS_DATA.find((t) => t.names.some((n) => normalizeTeamName(n) === normalized) ); } /** * Simulate projected wins for a team over its remaining games. * Each game is an independent Bernoulli trial. */ function simulateProjectedWins( currentWins: number, gamesPlayed: number, winProb: number ): number { const remaining = Math.max(0, NFL_REGULAR_SEASON_GAMES - gamesPlayed); let additional = 0; for (let g = 0; g < remaining; g++) { if (Math.random() < winProb) additional++; } return currentWins + additional; } /** * Determine the 7-team playoff seeding for one conference. * * Seeds 1-4 are division winners (sorted by projectedWins desc). * Seeds 5-7 are wildcards (top 3 non-division-winners). * Seed 1 receives the bye. * * Ties in projectedWins are broken by a pre-computed random jitter, which * avoids the instability of using Math.random() directly inside a sort comparator. */ export function seedConference( teams: Array<{ participantId: string; elo: number; division: Division; projectedWins: number }> ): SeededTeam[] { // Pre-compute per-team jitter so each sort comparison is deterministic const jitter = new Map(teams.map((t) => [t.participantId, Math.random()])); const cmp = (a: typeof teams[0], b: typeof teams[0]) => b.projectedWins - a.projectedWins || (jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0); const divisionWinners: typeof teams[0][] = []; const nonWinners: typeof teams[0][] = []; for (const div of DIVISIONS) { const divTeams = teams.filter((t) => t.division === div).toSorted(cmp); if (divTeams.length === 0) continue; divisionWinners.push(divTeams[0]); nonWinners.push(...divTeams.slice(1)); } divisionWinners.sort(cmp); nonWinners.sort(cmp); const wildcards = nonWinners.slice(0, 3); const seeded: SeededTeam[] = []; divisionWinners.forEach((t, i) => seeded.push({ participantId: t.participantId, elo: t.elo, seed: i + 1 }) ); wildcards.forEach((t, i) => seeded.push({ participantId: t.participantId, elo: t.elo, seed: i + 5 }) ); return seeded; } /** * Simulate a playoff game with home-field advantage for the higher seed. * The team with the lower seed number (better seed) is the home team. */ function playGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } { const [home, away] = a.seed < b.seed ? [a, b] : [b, a]; const winProbHome = eloWinProbability( home.elo + NFL_HOME_FIELD_ELO_ADVANTAGE, away.elo ); if (Math.random() < winProbHome) return { winner: home, loser: away }; return { winner: away, loser: home }; } /** * Simulate a game at a neutral site (no home-field adjustment). * Used for the Super Bowl. */ function playNeutralGame(a: SeededTeam, b: SeededTeam): { winner: SeededTeam; loser: SeededTeam } { const winProbA = eloWinProbability(a.elo, b.elo); if (Math.random() < winProbA) return { winner: a, loser: b }; return { winner: b, loser: a }; } /** * Simulate one conference's playoff bracket (seeds 1-7, seed 1 has bye). * * Wild Card: 2v7, 3v6, 4v5 (seed 1 bye; higher seed is home) * Divisional: 1 vs lowest remaining, 2 vs next (higher seed is home) * Conference Champ: divisional winners (higher seed is home) * * Returns the finalist, the conference championship loser, and the two * divisional-round losers (for 5th-8th place EV). */ export function simulateConferenceBracket(seeds: SeededTeam[]): ConferenceBracketResult { const byIndex = new Map(seeds.map((t) => [t.seed, t])); const s = (seed: number) => byIndex.get(seed) as SeededTeam; // Wild Card (seed 1 bye; lower seed = home) const wc27 = playGame(s(2), s(7)); const wc36 = playGame(s(3), s(6)); const wc45 = playGame(s(4), s(5)); // Divisional: sort WC winners by original seed (ascending = better seed = home) // Seed 1 hosts the lowest-ranked (highest seed number) WC winner const wcWinners = [wc27.winner, wc36.winner, wc45.winner].toSorted( (a, b) => a.seed - b.seed ); const div1 = playGame(s(1), wcWinners[wcWinners.length - 1]); const div2 = playGame(wcWinners[0], wcWinners[1]); // Conference Championship (higher remaining seed is home) const conf = playGame(div1.winner, div2.winner); return { finalist: conf.winner, confChampLoser: conf.loser, divisionalLosers: [div1.loser, div2.loser], }; } // ─── Simulator class ────────────────────────────────────────────────────────── export class NFLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // Load participants const participants = await db.query.participants.findMany({ where: eq(schema.participants.sportsSeasonId, sportsSeasonId), }); if (participants.length === 0) { throw new Error(`No participants found for sports season ${sportsSeasonId}.`); } // Load EV rows (sourceElo + sourceOdds) const evRows = await db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, sourceOdds: schema.participantExpectedValues.sourceOdds, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); // Build Elo map: prefer sourceElo, fall back to converting sourceOdds const eloMap = new Map(); const hasSourceElo = evRows.some((r) => r.sourceElo !== null); if (hasSourceElo) { for (const r of evRows) { if (r.sourceElo !== null) eloMap.set(r.participantId, r.sourceElo); } } else { const hasOdds = evRows.some((r) => r.sourceOdds !== null); if (!hasOdds) { throw new Error( `No Elo ratings or futures odds found for sports season ${sportsSeasonId}. ` + `Enter sourceElo ratings via Admin → Expected Values before simulating.` ); } const oddsInput = evRows .filter((r) => r.sourceOdds !== null) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds as number })); const converted = convertFuturesToElo(oddsInput); for (const [id, elo] of converted) eloMap.set(id, elo); } // Load standings (empty pre-season) const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db); const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r])); // Build team state list — skip participants with no Elo or unknown team name const teams: TeamState[] = []; for (const p of participants) { const teamData = getTeamData(p.name); if (!teamData) continue; const elo = eloMap.get(p.id); if (elo === undefined) continue; const standing = standingsMap.get(p.id); teams.push({ participantId: p.id, conference: teamData.conference, division: teamData.division, elo, currentWins: standing?.wins ?? 0, gamesPlayed: standing?.gamesPlayed ?? 0, }); } if (teams.length === 0) { throw new Error( `Could not match any participants to NFL team data for season ${sportsSeasonId}. ` + `Ensure participant names match (e.g. "Kansas City Chiefs").` ); } // Validate that every division in every conference has at least one Elo-rated team. // A missing division means seeding is broken for the whole simulation. const covered = new Set(teams.map((t) => `${t.conference} ${t.division}`)); const missing: string[] = []; for (const conf of CONFERENCES) { for (const div of DIVISIONS) { if (!covered.has(`${conf} ${div}`)) missing.push(`${conf} ${div}`); } } if (missing.length > 0) { throw new Error( `Missing Elo-rated teams in: ${missing.join(", ")}. ` + `All 32 NFL teams must have Elo ratings set before simulating.` ); } // Pre-compute per-team win probability vs average opponent (constant across sims) const winProbMap = new Map(); for (const t of teams) { winProbMap.set(t.participantId, eloWinProbability(t.elo, AVG_OPPONENT_ELO)); } // Placement counters const counts: Record> = {}; for (const t of teams) { counts[t.participantId] = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 }; } // ─── Monte Carlo ────────────────────────────────────────────────────────── for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { // Project wins for each team this simulation const projected = teams.map((t) => ({ participantId: t.participantId, elo: t.elo, conference: t.conference, division: t.division, projectedWins: simulateProjectedWins( t.currentWins, t.gamesPlayed, winProbMap.get(t.participantId) as number ), })); const afcTeams = projected.filter((t) => t.conference === "AFC"); const nfcTeams = projected.filter((t) => t.conference === "NFC"); const afcSeeds = seedConference(afcTeams); const nfcSeeds = seedConference(nfcTeams); // Simulate conference brackets const afcResult = simulateConferenceBracket(afcSeeds); const nfcResult = simulateConferenceBracket(nfcSeeds); // Super Bowl (neutral site) const sb = playNeutralGame(afcResult.finalist, nfcResult.finalist); // 1st / 2nd counts[sb.winner.participantId][1] += 1; counts[sb.loser.participantId][2] += 1; // 3rd / 4th — Conference Championship losers (1 per conference, split 50/50) counts[afcResult.confChampLoser.participantId][3] += 0.5; counts[afcResult.confChampLoser.participantId][4] += 0.5; counts[nfcResult.confChampLoser.participantId][3] += 0.5; counts[nfcResult.confChampLoser.participantId][4] += 0.5; // 5th–8th — Divisional losers (4 total: 2 per conference, split evenly) for (const loser of [...afcResult.divisionalLosers, ...nfcResult.divisionalLosers]) { counts[loser.participantId][5] += 0.25; counts[loser.participantId][6] += 0.25; counts[loser.participantId][7] += 0.25; counts[loser.participantId][8] += 0.25; } // Wild Card losers and missed playoffs remain at 0 (already initialized) } // Convert counts to probabilities return teams.map((t) => { const c = counts[t.participantId]; const N = NUM_SIMULATIONS; return { participantId: t.participantId, probabilities: { probFirst: c[1] / N, probSecond: c[2] / N, probThird: c[3] / N, probFourth: c[4] / N, probFifth: c[5] / N, probSixth: c[6] / N, probSeventh: c[7] / N, probEighth: c[8] / N, }, source: "nfl_elo_simulation", }; }); } }