feat: NBA playoff bracket template and bracket-aware simulator (#291)
Adds the NBA_20 bracket template (20-team structure with play-in
tournament) and rewrites the NBA simulator to be bracket-aware like UCL.
- Add NBA_20 bracket template: PIR1 (4) → PIR2 (2) → First Round (8)
→ Conference Semifinals → Conference Finals → NBA Finals
- Add participantLabels (East/West 1–10) for admin bracket UI slot labels
- Add generateNBA20Bracket and advanceNBAPlayInWinner in playoff-match.ts
with custom play-in advancement logic (PIR1 winners go to two different
destinations)
- Rewrite NBASimulator with two auto-detected modes:
- Bracket-aware (preferred): reads actual bracket state, simulates only
remaining rounds forward using 50k Monte Carlo iterations
- Season-projection (fallback): existing logic for pre-playoff use
- Guard resolveGame/resolveSeries against null participants (throws with
match ID and slot info instead of silently using empty string)
- Document load-bearing First Round match ordering in generateNBA20Bracket
- Add 25 unit tests covering Elo math, team data, template structure,
and all simulator integration paths
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cf91de941b
commit
040c97137b
5 changed files with 1257 additions and 156 deletions
|
|
@ -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<string, BracketTemplate> = {
|
|||
fifa_48: FIFA_48,
|
||||
darts_128: DARTS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const p = (idx: number): string | null =>
|
||||
participantIds ? (participantIds[idx] ?? null) : null;
|
||||
|
||||
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
|
||||
const pir1Seeding: [number, number, string][] = [
|
||||
[6, 7, "East: 7 vs 8"],
|
||||
[8, 9, "East: 9 vs 10"],
|
||||
[16, 17, "West: 7 vs 8"],
|
||||
[18, 19, "West: 9 vs 10"],
|
||||
];
|
||||
for (let i = 0; i < pir1Seeding.length; i++) {
|
||||
const [idx1, idx2, seedInfo] = pir1Seeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Play-In Round 1",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: p(idx1),
|
||||
participant2Id: p(idx2),
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Play-In Round 1",
|
||||
seedInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
|
||||
const pir2SeedInfo = [
|
||||
"East: 7/8 loser vs 9/10 winner",
|
||||
"West: 7/8 loser vs 9/10 winner",
|
||||
];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Play-In Round 2",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Play-In Round 2",
|
||||
seedInfo: pir2SeedInfo[i],
|
||||
});
|
||||
}
|
||||
|
||||
// ── First Round (8 series) ────────────────────────────────────────────────────
|
||||
// IMPORTANT: This match ordering is load-bearing.
|
||||
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
|
||||
// seed slots: FR M1 p2 = E8, FR M3 p1 = E7, FR M5 p2 = W8, FR M7 p1 = W7.
|
||||
// Do not change the match order without updating the simulator's resolveGame/
|
||||
// resolveSeries calls and the corresponding test fixtures.
|
||||
const firstRoundSeeding: [number | null, number | null, string][] = [
|
||||
[0, null, "East: 1 vs 8"],
|
||||
[3, 4, "East: 4 vs 5"],
|
||||
[1, null, "East: 2 vs 7"],
|
||||
[2, 5, "East: 3 vs 6"],
|
||||
[10, null, "West: 1 vs 8"],
|
||||
[13, 14, "West: 4 vs 5"],
|
||||
[11, null, "West: 2 vs 7"],
|
||||
[12, 15, "West: 3 vs 6"],
|
||||
];
|
||||
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
||||
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "First Round",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: idx1 !== null ? p(idx1) : null,
|
||||
participant2Id: idx2 !== null ? p(idx2) : null,
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "First Round",
|
||||
seedInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
|
||||
for (let roundIndex = 3; roundIndex < template.rounds.length; roundIndex++) {
|
||||
const round = template.rounds[roundIndex];
|
||||
for (let i = 0; i < round.matchCount; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i + 1,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* NBA play-in advancement logic.
|
||||
*
|
||||
* Play-In Round 1:
|
||||
* M1 (E7v8): winner → First Round M3 p1; loser → PIR2 M1 p1
|
||||
* M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
|
||||
* M3 (W7v8): winner → First Round M7 p1; loser → PIR2 M2 p1
|
||||
* M4 (W9v10): winner → PIR2 M2 p2; loser eliminated
|
||||
*
|
||||
* Play-In Round 2:
|
||||
* M1: winner → First Round M1 p2 (E8 seed); loser eliminated
|
||||
* M2: winner → First Round M5 p2 (W8 seed); loser eliminated
|
||||
*/
|
||||
async function advanceNBAPlayInWinner(
|
||||
match: PlayoffMatch,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
|
||||
if (match.round === "Play-In Round 1") {
|
||||
if (match.matchNumber === 1) {
|
||||
// E7v8: winner → First Round M3 p1; loser → PIR2 M1 p1
|
||||
const [frMatches, pir2Matches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
||||
]);
|
||||
const frM3 = frMatches.find((m) => m.matchNumber === 3);
|
||||
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
||||
if (!frM3) throw new Error("First Round match 3 not found");
|
||||
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
||||
if (frM3.participant1Id) throw new Error("First Round M3 participant1 already filled");
|
||||
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
|
||||
await Promise.all([
|
||||
updatePlayoffMatch(frM3.id, { participant1Id: winnerId }),
|
||||
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.matchNumber === 2) {
|
||||
// E9v10: winner → PIR2 M1 p2; loser eliminated
|
||||
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
||||
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
||||
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
||||
if (pir2M1.participant2Id) throw new Error("Play-In Round 2 M1 participant2 already filled");
|
||||
await updatePlayoffMatch(pir2M1.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.matchNumber === 3) {
|
||||
// W7v8: winner → First Round M7 p1; loser → PIR2 M2 p1
|
||||
const [frMatches, pir2Matches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
||||
]);
|
||||
const frM7 = frMatches.find((m) => m.matchNumber === 7);
|
||||
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
||||
if (!frM7) throw new Error("First Round match 7 not found");
|
||||
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
||||
if (frM7.participant1Id) throw new Error("First Round M7 participant1 already filled");
|
||||
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
|
||||
await Promise.all([
|
||||
updatePlayoffMatch(frM7.id, { participant1Id: winnerId }),
|
||||
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.matchNumber === 4) {
|
||||
// W9v10: winner → PIR2 M2 p2; loser eliminated
|
||||
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
||||
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
||||
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
||||
if (pir2M2.participant2Id) throw new Error("Play-In Round 2 M2 participant2 already filled");
|
||||
await updatePlayoffMatch(pir2M2.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown Play-In Round 1 match number: ${match.matchNumber}`);
|
||||
}
|
||||
|
||||
if (match.round === "Play-In Round 2") {
|
||||
if (match.matchNumber === 1) {
|
||||
// E8 seed: winner → First Round M1 p2; loser eliminated
|
||||
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
||||
const frM1 = frMatches.find((m) => m.matchNumber === 1);
|
||||
if (!frM1) throw new Error("First Round match 1 not found");
|
||||
if (frM1.participant2Id) throw new Error("First Round M1 participant2 already filled");
|
||||
await updatePlayoffMatch(frM1.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.matchNumber === 2) {
|
||||
// W8 seed: winner → First Round M5 p2; loser eliminated
|
||||
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
||||
const frM5 = frMatches.find((m) => m.matchNumber === 5);
|
||||
if (!frM5) throw new Error("First Round match 5 not found");
|
||||
if (frM5.participant2Id) throw new Error("First Round M5 participant2 already filled");
|
||||
await updatePlayoffMatch(frM5.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -643,12 +643,13 @@ export default function EventBracket({
|
|||
</p>
|
||||
{[...Array(template?.totalTeams || 8)].map((_, i) => {
|
||||
const availableParticipants = getAvailableParticipants(i);
|
||||
const slotLabel = template?.participantLabels?.[i] ?? `Seed ${i + 1}`;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||||
Seed {i + 1}
|
||||
{slotLabel}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
|
|
@ -661,7 +662,7 @@ export default function EventBracket({
|
|||
participants={availableParticipants}
|
||||
value={selectedParticipants[i] || ""}
|
||||
onValueChange={(value) => handleParticipantChange(i, value)}
|
||||
placeholder={`Select seed ${i + 1}...`}
|
||||
placeholder={`Select ${slotLabel.toLowerCase()}...`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
497
app/services/simulations/__tests__/nba-simulator.test.ts
Normal file
497
app/services/simulations/__tests__/nba-simulator.test.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { getTeamData, eloWinProbability, NBASimulator } from "../nba-simulator";
|
||||
import { NBA_20, BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
|
||||
// ─── Pure math / team data ────────────────────────────────────────────────────
|
||||
|
||||
describe("eloWinProbability", () => {
|
||||
it("returns 0.5 for equal Elo ratings", () => {
|
||||
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it("favors the higher-rated team", () => {
|
||||
expect(eloWinProbability(1600, 1400)).toBeGreaterThan(0.5);
|
||||
expect(eloWinProbability(1400, 1600)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("is symmetric: P(A>B) + P(B>A) = 1", () => {
|
||||
const a = 1650;
|
||||
const b = 1480;
|
||||
expect(eloWinProbability(a, b) + eloWinProbability(b, a)).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it("approaches 1 for a very large Elo advantage", () => {
|
||||
expect(eloWinProbability(2000, 1000)).toBeGreaterThan(0.99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamData", () => {
|
||||
it("finds teams by full name", () => {
|
||||
expect(getTeamData("Boston Celtics")).toMatchObject({ conference: "Eastern" });
|
||||
expect(getTeamData("Oklahoma City Thunder")).toMatchObject({ conference: "Western" });
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(getTeamData("boston celtics")).toBeDefined();
|
||||
expect(getTeamData("OKLAHOMA CITY THUNDER")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown teams", () => {
|
||||
expect(getTeamData("Springfield Nuclears")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("covers all 30 NBA teams", () => {
|
||||
const allTeams = [
|
||||
// Eastern Conference
|
||||
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
|
||||
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
|
||||
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
|
||||
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
|
||||
// Western Conference
|
||||
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
|
||||
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
|
||||
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
|
||||
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
|
||||
];
|
||||
|
||||
for (const name of allTeams) {
|
||||
expect(getTeamData(name), `${name} not found`).toBeDefined();
|
||||
}
|
||||
expect(allTeams).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("has exactly 15 Eastern and 15 Western teams", () => {
|
||||
const allTeams = [
|
||||
"Detroit Pistons", "Boston Celtics", "New York Knicks", "Cleveland Cavaliers",
|
||||
"Orlando Magic", "Miami Heat", "Toronto Raptors", "Atlanta Hawks",
|
||||
"Philadelphia 76ers", "Charlotte Hornets", "Milwaukee Bucks", "Chicago Bulls",
|
||||
"Brooklyn Nets", "Indiana Pacers", "Washington Wizards",
|
||||
"Oklahoma City Thunder", "San Antonio Spurs", "Houston Rockets", "Denver Nuggets",
|
||||
"LA Lakers", "Minnesota Timberwolves", "Phoenix Suns", "LA Clippers",
|
||||
"Golden State Warriors", "Portland Trail Blazers", "Dallas Mavericks",
|
||||
"Memphis Grizzlies", "New Orleans Pelicans", "Sacramento Kings", "Utah Jazz",
|
||||
];
|
||||
const east = allTeams.filter((n) => getTeamData(n)?.conference === "Eastern");
|
||||
const west = allTeams.filter((n) => getTeamData(n)?.conference === "Western");
|
||||
expect(east).toHaveLength(15);
|
||||
expect(west).toHaveLength(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NBA_20 bracket template structure ───────────────────────────────────────
|
||||
|
||||
describe("NBA_20 bracket template", () => {
|
||||
it("is registered in BRACKET_TEMPLATES", () => {
|
||||
expect(BRACKET_TEMPLATES.nba_20).toBe(NBA_20);
|
||||
});
|
||||
|
||||
it("has correct totalTeams and id", () => {
|
||||
expect(NBA_20.id).toBe("nba_20");
|
||||
expect(NBA_20.totalTeams).toBe(20);
|
||||
expect(NBA_20.scoringStartsAtRound).toBe("Conference Semifinals");
|
||||
});
|
||||
|
||||
it("has 6 rounds in the correct order", () => {
|
||||
const names = NBA_20.rounds.map((r) => r.name);
|
||||
expect(names).toEqual([
|
||||
"Play-In Round 1",
|
||||
"Play-In Round 2",
|
||||
"First Round",
|
||||
"Conference Semifinals",
|
||||
"Conference Finals",
|
||||
"NBA Finals",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has correct match counts per round", () => {
|
||||
const counts = NBA_20.rounds.map((r) => r.matchCount);
|
||||
expect(counts).toEqual([4, 2, 8, 4, 2, 1]);
|
||||
});
|
||||
|
||||
it("non-scoring rounds have isScoring=false", () => {
|
||||
const nonScoring = ["Play-In Round 1", "Play-In Round 2", "First Round"];
|
||||
for (const name of nonScoring) {
|
||||
const round = NBA_20.rounds.find((r) => r.name === name);
|
||||
expect(round?.isScoring, `${name} should be non-scoring`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("scoring rounds have isScoring=true", () => {
|
||||
const scoring = ["Conference Semifinals", "Conference Finals", "NBA Finals"];
|
||||
for (const name of scoring) {
|
||||
const round = NBA_20.rounds.find((r) => r.name === name);
|
||||
expect(round?.isScoring, `${name} should be scoring`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("feedsInto forms a valid chain ending at null", () => {
|
||||
const roundByName = new Map(NBA_20.rounds.map((r) => [r.name, r]));
|
||||
let current = NBA_20.rounds[0];
|
||||
const visited: string[] = [];
|
||||
while (current.feedsInto !== null) {
|
||||
visited.push(current.name);
|
||||
const next = roundByName.get(current.feedsInto);
|
||||
expect(next, `${current.feedsInto} not found in rounds`).toBeDefined();
|
||||
if (!next) break;
|
||||
current = next;
|
||||
}
|
||||
visited.push(current.name);
|
||||
expect(visited).toHaveLength(NBA_20.rounds.length);
|
||||
expect(current.name).toBe("NBA Finals");
|
||||
});
|
||||
|
||||
it("has 20 participantLabels with correct East/West structure", () => {
|
||||
const labels = NBA_20.participantLabels ?? [];
|
||||
expect(labels).toHaveLength(20);
|
||||
// East seeds 1-10 at indices 0-9
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(labels[i]).toMatch(/^East \d+$/);
|
||||
}
|
||||
// West seeds 1-10 at indices 10-19
|
||||
for (let i = 10; i < 20; i++) {
|
||||
expect(labels[i]).toMatch(/^West \d+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Simulator integration tests (mocked DB) ─────────────────────────────────
|
||||
|
||||
const TEAM_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
|
||||
|
||||
/** Build a minimal DB match object */
|
||||
function makeMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
opts: {
|
||||
p1?: string | null;
|
||||
p2?: string | null;
|
||||
winnerId?: string | null;
|
||||
loserId?: string | null;
|
||||
isComplete?: boolean;
|
||||
isScoring?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`,
|
||||
scoringEventId: "event-1",
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: opts.p1 ?? null,
|
||||
participant2Id: opts.p2 ?? null,
|
||||
winnerId: opts.winnerId ?? null,
|
||||
loserId: opts.loserId ?? null,
|
||||
isComplete: opts.isComplete ?? false,
|
||||
isScoring: opts.isScoring ?? false,
|
||||
templateRound: round,
|
||||
seedInfo: null,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full 20-team NBA bracket with all Play-In and First Round slots
|
||||
* filled so the simulator has valid participants on every match.
|
||||
*
|
||||
* Team layout matches generateNBA20Bracket:
|
||||
* PIR1 M1: idx6(E7) vs idx7(E8) PIR1 M2: idx8(E9) vs idx9(E10)
|
||||
* PIR1 M3: idx16(W7) vs idx17(W8) PIR1 M4: idx18(W9) vs idx19(W10)
|
||||
* PIR2 M1: e8Cand vs e9e10W PIR2 M2: w8Cand vs w9w10W
|
||||
* FR M1: idx0(E1) vs e8 FR M2: idx3(E4) vs idx4(E5)
|
||||
* FR M3: e7 vs idx1(E2) FR M4: idx2(E3) vs idx5(E6)
|
||||
* FR M5: idx10(W1) vs w8 FR M6: idx13(W4) vs idx14(W5)
|
||||
* FR M7: w7 vs idx11(W2) FR M8: idx12(W3) vs idx15(W6)
|
||||
*/
|
||||
function buildFullBracket(
|
||||
pir1Results: { m1Winner: string; m2Winner: string; m3Winner: string; m4Winner: string },
|
||||
pir2Results: { m1Winner: string; m2Winner: string }
|
||||
) {
|
||||
const t = TEAM_IDS;
|
||||
|
||||
const e7 = pir1Results.m1Winner;
|
||||
const e8Cand = t[7]; // E8 candidate = PIR1 M1 loser (for simplicity, hard-code)
|
||||
const e9e10W = pir1Results.m2Winner;
|
||||
const w7 = pir1Results.m3Winner;
|
||||
const w8Cand = t[17];
|
||||
const w9w10W = pir1Results.m4Winner;
|
||||
|
||||
const e8Seed = pir2Results.m1Winner;
|
||||
const w8Seed = pir2Results.m2Winner;
|
||||
|
||||
return [
|
||||
// Play-In Round 1
|
||||
makeMatch("Play-In Round 1", 1, { p1: t[6], p2: t[7], winnerId: pir1Results.m1Winner, loserId: pir1Results.m1Winner === t[6] ? t[7] : t[6], isComplete: true }),
|
||||
makeMatch("Play-In Round 1", 2, { p1: t[8], p2: t[9], winnerId: pir1Results.m2Winner, loserId: pir1Results.m2Winner === t[8] ? t[9] : t[8], isComplete: true }),
|
||||
makeMatch("Play-In Round 1", 3, { p1: t[16], p2: t[17], winnerId: pir1Results.m3Winner, loserId: pir1Results.m3Winner === t[16] ? t[17] : t[16], isComplete: true }),
|
||||
makeMatch("Play-In Round 1", 4, { p1: t[18], p2: t[19], winnerId: pir1Results.m4Winner, loserId: pir1Results.m4Winner === t[18] ? t[19] : t[18], isComplete: true }),
|
||||
// Play-In Round 2
|
||||
makeMatch("Play-In Round 2", 1, { p1: e8Cand, p2: e9e10W, winnerId: pir2Results.m1Winner, loserId: pir2Results.m1Winner === e8Cand ? e9e10W : e8Cand, isComplete: true }),
|
||||
makeMatch("Play-In Round 2", 2, { p1: w8Cand, p2: w9w10W, winnerId: pir2Results.m2Winner, loserId: pir2Results.m2Winner === w8Cand ? w9w10W : w8Cand, isComplete: true }),
|
||||
// First Round (play-in slots filled)
|
||||
makeMatch("First Round", 1, { p1: t[0], p2: e8Seed, isScoring: false }),
|
||||
makeMatch("First Round", 2, { p1: t[3], p2: t[4], isScoring: false }),
|
||||
makeMatch("First Round", 3, { p1: e7, p2: t[1], isScoring: false }),
|
||||
makeMatch("First Round", 4, { p1: t[2], p2: t[5], isScoring: false }),
|
||||
makeMatch("First Round", 5, { p1: t[10], p2: w8Seed, isScoring: false }),
|
||||
makeMatch("First Round", 6, { p1: t[13], p2: t[14], isScoring: false }),
|
||||
makeMatch("First Round", 7, { p1: w7, p2: t[11], isScoring: false }),
|
||||
makeMatch("First Round", 8, { p1: t[12], p2: t[15], isScoring: false }),
|
||||
// Conference Semifinals (TBD)
|
||||
makeMatch("Conference Semifinals", 1, { isScoring: true }),
|
||||
makeMatch("Conference Semifinals", 2, { isScoring: true }),
|
||||
makeMatch("Conference Semifinals", 3, { isScoring: true }),
|
||||
makeMatch("Conference Semifinals", 4, { isScoring: true }),
|
||||
// Conference Finals (TBD)
|
||||
makeMatch("Conference Finals", 1, { isScoring: true }),
|
||||
makeMatch("Conference Finals", 2, { isScoring: true }),
|
||||
// NBA Finals (TBD)
|
||||
makeMatch("NBA Finals", 1, { isScoring: true }),
|
||||
];
|
||||
}
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/regular-season-standings", () => ({
|
||||
getRegularSeasonStandings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe("NBASimulator — bracket-aware mode", () => {
|
||||
let mockDb: {
|
||||
query: {
|
||||
scoringEvents: { findFirst: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
select: MockInstance;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
|
||||
mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findFirst: vi.fn() },
|
||||
playoffMatches: { findMany: vi.fn() },
|
||||
},
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(
|
||||
TEAM_IDS.map((id) => ({ id, name: id })) // name = id (no real team name → fallback Elo 1400)
|
||||
),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||
|
||||
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "season-1",
|
||||
eventType: "playoff_game",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to season-projection when no playoff matches exist", async () => {
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
|
||||
|
||||
// Season projection needs standings — mock getRegularSeasonStandings via the model import
|
||||
// Since season-projection throws when conference split < 10, we just verify the right path:
|
||||
const sim = new NBASimulator();
|
||||
// select().from().where() returns 20 teams, but no conference data → East assigned via TEAMS_DATA
|
||||
// All teams have unknown names so conference defaults to "Eastern" → <10 Western → error
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/Each conference needs at least 10/);
|
||||
});
|
||||
|
||||
it("throws if bracket structure is wrong (wrong match count)", async () => {
|
||||
// Only return PIR1 matches — missing rest of bracket
|
||||
const pir1Only = [1, 2, 3, 4].map((n) =>
|
||||
makeMatch("Play-In Round 1", n, { p1: TEAM_IDS[n - 1], p2: TEAM_IDS[n + 3] })
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(pir1Only);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/);
|
||||
});
|
||||
|
||||
it("throws if a participant slot is missing (no override and DB null)", async () => {
|
||||
const t = TEAM_IDS;
|
||||
// Build bracket but leave FR M2 with null p2 (no override provided)
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
// Null out FR M2 p2
|
||||
const frM2 = matches.find((m) => m.round === "First Round" && m.matchNumber === 2);
|
||||
if (!frM2) throw new Error("FR M2 not found in test fixture");
|
||||
frM2.participant2Id = null;
|
||||
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
await expect(sim.simulate("season-1")).rejects.toThrow(/Cannot resolve series/);
|
||||
});
|
||||
|
||||
it("returns results for all 20 participants", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("each probability column sums to 1.0 across all 20 participants", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
|
||||
for (const key of keys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(colSum, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("all probabilities are non-negative", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
for (const v of Object.values(r.probabilities)) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("completed tournament: champion has probFirst=1, all others probFirst≈0", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const champion = t[0]; // E1
|
||||
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
|
||||
// Mark all bracket matches complete with t[0] winning everything on the East side
|
||||
// and t[10] winning West side, then t[0] beats t[10] in finals
|
||||
const frMatches = matches.filter((m) => m.round === "First Round");
|
||||
const csMatches = matches.filter((m) => m.round === "Conference Semifinals");
|
||||
const cfMatches = matches.filter((m) => m.round === "Conference Finals");
|
||||
const finalMatch = matches.find((m) => m.round === "NBA Finals");
|
||||
if (!finalMatch) throw new Error("Finals match not found in test fixture");;
|
||||
|
||||
// Fully script the bracket:
|
||||
// FR: top seed wins each series
|
||||
for (const m of frMatches) {
|
||||
const w = m.participant1Id ?? "";
|
||||
m.winnerId = w;
|
||||
m.loserId = m.participant2Id;
|
||||
m.isComplete = true;
|
||||
}
|
||||
|
||||
// CS M1: FR M1 winner vs FR M2 winner → FR M1 winner (t[0]) wins
|
||||
csMatches[0].participant1Id = frMatches[0].winnerId;
|
||||
csMatches[0].participant2Id = frMatches[1].winnerId;
|
||||
csMatches[0].winnerId = frMatches[0].winnerId; // t[0]
|
||||
csMatches[0].loserId = frMatches[1].winnerId;
|
||||
csMatches[0].isComplete = true;
|
||||
|
||||
// CS M2: FR M3 winner vs FR M4 winner
|
||||
csMatches[1].participant1Id = frMatches[2].winnerId;
|
||||
csMatches[1].participant2Id = frMatches[3].winnerId;
|
||||
csMatches[1].winnerId = frMatches[2].winnerId;
|
||||
csMatches[1].loserId = frMatches[3].winnerId;
|
||||
csMatches[1].isComplete = true;
|
||||
|
||||
// CS M3: FR M5 winner vs FR M6 winner
|
||||
csMatches[2].participant1Id = frMatches[4].winnerId;
|
||||
csMatches[2].participant2Id = frMatches[5].winnerId;
|
||||
csMatches[2].winnerId = frMatches[4].winnerId; // t[10]
|
||||
csMatches[2].loserId = frMatches[5].winnerId;
|
||||
csMatches[2].isComplete = true;
|
||||
|
||||
// CS M4: FR M7 winner vs FR M8 winner
|
||||
csMatches[3].participant1Id = frMatches[6].winnerId;
|
||||
csMatches[3].participant2Id = frMatches[7].winnerId;
|
||||
csMatches[3].winnerId = frMatches[6].winnerId;
|
||||
csMatches[3].loserId = frMatches[7].winnerId;
|
||||
csMatches[3].isComplete = true;
|
||||
|
||||
// CF M1: East champion (CS M1 winner = t[0])
|
||||
cfMatches[0].participant1Id = csMatches[0].winnerId;
|
||||
cfMatches[0].participant2Id = csMatches[1].winnerId;
|
||||
cfMatches[0].winnerId = csMatches[0].winnerId; // t[0]
|
||||
cfMatches[0].loserId = csMatches[1].winnerId;
|
||||
cfMatches[0].isComplete = true;
|
||||
|
||||
// CF M2: West champion (CS M3 winner = t[10])
|
||||
cfMatches[1].participant1Id = csMatches[2].winnerId;
|
||||
cfMatches[1].participant2Id = csMatches[3].winnerId;
|
||||
cfMatches[1].winnerId = csMatches[2].winnerId; // t[10]
|
||||
cfMatches[1].loserId = csMatches[3].winnerId;
|
||||
cfMatches[1].isComplete = true;
|
||||
|
||||
// Finals: t[0] beats t[10]
|
||||
finalMatch.participant1Id = cfMatches[0].winnerId;
|
||||
finalMatch.participant2Id = cfMatches[1].winnerId;
|
||||
finalMatch.winnerId = champion;
|
||||
finalMatch.loserId = t[10];
|
||||
finalMatch.isComplete = true;
|
||||
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
const champResult = results.find((r) => r.participantId === champion);
|
||||
expect(champResult, "champion result should exist").toBeDefined();
|
||||
expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.participantId !== champion) {
|
||||
expect(r.probabilities.probFirst).toBeCloseTo(0, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses source: 'nba_bracket_monte_carlo' on all results", async () => {
|
||||
const t = TEAM_IDS;
|
||||
const matches = buildFullBracket(
|
||||
{ m1Winner: t[6], m2Winner: t[8], m3Winner: t[16], m4Winner: t[18] },
|
||||
{ m1Winner: t[7], m2Winner: t[17] }
|
||||
);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
|
||||
const sim = new NBASimulator();
|
||||
const results = await sim.simulate("season-1");
|
||||
|
||||
for (const r of results) {
|
||||
expect(r.source).toBe("nba_bracket_monte_carlo");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, NbaTeamData> = {
|
|||
"Utah Jazz": { conference: "Western", elo: 1334 },
|
||||
};
|
||||
|
||||
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
||||
// ─── Public helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export { normalizeTeamName };
|
||||
|
||||
|
|
@ -135,42 +128,113 @@ export function getTeamData(name: string): NbaTeamData | undefined {
|
|||
/**
|
||||
* Elo win probability for team A over team B.
|
||||
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function eloWinProbability(eloA: number, eloB: number): number {
|
||||
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
|
||||
}
|
||||
|
||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||
// ─── Per-position normalization ───────────────────────────────────────────────
|
||||
|
||||
interface TeamEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
data: NbaTeamData | undefined;
|
||||
conference: "Eastern" | "Western";
|
||||
/** Actual wins from the standings table (0 if no standings loaded). */
|
||||
currentWins: number;
|
||||
/** Remaining regular season games = 82 - gamesPlayed (0 if season is complete). */
|
||||
remainingGames: number;
|
||||
/** Elo win probability vs. average opponent (1500) — constant per team. */
|
||||
winProb: number;
|
||||
const POSITION_KEYS = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// ─── Bracket-aware helpers ────────────────────────────────────────────────────
|
||||
|
||||
type DbMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
|
||||
/** Simulate a single-game matchup. Returns winner and loser. */
|
||||
function simGame(
|
||||
eloMap: Map<string, number>,
|
||||
a: string,
|
||||
b: string
|
||||
): { winner: string; loser: string } {
|
||||
const eloA = eloMap.get(a) ?? 1400;
|
||||
const eloB = eloMap.get(b) ?? 1400;
|
||||
const winner = Math.random() < eloWinProbability(eloA, eloB) ? a : b;
|
||||
return { winner, loser: winner === a ? b : a };
|
||||
}
|
||||
|
||||
/** Simulate 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++;
|
||||
/** Simulate a best-of-7 series. Returns winner and loser. */
|
||||
function simSeries(
|
||||
eloMap: Map<string, number>,
|
||||
a: string,
|
||||
b: string
|
||||
): { winner: string; loser: string } {
|
||||
const winProb = eloWinProbability(eloMap.get(a) ?? 1400, eloMap.get(b) ?? 1400);
|
||||
let wA = 0;
|
||||
let wB = 0;
|
||||
while (wA < 4 && wB < 4) {
|
||||
if (Math.random() < winProb) wA++; else wB++;
|
||||
}
|
||||
return entry.currentWins + extra;
|
||||
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a play-in single game.
|
||||
* Uses the real result if the match is complete; otherwise simulates.
|
||||
* p1Override / p2Override are used when the DB participant slot is still null
|
||||
* (i.e. filled in by the previous round's simulated result).
|
||||
*/
|
||||
function resolveGame(
|
||||
eloMap: Map<string, number>,
|
||||
match: DbMatch | undefined,
|
||||
p1Override?: string,
|
||||
p2Override?: string
|
||||
): { winner: string; loser: string } {
|
||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
||||
return { winner: match.winnerId, loser: match.loserId };
|
||||
}
|
||||
const p1 = match?.participant1Id ?? p1Override;
|
||||
const p2 = match?.participant2Id ?? p2Override;
|
||||
if (!p1 || !p2) {
|
||||
throw new Error(
|
||||
`Cannot resolve game: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
|
||||
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
|
||||
`Ensure the bracket is fully generated before simulating.`
|
||||
);
|
||||
}
|
||||
return simGame(eloMap, p1, p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a playoff series.
|
||||
* Uses the real result if the match is complete; otherwise simulates best-of-7.
|
||||
* p1Override / p2Override fill null DB participant slots with the simulated seed.
|
||||
*/
|
||||
function resolveSeries(
|
||||
eloMap: Map<string, number>,
|
||||
match: DbMatch | undefined,
|
||||
p1Override?: string,
|
||||
p2Override?: string
|
||||
): { winner: string; loser: string } {
|
||||
if (match?.isComplete && match.winnerId && match.loserId) {
|
||||
return { winner: match.winnerId, loser: match.loserId };
|
||||
}
|
||||
const p1 = match?.participant1Id ?? p1Override;
|
||||
const p2 = match?.participant2Id ?? p2Override;
|
||||
if (!p1 || !p2) {
|
||||
throw new Error(
|
||||
`Cannot resolve series: missing participant(s) on match ${match?.id ?? "(undefined)"} ` +
|
||||
`(p1=${p1 ?? "null"}, p2=${p2 ?? "null"}). ` +
|
||||
`Ensure the bracket is fully generated before simulating.`
|
||||
);
|
||||
}
|
||||
return simSeries(eloMap, p1, p2);
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -179,7 +243,217 @@ export class NBASimulator implements Simulator {
|
|||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// 1. Load participants and standings in parallel.
|
||||
// ── Mode detection: bracket-aware if a playoff event with matches exists ──
|
||||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||||
),
|
||||
});
|
||||
|
||||
if (bracketEvent) {
|
||||
const bracketMatches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
||||
});
|
||||
|
||||
if (bracketMatches.length > 0) {
|
||||
return this.simulateBracketAware(sportsSeasonId, bracketMatches);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fall back to season-projection mode ───────────────────────────────────
|
||||
return this.simulateSeasonProjection(sportsSeasonId);
|
||||
}
|
||||
|
||||
// ── Mode 1: Bracket-Aware ──────────────────────────────────────────────────
|
||||
|
||||
private async simulateBracketAware(
|
||||
sportsSeasonId: string,
|
||||
allMatches: DbMatch[]
|
||||
): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// Group matches by round.
|
||||
const pir1 = allMatches.filter((m) => m.round === "Play-In Round 1")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
const pir2 = allMatches.filter((m) => m.round === "Play-In Round 2")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
const fr = allMatches.filter((m) => m.round === "First Round")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
const cs = allMatches.filter((m) => m.round === "Conference Semifinals")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
const cf = allMatches.filter((m) => m.round === "Conference Finals")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
const finals = allMatches.filter((m) => m.round === "NBA Finals");
|
||||
|
||||
if (
|
||||
pir1.length !== 4 || pir2.length !== 2 || fr.length !== 8 ||
|
||||
cs.length !== 4 || cf.length !== 2 || finals.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`NBA bracket has unexpected structure. Expected PIR1×4, PIR2×2, FR×8, CS×4, CF×2, Finals×1. ` +
|
||||
`Got PIR1×${pir1.length}, PIR2×${pir2.length}, FR×${fr.length}, ` +
|
||||
`CS×${cs.length}, CF×${cf.length}, Finals×${finals.length}. ` +
|
||||
`Ensure the bracket was generated using the nba_20 template.`
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all participant IDs from the bracket.
|
||||
const participantIdSet = new Set<string>();
|
||||
for (const m of allMatches) {
|
||||
if (m.participant1Id) participantIdSet.add(m.participant1Id);
|
||||
if (m.participant2Id) participantIdSet.add(m.participant2Id);
|
||||
if (m.winnerId) participantIdSet.add(m.winnerId);
|
||||
if (m.loserId) participantIdSet.add(m.loserId);
|
||||
}
|
||||
const participantIds = [...participantIdSet];
|
||||
|
||||
// Load participant names to map IDs → Elo via TEAMS_DATA.
|
||||
const participantRows = await db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
|
||||
|
||||
// Build Elo map: TEAMS_DATA lookup by name, fallback 1400.
|
||||
const eloMap = new Map<string, number>();
|
||||
for (const id of participantIds) {
|
||||
const name = nameById.get(id) ?? "";
|
||||
eloMap.set(id, getTeamData(name)?.elo ?? 1400);
|
||||
}
|
||||
|
||||
// Build per-round lookup maps for O(1) access in the hot loop.
|
||||
const pir1ByNum = new Map(pir1.map((m) => [m.matchNumber, m]));
|
||||
const pir2ByNum = new Map(pir2.map((m) => [m.matchNumber, m]));
|
||||
const frByNum = new Map(fr.map((m) => [m.matchNumber, m]));
|
||||
const csByNum = new Map(cs.map((m) => [m.matchNumber, m]));
|
||||
const cfByNum = new Map(cf.map((m) => [m.matchNumber, m]));
|
||||
const finalMatch = finals[0];
|
||||
|
||||
// Integer placement counts — avoids fractional accumulation error.
|
||||
const championCounts = new Map(participantIds.map((id) => [id, 0]));
|
||||
const finalistCounts = new Map(participantIds.map((id) => [id, 0]));
|
||||
const cfLoserCounts = new Map(participantIds.map((id) => [id, 0]));
|
||||
const csLoserCounts = new Map(participantIds.map((id) => [id, 0]));
|
||||
|
||||
// ── Monte Carlo loop ──────────────────────────────────────────────────────
|
||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||
|
||||
// ── Play-In Round 1 (4 single games) ─────────────────────────────────
|
||||
// M1: East 7 vs 8 → winner = E7 seed; loser enters PIR2 as p1
|
||||
// M2: East 9 vs 10 → winner enters PIR2 as p2; loser eliminated
|
||||
// M3: West 7 vs 8 → winner = W7 seed; loser enters PIR2 as p1
|
||||
// M4: West 9 vs 10 → winner enters PIR2 as p2; loser eliminated
|
||||
const pir1M1 = resolveGame(eloMap, pir1ByNum.get(1));
|
||||
const pir1M2 = resolveGame(eloMap, pir1ByNum.get(2));
|
||||
const pir1M3 = resolveGame(eloMap, pir1ByNum.get(3));
|
||||
const pir1M4 = resolveGame(eloMap, pir1ByNum.get(4));
|
||||
|
||||
const e7Seed = pir1M1.winner;
|
||||
const e8Cand = pir1M1.loser; // plays PIR2 M1 p1
|
||||
const e9e10W = pir1M2.winner; // plays PIR2 M1 p2
|
||||
const w7Seed = pir1M3.winner;
|
||||
const w8Cand = pir1M3.loser; // plays PIR2 M2 p1
|
||||
const w9w10W = pir1M4.winner; // plays PIR2 M2 p2
|
||||
|
||||
// ── Play-In Round 2 (2 single games) ─────────────────────────────────
|
||||
// M1: E8 candidate vs E9/10 winner → winner = E8 seed
|
||||
// M2: W8 candidate vs W9/10 winner → winner = W8 seed
|
||||
// Use DB participant slots when already filled (after PIR1 locked in),
|
||||
// otherwise fall back to simulated values from above.
|
||||
const pir2M1 = resolveGame(eloMap, pir2ByNum.get(1), e8Cand, e9e10W);
|
||||
const pir2M2 = resolveGame(eloMap, pir2ByNum.get(2), w8Cand, w9w10W);
|
||||
|
||||
const e8Seed = pir2M1.winner;
|
||||
const w8Seed = pir2M2.winner;
|
||||
|
||||
// ── First Round (8 best-of-7 series) ─────────────────────────────────
|
||||
// Bracket order (matches correct Conference Semis pairings):
|
||||
// M1: E1 vs E8 M2: E4 vs E5 M3: E2 vs E7 M4: E3 vs E6
|
||||
// M5: W1 vs W8 M6: W4 vs W5 M7: W2 vs W7 M8: W3 vs W6
|
||||
//
|
||||
// Slots that stay null until play-in resolves use simmed seeds as fallback.
|
||||
// Once the play-in result is recorded, the DB slot is filled, so participant
|
||||
// coalescence (DB ?? simmed) always produces the correct team.
|
||||
const frM1 = resolveSeries(eloMap, frByNum.get(1), undefined, e8Seed); // E1(DB) vs E8
|
||||
const frM2 = resolveSeries(eloMap, frByNum.get(2)); // E4(DB) vs E5(DB)
|
||||
const frM3 = resolveSeries(eloMap, frByNum.get(3), e7Seed, undefined); // E7 vs E2(DB)
|
||||
const frM4 = resolveSeries(eloMap, frByNum.get(4)); // E3(DB) vs E6(DB)
|
||||
const frM5 = resolveSeries(eloMap, frByNum.get(5), undefined, w8Seed); // W1(DB) vs W8
|
||||
const frM6 = resolveSeries(eloMap, frByNum.get(6)); // W4(DB) vs W5(DB)
|
||||
const frM7 = resolveSeries(eloMap, frByNum.get(7), w7Seed, undefined); // W7 vs W2(DB)
|
||||
const frM8 = resolveSeries(eloMap, frByNum.get(8)); // W3(DB) vs W6(DB)
|
||||
|
||||
// ── Conference Semifinals (4 best-of-7 series) ────────────────────────
|
||||
// Standard ceil bracket path:
|
||||
// CS M1: FR M1/M2 winners (East upper: E1/8 vs E4/5)
|
||||
// CS M2: FR M3/M4 winners (East lower: E2/7 vs E3/6)
|
||||
// CS M3: FR M5/M6 winners (West upper: W1/8 vs W4/5)
|
||||
// CS M4: FR M7/M8 winners (West lower: W2/7 vs W3/6)
|
||||
const csM1 = resolveSeries(eloMap, csByNum.get(1), frM1.winner, frM2.winner);
|
||||
const csM2 = resolveSeries(eloMap, csByNum.get(2), frM3.winner, frM4.winner);
|
||||
const csM3 = resolveSeries(eloMap, csByNum.get(3), frM5.winner, frM6.winner);
|
||||
const csM4 = resolveSeries(eloMap, csByNum.get(4), frM7.winner, frM8.winner);
|
||||
|
||||
csLoserCounts.set(csM1.loser, (csLoserCounts.get(csM1.loser) ?? 0) + 1);
|
||||
csLoserCounts.set(csM2.loser, (csLoserCounts.get(csM2.loser) ?? 0) + 1);
|
||||
csLoserCounts.set(csM3.loser, (csLoserCounts.get(csM3.loser) ?? 0) + 1);
|
||||
csLoserCounts.set(csM4.loser, (csLoserCounts.get(csM4.loser) ?? 0) + 1);
|
||||
|
||||
// ── Conference Finals (2 best-of-7 series) ────────────────────────────
|
||||
// CF M1: East champion (CS M1/M2 winners)
|
||||
// CF M2: West champion (CS M3/M4 winners)
|
||||
const cfM1 = resolveSeries(eloMap, cfByNum.get(1), csM1.winner, csM2.winner);
|
||||
const cfM2 = resolveSeries(eloMap, cfByNum.get(2), csM3.winner, csM4.winner);
|
||||
|
||||
cfLoserCounts.set(cfM1.loser, (cfLoserCounts.get(cfM1.loser) ?? 0) + 1);
|
||||
cfLoserCounts.set(cfM2.loser, (cfLoserCounts.get(cfM2.loser) ?? 0) + 1);
|
||||
|
||||
// ── NBA Finals ────────────────────────────────────────────────────────
|
||||
const nbaFinals = resolveSeries(eloMap, finalMatch, cfM1.winner, cfM2.winner);
|
||||
|
||||
championCounts.set(nbaFinals.winner, (championCounts.get(nbaFinals.winner) ?? 0) + 1);
|
||||
finalistCounts.set(nbaFinals.loser, (finalistCounts.get(nbaFinals.loser) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// ── Convert counts to probability distributions ───────────────────────────
|
||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||
// probFirst/Second → N total (1 per sim)
|
||||
// probThird/Fourth → cfLoserCounts / (2×N) — 2 CF losers per sim
|
||||
// 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<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
const [participantRows, standings] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
|
|
@ -195,12 +469,19 @@ export class NBASimulator implements Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
// 2. Build standings lookup and construct team entries.
|
||||
// Conference, currentWins, remainingGames, and per-game winProb are all
|
||||
// resolved once here so nothing is recomputed inside the hot loop.
|
||||
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
|
||||
const participantIds = participantRows.map((r) => r.id);
|
||||
|
||||
interface TeamEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
data: NbaTeamData | undefined;
|
||||
conference: "Eastern" | "Western";
|
||||
currentWins: number;
|
||||
remainingGames: number;
|
||||
winProb: number;
|
||||
}
|
||||
|
||||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||||
const standing = standingsMap.get(r.id);
|
||||
const data = getTeamData(r.name);
|
||||
|
|
@ -221,11 +502,9 @@ export class NBASimulator implements Simulator {
|
|||
};
|
||||
});
|
||||
|
||||
// 3. Separate by conference for simulation.
|
||||
const easternTeams = teams.filter((t) => t.conference === "Eastern");
|
||||
const westernTeams = teams.filter((t) => t.conference === "Western");
|
||||
|
||||
// Validate: each conference needs at least 10 teams to fill the bracket + play-in.
|
||||
if (easternTeams.length < 10 || westernTeams.length < 10) {
|
||||
throw new Error(
|
||||
`Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` +
|
||||
|
|
@ -233,91 +512,70 @@ export class NBASimulator implements Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||
const simTeamGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||
Math.random() < eloWinProbability(eloOfEntry(a), eloOfEntry(b)) ? a : b;
|
||||
|
||||
/** Simulate a single playoff game. Returns the winner. */
|
||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
|
||||
|
||||
/** Simulate a best-of-7 series. Returns winner and loser. */
|
||||
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
||||
const winProb = eloWinProbability(elo(a), elo(b));
|
||||
let winsA = 0;
|
||||
let winsB = 0;
|
||||
while (winsA < 4 && winsB < 4) {
|
||||
if (Math.random() < winProb) winsA++; else winsB++;
|
||||
const simTeamSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
|
||||
const winProb = eloWinProbability(eloOfEntry(a), eloOfEntry(b));
|
||||
let wA = 0;
|
||||
let wB = 0;
|
||||
while (wA < 4 && wB < 4) {
|
||||
if (Math.random() < winProb) wA++; else wB++;
|
||||
}
|
||||
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
|
||||
return wA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
|
||||
};
|
||||
|
||||
/** Simulate the Play-In tournament.
|
||||
* @param candidates 4 teams sorted by seeding position [7th, 8th, 9th, 10th]
|
||||
* @returns [7th playoff seed, 8th playoff seed] */
|
||||
const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => {
|
||||
// Game 1: 7 vs 8 — winner locks up the 7th seed
|
||||
const game1Winner = simGame(s7, s8);
|
||||
const game1Winner = simTeamGame(s7, s8);
|
||||
const game1Loser = game1Winner === s7 ? s8 : s7;
|
||||
// Game 2: 9 vs 10 — winner advances to the final play-in game
|
||||
const game2Winner = simGame(s9, s10);
|
||||
// Game 3: loser of Game 1 vs winner of Game 2 — winner gets the 8th seed
|
||||
return [game1Winner, simGame(game1Loser, game2Winner)];
|
||||
const game2Winner = simTeamGame(s9, s10);
|
||||
return [game1Winner, simTeamGame(game1Loser, game2Winner)];
|
||||
};
|
||||
|
||||
/** Build an 8-team conference bracket [s1..s8] for one simulation iteration.
|
||||
* Seeds are determined by simulated projected wins; positions 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<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
|
||||
// 5. Monte Carlo simulation loop.
|
||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||
// ── Build brackets ───────────────────────────────────────────────────────
|
||||
const eastBracket = buildConferenceBracket(easternTeams);
|
||||
const westBracket = buildConferenceBracket(westernTeams);
|
||||
|
||||
// ── Simulate rounds ──────────────────────────────────────────────────────
|
||||
const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket));
|
||||
const { winner: eastChamp, loser: eastCFLoser } = simSeries(eastR2Winners[0], eastR2Winners[1]);
|
||||
const { winner: eastChamp, loser: eastCFLoser } = simTeamSeries(eastR2Winners[0], eastR2Winners[1]);
|
||||
|
||||
const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket));
|
||||
const { winner: westChamp, loser: westCFLoser } = simSeries(westR2Winners[0], westR2Winners[1]);
|
||||
const { winner: westChamp, loser: westCFLoser } = simTeamSeries(westR2Winners[0], westR2Winners[1]);
|
||||
|
||||
const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
|
||||
const { winner: champion, loser: finalist } = simTeamSeries(eastChamp, westChamp);
|
||||
|
||||
// ── Record counts (maps are pre-populated so .get() always returns a number) ───
|
||||
championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
|
||||
finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1);
|
||||
confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
|
||||
|
|
@ -325,14 +583,9 @@ export class NBASimulator implements Simulator {
|
|||
for (const loser of [...eastR2Losers, ...westR2Losers]) {
|
||||
confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1);
|
||||
}
|
||||
// Round 1 losers are not counted (0 points per scoring rules).
|
||||
}
|
||||
|
||||
// 6. Convert integer counts to probability distributions.
|
||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||
// probFirst/Second → NUM_SIMULATIONS total (1 per sim)
|
||||
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
|
||||
// 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<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
];
|
||||
for (const key of positionKeys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
const residual = 1.0 - colSum;
|
||||
if (residual !== 0) {
|
||||
const maxResult = results.reduce((best, r) =>
|
||||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||||
);
|
||||
maxResult.probabilities[key] += residual;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeColumns(results);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Season-projection helpers (used only in Mode 2) ─────────────────────────
|
||||
|
||||
// TeamEntryBase is a structural subset of TeamEntry (the private interface defined inside
|
||||
// simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for
|
||||
// "consistent-function-scoping" (it doesn't close over any local variables).
|
||||
interface TeamEntryBase {
|
||||
data: { elo: number } | undefined;
|
||||
}
|
||||
|
||||
function eloOfEntry(entry: TeamEntryBase): number {
|
||||
return entry.data?.elo ?? 1400;
|
||||
}
|
||||
|
||||
interface TeamForProjection {
|
||||
currentWins: number;
|
||||
remainingGames: number;
|
||||
winProb: number;
|
||||
}
|
||||
|
||||
function simulateProjectedWins(entry: TeamForProjection): number {
|
||||
let extra = 0;
|
||||
for (let g = 0; g < entry.remainingGames; g++) {
|
||||
if (Math.random() < entry.winProb) extra++;
|
||||
}
|
||||
return entry.currentWins + extra;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue