Add NCAA Football CFP simulator (12-team bracket)

Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).

- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
  in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
  Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
  probability distributions, seeding, edge cases, futures blending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-08 12:41:56 +00:00
parent a99b67059a
commit 87acc1adce
10 changed files with 5232 additions and 1 deletions

View file

@ -633,6 +633,46 @@ export const FIFA_48: BracketTemplate = {
],
};
/**
* College Football Playoff (12 teams, 2024present format)
* First Round: 5v12, 6v11, 7v10, 8v9 (campus sites, no fantasy points)
* Quarterfinals: Seeds 14 (bye) vs first-round winners (scoring starts here)
* Semifinals: 4 2
* National Championship: 1 game
*/
export const CFP_12: BracketTemplate = {
id: "cfp_12",
name: "College Football Playoff (12 teams)",
totalTeams: 12,
scoringStartsAtRound: "Quarterfinals",
rounds: [
{
name: "First Round",
matchCount: 4,
feedsInto: "Quarterfinals",
isScoring: false, // 8 seeds play, top 4 have byes
},
{
name: "Quarterfinals",
matchCount: 4,
feedsInto: "Semifinals",
isScoring: true, // Losers share 5th8th
},
{
name: "Semifinals",
matchCount: 2,
feedsInto: "National Championship",
isScoring: true, // Losers share 3rd4th
},
{
name: "National Championship",
matchCount: 1,
feedsInto: null,
isScoring: true, // Winner 1st, loser 2nd
},
],
};
/**
* All available bracket templates
*/
@ -646,6 +686,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
afl_10: AFL_10,
fifa_48: FIFA_48,
darts_128: DARTS_128,
cfp_12: CFP_12,
};
/**

View file

@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { CFP_12, BRACKET_TEMPLATES, getScoringRoundType } from "~/lib/bracket-templates";
describe("CFP_12 Bracket Template", () => {
describe("Template structure", () => {
it("has correct total teams", () => {
expect(CFP_12.totalTeams).toBe(12);
});
it("has 4 rounds", () => {
expect(CFP_12.rounds).toHaveLength(4);
});
it("has correct round names", () => {
expect(CFP_12.rounds[0].name).toBe("First Round");
expect(CFP_12.rounds[1].name).toBe("Quarterfinals");
expect(CFP_12.rounds[2].name).toBe("Semifinals");
expect(CFP_12.rounds[3].name).toBe("National Championship");
});
it("has correct match counts per round", () => {
expect(CFP_12.rounds[0].matchCount).toBe(4); // 8 seeds play, 4 byes
expect(CFP_12.rounds[1].matchCount).toBe(4); // 8 → 4
expect(CFP_12.rounds[2].matchCount).toBe(2); // 4 → 2
expect(CFP_12.rounds[3].matchCount).toBe(1); // 2 → 1
});
it("has correct round advancement chain", () => {
expect(CFP_12.rounds[0].feedsInto).toBe("Quarterfinals");
expect(CFP_12.rounds[1].feedsInto).toBe("Semifinals");
expect(CFP_12.rounds[2].feedsInto).toBe("National Championship");
expect(CFP_12.rounds[3].feedsInto).toBeNull();
});
});
describe("Scoring configuration", () => {
it("scoring starts at Quarterfinals", () => {
expect(CFP_12.scoringStartsAtRound).toBe("Quarterfinals");
});
it("First Round is not scoring", () => {
expect(CFP_12.rounds[0].isScoring).toBe(false);
});
it("Quarterfinals is scoring", () => {
expect(CFP_12.rounds[1].isScoring).toBe(true);
});
it("Semifinals is scoring", () => {
expect(CFP_12.rounds[2].isScoring).toBe(true);
});
it("National Championship is scoring", () => {
expect(CFP_12.rounds[3].isScoring).toBe(true);
});
});
describe("Template ID and registry", () => {
it("has id cfp_12", () => {
expect(CFP_12.id).toBe("cfp_12");
});
it("is registered in BRACKET_TEMPLATES", () => {
expect(BRACKET_TEMPLATES["cfp_12"]).toBeDefined();
expect(BRACKET_TEMPLATES["cfp_12"]).toBe(CFP_12);
});
});
describe("getScoringRoundType", () => {
it("classifies Quarterfinals as quarterfinals (losers share 5th8th)", () => {
expect(getScoringRoundType("Quarterfinals", CFP_12)).toBe("quarterfinals");
});
it("classifies Semifinals as semifinals (losers share 3rd4th)", () => {
expect(getScoringRoundType("Semifinals", CFP_12)).toBe("semifinals");
});
it("classifies National Championship as finals", () => {
expect(getScoringRoundType("National Championship", CFP_12)).toBe("finals");
});
it("returns null for First Round (non-scoring)", () => {
expect(getScoringRoundType("First Round", CFP_12)).toBeNull();
});
});
describe("Total matches", () => {
it("has 11 total matches across all rounds", () => {
const total = CFP_12.rounds.reduce((sum, r) => sum + r.matchCount, 0);
expect(total).toBe(11); // 4 + 4 + 2 + 1
});
});
});

View file

@ -355,6 +355,11 @@ export async function generateBracketFromTemplate(
return await generateAFL10Bracket(eventId, template, participantIds);
}
// CFP 12 requires special handling for bye weeks (seeds 14 skip First Round)
if (templateId === "cfp_12") {
return await generateCFP12Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
@ -1069,3 +1074,97 @@ export async function assignParticipantsToKnockout(
});
}
}
/**
* Generate College Football Playoff 12-team bracket.
*
* First Round (seeds 512, 4 games, not scoring):
* Match 1: 5 vs 12
* Match 2: 6 vs 11
* Match 3: 7 vs 10
* Match 4: 8 vs 9
*
* Quarterfinals (4 games, scoring starts):
* Match 1: 1 vs First Round match 4 winner (8/9)
* Match 2: 4 vs First Round match 1 winner (5/12)
* Match 3: 3 vs First Round match 2 winner (6/11)
* Match 4: 2 vs First Round match 3 winner (7/10)
*
* Semifinals and National Championship: TBD vs TBD
*/
async function generateCFP12Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// First Round: seeds 512 (indices 411), top 4 (indices 03) have byes
const firstRoundSeeding: [number, number][] = [
[4, 11], // 5 vs 12
[5, 10], // 6 vs 11
[6, 9], // 7 vs 10
[7, 8], // 8 vs 9
];
const firstRound = template.rounds[0]; // "First Round"
for (let i = 0; i < firstRoundSeeding.length; i++) {
const [s1, s2] = firstRoundSeeding[i];
matches.push({
scoringEventId: eventId,
round: firstRound.name,
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[s1] : null,
participant2Id: participantIds ? participantIds[s2] : null,
isComplete: false,
isScoring: false,
templateRound: firstRound.name,
seedInfo: `${s1 + 1} vs ${s2 + 1}`,
});
}
// Quarterfinals: seeds 14 (indices 03) receive byes, face First Round winners
// Matchup order mirrors CFP bracket: 1 vs 8/9 winner, 4 vs 5/12 winner, etc.
const quarterfinalsSeeding: [number, string][] = [
[0, "1 vs TBD"], // 1 seed vs First Round match 4 winner (8v9)
[3, "4 vs TBD"], // 4 seed vs First Round match 1 winner (5v12)
[2, "3 vs TBD"], // 3 seed vs First Round match 2 winner (6v11)
[1, "2 vs TBD"], // 2 seed vs First Round match 3 winner (7v10)
];
const quarterfinalsRound = template.rounds[1]; // "Quarterfinals"
for (let i = 0; i < quarterfinalsSeeding.length; i++) {
const [byeSeedIndex, seedInfo] = quarterfinalsSeeding[i];
matches.push({
scoringEventId: eventId,
round: quarterfinalsRound.name,
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[byeSeedIndex] : null,
participant2Id: null, // Filled when First Round winner is known
isComplete: false,
isScoring: true,
templateRound: quarterfinalsRound.name,
seedInfo,
});
}
// Semifinals and National Championship: all TBD
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}

View file

@ -0,0 +1,232 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { NCAAFootballSimulator } from "../ncaa-football-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
// Use real implementations of pure math functions to avoid mock call accumulation
// across 50k-iteration simulations. Only mock convertFuturesToElo (complex behavior).
vi.mock("~/services/probability-engine", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
convertFuturesToElo: vi.fn((oddsInput: { participantId: string; odds: number }[]) => {
return new Map(oddsInput.map((o, i) => [o.participantId, 1600 - i * 50]));
}),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
const TEAM_IDS = Array.from({ length: 12 }, (_, i) => `team-${i + 1}`);
/** Build EV rows with sourceElo ratings (descending — team-1 is best). */
function makeEvRows(
ids: string[],
opts: { includeOdds?: boolean } = {}
) {
return ids.map((participantId, i) => ({
participantId,
sourceElo: 1700 - i * 50, // 1700, 1650, 1600, …
sourceOdds: opts.includeOdds ? (i === 0 ? -200 : 300 + i * 50) : null,
}));
}
/** Build participant rows. */
function makeParticipants(ids: string[]) {
return ids.map((id) => ({ id }));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("NCAAFootballSimulator", () => {
let mockDb: {
select: MockInstance;
};
// select() returns a chain: .from().where() → resolves to an array
// We need two calls: one for participants, one for EV rows.
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = {
select: vi.fn(),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupMockDb(
participantIds: string[],
opts: { includeOdds?: boolean } = {}
) {
const participants = makeParticipants(participantIds);
const evRows = makeEvRows(participantIds, opts);
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(data),
}),
};
});
}
it("returns one result per participant", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(12);
});
it("all probabilities are between 0 and 1", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probFirst).toBeLessThanOrEqual(1);
expect(p.probSecond).toBeGreaterThanOrEqual(0);
expect(p.probSecond).toBeLessThanOrEqual(1);
}
});
it("top-rated team has highest champion probability", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
const team1Prob = byId.get("team-1")!.probabilities.probFirst;
const team12Prob = byId.get("team-12")!.probabilities.probFirst;
expect(team1Prob).toBeGreaterThan(team12Prob);
});
it("probFirst sums to approximately 1.0 across all teams", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSecond sums to approximately 1.0 across all teams", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probThird equals probFourth for every team (tied SF placement)", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth through probEighth are equal for every team (tied QF placement)", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
it("weakest team has very low champion probability", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
// team-12 (Elo 1150) vs team-1 (Elo 1700): team-12 should rarely win
const byId = new Map(results.map((r) => [r.participantId, r]));
expect(byId.get("team-12")!.probabilities.probFirst).toBeLessThan(0.02);
});
it("source is cfp_monte_carlo", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("cfp_monte_carlo");
}
});
it("throws when no participants found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const sim = new NCAAFootballSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("throws when fewer than 12 participants are provided", async () => {
const elevenTeams = TEAM_IDS.slice(0, 11);
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? makeParticipants(elevenTeams) : makeEvRows(elevenTeams);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
const sim = new NCAAFootballSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/12 participants/);
});
it("uses futures odds when sourceOdds present (does not throw)", async () => {
setupMockDb(TEAM_IDS, { includeOdds: true });
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
// Should still return 12 results with valid probabilities
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("teams outside top 12 have all-zero probabilities (15-team season)", async () => {
// 15 participants — only top 12 enter the bracket, bottom 3 stay at 0 EV
const fifteenTeams = Array.from({ length: 15 }, (_, i) => `team-${i + 1}`);
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? makeParticipants(fifteenTeams) : makeEvRows(fifteenTeams);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(15);
for (const id of ["team-13", "team-14", "team-15"]) {
const r = results.find((x) => x.participantId === id)!;
expect(r.probabilities.probFirst).toBe(0);
expect(r.probabilities.probSecond).toBe(0);
expect(r.probabilities.probThird).toBe(0);
expect(r.probabilities.probEighth).toBe(0);
}
});
});

View file

@ -0,0 +1,280 @@
/**
* NCAA Football CFP Simulator
*
* Monte Carlo simulation of the College Football Playoff (12-team format, 2024present).
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
* 3. If sourceOdds (American format) are also stored, blend the Elo-based and
* odds-based per-game win probabilities: P = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* 4. Sort teams by blended strength (descending) to assign seeds 112
* 5. Simulate 50,000 CFP brackets per the official seeding structure:
* First Round (not scoring): 5v12, 6v11, 7v10, 8v9
* Quarterfinals (scoring): 1 vs 8/9w, 4 vs 5/12w, 3 vs 6/11w, 2 vs 7/10w
* Semifinals (scoring): QF1w vs QF2w, QF3w vs QF4w
* National Championship: SF1w vs SF2w
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
*
* Win probability: eloWinProbability() from probability-engine (standard 400-divisor Elo formula).
*
* Futures blending (when sourceOdds are present):
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* ELO_WEIGHT = 0.6, ODDS_WEIGHT = 0.4
* A slightly lower Elo weight than other sports (0.7) gives more influence to
* Vegas championship odds, which are highly informative in college football.
* Falls back to Elo-only when no sourceOdds are stored.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = National Champion (1 per sim)
* probSecond = Championship game loser (1 per sim)
* probThird / probFourth = Semifinal losers (2 per sim split evenly)
* probFifthprobEighth = Quarterfinal losers (4 per sim split evenly)
* First Round losers all 0 (score 0 fantasy points)
*
* Admin setup:
* 1. Create a Sport with simulatorType = "ncaa_football_bracket"
* 2. Create a Sports Season and add 12 team participants
* 3. Enter FPI ratings via Admin Elo Ratings (stored as sourceElo)
* 4. Optionally enter championship futures odds via Admin Futures Odds (stored as sourceOdds)
* 5. Run simulation via Admin Simulate
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
convertAmericanOddsToProbability,
convertFuturesToElo,
eloWinProbability,
} from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12;
/**
* Weight for the Elo-based probability component.
* Remaining (ODDS_WEIGHT) goes to the Vegas futures-derived component.
*/
const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Types ────────────────────────────────────────────────────────────────────
interface Team {
participantId: string;
elo: number;
/** Normalized futures win probability (01). Used for blending when odds available. */
oddsProb: number;
}
// ─── Win probability helpers ──────────────────────────────────────────────────
/**
* Blended win probability for team1 vs team2.
* When oddsProbs are both 0 (no futures data), falls back to pure Elo.
*/
function blendedWinProb(team1: Team, team2: Team): number {
const eloProbValue = eloWinProbability(team1.elo, team2.elo);
if (team1.oddsProb === 0 && team2.oddsProb === 0) {
return eloProbValue;
}
const oddsSum = team1.oddsProb + team2.oddsProb;
const oddsProbValue = oddsSum > 0 ? team1.oddsProb / oddsSum : 0.5;
return ELO_WEIGHT * eloProbValue + ODDS_WEIGHT * oddsProbValue;
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < blendedWinProb(team1, team2);
return p1Wins
? { winner: team1, loser: team2 }
: { winner: team2, loser: team1 };
}
// ─── Bracket simulation ───────────────────────────────────────────────────────
interface PlacementCounts {
champion: number;
finalist: number;
sfLoser: number;
qfLoser: number;
}
/**
* Simulate one full 12-team CFP bracket.
*
* Seeding (teams sorted bestworst, index 0 = seed 1):
* First Round: [4]v[11], [5]v[10], [6]v[9], [7]v[8]
* Quarterfinals: [0] vs fr4w, [3] vs fr1w, [2] vs fr2w, [1] vs fr3w
* Semifinals: qf1w vs qf2w, qf3w vs qf4w
* Championship: sf1w vs sf2w
*/
function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
// ── First Round (seeds 512) ───────────────────────────────────────────────
const fr1 = simGame(teams[4], teams[11]); // 5 vs 12
const fr2 = simGame(teams[5], teams[10]); // 6 vs 11
const fr3 = simGame(teams[6], teams[9]); // 7 vs 10
const fr4 = simGame(teams[7], teams[8]); // 8 vs 9
// ── Quarterfinals (seeds 14 get byes) ────────────────────────────────────
const qf1 = simGame(teams[0], fr4.winner); // 1 vs 8/9 winner
const qf2 = simGame(teams[3], fr1.winner); // 4 vs 5/12 winner
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
counts.get(qf1.loser.participantId)!.qfLoser++;
counts.get(qf2.loser.participantId)!.qfLoser++;
counts.get(qf3.loser.participantId)!.qfLoser++;
counts.get(qf4.loser.participantId)!.qfLoser++;
// ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner);
counts.get(sf1.loser.participantId)!.sfLoser++;
counts.get(sf2.loser.participantId)!.sfLoser++;
// ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner);
counts.get(final.winner.participantId)!.champion++;
counts.get(final.loser.participantId)!.finalist++;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NCAAFootballSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participants = await db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// 2. Load Elo/FPI ratings and optional futures odds in a single query.
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 and odds maps in a single pass over evRows.
const eloFromDb = new Map<string, number>();
const rawOddsProbs = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null) {
eloFromDb.set(row.participantId, row.sourceElo);
}
if (row.sourceOdds !== null) {
rawOddsProbs.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
}
}
// 3. Build normalized odds probability map (vig removed).
// If no sourceOdds, all teams get oddsProb = 0 → falls back to pure Elo.
let normalizedOddsMap = new Map<string, number>();
if (rawOddsProbs.size > 0) {
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawOddsProbs) {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
}
// Backfill Elo from futures odds for any team that has sourceOdds but no sourceElo.
if (eloFromDb.size < participants.length) {
const oddsInput = [...rawOddsProbs.keys()]
.filter((id) => !eloFromDb.has(id))
.map((id) => ({ participantId: id, odds: evRows.find((r) => r.participantId === id)!.sourceOdds! }));
if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
for (const [id, elo] of oddsEloMap) {
eloFromDb.set(id, elo);
}
}
}
}
// 4. Build and seed team list (top 12 by blended strength, best→worst).
const allTeams: Team[] = participants.map((p) => ({
participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
}));
// Normalize Elo to [0,1] range for the blended sort score.
const eloValues = allTeams.map((t) => t.elo);
const minElo = Math.min(...eloValues);
const eloRange = (Math.max(...eloValues) - minElo) || 1;
const seededTeams = [...allTeams].sort((a, b) => {
const aScore = ELO_WEIGHT * ((a.elo - minElo) / eloRange) + ODDS_WEIGHT * a.oddsProb;
const bScore = ELO_WEIGHT * ((b.elo - minElo) / eloRange) + ODDS_WEIGHT * b.oddsProb;
return bScore - aScore;
});
if (seededTeams.length < BRACKET_SIZE) {
throw new Error(
`CFP simulator requires ${BRACKET_SIZE} participants, found ${seededTeams.length}. ` +
`Add all teams to the sports season before running simulation.`
);
}
const bracketTeams = seededTeams.slice(0, BRACKET_SIZE);
// 5. Initialise placement count accumulators for all participants.
// Teams outside the top 12 keep all zeros (0 EV).
const allParticipantIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>(
allParticipantIds.map((id) => [id, { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }])
);
// 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
simulateBracket(bracketTeams, counts);
}
// 7. Convert counts to probability distributions and return.
// SF losers: 2 per sim, so each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim, so each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * NUM_SIMULATIONS;
return allParticipantIds.map((id) => {
const c = counts.get(id)!;
const sfProb = c.sfLoser / sfDivisor;
const qfProb = c.qfLoser / qfDivisor;
return {
participantId: id,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
probThird: sfProb,
probFourth: sfProb,
probFifth: qfProb,
probSixth: qfProb,
probSeventh: qfProb,
probEighth: qfProb,
},
source: "cfp_monte_carlo",
};
});
}
}

View file

@ -23,6 +23,7 @@ import { CSMajorSimulator } from "./cs-major-simulator";
import { MLBSimulator } from "./mlb-simulator";
import { WNBASimulator } from "./wnba-simulator";
import { WorldCupSimulator } from "./world-cup-simulator";
import { NCAAFootballSimulator } from "./ncaa-football-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -42,6 +43,7 @@ export const SIMULATOR_TYPES = [
"world_cup",
"darts_bracket",
"cs2_major_qualifying_points",
"ncaa_football_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -134,6 +136,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "CS2 Major Qualifying Points Monte Carlo", description: "Simulates 2 CS2 Majors per season (3 Swiss stages: Opening Bo1, Elimination Bo1, Decider Bo3, then Champions Stage single-elimination). Awards QP by final placement; ranks teams by total QP across both majors. Stage 3 exits (placements 916) are sub-ranked by W-L record." },
create: () => new CSMajorSimulator(),
},
ncaa_football_bracket: {
info: { name: "NCAA Football CFP Monte Carlo", description: "Simulates the 12-team College Football Playoff bracket using Elo/FPI ratings entered via Admin → Elo Ratings. Optionally blends with championship futures odds (60% Elo / 40% odds). Seeds 14 receive first-round byes; First Round losers score 0 points." },
create: () => new NCAAFootballSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -100,6 +100,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"world_cup",
"darts_bracket",
"cs2_major_qualifying_points",
"ncaa_football_bracket",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [

View file

@ -0,0 +1 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaa_football_bracket';

File diff suppressed because it is too large Load diff

View file

@ -498,6 +498,13 @@
"when": 1775485630297,
"tag": "0070_verify_migrations",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1775650608181,
"tag": "0071_flippant_amazoness",
"breakpoints": true
}
]
}
}