Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124 * 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> * Fix lint errors in NCAA Football CFP simulator Replace non-null assertions with optional chaining, change let to const, use toSorted() instead of sort(), and add a bump() helper to avoid repeated map lookups with non-null assertions in simulateBracket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TS2345 in NCAA football simulator test Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ad49d6246d
commit
4d5fea05ab
10 changed files with 5297 additions and 0 deletions
|
|
@ -633,6 +633,46 @@ export const FIFA_48: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* College Football Playoff (12 teams, 2024–present format)
|
||||
* First Round: 5v12, 6v11, 7v10, 8v9 (campus sites, no fantasy points)
|
||||
* Quarterfinals: Seeds 1–4 (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 5th–8th
|
||||
},
|
||||
{
|
||||
name: "Semifinals",
|
||||
matchCount: 2,
|
||||
feedsInto: "National Championship",
|
||||
isScoring: true, // Losers share 3rd–4th
|
||||
},
|
||||
{
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
93
app/models/__tests__/cfp-12-bracket.test.ts
Normal file
93
app/models/__tests__/cfp-12-bracket.test.ts
Normal 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 5th–8th)", () => {
|
||||
expect(getScoringRoundType("Quarterfinals", CFP_12)).toBe("quarterfinals");
|
||||
});
|
||||
|
||||
it("classifies Semifinals as semifinals (losers share 3rd–4th)", () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -355,6 +355,11 @@ export async function generateBracketFromTemplate(
|
|||
return await generateAFL10Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
// CFP 12 requires special handling for bye weeks (seeds 1–4 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 5–12, 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 5–12 (indices 4–11), top 4 (indices 0–3) 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 1–4 (indices 0–3) 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeParticipants(ids: string[]) {
|
||||
return ids.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NCAAFootballSimulator", () => {
|
||||
let mockDb: { select: MockInstance };
|
||||
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) }) };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Post-bracket mode (exactly 12 teams) ─────────────────────────────────
|
||||
|
||||
describe("post-bracket mode (exactly 12 teams)", () => {
|
||||
it("returns one result per participant", async () => {
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
expect(results).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("all probabilities are between 0 and 1", async () => {
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().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 results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
|
||||
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||||
expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
|
||||
byId.get("team-12")?.probabilities.probFirst ?? 0
|
||||
);
|
||||
});
|
||||
|
||||
it("probFirst sums to approximately 1.0", async () => {
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().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", async () => {
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().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 results = await new NCAAFootballSimulator().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 results = await new NCAAFootballSimulator().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 results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||||
// team-12 (Elo 1150) vs team-1 (Elo 1700): 550-point gap → should rarely win
|
||||
expect(byId.get("team-12")?.probabilities.probFirst).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
it("source is cfp_monte_carlo", async () => {
|
||||
setupMockDb(TEAM_IDS);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
for (const r of results) {
|
||||
expect(r.source).toBe("cfp_monte_carlo");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses futures odds when sourceOdds present", async () => {
|
||||
setupMockDb(TEAM_IDS, { includeOdds: true });
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
expect(results).toHaveLength(12);
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
|
||||
|
||||
describe("pre-bracket mode (>12 teams)", () => {
|
||||
it("returns one result per participant when pool is larger than 12", async () => {
|
||||
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
setupMockDb(twentyTeams);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("probFirst still sums to ~1.0 across all teams (one champion per sim)", async () => {
|
||||
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
setupMockDb(twentyTeams);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("top team has much higher probFirst than bottom team in larger pool", async () => {
|
||||
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
setupMockDb(twentyTeams);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||||
// team-1 (Elo 1700) should win far more often than team-20 (Elo 750)
|
||||
expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
|
||||
(byId.get("team-20")?.probabilities.probFirst ?? 0) * 10
|
||||
);
|
||||
});
|
||||
|
||||
it("bottom-pool teams (large Elo gap) rarely appear in champion slot", async () => {
|
||||
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
setupMockDb(twentyTeams);
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
const byId = new Map(results.map((r) => [r.participantId, r]));
|
||||
// team-19 and team-20 have Elos of 800 and 750 — far below the top 12 (~1150+)
|
||||
// With softmax T=100, their selection weight is negligible
|
||||
expect(byId.get("team-19")?.probabilities.probFirst).toBeLessThan(0.01);
|
||||
expect(byId.get("team-20")?.probabilities.probFirst).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("uses futures odds as selection weights when provided", async () => {
|
||||
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
setupMockDb(twentyTeams, { includeOdds: true });
|
||||
const results = await new NCAAFootballSimulator().simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error cases ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("error cases", () => {
|
||||
it("throws when fewer than 12 participants 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) }) };
|
||||
});
|
||||
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
|
||||
});
|
||||
|
||||
it("throws when participant list is empty", async () => {
|
||||
let call = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
const data = call++ === 0 ? [] : [];
|
||||
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
|
||||
});
|
||||
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
|
||||
});
|
||||
});
|
||||
});
|
||||
350
app/services/simulations/ncaa-football-simulator.ts
Normal file
350
app/services/simulations/ncaa-football-simulator.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* NCAA Football CFP Simulator
|
||||
*
|
||||
* Monte Carlo simulation of the College Football Playoff (12-team format, 2024–present).
|
||||
*
|
||||
* 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, build a normalized selection
|
||||
* weight from implied championship probability (used for field selection in step 4)
|
||||
* and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
|
||||
* 4. Per simulation, select 12 teams for the CFP field:
|
||||
* - If the pool has exactly 12 teams: use all of them (post-bracket mode).
|
||||
* - If the pool has >12 teams: weighted sample without replacement using each
|
||||
* team's selection weight — odds-derived if available, Elo-based otherwise.
|
||||
* Teams with stronger championship odds are sampled more often, naturally
|
||||
* encoding both selection probability and bracket strength into one signal.
|
||||
* 5. Seed the 12 selected teams by Elo (best Elo = seed 1).
|
||||
* 6. Simulate the CFP bracket:
|
||||
* 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
|
||||
* 7. Track placement counts per scoring tier across all simulations.
|
||||
* 8. Convert counts to probability distributions.
|
||||
*
|
||||
* Pre-bracket vs post-bracket mode:
|
||||
* Pre-bracket (>12 participants): probabilities reflect both selection uncertainty
|
||||
* and bracket performance. A bubble team might appear in only 40% of simulated
|
||||
* fields, so its champion probability accounts for that.
|
||||
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
|
||||
*
|
||||
* Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
|
||||
* Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
|
||||
*
|
||||
* Selection weight (pre-bracket mode):
|
||||
* With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
|
||||
* Without sourceOdds: softmax on Elo with temperature SELECTION_TEMP (sharply favors
|
||||
* higher-rated teams — a 200-point Elo gap yields ~7× selection weight difference).
|
||||
*
|
||||
* 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)
|
||||
* probFifth–probEighth = Quarterfinal losers (4 per sim — split evenly)
|
||||
* First Round losers → all 0 (score 0 fantasy points)
|
||||
* Teams not selected → all 0 (not in field for that sim)
|
||||
*
|
||||
* Admin setup:
|
||||
* 1. Create a Sport with simulatorType = "ncaa_football_bracket"
|
||||
* 2. Create a Sports Season and add all contender participants (12 or more)
|
||||
* 3. Enter FPI ratings via Admin → Elo Ratings (stored as sourceElo)
|
||||
* 4. Optionally enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||
* — strongly recommended for pre-bracket mode; drives both selection and bracket strength
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Blend weights for per-game win probability when sourceOdds are present.
|
||||
* Lower Elo weight than other sports (0.7) gives more influence to Vegas
|
||||
* championship odds, which are highly informative in college football.
|
||||
*/
|
||||
const ELO_WEIGHT = 0.6;
|
||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||||
|
||||
/**
|
||||
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
|
||||
* At T=100, a 200-point Elo gap produces ~7× weight difference — enough to strongly
|
||||
* favour the top teams while still giving bubble teams meaningful selection probability.
|
||||
*/
|
||||
const SELECTION_TEMP = 100;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Team {
|
||||
participantId: string;
|
||||
elo: number;
|
||||
/** Normalized futures win probability (0–1). Used for blending per-game win prob. */
|
||||
oddsProb: number;
|
||||
/**
|
||||
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
|
||||
* Derived from oddsProb when available; otherwise softmax on Elo.
|
||||
*/
|
||||
selectionWeight: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Blended win probability for team1 vs team2.
|
||||
* Falls back to pure Elo when no futures data is present.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted sample without replacement — selects `n` teams from `pool` where each
|
||||
* team's probability of being drawn is proportional to its selectionWeight.
|
||||
* Returns the selected teams sorted by Elo descending (seed 1 = best Elo).
|
||||
*/
|
||||
function sampleBracketField(pool: Team[], n: number): Team[] {
|
||||
const remaining = [...pool];
|
||||
const selected: Team[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const totalWeight = remaining.reduce((sum, t) => sum + t.selectionWeight, 0);
|
||||
let r = Math.random() * totalWeight;
|
||||
let j = 0;
|
||||
for (; j < remaining.length - 1; j++) {
|
||||
r -= remaining[j].selectionWeight;
|
||||
if (r <= 0) break;
|
||||
}
|
||||
selected.push(remaining[j]);
|
||||
remaining.splice(j, 1);
|
||||
}
|
||||
|
||||
// Seed by Elo so that the best team in the sampled field is always seed 1.
|
||||
return selected.toSorted((a, b) => b.elo - a.elo);
|
||||
}
|
||||
|
||||
// ─── Bracket simulation ───────────────────────────────────────────────────────
|
||||
|
||||
interface PlacementCounts {
|
||||
champion: number;
|
||||
finalist: number;
|
||||
sfLoser: number;
|
||||
qfLoser: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate one full 12-team CFP bracket.
|
||||
*
|
||||
* Seeding (teams sorted best→worst Elo, 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 5–12) ───────────────────────────────────────────────
|
||||
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 1–4 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
|
||||
|
||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||
const entry = counts.get(id);
|
||||
if (entry) entry[key]++;
|
||||
};
|
||||
|
||||
bump(qf1.loser.participantId, "qfLoser");
|
||||
bump(qf2.loser.participantId, "qfLoser");
|
||||
bump(qf3.loser.participantId, "qfLoser");
|
||||
bump(qf4.loser.participantId, "qfLoser");
|
||||
|
||||
// ── Semifinals ────────────────────────────────────────────────────────────
|
||||
const sf1 = simGame(qf1.winner, qf2.winner);
|
||||
const sf2 = simGame(qf3.winner, qf4.winner);
|
||||
|
||||
bump(sf1.loser.participantId, "sfLoser");
|
||||
bump(sf2.loser.participantId, "sfLoser");
|
||||
|
||||
// ── National Championship ─────────────────────────────────────────────────
|
||||
const final = simGame(sf1.winner, sf2.winner);
|
||||
|
||||
bump(final.winner.participantId, "champion");
|
||||
bump(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 < BRACKET_SIZE) {
|
||||
throw new Error(
|
||||
`CFP simulator requires at least ${BRACKET_SIZE} participants, ` +
|
||||
`found ${participants.length}. Add all contender teams to the sports season.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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 raw odds maps in a single pass.
|
||||
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).
|
||||
const 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 for any team missing sourceElo.
|
||||
if (eloFromDb.size < participants.length) {
|
||||
const oddsInput = evRows
|
||||
.filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
|
||||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
||||
|
||||
if (oddsInput.length > 0) {
|
||||
const oddsEloMap = convertFuturesToElo(oddsInput, "american");
|
||||
for (const [id, elo] of oddsEloMap) {
|
||||
eloFromDb.set(id, elo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build team list with Elo, oddsProb, and selectionWeight.
|
||||
const hasOdds = normalizedOddsMap.size > 0;
|
||||
|
||||
const allTeams: Team[] = participants.map((p) => ({
|
||||
participantId: p.id,
|
||||
elo: eloFromDb.get(p.id) ?? 1500,
|
||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||
selectionWeight: 0, // computed below
|
||||
}));
|
||||
|
||||
if (hasOdds) {
|
||||
// Selection weight = normalized championship implied probability.
|
||||
// This encodes both "probability of making the field" and "strength once there."
|
||||
for (const team of allTeams) {
|
||||
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
|
||||
}
|
||||
} else {
|
||||
// No odds: softmax on Elo so top-rated teams are strongly favoured.
|
||||
const eloValues = allTeams.map((t) => t.elo);
|
||||
const maxElo = Math.max(...eloValues);
|
||||
// Subtract max for numerical stability before exp().
|
||||
const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
|
||||
const expSum = expWeights.reduce((a, b) => a + b, 0);
|
||||
for (let i = 0; i < allTeams.length; i++) {
|
||||
allTeams[i].selectionWeight = expWeights[i] / expSum;
|
||||
}
|
||||
}
|
||||
|
||||
const preBracketMode = participants.length > BRACKET_SIZE;
|
||||
|
||||
// In post-bracket mode (exactly 12), sort once and reuse the same field every sim.
|
||||
const deterministicField = preBracketMode
|
||||
? null
|
||||
: [...allTeams].toSorted((a, b) => b.elo - a.elo);
|
||||
|
||||
// 5. Initialise placement count accumulators for all participants.
|
||||
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++) {
|
||||
const field = preBracketMode
|
||||
? sampleBracketField(allTeams, BRACKET_SIZE)
|
||||
: (deterministicField ?? []);
|
||||
simulateBracket(field, counts);
|
||||
}
|
||||
|
||||
// 7. Convert counts to probability distributions.
|
||||
// SF losers: 2 per sim → each team's share = sfLoser / (2 * N).
|
||||
// QF losers: 4 per sim → 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) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
|
||||
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",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { MLBSimulator } from "./mlb-simulator";
|
|||
import { NFLSimulator } from "./nfl-simulator";
|
||||
import { WNBASimulator } from "./wnba-simulator";
|
||||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
import { NCAAFootballSimulator } from "./ncaa-football-simulator";
|
||||
|
||||
export const SIMULATOR_TYPES = [
|
||||
"f1_standings",
|
||||
|
|
@ -44,6 +45,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];
|
||||
|
|
@ -143,6 +145,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 9–16) 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 1–4 receive first-round byes; First Round losers score 0 points." },
|
||||
create: () => new NCAAFootballSimulator(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getSimulator(simulatorType: SimulatorType): Simulator {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,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", [
|
||||
|
|
|
|||
1
drizzle/0072_jittery_steve_rogers.sql
Normal file
1
drizzle/0072_jittery_steve_rogers.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'ncaa_football_bracket';
|
||||
4472
drizzle/meta/0072_snapshot.json
Normal file
4472
drizzle/meta/0072_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -505,6 +505,13 @@
|
|||
"when": 1775528386356,
|
||||
"tag": "0071_many_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1775652883157,
|
||||
"tag": "0072_jittery_steve_rogers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue