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
handleParticipantChange(i, value)} - placeholder={`Select seed ${i + 1}...`} + placeholder={`Select ${slotLabel.toLowerCase()}...`} />
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; +}