feat: NBA playoff bracket template and bracket-aware simulator (#291)

Adds the NBA_20 bracket template (20-team structure with play-in
tournament) and rewrites the NBA simulator to be bracket-aware like UCL.

- Add NBA_20 bracket template: PIR1 (4) → PIR2 (2) → First Round (8)
  → Conference Semifinals → Conference Finals → NBA Finals
- Add participantLabels (East/West 1–10) for admin bracket UI slot labels
- Add generateNBA20Bracket and advanceNBAPlayInWinner in playoff-match.ts
  with custom play-in advancement logic (PIR1 winners go to two different
  destinations)
- Rewrite NBASimulator with two auto-detected modes:
  - Bracket-aware (preferred): reads actual bracket state, simulates only
    remaining rounds forward using 50k Monte Carlo iterations
  - Season-projection (fallback): existing logic for pre-playoff use
- Guard resolveGame/resolveSeries against null participants (throws with
  match ID and slot info instead of silently using empty string)
- Document load-bearing First Round match ordering in generateNBA20Bracket
- Add 25 unit tests covering Elo math, team data, template structure,
  and all simulator integration paths

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-13 00:51:53 -04:00 committed by GitHub
parent cf91de941b
commit 040c97137b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1257 additions and 156 deletions

View file

@ -82,6 +82,12 @@ export interface BracketTemplate {
* all play-in teams grouped by region (and play-in order within each region).
*/
regions?: BracketRegion[];
/**
* Optional human-readable labels for each participant slot (0-indexed).
* When provided, the admin bracket UI shows these labels instead of "Seed N".
* Length must equal totalTeams.
*/
participantLabels?: string[];
}
/** All seed numbers in a standard 16-team regional bracket (116) */
@ -673,6 +679,96 @@ export const CFP_12: BracketTemplate = {
],
};
/**
* NBA Playoffs with Play-In Tournament (20 teams)
*
* Structure:
* Play-In Round 1 (4 games, not scoring):
* East: 7 vs 8 (winner = E7 seed), 9 vs 10 (winner advances; loser eliminated)
* West: 7 vs 8 (winner = W7 seed), 9 vs 10 (winner advances; loser eliminated)
* Play-In Round 2 (2 games, not scoring):
* East: loser of 7/8 vs winner of 9/10 winner = E8 seed; loser eliminated
* West: loser of 7/8 vs winner of 9/10 winner = W8 seed; loser eliminated
* First Round (8 series, not scoring):
* East: 1v8, 4v5, 2v7, 3v6
* West: 1v8, 4v5, 2v7, 3v6
* Conference Semifinals (4 series, scoring starts 5th-8th for losers)
* Conference Finals (2 series, scoring)
* NBA Finals (1 series, scoring)
*
* Participant array layout (20 slots, indices 0-19):
* [09] East seeds 110
* [1019] West seeds 110
*
* Play-in loser paths (custom advancement logic):
* PIR1 M1 (E7v8): winner First Round M3 p1; loser PIR2 M1 p1
* PIR1 M2 (E9v10): winner PIR2 M1 p2; loser eliminated
* PIR1 M3 (W7v8): winner First Round M7 p1; loser PIR2 M2 p1
* PIR1 M4 (W9v10): winner PIR2 M2 p2; loser eliminated
* PIR2 M1: winner First Round M1 p2; loser eliminated
* PIR2 M2: winner First Round M5 p2; loser eliminated
*
* First Round bracket order (enables correct Conference Semis matchups via standard ceil logic):
* M1: E1 vs E8 (play-in winner) CS M1 p1
* M2: E4 vs E5 CS M1 p2
* M3: E2 vs E7 (play-in winner) CS M2 p1
* M4: E3 vs E6 CS M2 p2
* M5: W1 vs W8 (play-in winner) CS M3 p1
* M6: W4 vs W5 CS M3 p2
* M7: W2 vs W7 (play-in winner) CS M4 p1
* M8: W3 vs W6 CS M4 p2
*/
export const NBA_20: BracketTemplate = {
id: "nba_20",
name: "NBA Playoffs with Play-In (20 teams)",
totalTeams: 20,
scoringStartsAtRound: "Conference Semifinals",
rounds: [
{
name: "Play-In Round 1",
matchCount: 4,
feedsInto: "Play-In Round 2",
isScoring: false, // Play-in games don't score
},
{
name: "Play-In Round 2",
matchCount: 2,
feedsInto: "First Round",
isScoring: false, // Still determining playoff field
},
{
name: "First Round",
matchCount: 8,
feedsInto: "Conference Semifinals",
isScoring: false, // First Round losers score 0 points
},
{
name: "Conference Semifinals",
matchCount: 4,
feedsInto: "Conference Finals",
isScoring: true, // Losers share 5th-8th
},
{
name: "Conference Finals",
matchCount: 2,
feedsInto: "NBA Finals",
isScoring: true, // Losers share 3rd-4th
},
{
name: "NBA Finals",
matchCount: 1,
feedsInto: null,
isScoring: true, // Winner 1st, Loser 2nd
},
],
participantLabels: [
"East 1", "East 2", "East 3", "East 4", "East 5",
"East 6", "East 7", "East 8", "East 9", "East 10",
"West 1", "West 2", "West 3", "West 4", "West 5",
"West 6", "West 7", "West 8", "West 9", "West 10",
],
};
/**
* All available bracket templates
*/
@ -687,6 +783,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
fifa_48: FIFA_48,
darts_128: DARTS_128,
cfp_12: CFP_12,
nba_20: NBA_20,
};
/**

View file

@ -360,6 +360,11 @@ export async function generateBracketFromTemplate(
return await generateCFP12Bracket(eventId, template, participantIds);
}
// NBA 20 requires special handling for the play-in tournament
if (templateId === "nba_20") {
return await generateNBA20Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
@ -866,6 +871,13 @@ export async function advanceWinnerTemplate(
const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found");
// Special handling for NBA 20 play-in tournament
if (template.id === "nba_20" && (match.round === "Play-In Round 1" || match.round === "Play-In Round 2")) {
const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for NBA play-in advancement");
return await advanceNBAPlayInWinner(match, winnerId, loserId);
}
// Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") {
@ -1168,3 +1180,233 @@ async function generateCFP12Bracket(
return await createManyPlayoffMatches(matches);
}
/**
* Generate NBA 20-team bracket (Play-In + Playoffs).
*
* Participant array layout (indices 0-19):
* [09] East seeds 110 (E1=0, E2=1, ..., E10=9)
* [1019] West seeds 110 (W1=10, W2=11, ..., W10=19)
*
* Play-In Round 1 (4 matches):
* M1: E7(6) vs E8(7) M2: E9(8) vs E10(9)
* M3: W7(16) vs W8(17) M4: W9(18) vs W10(19)
*
* Play-In Round 2 (2 matches, TBD):
* M1: East loser(7v8) vs East winner(9v10) E8 seed
* M2: West loser(7v8) vs West winner(9v10) W8 seed
*
* First Round (8 series bracket order for correct Conference Semis matchups via ceil logic):
* M1: E1 vs E8 (PIR2-M1 winner) M2: E4 vs E5
* M3: E2 vs E7 (PIR1-M1 winner) M4: E3 vs E6
* M5: W1 vs W8 (PIR2-M2 winner) M6: W4 vs W5
* M7: W2 vs W7 (PIR1-M3 winner) M8: W3 vs W6
*/
async function generateNBA20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
const pir1Seeding: [number, number, string][] = [
[6, 7, "East: 7 vs 8"],
[8, 9, "East: 9 vs 10"],
[16, 17, "West: 7 vs 8"],
[18, 19, "West: 9 vs 10"],
];
for (let i = 0; i < pir1Seeding.length; i++) {
const [idx1, idx2, seedInfo] = pir1Seeding[i];
matches.push({
scoringEventId: eventId,
round: "Play-In Round 1",
matchNumber: i + 1,
participant1Id: p(idx1),
participant2Id: p(idx2),
isComplete: false,
isScoring: false,
templateRound: "Play-In Round 1",
seedInfo,
});
}
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
const pir2SeedInfo = [
"East: 7/8 loser vs 9/10 winner",
"West: 7/8 loser vs 9/10 winner",
];
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Play-In Round 2",
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: false,
templateRound: "Play-In Round 2",
seedInfo: pir2SeedInfo[i],
});
}
// ── First Round (8 series) ────────────────────────────────────────────────────
// IMPORTANT: This match ordering is load-bearing.
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
// seed slots: FR M1 p2 = E8, FR M3 p1 = E7, FR M5 p2 = W8, FR M7 p1 = W7.
// Do not change the match order without updating the simulator's resolveGame/
// resolveSeries calls and the corresponding test fixtures.
const firstRoundSeeding: [number | null, number | null, string][] = [
[0, null, "East: 1 vs 8"],
[3, 4, "East: 4 vs 5"],
[1, null, "East: 2 vs 7"],
[2, 5, "East: 3 vs 6"],
[10, null, "West: 1 vs 8"],
[13, 14, "West: 4 vs 5"],
[11, null, "West: 2 vs 7"],
[12, 15, "West: 3 vs 6"],
];
for (let i = 0; i < firstRoundSeeding.length; i++) {
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
matches.push({
scoringEventId: eventId,
round: "First Round",
matchNumber: i + 1,
participant1Id: idx1 !== null ? p(idx1) : null,
participant2Id: idx2 !== null ? p(idx2) : null,
isComplete: false,
isScoring: false,
templateRound: "First Round",
seedInfo,
});
}
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
for (let roundIndex = 3; 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);
}
/**
* NBA play-in advancement logic.
*
* Play-In Round 1:
* M1 (E7v8): winner First Round M3 p1; loser PIR2 M1 p1
* M2 (E9v10): winner PIR2 M1 p2; loser eliminated
* M3 (W7v8): winner First Round M7 p1; loser PIR2 M2 p1
* M4 (W9v10): winner PIR2 M2 p2; loser eliminated
*
* Play-In Round 2:
* M1: winner First Round M1 p2 (E8 seed); loser eliminated
* M2: winner First Round M5 p2 (W8 seed); loser eliminated
*/
async function advanceNBAPlayInWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
if (match.round === "Play-In Round 1") {
if (match.matchNumber === 1) {
// E7v8: winner → First Round M3 p1; loser → PIR2 M1 p1
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
]);
const frM3 = frMatches.find((m) => m.matchNumber === 3);
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
if (!frM3) throw new Error("First Round match 3 not found");
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
if (frM3.participant1Id) throw new Error("First Round M3 participant1 already filled");
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM3.id, { participant1Id: winnerId }),
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
]);
return;
}
if (match.matchNumber === 2) {
// E9v10: winner → PIR2 M1 p2; loser eliminated
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
if (pir2M1.participant2Id) throw new Error("Play-In Round 2 M1 participant2 already filled");
await updatePlayoffMatch(pir2M1.id, { participant2Id: winnerId });
return;
}
if (match.matchNumber === 3) {
// W7v8: winner → First Round M7 p1; loser → PIR2 M2 p1
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
]);
const frM7 = frMatches.find((m) => m.matchNumber === 7);
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
if (!frM7) throw new Error("First Round match 7 not found");
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
if (frM7.participant1Id) throw new Error("First Round M7 participant1 already filled");
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM7.id, { participant1Id: winnerId }),
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
]);
return;
}
if (match.matchNumber === 4) {
// W9v10: winner → PIR2 M2 p2; loser eliminated
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
if (pir2M2.participant2Id) throw new Error("Play-In Round 2 M2 participant2 already filled");
await updatePlayoffMatch(pir2M2.id, { participant2Id: winnerId });
return;
}
throw new Error(`Unknown Play-In Round 1 match number: ${match.matchNumber}`);
}
if (match.round === "Play-In Round 2") {
if (match.matchNumber === 1) {
// E8 seed: winner → First Round M1 p2; loser eliminated
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
const frM1 = frMatches.find((m) => m.matchNumber === 1);
if (!frM1) throw new Error("First Round match 1 not found");
if (frM1.participant2Id) throw new Error("First Round M1 participant2 already filled");
await updatePlayoffMatch(frM1.id, { participant2Id: winnerId });
return;
}
if (match.matchNumber === 2) {
// W8 seed: winner → First Round M5 p2; loser eliminated
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
const frM5 = frMatches.find((m) => m.matchNumber === 5);
if (!frM5) throw new Error("First Round match 5 not found");
if (frM5.participant2Id) throw new Error("First Round M5 participant2 already filled");
await updatePlayoffMatch(frM5.id, { participant2Id: winnerId });
return;
}
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
}
}

View file

@ -643,12 +643,13 @@ export default function EventBracket({
</p>
{[...Array(template?.totalTeams || 8)].map((_, i) => {
const availableParticipants = getAvailableParticipants(i);
const slotLabel = template?.participantLabels?.[i] ?? `Seed ${i + 1}`;
return (
// eslint-disable-next-line react/no-array-index-key
<div key={i} className="flex items-center gap-2">
<Label className="w-20 text-sm text-muted-foreground shrink-0">
Seed {i + 1}
{slotLabel}
</Label>
<div className="flex-1 min-w-0">
<input
@ -661,7 +662,7 @@ export default function EventBracket({
participants={availableParticipants}
value={selectedParticipants[i] || ""}
onValueChange={(value) => handleParticipantChange(i, value)}
placeholder={`Select seed ${i + 1}...`}
placeholder={`Select ${slotLabel.toLowerCase()}...`}
/>
</div>
</div>

View file

@ -0,0 +1,497 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { getTeamData, eloWinProbability, NBASimulator } from "../nba-simulator";
import { NBA_20, BRACKET_TEMPLATES } from "~/lib/bracket-templates";
// ─── Pure math / team data ────────────────────────────────────────────────────
describe("eloWinProbability", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 10);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1600, 1400)).toBeGreaterThan(0.5);
expect(eloWinProbability(1400, 1600)).toBeLessThan(0.5);
});
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
const a = 1650;
const b = 1480;
expect(eloWinProbability(a, b) + eloWinProbability(b, a)).toBeCloseTo(1.0, 10);
});
it("approaches 1 for a very large Elo advantage", () => {
expect(eloWinProbability(2000, 1000)).toBeGreaterThan(0.99);
});
});
describe("getTeamData", () => {
it("finds teams by full name", () => {
expect(getTeamData("Boston Celtics")).toMatchObject({ conference: "Eastern" });
expect(getTeamData("Oklahoma City Thunder")).toMatchObject({ conference: "Western" });
});
it("is case-insensitive", () => {
expect(getTeamData("boston celtics")).toBeDefined();
expect(getTeamData("OKLAHOMA CITY THUNDER")).toBeDefined();
});
it("returns undefined for unknown teams", () => {
expect(getTeamData("Springfield Nuclears")).toBeUndefined();
});
it("covers all 30 NBA teams", () => {
const allTeams = [
// Eastern Conference
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
// Western Conference
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
];
for (const name of allTeams) {
expect(getTeamData(name), `${name} not found`).toBeDefined();
}
expect(allTeams).toHaveLength(30);
});
it("has exactly 15 Eastern and 15 Western teams", () => {
const allTeams = [
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
];
const east = allTeams.filter((n) => getTeamData(n)?.conference === "Eastern");
const west = allTeams.filter((n) => getTeamData(n)?.conference === "Western");
expect(east).toHaveLength(15);
expect(west).toHaveLength(15);
});
});
// ─── NBA_20 bracket template structure ───────────────────────────────────────
describe("NBA_20 bracket template", () => {
it("is registered in BRACKET_TEMPLATES", () => {
expect(BRACKET_TEMPLATES.nba_20).toBe(NBA_20);
});
it("has correct totalTeams and id", () => {
expect(NBA_20.id).toBe("nba_20");
expect(NBA_20.totalTeams).toBe(20);
expect(NBA_20.scoringStartsAtRound).toBe("Conference Semifinals");
});
it("has 6 rounds in the correct order", () => {
const names = NBA_20.rounds.map((r) => r.name);
expect(names).toEqual([
"Play-In Round 1",
"Play-In Round 2",
"First Round",
"Conference Semifinals",
"Conference Finals",
"NBA Finals",
]);
});
it("has correct match counts per round", () => {
const counts = NBA_20.rounds.map((r) => r.matchCount);
expect(counts).toEqual([4, 2, 8, 4, 2, 1]);
});
it("non-scoring rounds have isScoring=false", () => {
const nonScoring = ["Play-In Round 1", "Play-In Round 2", "First Round"];
for (const name of nonScoring) {
const round = NBA_20.rounds.find((r) => r.name === name);
expect(round?.isScoring, `${name} should be non-scoring`).toBe(false);
}
});
it("scoring rounds have isScoring=true", () => {
const scoring = ["Conference Semifinals", "Conference Finals", "NBA Finals"];
for (const name of scoring) {
const round = NBA_20.rounds.find((r) => r.name === name);
expect(round?.isScoring, `${name} should be scoring`).toBe(true);
}
});
it("feedsInto forms a valid chain ending at null", () => {
const roundByName = new Map(NBA_20.rounds.map((r) => [r.name, r]));
let current = NBA_20.rounds[0];
const visited: string[] = [];
while (current.feedsInto !== null) {
visited.push(current.name);
const next = roundByName.get(current.feedsInto);
expect(next, `${current.feedsInto} not found in rounds`).toBeDefined();
if (!next) break;
current = next;
}
visited.push(current.name);
expect(visited).toHaveLength(NBA_20.rounds.length);
expect(current.name).toBe("NBA Finals");
});
it("has 20 participantLabels with correct East/West structure", () => {
const labels = NBA_20.participantLabels ?? [];
expect(labels).toHaveLength(20);
// East seeds 1-10 at indices 0-9
for (let i = 0; i < 10; i++) {
expect(labels[i]).toMatch(/^East \d+$/);
}
// West seeds 1-10 at indices 10-19
for (let i = 10; i < 20; i++) {
expect(labels[i]).toMatch(/^West \d+$/);
}
});
});
// ─── Simulator integration tests (mocked DB) ─────────────────────────────────
const TEAM_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
/** Build a minimal DB match object */
function makeMatch(
round: string,
matchNumber: number,
opts: {
p1?: string | null;
p2?: string | null;
winnerId?: string | null;
loserId?: string | null;
isComplete?: boolean;
isScoring?: boolean;
} = {}
) {
return {
id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`,
scoringEventId: "event-1",
round,
matchNumber,
participant1Id: opts.p1 ?? null,
participant2Id: opts.p2 ?? null,
winnerId: opts.winnerId ?? null,
loserId: opts.loserId ?? null,
isComplete: opts.isComplete ?? false,
isScoring: opts.isScoring ?? false,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/**
* Builds a full 20-team NBA bracket with all Play-In and First Round slots
* filled so the simulator has valid participants on every match.
*
* Team layout matches generateNBA20Bracket:
* PIR1 M1: idx6(E7) vs idx7(E8) PIR1 M2: idx8(E9) vs idx9(E10)
* PIR1 M3: idx16(W7) vs idx17(W8) PIR1 M4: idx18(W9) vs idx19(W10)
* PIR2 M1: e8Cand vs e9e10W PIR2 M2: w8Cand vs w9w10W
* FR M1: idx0(E1) vs e8 FR M2: idx3(E4) vs idx4(E5)
* FR M3: e7 vs idx1(E2) FR M4: idx2(E3) vs idx5(E6)
* FR M5: idx10(W1) vs w8 FR M6: idx13(W4) vs idx14(W5)
* FR M7: w7 vs idx11(W2) FR M8: idx12(W3) vs idx15(W6)
*/
function buildFullBracket(
pir1Results: { m1Winner: string; m2Winner: string; m3Winner: string; m4Winner: string },
pir2Results: { m1Winner: string; m2Winner: string }
) {
const t = TEAM_IDS;
const e7 = pir1Results.m1Winner;
const e8Cand = t[7]; // E8 candidate = PIR1 M1 loser (for simplicity, hard-code)
const e9e10W = pir1Results.m2Winner;
const w7 = pir1Results.m3Winner;
const w8Cand = t[17];
const w9w10W = pir1Results.m4Winner;
const e8Seed = pir2Results.m1Winner;
const w8Seed = pir2Results.m2Winner;
return [
// Play-In Round 1
makeMatch("Play-In Round 1", 1, { p1: t[6], p2: t[7], winnerId: pir1Results.m1Winner, loserId: pir1Results.m1Winner === t[6] ? t[7] : t[6], isComplete: true }),
makeMatch("Play-In Round 1", 2, { p1: t[8], p2: t[9], winnerId: pir1Results.m2Winner, loserId: pir1Results.m2Winner === t[8] ? t[9] : t[8], isComplete: true }),
makeMatch("Play-In Round 1", 3, { p1: t[16], p2: t[17], winnerId: pir1Results.m3Winner, loserId: pir1Results.m3Winner === t[16] ? t[17] : t[16], isComplete: true }),
makeMatch("Play-In Round 1", 4, { p1: t[18], p2: t[19], winnerId: pir1Results.m4Winner, loserId: pir1Results.m4Winner === t[18] ? t[19] : t[18], isComplete: true }),
// Play-In Round 2
makeMatch("Play-In Round 2", 1, { p1: e8Cand, p2: e9e10W, winnerId: pir2Results.m1Winner, loserId: pir2Results.m1Winner === e8Cand ? e9e10W : e8Cand, isComplete: true }),
makeMatch("Play-In Round 2", 2, { p1: w8Cand, p2: w9w10W, winnerId: pir2Results.m2Winner, loserId: pir2Results.m2Winner === w8Cand ? w9w10W : w8Cand, isComplete: true }),
// First Round (play-in slots filled)
makeMatch("First Round", 1, { p1: t[0], p2: e8Seed, isScoring: false }),
makeMatch("First Round", 2, { p1: t[3], p2: t[4], isScoring: false }),
makeMatch("First Round", 3, { p1: e7, p2: t[1], isScoring: false }),
makeMatch("First Round", 4, { p1: t[2], p2: t[5], isScoring: false }),
makeMatch("First Round", 5, { p1: t[10], p2: w8Seed, isScoring: false }),
makeMatch("First Round", 6, { p1: t[13], p2: t[14], isScoring: false }),
makeMatch("First Round", 7, { p1: w7, p2: t[11], isScoring: false }),
makeMatch("First Round", 8, { p1: t[12], p2: t[15], isScoring: false }),
// Conference Semifinals (TBD)
makeMatch("Conference Semifinals", 1, { isScoring: true }),
makeMatch("Conference Semifinals", 2, { isScoring: true }),
makeMatch("Conference Semifinals", 3, { isScoring: true }),
makeMatch("Conference Semifinals", 4, { isScoring: true }),
// Conference Finals (TBD)
makeMatch("Conference Finals", 1, { isScoring: true }),
makeMatch("Conference Finals", 2, { isScoring: true }),
// NBA Finals (TBD)
makeMatch("NBA Finals", 1, { isScoring: true }),
];
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
}));
describe("NBASimulator — bracket-aware mode", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
beforeEach(async () => {
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(
TEAM_IDS.map((id) => ({ id, name: id })) // name = id (no real team name → fallback Elo 1400)
),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
});
it("falls back to season-projection when no playoff matches exist", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
// Season projection needs standings — mock getRegularSeasonStandings via the model import
// Since season-projection throws when conference split < 10, we just verify the right path:
const sim = new NBASimulator();
// select().from().where() returns 20 teams, but no conference data → East assigned via TEAMS_DATA
// All teams have unknown names so conference defaults to "Eastern" → <10 Western → error
await expect(sim.simulate("season-1")).rejects.toThrow(/Each conference needs at least 10/);
});
it("throws if bracket structure is wrong (wrong match count)", async () => {
// Only return PIR1 matches — missing rest of bracket
const pir1Only = [1, 2, 3, 4].map((n) =>
makeMatch("Play-In Round 1", n, { p1: TEAM_IDS[n - 1], p2: TEAM_IDS[n + 3] })
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(pir1Only);
const sim = new NBASimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
});
it("throws if a participant slot is missing (no override and DB null)", async () => {
const t = TEAM_IDS;
// Build bracket but leave FR M2 with null p2 (no override provided)
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
// Null out FR M2 p2
const frM2 = matches.find((m) => m.round === "First Round" && m.matchNumber === 2);
if (!frM2) throw new Error("FR M2 not found in test fixture");
frM2.participant2Id = null;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Cannot resolve series/);
});
it("returns results for all 20 participants", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(20);
});
it("each probability column sums to 1.0 across all 20 participants", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1);
}
});
it("all probabilities are non-negative", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const v of Object.values(r.probabilities)) {
expect(v).toBeGreaterThanOrEqual(0);
}
}
});
it("completed tournament: champion has probFirst=1, all others probFirst≈0", async () => {
const t = TEAM_IDS;
const champion = t[0]; // E1
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
// Mark all bracket matches complete with t[0] winning everything on the East side
// and t[10] winning West side, then t[0] beats t[10] in finals
const frMatches = matches.filter((m) => m.round === "First Round");
const csMatches = matches.filter((m) => m.round === "Conference Semifinals");
const cfMatches = matches.filter((m) => m.round === "Conference Finals");
const finalMatch = matches.find((m) => m.round === "NBA Finals");
if (!finalMatch) throw new Error("Finals match not found in test fixture");;
// Fully script the bracket:
// FR: top seed wins each series
for (const m of frMatches) {
const w = m.participant1Id ?? "";
m.winnerId = w;
m.loserId = m.participant2Id;
m.isComplete = true;
}
// CS M1: FR M1 winner vs FR M2 winner → FR M1 winner (t[0]) wins
csMatches[0].participant1Id = frMatches[0].winnerId;
csMatches[0].participant2Id = frMatches[1].winnerId;
csMatches[0].winnerId = frMatches[0].winnerId; // t[0]
csMatches[0].loserId = frMatches[1].winnerId;
csMatches[0].isComplete = true;
// CS M2: FR M3 winner vs FR M4 winner
csMatches[1].participant1Id = frMatches[2].winnerId;
csMatches[1].participant2Id = frMatches[3].winnerId;
csMatches[1].winnerId = frMatches[2].winnerId;
csMatches[1].loserId = frMatches[3].winnerId;
csMatches[1].isComplete = true;
// CS M3: FR M5 winner vs FR M6 winner
csMatches[2].participant1Id = frMatches[4].winnerId;
csMatches[2].participant2Id = frMatches[5].winnerId;
csMatches[2].winnerId = frMatches[4].winnerId; // t[10]
csMatches[2].loserId = frMatches[5].winnerId;
csMatches[2].isComplete = true;
// CS M4: FR M7 winner vs FR M8 winner
csMatches[3].participant1Id = frMatches[6].winnerId;
csMatches[3].participant2Id = frMatches[7].winnerId;
csMatches[3].winnerId = frMatches[6].winnerId;
csMatches[3].loserId = frMatches[7].winnerId;
csMatches[3].isComplete = true;
// CF M1: East champion (CS M1 winner = t[0])
cfMatches[0].participant1Id = csMatches[0].winnerId;
cfMatches[0].participant2Id = csMatches[1].winnerId;
cfMatches[0].winnerId = csMatches[0].winnerId; // t[0]
cfMatches[0].loserId = csMatches[1].winnerId;
cfMatches[0].isComplete = true;
// CF M2: West champion (CS M3 winner = t[10])
cfMatches[1].participant1Id = csMatches[2].winnerId;
cfMatches[1].participant2Id = csMatches[3].winnerId;
cfMatches[1].winnerId = csMatches[2].winnerId; // t[10]
cfMatches[1].loserId = csMatches[3].winnerId;
cfMatches[1].isComplete = true;
// Finals: t[0] beats t[10]
finalMatch.participant1Id = cfMatches[0].winnerId;
finalMatch.participant2Id = cfMatches[1].winnerId;
finalMatch.winnerId = champion;
finalMatch.loserId = t[10];
finalMatch.isComplete = true;
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
const champResult = results.find((r) => r.participantId === champion);
expect(champResult, "champion result should exist").toBeDefined();
expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3);
for (const r of results) {
if (r.participantId !== champion) {
expect(r.probabilities.probFirst).toBeCloseTo(0, 3);
}
}
});
it("uses source: 'nba_bracket_monte_carlo' on all results", async () => {
const t = TEAM_IDS;
const matches = buildFullBracket(
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
{ m1Winner: t[7], m2Winner: t[17] }
);
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new NBASimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("nba_bracket_monte_carlo");
}
});
});

View file

@ -1,54 +1,54 @@
/**
* NBA Playoff Simulator
*
* Monte Carlo simulation of the NBA playoffs including seeding projection
* for the current season (2025-26).
* Two modes, auto-detected at runtime:
*
* Mode 1: Bracket-Aware (preferred)
* Used when a playoff_game scoring event with generated matches exists for the
* sports season. Reads the actual bracket state from the DB and simulates only
* the remaining rounds forward.
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Load current regular season standings (wins, gamesPlayed, conference)
* 3. Match participant names to hardcoded team data (Elo ratings)
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (82 - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
* projectedWins = currentWins + simulatedRemainingWins
* b. Sort each conference by projected wins (desc) + random tiebreaker seeds 110
* Top 6 lock in directly; seeds 710 enter the Play-In tournament
* c. Simulate Play-In (single game each):
* - Game 1: seed 7 vs seed 8 winner becomes 7th playoff seed
* - Game 2: seed 9 vs seed 10 winner advances
* - Game 3: Game 1 loser vs Game 2 winner winner becomes 8th playoff seed
* d. Simulate NBA playoff bracket (best-of-7 series each round):
* Round 1: 1v8, 4v5, 2v7, 3v6 (per conference)
* Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6)
* Round 3: Conference Finals
* NBA Finals: East champion vs West champion
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
* 1. Find the playoff_game scoring event; load all playoffMatches
* 2. Group matches by round: Play-In Round 1/2, First Round, Conference
* Semifinals, Conference Finals, NBA Finals
* 3. Build Elo map from TEAMS_DATA (name-matched from participants table)
* 4. For each of 50k simulations:
* a. Play-In Round 1 (4 single games): use real result if isComplete, else sim
* derives E7/E8-candidate/E9-E10-winner and their West equivalents
* b. Play-In Round 2 (2 single games): uses PIR1 results as participant
* fallbacks when DB slots are still null
* derives E8 and W8 seeds
* c. First Round (8 best-of-7 series): uses simmed seeds as participant
* fallbacks for the 4 play-in slots; real results respected
* d. Conference Semifinals Conference Finals NBA Finals: same
* completed-vs-simulate pattern; standard ceil bracket path
* 5. Track integer placement counts per tier (champion/finalist/CF loser/CS loser)
* 6. Convert to probability distributions with exact denominators
*
* Win probability (Elo, PARITY_FACTOR = 400):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400))
* In-progress series: treated as a fresh best-of-7 (partial game scores are not
* used). A series only locks once isComplete + winnerId + loserId are all set.
*
* Regular season projection:
* Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent.
* If no standings exist in DB, defaults to 0 wins / 82 remaining games (seeding by Elo only).
* Conference is read from standings table; falls back to TEAMS_DATA if missing.
* Mode 2: Season Projection (fallback)
* Used when no bracket event / matches exist yet (pre-playoffs). Projects
* remaining regular season games seeds play-in playoffs from scratch.
* See inline comments for details.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = NBA champion (1 per sim)
* probSecond = NBA Finals loser (1 per sim)
* probThird/Fourth = Conference Finals losers (2 per sim East + West)
* Placement tiers (both modes):
* probFirst = NBA champion
* probSecond = NBA Finals loser
* probThird/Fourth = Conference Finals losers (2 per sim)
* probFifthEighth = Conference Semis losers (4 per sim)
* Round 1 losers all 0 (score 0 points)
* Missed playoffs all 0
* First Round / Play-In losers all 0 (score 0 points)
* Missed playoffs (Mode 2 only) all 0
*
* Elo ratings are hardcoded below (March 2026 data).
* Elo ratings are hardcoded (March 2026 data).
* Source: Neil Paine Substack playoff Elo estimates (last 110 games, no regression,
* postseason games 3× weight). Update at the start of each season.
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
@ -68,13 +68,6 @@ const PARITY_FACTOR = 400;
const NBA_REGULAR_SEASON_GAMES = 82;
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
//
// elo: Estimated Elo rating (higher = stronger).
// Source: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight).
// This is the appropriate signal for simulating both regular season win probability
// (vs. average opponent) and playoff matchups.
//
// conference: Used as a fallback when the standings table has no conference data.
interface NbaTeamData {
conference: "Eastern" | "Western";
@ -119,7 +112,7 @@ const TEAMS_DATA: Record<string, NbaTeamData> = {
"Utah Jazz": { conference: "Western", elo: 1334 },
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
// ─── Public helpers ───────────────────────────────────────────────────────────
export { normalizeTeamName };
@ -135,42 +128,113 @@ export function getTeamData(name: string): NbaTeamData | undefined {
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
// ─── Per-position normalization ───────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: NbaTeamData | undefined;
conference: "Eastern" | "Western";
/** Actual wins from the standings table (0 if no standings loaded). */
currentWins: number;
/** Remaining regular season games = 82 - gamesPlayed (0 if season is complete). */
remainingGames: number;
/** Elo win probability vs. average opponent (1500) — constant per team. */
winProb: number;
}
const POSITION_KEYS = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/** Simulate remaining regular season games for a team.
* Uses the pre-computed per-team winProb (Elo vs. average opponent).
* Returns projected total wins for the season. */
function simulateProjectedWins(entry: TeamEntry): number {
let extra = 0;
for (let g = 0; g < entry.remainingGames; g++) {
if (Math.random() < entry.winProb) extra++;
function normalizeColumns(results: SimulationResult[]): void {
for (const key of POSITION_KEYS) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return entry.currentWins + extra;
}
// ─── Bracket-aware helpers ────────────────────────────────────────────────────
type DbMatch = typeof schema.playoffMatches.$inferSelect;
/** Simulate a single-game matchup. Returns winner and loser. */
function simGame(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const eloA = eloMap.get(a) ?? 1400;
const eloB = eloMap.get(b) ?? 1400;
const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b;
return { winner, loser: winner === a ? b : a };
}
/** Simulate a best-of-7 series. Returns winner and loser. */
function simSeries(
eloMap: Map<string, number>,
a: string,
b: string
): { winner: string; loser: string } {
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}
/**
* Resolve a play-in single game.
* Uses the real result if the match is complete; otherwise simulates.
* p1Override / p2Override are used when the DB participant slot is still null
* (i.e. filled in by the previous round's simulated result).
*/
function resolveGame(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simGame(eloMap, p1, p2);
}
/**
* Resolve a playoff series.
* Uses the real result if the match is complete; otherwise simulates best-of-7.
* p1Override / p2Override fill null DB participant slots with the simulated seed.
*/
function resolveSeries(
eloMap: Map<string, number>,
match: DbMatch | undefined,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
`Ensure the bracket is fully generated before simulating.`
);
}
return simSeries(eloMap, p1, p2);
}
// ─── Simulator ────────────────────────────────────────────────────────────────
@ -179,7 +243,217 @@ export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load participants and standings in parallel.
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
if (bracketEvent) {
const bracketMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
if (bracketMatches.length > 0) {
return this.simulateBracketAware(sportsSeasonId, bracketMatches);
}
}
// ── Fall back to season-projection mode ───────────────────────────────────
return this.simulateSeasonProjection(sportsSeasonId);
}
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
private async simulateBracketAware(
sportsSeasonId: string,
allMatches: DbMatch[]
): Promise<SimulationResult[]> {
const db = database();
// Group matches by round.
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const pir2 = allMatches.filter((m) => m.round === "Play-In Round 2")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const fr = allMatches.filter((m) => m.round === "First Round")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const cs = allMatches.filter((m) => m.round === "Conference Semifinals")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const cf = allMatches.filter((m) => m.round === "Conference Finals")
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const finals = allMatches.filter((m) => m.round === "NBA Finals");
if (
pir1.length !== 4 || pir2.length !== 2 || fr.length !== 8 ||
cs.length !== 4 || cf.length !== 2 || finals.length !== 1
) {
throw new Error(
`NBA bracket has unexpected structure. Expected PIR1×4, PIR2×2, FR×8, CS×4, CF×2, Finals×1. ` +
`Got PIR1×${pir1.length}, PIR2×${pir2.length}, FR×${fr.length}, ` +
`CS×${cs.length}, CF×${cf.length}, Finals×${finals.length}. ` +
`Ensure the bracket was generated using the nba_20 template.`
);
}
// Collect all participant IDs from the bracket.
const participantIdSet = new Set<string>();
for (const m of allMatches) {
if (m.participant1Id) participantIdSet.add(m.participant1Id);
if (m.participant2Id) participantIdSet.add(m.participant2Id);
if (m.winnerId) participantIdSet.add(m.winnerId);
if (m.loserId) participantIdSet.add(m.loserId);
}
const participantIds = [...participantIdSet];
// Load participant names to map IDs → Elo via TEAMS_DATA.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
// Build Elo map: TEAMS_DATA lookup by name, fallback 1400.
const eloMap = new Map<string, number>();
for (const id of participantIds) {
const name = nameById.get(id) ?? "";
eloMap.set(id, getTeamData(name)?.elo ?? 1400);
}
// Build per-round lookup maps for O(1) access in the hot loop.
const pir1ByNum = new Map(pir1.map((m) => [m.matchNumber, m]));
const pir2ByNum = new Map(pir2.map((m) => [m.matchNumber, m]));
const frByNum = new Map(fr.map((m) => [m.matchNumber, m]));
const csByNum = new Map(cs.map((m) => [m.matchNumber, m]));
const cfByNum = new Map(cf.map((m) => [m.matchNumber, m]));
const finalMatch = finals[0];
// Integer placement counts — avoids fractional accumulation error.
const championCounts = new Map(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map(participantIds.map((id) => [id, 0]));
const cfLoserCounts = new Map(participantIds.map((id) => [id, 0]));
const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
// ── Monte Carlo loop ──────────────────────────────────────────────────────
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Play-In Round 1 (4 single games) ─────────────────────────────────
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
// M2: East 9 vs 10 → winner enters PIR2 as p2; loser eliminated
// M3: West 7 vs 8 → winner = W7 seed; loser enters PIR2 as p1
// M4: West 9 vs 10 → winner enters PIR2 as p2; loser eliminated
const pir1M1 = resolveGame(eloMap, pir1ByNum.get(1));
const pir1M2 = resolveGame(eloMap, pir1ByNum.get(2));
const pir1M3 = resolveGame(eloMap, pir1ByNum.get(3));
const pir1M4 = resolveGame(eloMap, pir1ByNum.get(4));
const e7Seed = pir1M1.winner;
const e8Cand = pir1M1.loser; // plays PIR2 M1 p1
const e9e10W = pir1M2.winner; // plays PIR2 M1 p2
const w7Seed = pir1M3.winner;
const w8Cand = pir1M3.loser; // plays PIR2 M2 p1
const w9w10W = pir1M4.winner; // plays PIR2 M2 p2
// ── Play-In Round 2 (2 single games) ─────────────────────────────────
// M1: E8 candidate vs E9/10 winner → winner = E8 seed
// M2: W8 candidate vs W9/10 winner → winner = W8 seed
// Use DB participant slots when already filled (after PIR1 locked in),
// otherwise fall back to simulated values from above.
const pir2M1 = resolveGame(eloMap, pir2ByNum.get(1), e8Cand, e9e10W);
const pir2M2 = resolveGame(eloMap, pir2ByNum.get(2), w8Cand, w9w10W);
const e8Seed = pir2M1.winner;
const w8Seed = pir2M2.winner;
// ── First Round (8 best-of-7 series) ─────────────────────────────────
// Bracket order (matches correct Conference Semis pairings):
// M1: E1 vs E8 M2: E4 vs E5 M3: E2 vs E7 M4: E3 vs E6
// M5: W1 vs W8 M6: W4 vs W5 M7: W2 vs W7 M8: W3 vs W6
//
// Slots that stay null until play-in resolves use simmed seeds as fallback.
// Once the play-in result is recorded, the DB slot is filled, so participant
// coalescence (DB ?? simmed) always produces the correct team.
const frM1 = resolveSeries(eloMap, frByNum.get(1), undefined, e8Seed); // E1(DB) vs E8
const frM2 = resolveSeries(eloMap, frByNum.get(2)); // E4(DB) vs E5(DB)
const frM3 = resolveSeries(eloMap, frByNum.get(3), e7Seed, undefined); // E7 vs E2(DB)
const frM4 = resolveSeries(eloMap, frByNum.get(4)); // E3(DB) vs E6(DB)
const frM5 = resolveSeries(eloMap, frByNum.get(5), undefined, w8Seed); // W1(DB) vs W8
const frM6 = resolveSeries(eloMap, frByNum.get(6)); // W4(DB) vs W5(DB)
const frM7 = resolveSeries(eloMap, frByNum.get(7), w7Seed, undefined); // W7 vs W2(DB)
const frM8 = resolveSeries(eloMap, frByNum.get(8)); // W3(DB) vs W6(DB)
// ── Conference Semifinals (4 best-of-7 series) ────────────────────────
// Standard ceil bracket path:
// CS M1: FR M1/M2 winners (East upper: E1/8 vs E4/5)
// CS M2: FR M3/M4 winners (East lower: E2/7 vs E3/6)
// CS M3: FR M5/M6 winners (West upper: W1/8 vs W4/5)
// CS M4: FR M7/M8 winners (West lower: W2/7 vs W3/6)
const csM1 = resolveSeries(eloMap, csByNum.get(1), frM1.winner, frM2.winner);
const csM2 = resolveSeries(eloMap, csByNum.get(2), frM3.winner, frM4.winner);
const csM3 = resolveSeries(eloMap, csByNum.get(3), frM5.winner, frM6.winner);
const csM4 = resolveSeries(eloMap, csByNum.get(4), frM7.winner, frM8.winner);
csLoserCounts.set(csM1.loser, (csLoserCounts.get(csM1.loser) ?? 0) + 1);
csLoserCounts.set(csM2.loser, (csLoserCounts.get(csM2.loser) ?? 0) + 1);
csLoserCounts.set(csM3.loser, (csLoserCounts.get(csM3.loser) ?? 0) + 1);
csLoserCounts.set(csM4.loser, (csLoserCounts.get(csM4.loser) ?? 0) + 1);
// ── Conference Finals (2 best-of-7 series) ────────────────────────────
// CF M1: East champion (CS M1/M2 winners)
// CF M2: West champion (CS M3/M4 winners)
const cfM1 = resolveSeries(eloMap, cfByNum.get(1), csM1.winner, csM2.winner);
const cfM2 = resolveSeries(eloMap, cfByNum.get(2), csM3.winner, csM4.winner);
cfLoserCounts.set(cfM1.loser, (cfLoserCounts.get(cfM1.loser) ?? 0) + 1);
cfLoserCounts.set(cfM2.loser, (cfLoserCounts.get(cfM2.loser) ?? 0) + 1);
// ── NBA Finals ────────────────────────────────────────────────────────
const nbaFinals = resolveSeries(eloMap, finalMatch, cfM1.winner, cfM2.winner);
championCounts.set(nbaFinals.winner, (championCounts.get(nbaFinals.winner) ?? 0) + 1);
finalistCounts.set(nbaFinals.loser, (finalistCounts.get(nbaFinals.loser) ?? 0) + 1);
}
// ── Convert counts to probability distributions ───────────────────────────
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → N total (1 per sim)
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
// probFifthEighth → csLoserCounts / (4×N) — 4 CS losers per sim
const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const cfCount = cfLoserCounts.get(participantId) ?? 0;
const csCount = csLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: cfCount / (2 * N),
probFourth: cfCount / (2 * N),
probFifth: csCount / (4 * N),
probSixth: csCount / (4 * N),
probSeventh: csCount / (4 * N),
probEighth: csCount / (4 * N),
},
source: "nba_bracket_monte_carlo",
};
});
normalizeColumns(results);
return results;
}
// ── Mode 2: Season Projection ──────────────────────────────────────────────
private async simulateSeasonProjection(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const [participantRows, standings] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
@ -195,12 +469,19 @@ export class NBASimulator implements Simulator {
);
}
// 2. Build standings lookup and construct team entries.
// Conference, currentWins, remainingGames, and per-game winProb are all
// resolved once here so nothing is recomputed inside the hot loop.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id);
interface TeamEntry {
id: string;
name: string;
data: NbaTeamData | undefined;
conference: "Eastern" | "Western";
currentWins: number;
remainingGames: number;
winProb: number;
}
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
const data = getTeamData(r.name);
@ -221,11 +502,9 @@ export class NBASimulator implements Simulator {
};
});
// 3. Separate by conference for simulation.
const easternTeams = teams.filter((t) => t.conference === "Eastern");
const westernTeams = teams.filter((t) => t.conference === "Western");
// Validate: each conference needs at least 10 teams to fill the bracket + play-in.
if (easternTeams.length < 10 || westernTeams.length < 10) {
throw new Error(
`Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` +
@ -233,91 +512,70 @@ export class NBASimulator implements Simulator {
);
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b;
/** Simulate a single playoff game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(elo(a), elo(b));
let winsA = 0;
let winsB = 0;
while (winsA < 4 && winsB < 4) {
if (Math.random() < winProb) winsA++; else winsB++;
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
};
/** Simulate the Play-In tournament.
* @param candidates 4 teams sorted by seeding position [7th, 8th, 9th, 10th]
* @returns [7th playoff seed, 8th playoff seed] */
const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => {
// Game 1: 7 vs 8 — winner locks up the 7th seed
const game1Winner = simGame(s7, s8);
const game1Winner = simTeamGame(s7, s8);
const game1Loser = game1Winner === s7 ? s8 : s7;
// Game 2: 9 vs 10 — winner advances to the final play-in game
const game2Winner = simGame(s9, s10);
// Game 3: loser of Game 1 vs winner of Game 2 — winner gets the 8th seed
return [game1Winner, simGame(game1Loser, game2Winner)];
const game2Winner = simTeamGame(s9, s10);
return [game1Winner, simTeamGame(game1Loser, game2Winner)];
};
/** Build an 8-team conference bracket [s1..s8] for one simulation iteration.
* Seeds are determined by simulated projected wins; positions 710 go through the Play-In. */
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
const projected = confTeams.map((t) => ({
team: t,
projectedWins: simulateProjectedWins(t),
tiebreaker: Math.random(),
}));
// Higher projected wins = better seed (sort descending; random tiebreaker for ties).
projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
const top6 = projected.slice(0, 6).map((x) => x.team);
const playIn = projected.slice(6, 10).map((x) => x.team) as
const ranked = projected.toSorted((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
const top6 = ranked.slice(0, 6).map((x) => x.team);
const playIn = ranked.slice(6, 10).map((x) => x.team) as
[TeamEntry, TeamEntry, TeamEntry, TeamEntry];
const [seed7, seed8] = simPlayIn(playIn);
return [...top6, seed7, seed8];
};
/** Round 1: 1v8, 4v5, 2v7, 3v6. Returns 4 winners. */
const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [
simSeries(s1, s8).winner,
simSeries(s4, s5).winner,
simSeries(s2, s7).winner,
simSeries(s3, s6).winner,
simTeamSeries(s1, s8).winner,
simTeamSeries(s4, s5).winner,
simTeamSeries(s2, s7).winner,
simTeamSeries(s3, s6).winner,
];
/** Conference Semis: winner(1v8) vs winner(4v5), winner(2v7) vs winner(3v6). */
const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => {
const m1 = simSeries(w0, w1);
const m2 = simSeries(w2, w3);
const m1 = simTeamSeries(w0, w1);
const m2 = simTeamSeries(w2, w3);
return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] };
};
// 4. Integer placement count maps — initialized to 0 for all participants.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 5. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Build brackets ───────────────────────────────────────────────────────
const eastBracket = buildConferenceBracket(easternTeams);
const westBracket = buildConferenceBracket(westernTeams);
// ── Simulate rounds ──────────────────────────────────────────────────────
const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket));
const { winner: eastChamp, loser: eastCFLoser } = simSeries(eastR2Winners[0], eastR2Winners[1]);
const { winner: eastChamp, loser: eastCFLoser } = simTeamSeries(eastR2Winners[0], eastR2Winners[1]);
const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket));
const { winner: westChamp, loser: westCFLoser } = simSeries(westR2Winners[0], westR2Winners[1]);
const { winner: westChamp, loser: westCFLoser } = simTeamSeries(westR2Winners[0], westR2Winners[1]);
const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
const { winner: champion, loser: finalist } = simTeamSeries(eastChamp, westChamp);
// ── Record counts (maps are pre-populated so .get() always returns a number) ───
championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1);
confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
@ -325,14 +583,9 @@ export class NBASimulator implements Simulator {
for (const loser of [...eastR2Losers, ...westR2Losers]) {
confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1);
}
// Round 1 losers are not counted (0 points per scoring rules).
}
// 6. Convert integer counts to probability distributions.
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → NUM_SIMULATIONS total (1 per sim)
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
// probFifthEighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim
const N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
@ -341,36 +594,47 @@ export class NBASimulator implements Simulator {
return {
participantId,
probabilities: {
probFirst: c / NUM_SIMULATIONS,
probSecond: f / NUM_SIMULATIONS,
probThird: cf / (2 * NUM_SIMULATIONS),
probFourth: cf / (2 * NUM_SIMULATIONS),
probFifth: cs / (4 * NUM_SIMULATIONS),
probSixth: cs / (4 * NUM_SIMULATIONS),
probSeventh: cs / (4 * NUM_SIMULATIONS),
probEighth: cs / (4 * NUM_SIMULATIONS),
probFirst: c / N,
probSecond: f / N,
probThird: cf / (2 * N),
probFourth: cf / (2 * N),
probFifth: cs / (4 * N),
probSixth: cs / (4 * N),
probSeventh: cs / (4 * N),
probEighth: cs / (4 * N),
},
source: "nba_bracket_monte_carlo",
};
});
// 7. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 6.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
normalizeColumns(results);
return results;
}
}
// ─── Season-projection helpers (used only in Mode 2) ─────────────────────────
// TeamEntryBase is a structural subset of TeamEntry (the private interface defined inside
// simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for
// "consistent-function-scoping" (it doesn't close over any local variables).
interface TeamEntryBase {
data: { elo: number } | undefined;
}
function eloOfEntry(entry: TeamEntryBase): number {
return entry.data?.elo ?? 1400;
}
interface TeamForProjection {
currentWins: number;
remainingGames: number;
winProb: number;
}
function simulateProjectedWins(entry: TeamForProjection): number {
let extra = 0;
for (let g = 0; g < entry.remainingGames; g++) {
if (Math.random() < entry.winProb) extra++;
}
return entry.currentWins + extra;
}