brackt/app/services/simulations/__tests__/nfl-simulator.test.ts
Chris Parsons 102cb781ad
Add NFL season + playoffs Monte Carlo simulator (#272)
Implements a new `nfl_bracket` simulator that projects the NFL regular
season and full playoff bracket using nfelo Elo ratings.

- Simulates remaining regular season games (17 total) per team using
  Elo win probability vs an average opponent, then seeds both conferences
  (division winners = seeds 1–4, wildcards = 5–7) per simulation
- Simulates Wild Card, Divisional, Conference Championship, and Super
  Bowl rounds with correct NFL bracket structure (seed 1 bye)
- Applies +48 Elo home-field advantage (~57% win rate) for all rounds
  except the neutral-site Super Bowl
- Pre-season mode (no standings in DB): simulates all 17 games from
  scratch; falls back to futures odds → Elo conversion if no sourceElo
- Validates all 8 NFL divisions have Elo-rated teams before simulating,
  with a clear error listing missing divisions
- Registers as `nfl_bracket` simulator type in the registry and schema
- Adds migration to extend the `simulator_type` enum
- Also updates drizzle.config.ts to prefer DIRECT_DATABASE_URL when set

Fixes #129

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 09:39:34 -04:00

305 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
getTeamData,
seedConference,
simulateConferenceBracket,
} from "../nfl-simulator";
// ─── Team data ────────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("finds teams by full name", () => {
expect(getTeamData("Kansas City Chiefs")).toMatchObject({
conference: "AFC",
division: "West",
});
expect(getTeamData("Philadelphia Eagles")).toMatchObject({
conference: "NFC",
division: "East",
});
});
it("finds teams by short name", () => {
expect(getTeamData("Chiefs")).toMatchObject({ conference: "AFC", division: "West" });
expect(getTeamData("Eagles")).toMatchObject({ conference: "NFC", division: "East" });
});
it("is case-insensitive", () => {
expect(getTeamData("kansas city chiefs")).toBeDefined();
expect(getTeamData("PATRIOTS")).toBeDefined();
});
it("returns undefined for unknown teams", () => {
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
});
it("covers all 32 NFL teams across both conferences", () => {
const allTeams = [
// AFC East
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
// AFC North
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
// AFC South
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
// AFC West
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
// NFC East
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
// NFC North
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
// NFC South
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
// NFC West
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
];
for (const name of allTeams) {
expect(getTeamData(name), `${name} not found`).toBeDefined();
}
expect(allTeams).toHaveLength(32);
});
it("has exactly 16 AFC and 16 NFC teams", () => {
const allTeams = [
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
];
const afcCount = allTeams.filter((n) => getTeamData(n)?.conference === "AFC").length;
const nfcCount = allTeams.filter((n) => getTeamData(n)?.conference === "NFC").length;
expect(afcCount).toBe(16);
expect(nfcCount).toBe(16);
});
it("has exactly 4 teams per division per conference", () => {
const allTeams = [
"Buffalo Bills", "Miami Dolphins", "New England Patriots", "New York Jets",
"Baltimore Ravens", "Cincinnati Bengals", "Cleveland Browns", "Pittsburgh Steelers",
"Houston Texans", "Indianapolis Colts", "Jacksonville Jaguars", "Tennessee Titans",
"Denver Broncos", "Kansas City Chiefs", "Las Vegas Raiders", "Los Angeles Chargers",
"Dallas Cowboys", "New York Giants", "Philadelphia Eagles", "Washington Commanders",
"Chicago Bears", "Detroit Lions", "Green Bay Packers", "Minnesota Vikings",
"Atlanta Falcons", "Carolina Panthers", "New Orleans Saints", "Tampa Bay Buccaneers",
"Arizona Cardinals", "Los Angeles Rams", "San Francisco 49ers", "Seattle Seahawks",
];
const divisions = ["East", "North", "South", "West"] as const;
const conferences = ["AFC", "NFC"] as const;
for (const conf of conferences) {
for (const div of divisions) {
const count = allTeams.filter((n) => {
const d = getTeamData(n);
return d?.conference === conf && d?.division === div;
}).length;
expect(count, `${conf} ${div}`).toBe(4);
}
}
});
});
// ─── seedConference ───────────────────────────────────────────────────────────
function makeTeam(
id: string,
division: "East" | "North" | "South" | "West",
projectedWins: number,
elo = 1500
) {
return { participantId: id, elo, division, projectedWins };
}
describe("seedConference", () => {
// Build a deterministic 16-team AFC where wins are all unique (no tiebreaks needed)
const afcTeams = [
// East: Bills win
makeTeam("bills", "East", 13),
makeTeam("dolphins", "East", 9),
makeTeam("patriots", "East", 5),
makeTeam("jets", "East", 4),
// North: Ravens win
makeTeam("ravens", "North", 12),
makeTeam("bengals", "North", 10),
makeTeam("steelers", "North", 8),
makeTeam("browns", "North", 6),
// South: Texans win
makeTeam("texans", "South", 11),
makeTeam("colts", "South", 7),
makeTeam("jaguars", "South", 3),
makeTeam("titans", "South", 2),
// West: Chiefs win
makeTeam("chiefs", "West", 14),
makeTeam("chargers", "West", 10.5), // non-integer to stay unique
makeTeam("broncos", "West", 8.5),
makeTeam("raiders", "West", 1),
];
it("returns 7 seeds", () => {
const seeds = seedConference(afcTeams);
expect(seeds).toHaveLength(7);
});
it("seed 1 is the team with the most wins (Chiefs, 14)", () => {
const seeds = seedConference(afcTeams);
const seed1 = seeds.find((s) => s.seed === 1);
expect(seed1?.participantId).toBe("chiefs");
});
it("division winners occupy seeds 1-4", () => {
const seeds = seedConference(afcTeams);
const divWinnerIds = new Set(["chiefs", "bills", "ravens", "texans"]);
const seedsOneToFour = seeds.filter((s) => s.seed <= 4).map((s) => s.participantId);
expect(new Set(seedsOneToFour)).toEqual(divWinnerIds);
});
it("wildcards occupy seeds 5-7", () => {
const seeds = seedConference(afcTeams);
// Wildcards = top 3 non-division-winners: bengals(10), chargers(10.5), colts(7)
const wcIds = seeds.filter((s) => s.seed >= 5).map((s) => s.participantId);
expect(wcIds).toHaveLength(3);
// Top 3 non-winners by wins: chargers(10.5), bengals(10), dolphins(9)
expect(new Set(wcIds)).toEqual(new Set(["chargers", "bengals", "dolphins"]));
});
it("seed 5 is the best wildcard", () => {
const seeds = seedConference(afcTeams);
const seed5 = seeds.find((s) => s.seed === 5);
// chargers has 10.5 wins, highest non-division-winner
expect(seed5?.participantId).toBe("chargers");
});
});
// ─── simulateConferenceBracket ────────────────────────────────────────────────
describe("simulateConferenceBracket", () => {
// Dominant team: one team has overwhelming Elo advantage
const dominantSeeds = [
{ participantId: "king", elo: 2500, seed: 1 }, // bye, nearly unbeatable
{ participantId: "s2", elo: 1500, seed: 2 },
{ participantId: "s3", elo: 1500, seed: 3 },
{ participantId: "s4", elo: 1500, seed: 4 },
{ participantId: "s5", elo: 1500, seed: 5 },
{ participantId: "s6", elo: 1500, seed: 6 },
{ participantId: "s7", elo: 1500, seed: 7 },
];
it("returns a finalist, confChampLoser, and two divisionalLosers", () => {
const result = simulateConferenceBracket(dominantSeeds);
expect(result.finalist).toBeDefined();
expect(result.confChampLoser).toBeDefined();
expect(result.divisionalLosers).toHaveLength(2);
});
it("finalist and confChampLoser are different teams", () => {
const result = simulateConferenceBracket(dominantSeeds);
expect(result.finalist.participantId).not.toBe(result.confChampLoser.participantId);
});
it("all returned participant IDs are distinct", () => {
const result = simulateConferenceBracket(dominantSeeds);
const ids = [
result.finalist.participantId,
result.confChampLoser.participantId,
...result.divisionalLosers.map((l) => l.participantId),
];
expect(new Set(ids).size).toBe(ids.length);
});
it("dominant seed 1 team reaches the conference championship with high frequency", () => {
// Run 200 trials; seed 1 with Elo 2500 should reach conf champ almost always
let reachedConfChamp = 0;
for (let i = 0; i < 200; i++) {
const result = simulateConferenceBracket(dominantSeeds);
if (
result.finalist.participantId === "king" ||
result.confChampLoser.participantId === "king"
) {
reachedConfChamp++;
}
}
// With Elo 2500 vs 1500, P(win single game) ≈ 0.9997; reaching conf champ ≈ near certain
expect(reachedConfChamp).toBeGreaterThan(190);
});
it("higher seed wins more often than lower seed due to home-field advantage", () => {
// Evenly matched teams (same Elo). Seed 2 should beat seed 7 more than 50%
// because seed 2 is the home team (+48 Elo ≈ 57% expected win rate).
const evenSeeds = [
{ participantId: "s1", elo: 1500, seed: 1 },
{ participantId: "s2", elo: 1500, seed: 2 },
{ participantId: "s3", elo: 1500, seed: 3 },
{ participantId: "s4", elo: 1500, seed: 4 },
{ participantId: "s5", elo: 1500, seed: 5 },
{ participantId: "s6", elo: 1500, seed: 6 },
{ participantId: "s7", elo: 1500, seed: 7 },
];
let seed2WonWildCard = 0;
const trials = 500;
for (let i = 0; i < trials; i++) {
const result = simulateConferenceBracket(evenSeeds);
// If s7 is absent from all scored positions, it lost in Wild Card to seed 2
const s7Survived =
result.divisionalLosers.some((l) => l.participantId === "s7") ||
result.confChampLoser.participantId === "s7" ||
result.finalist.participantId === "s7";
if (!s7Survived) seed2WonWildCard++;
}
// Seed 2 (home, +48 Elo) vs seed 7 (away) with equal base Elo → ~57% win rate
expect(seed2WonWildCard).toBeGreaterThan(trials * 0.50);
});
});
// ─── Smoke test ───────────────────────────────────────────────────────────────
describe("seedConference + simulateConferenceBracket integration", () => {
it("produces valid probability-like fractions when run many times", () => {
const afcTeams = [
makeTeam("bills", "East", 13, 1650),
makeTeam("dolphins", "East", 9, 1480),
makeTeam("patriots", "East", 5, 1400),
makeTeam("jets", "East", 4, 1380),
makeTeam("ravens", "North", 12, 1620),
makeTeam("bengals", "North", 10, 1520),
makeTeam("steelers", "North", 8, 1490),
makeTeam("browns", "North", 6, 1440),
makeTeam("texans", "South", 11, 1560),
makeTeam("colts", "South", 7, 1460),
makeTeam("jaguars", "South", 3, 1390),
makeTeam("titans", "South", 2, 1370),
makeTeam("chiefs", "West", 14, 1700),
makeTeam("chargers", "West", 10.5, 1530),
makeTeam("broncos", "West", 8.5, 1500),
makeTeam("raiders", "West", 1, 1360),
];
const finalistCounts: Record<string, number> = {};
const N = 500;
for (let i = 0; i < N; i++) {
const seeds = seedConference(afcTeams);
if (seeds.length < 7) continue;
const result = simulateConferenceBracket(seeds);
const id = result.finalist.participantId;
finalistCounts[id] = (finalistCounts[id] ?? 0) + 1;
}
// Every team that appeared as finalist should have a non-negative count
for (const count of Object.values(finalistCounts)) {
expect(count).toBeGreaterThan(0);
}
// Total finalist appearances = N
const total = Object.values(finalistCounts).reduce((a, b) => a + b, 0);
expect(total).toBe(N);
// Strong teams (Chiefs, Ravens, Bills) should make conference finals more often
const chiefsFinalist = finalistCounts["chiefs"] ?? 0;
const jetsFinalist = finalistCounts["jets"] ?? 0;
expect(chiefsFinalist).toBeGreaterThan(jetsFinalist);
});
});