diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts
index 35d1d6b..6deb0b6 100644
--- a/app/lib/bracket-templates.ts
+++ b/app/lib/bracket-templates.ts
@@ -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 (1–16) */
@@ -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):
+ * [0–9] East seeds 1–10
+ * [10–19] West seeds 1–10
+ *
+ * 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 = {
fifa_48: FIFA_48,
darts_128: DARTS_128,
cfp_12: CFP_12,
+ nba_20: NBA_20,
};
/**
diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts
index 219fdb9..33c1790 100644
--- a/app/models/playoff-match.ts
+++ b/app/models/playoff-match.ts
@@ -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):
+ * [0–9] East seeds 1–10 (E1=0, E2=1, ..., E10=9)
+ * [10–19] West seeds 1–10 (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 {
+ 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 {
+ 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}`);
+ }
+}
diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
index 82466ad..98b8e2d 100644
--- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
+++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
@@ -643,12 +643,13 @@ export default function EventBracket({
{[...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
diff --git a/app/services/simulations/__tests__/nba-simulator.test.ts b/app/services/simulations/__tests__/nba-simulator.test.ts
new file mode 100644
index 0000000..6432be5
--- /dev/null
+++ b/app/services/simulations/__tests__/nba-simulator.test.ts
@@ -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");
+ }
+ });
+});
diff --git a/app/services/simulations/nba-simulator.ts b/app/services/simulations/nba-simulator.ts
index e068f96..6476755 100644
--- a/app/services/simulations/nba-simulator.ts
+++ b/app/services/simulations/nba-simulator.ts
@@ -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 1–10
- * → Top 6 lock in directly; seeds 7–10 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)
* probFifth–Eighth = 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 = {
"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,
+ 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,
+ 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,
+ 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,
+ 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 {
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 {
+ 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();
+ 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();
+ 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
+ // probFifth–Eighth → 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 {
+ 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 7–10 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(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map(participantIds.map((id) => [id, 0]));
const confFinalLoserCounts = new Map(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map(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
- // probFifth–Eighth → 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 = [
- "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;
+}