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>
305 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|