Fix NHL simulator seeding bugs and code quality issues (#281)
* Fix NHL simulator dynamic seeding and code quality issues - Replace hardcoded seeding probabilities with standings-based Monte Carlo projection: simulates remaining regular season games per team using Elo win probability vs. a league-average opponent, so mathematically eliminated teams naturally fall out of playoff contention - Fix double-simulation bug in wildcard pool: all 16 conference teams are now projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)), then filtered — wildcard pool no longer re-runs simulateProjectedPoints with fresh randomness independent of the division seeding - Move ProjectedTeam interface, projectTeam(), and sortProjected() to module scope (defined once, not recreated on every buildConferenceBracket call) - Replace conference-level participant count check (east < 8 || west < 8) with per-division checks (each division < 3) for a more actionable error message - Add logger.warn when standings.length === 0 so admins know the sim is running without synced data - Remove unused easternTeams/westernTeams variables - Simplify test: replace Object.fromEntries pattern with a plain for..of loop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix oxlint errors in NHL simulator and tests - Replace non-null assertions (data!) with optional chaining (data?.x ?? "") - Move makeEntry helper to module scope to avoid recreating on every call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0da80bd2c0
commit
9e5d1cda5f
2 changed files with 294 additions and 357 deletions
|
|
@ -3,6 +3,7 @@ import {
|
|||
normalizeTeamName,
|
||||
getTeamData,
|
||||
eloWinProbability,
|
||||
simulateProjectedPoints,
|
||||
} from "../nhl-simulator";
|
||||
|
||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||
|
|
@ -60,6 +61,35 @@ describe("getTeamData", () => {
|
|||
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("all 32 teams have a valid conference and division", () => {
|
||||
const validConferences = new Set(["Eastern", "Western"]);
|
||||
const validDivisions = new Set(["Atlantic", "Metropolitan", "Central", "Pacific"]);
|
||||
const allTeams = [
|
||||
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
|
||||
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
|
||||
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
|
||||
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
|
||||
"New Jersey Devils", "New York Rangers",
|
||||
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
|
||||
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
|
||||
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
|
||||
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
|
||||
];
|
||||
for (const name of allTeams) {
|
||||
const data = getTeamData(name);
|
||||
expect(validConferences.has(data?.conference ?? ""), `${name}: invalid conference`).toBe(true);
|
||||
expect(validDivisions.has(data?.division ?? ""), `${name}: invalid division`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("conference/division assignments are correct for spot-checked teams", () => {
|
||||
expect(getTeamData("Carolina Hurricanes")?.division).toBe("Metropolitan");
|
||||
expect(getTeamData("Colorado Avalanche")?.division).toBe("Central");
|
||||
expect(getTeamData("Vegas Golden Knights")?.division).toBe("Pacific");
|
||||
expect(getTeamData("Buffalo Sabres")?.conference).toBe("Eastern");
|
||||
expect(getTeamData("Edmonton Oilers")?.conference).toBe("Western");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── eloWinProbability ────────────────────────────────────────────────────────
|
||||
|
|
@ -80,57 +110,64 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
|
|||
});
|
||||
|
||||
it("a 100-pt gap gives ~55.7% win prob per game", () => {
|
||||
// At 1000: P = 1 / (1 + 10^(-100/1000)) ≈ 0.557
|
||||
const p = eloWinProbability(1600, 1500);
|
||||
expect(p).toBeCloseTo(0.557, 2);
|
||||
});
|
||||
|
||||
it("a 200-pt gap gives ~61.3% win prob per game", () => {
|
||||
// At 1000: P = 1 / (1 + 10^(-200/1000)) ≈ 0.613
|
||||
const p = eloWinProbability(1700, 1500);
|
||||
expect(p).toBeCloseTo(0.613, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Seeding probabilities sanity checks ─────────────────────────────────────
|
||||
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
|
||||
|
||||
describe("team seeding probabilities", () => {
|
||||
it("playoff probability sums to ≤ 1.0 per team", () => {
|
||||
const teamNames = [
|
||||
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
|
||||
"Carolina Hurricanes", "Buffalo Sabres", "Tampa Bay Lightning",
|
||||
];
|
||||
for (const name of teamNames) {
|
||||
const d = getTeamData(name);
|
||||
expect(d).toBeDefined();
|
||||
const total =
|
||||
(d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) +
|
||||
(d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0);
|
||||
expect(total, `${name} playoff prob > 1`).toBeLessThanOrEqual(1.001);
|
||||
const makeEntry = (overrides: Partial<{
|
||||
currentPoints: number;
|
||||
remainingGames: number;
|
||||
winProb: number;
|
||||
}> = {}) => ({
|
||||
id: "test",
|
||||
name: "Test Team",
|
||||
data: undefined,
|
||||
conference: "Western" as const,
|
||||
division: "Central",
|
||||
currentPoints: overrides.currentPoints ?? 80,
|
||||
remainingGames: overrides.remainingGames ?? 10,
|
||||
winProb: overrides.winProb ?? 0.55,
|
||||
});
|
||||
|
||||
describe("simulateProjectedPoints", () => {
|
||||
|
||||
it("returns exactly currentPoints when no games remain", () => {
|
||||
const entry = makeEntry({ currentPoints: 95, remainingGames: 0 });
|
||||
expect(simulateProjectedPoints(entry)).toBe(95);
|
||||
});
|
||||
|
||||
it("returns at least currentPoints (points never decrease)", () => {
|
||||
const entry = makeEntry({ currentPoints: 70, remainingGames: 20, winProb: 0.5 });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(simulateProjectedPoints(entry)).toBeGreaterThanOrEqual(70);
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminated teams have no seeding probability keys", () => {
|
||||
const eliminated = ["Toronto Maple Leafs", "New York Rangers", "Chicago Blackhawks", "Vancouver Canucks"];
|
||||
for (const name of eliminated) {
|
||||
const d = getTeamData(name);
|
||||
expect(d).toBeDefined();
|
||||
const total =
|
||||
(d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) +
|
||||
(d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0);
|
||||
expect(total, `${name} should have 0 playoff probability`).toBe(0);
|
||||
it("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => {
|
||||
const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("division leaders have the highest p_div1 in their division", () => {
|
||||
// COL should dominate Central div1
|
||||
const col = getTeamData("Colorado Avalanche");
|
||||
const dal = getTeamData("Dallas Stars");
|
||||
expect((col?.p_div1 ?? 0)).toBeGreaterThan(dal?.p_div1 ?? 0);
|
||||
|
||||
// CAR should dominate Metro div1
|
||||
const car = getTeamData("Carolina Hurricanes");
|
||||
const cbj = getTeamData("Columbus Blue Jackets");
|
||||
expect((car?.p_div1 ?? 0)).toBeGreaterThan(cbj?.p_div1 ?? 0);
|
||||
it("higher winProb produces higher projected points on average", () => {
|
||||
const base = makeEntry({ currentPoints: 0, remainingGames: 82 });
|
||||
const runs = 2000;
|
||||
const avg = (winProb: number) => {
|
||||
let total = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
total += simulateProjectedPoints({ ...base, winProb });
|
||||
}
|
||||
return total / runs;
|
||||
};
|
||||
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,28 +6,34 @@
|
|||
*
|
||||
* Algorithm:
|
||||
* 1. Load all participants for the sports season from DB
|
||||
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities)
|
||||
* 3. For each simulation:
|
||||
* a. For each conference, draw division placements (1st/2nd/3rd per division)
|
||||
* and two wildcard teams using weighted probability distributions
|
||||
* b. Build the NHL bracket:
|
||||
* - Higher-Elo division winner faces WC2; other division winner faces WC1
|
||||
* - Each division's 2nd vs 3rd seeds play each other
|
||||
* c. Simulate 4 rounds of best-of-7 series:
|
||||
* Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1,
|
||||
* 2. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division)
|
||||
* 3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback)
|
||||
* 4. For each simulation:
|
||||
* a. For each team, simulate remaining regular season games (82 - gamesPlayed):
|
||||
* - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500)
|
||||
* - OT/SO loss (1 pt) at NHL_OT_RATE × (1 − winProb)
|
||||
* - Regulation loss (0 pts) otherwise
|
||||
* → projectedPoints = currentPoints + simulated extra points
|
||||
* b. Per division: rank by projected points (random tiebreaker) → top 3 are division seeds
|
||||
* c. Per conference: top 2 non-division-seed teams by projected points → WC1 and WC2
|
||||
* (WC1 has more points than WC2)
|
||||
* d. Build the 8-team bracket per conference:
|
||||
* - Division winner with more points = 1st seed, faces WC2
|
||||
* - Other division winner = 2nd seed, faces WC1
|
||||
* - Each division's 2nd and 3rd seeds face each other
|
||||
* e. Simulate 4 rounds of best-of-7 series:
|
||||
* Round 1 (Wild Card): m0=1stSeed/WC2, m1=2ndSeed/WC1,
|
||||
* m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd
|
||||
* Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
|
||||
* Round 3 (Conf Finals): two division-final winners per conference
|
||||
* Stanley Cup Final: East champ vs West champ
|
||||
* 4. Track placement counts per scoring tier
|
||||
* 5. Convert counts to probability distributions
|
||||
* 5. Track placement counts per scoring tier
|
||||
* 6. Convert counts to probability distributions
|
||||
*
|
||||
* Win probability (Elo, PARITY_FACTOR = 550):
|
||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 550))
|
||||
* NHL uses a higher parity factor than the standard 400 to dampen the Elo
|
||||
* spread and reflect the high variance of hockey. 550 (down from an original
|
||||
* 800) was chosen to better match Vegas championship implied probabilities —
|
||||
* 800 compressed favorites too far toward 50/50 per game.
|
||||
* Win probability (Elo, PARITY_FACTOR = 1000):
|
||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000))
|
||||
* NHL uses a higher parity factor than the standard 400 to reflect the high
|
||||
* variance of hockey.
|
||||
*
|
||||
* Futures blending:
|
||||
* If sourceOdds are stored in participantExpectedValues for this season,
|
||||
|
|
@ -46,19 +52,11 @@
|
|||
* First Round losers → all 0 (score 0 points)
|
||||
* Missed playoffs → all 0
|
||||
*
|
||||
* Seeding probability keys per team:
|
||||
* p_div1 = prob of winning own division
|
||||
* p_div2 = prob of finishing 2nd in own division
|
||||
* p_div3 = prob of finishing 3rd in own division
|
||||
* p_wc1 = prob of being conference wildcard 1 (higher-ranked WC)
|
||||
* p_wc2 = prob of being conference wildcard 2 (lower-ranked WC)
|
||||
* Sum of all p_* ≈ team's overall playoff probability.
|
||||
* Teams with no p_* entries always miss the playoffs.
|
||||
*
|
||||
* Elo ratings and seeding probabilities are hardcoded below (March 2026 data).
|
||||
* Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration).
|
||||
* Seeding probabilities are hand-derived from current standings as of March 18, 2026.
|
||||
* Update at the start of each season.
|
||||
* Elo ratings are hardcoded (2025-26 season data).
|
||||
* Source: https://elo.harvitronix.com/nhl/2025-2026
|
||||
* Conference and division are read from the standings table (synced from the
|
||||
* NHL API); TEAMS_DATA values serve as fallbacks if standings are missing.
|
||||
* Update Elo values at the start of each season.
|
||||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
|
|
@ -70,11 +68,22 @@ import {
|
|||
convertAmericanOddsToProbability,
|
||||
normalizeProbabilities,
|
||||
} from "~/services/probability-engine";
|
||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
const NUM_SIMULATIONS = 50_000;
|
||||
|
||||
/** NHL regular season games per team. */
|
||||
const NHL_REGULAR_SEASON_GAMES = 82;
|
||||
|
||||
/**
|
||||
* Fraction of NHL games that go to overtime / shootout.
|
||||
* The losing team earns 1 point (instead of 0) in these games.
|
||||
* Historical average: ~23% of games.
|
||||
*/
|
||||
const NHL_OT_RATE = 0.23;
|
||||
|
||||
/**
|
||||
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
|
||||
* the elevated game-to-game variance in hockey.
|
||||
|
|
@ -88,17 +97,15 @@ const PARITY_FACTOR = 1000;
|
|||
const ELO_WEIGHT = 0.7;
|
||||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||||
|
||||
// ─── Team data (2025-26 season, as of March 18, 2026) ────────────────────────
|
||||
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
|
||||
//
|
||||
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
|
||||
// p_div1: Probability of finishing 1st in own division.
|
||||
// p_div2: Probability of finishing 2nd in own division.
|
||||
// p_div3: Probability of finishing 3rd in own division.
|
||||
// p_wc1: Probability of being conference wildcard 1 (higher seed).
|
||||
// p_wc2: Probability of being conference wildcard 2 (lower seed).
|
||||
// Used for both regular-season game simulation (vs. avg opponent Elo 1500)
|
||||
// and for all playoff matchup win probabilities.
|
||||
//
|
||||
// These are weights used in sequential weighted sampling (not strict distributions).
|
||||
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site.
|
||||
// conference / division: Used as fallbacks when the standings table has no
|
||||
// conference or division data for a participant. In normal operation these
|
||||
// are read from regularSeasonStandings (synced from the NHL API).
|
||||
//
|
||||
// Divisions:
|
||||
// Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
|
||||
|
|
@ -110,168 +117,48 @@ interface NhlTeamData {
|
|||
conference: "Eastern" | "Western";
|
||||
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
|
||||
elo: number;
|
||||
p_div1?: number;
|
||||
p_div2?: number;
|
||||
p_div3?: number;
|
||||
p_wc1?: number;
|
||||
p_wc2?: number;
|
||||
}
|
||||
|
||||
const TEAMS_DATA: Record<string, NhlTeamData> = {
|
||||
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
|
||||
// BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs.
|
||||
// MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC.
|
||||
// OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble.
|
||||
// FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated.
|
||||
|
||||
"Tampa Bay Lightning": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1587,
|
||||
p_div1: 0.50, p_div2: 0.40, p_div3: 0.06,
|
||||
},
|
||||
"Buffalo Sabres": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1572,
|
||||
p_div1: 0.45, p_div2: 0.42, p_div3: 0.07,
|
||||
},
|
||||
"Montreal Canadiens": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1527,
|
||||
p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06,
|
||||
},
|
||||
"Ottawa Senators": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1542,
|
||||
p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
|
||||
},
|
||||
"Detroit Red Wings": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1506,
|
||||
p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22,
|
||||
},
|
||||
"Boston Bruins": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1501,
|
||||
p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
|
||||
},
|
||||
"Florida Panthers": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1505,
|
||||
p_wc2: 0.005,
|
||||
},
|
||||
"Toronto Maple Leafs": {
|
||||
conference: "Eastern", division: "Atlantic", elo: 1479,
|
||||
// 0% playoff probability — always miss
|
||||
},
|
||||
"Tampa Bay Lightning": { conference: "Eastern", division: "Atlantic", elo: 1587 },
|
||||
"Buffalo Sabres": { conference: "Eastern", division: "Atlantic", elo: 1572 },
|
||||
"Montreal Canadiens": { conference: "Eastern", division: "Atlantic", elo: 1527 },
|
||||
"Ottawa Senators": { conference: "Eastern", division: "Atlantic", elo: 1542 },
|
||||
"Detroit Red Wings": { conference: "Eastern", division: "Atlantic", elo: 1506 },
|
||||
"Boston Bruins": { conference: "Eastern", division: "Atlantic", elo: 1501 },
|
||||
"Florida Panthers": { conference: "Eastern", division: "Atlantic", elo: 1505 },
|
||||
"Toronto Maple Leafs": { conference: "Eastern", division: "Atlantic", elo: 1479 },
|
||||
|
||||
// ── Eastern Conference — Metropolitan ─────────────────────────────────────
|
||||
// CAR 109 pts (99.9%) — clear 1st seed.
|
||||
// CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race.
|
||||
// WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots.
|
||||
// NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated.
|
||||
|
||||
"Carolina Hurricanes": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1574,
|
||||
p_div1: 0.98, p_div2: 0.01,
|
||||
},
|
||||
"Columbus Blue Jackets": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1530,
|
||||
p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04,
|
||||
},
|
||||
"Pittsburgh Penguins": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1518,
|
||||
p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
|
||||
},
|
||||
"New York Islanders": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1513,
|
||||
p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
|
||||
},
|
||||
"Philadelphia Flyers": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1473,
|
||||
p_wc1: 0.02, p_wc2: 0.05,
|
||||
},
|
||||
"Washington Capitals": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1506,
|
||||
p_wc1: 0.01, p_wc2: 0.03,
|
||||
},
|
||||
"New Jersey Devils": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1488,
|
||||
p_wc2: 0.005,
|
||||
},
|
||||
"New York Rangers": {
|
||||
conference: "Eastern", division: "Metropolitan", elo: 1480,
|
||||
// 0% playoff probability — always miss
|
||||
},
|
||||
"Carolina Hurricanes": { conference: "Eastern", division: "Metropolitan", elo: 1574 },
|
||||
"Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan", elo: 1530 },
|
||||
"Pittsburgh Penguins": { conference: "Eastern", division: "Metropolitan", elo: 1518 },
|
||||
"New York Islanders": { conference: "Eastern", division: "Metropolitan", elo: 1513 },
|
||||
"Philadelphia Flyers": { conference: "Eastern", division: "Metropolitan", elo: 1473 },
|
||||
"Washington Capitals": { conference: "Eastern", division: "Metropolitan", elo: 1506 },
|
||||
"New Jersey Devils": { conference: "Eastern", division: "Metropolitan", elo: 1488 },
|
||||
"New York Rangers": { conference: "Eastern", division: "Metropolitan", elo: 1480 },
|
||||
|
||||
// ── Western Conference — Central ───────────────────────────────────────────
|
||||
// COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3.
|
||||
// UTA 93 pts (92.8%) — leading WC candidate from Central.
|
||||
// NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots.
|
||||
// CHI 75 pts (0%) — eliminated.
|
||||
|
||||
"Colorado Avalanche": {
|
||||
conference: "Western", division: "Central", elo: 1594,
|
||||
p_div1: 0.90, p_div2: 0.09, p_div3: 0.01,
|
||||
},
|
||||
"Dallas Stars": {
|
||||
conference: "Western", division: "Central", elo: 1581,
|
||||
p_div1: 0.08, p_div2: 0.80, p_div3: 0.11,
|
||||
},
|
||||
"Minnesota Wild": {
|
||||
conference: "Western", division: "Central", elo: 1548,
|
||||
p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01,
|
||||
},
|
||||
"Utah Mammoth": {
|
||||
conference: "Western", division: "Central", elo: 1529,
|
||||
p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33,
|
||||
},
|
||||
"Nashville Predators": {
|
||||
conference: "Western", division: "Central", elo: 1471,
|
||||
p_wc1: 0.04, p_wc2: 0.17,
|
||||
},
|
||||
"Winnipeg Jets": {
|
||||
conference: "Western", division: "Central", elo: 1483,
|
||||
p_wc1: 0.01, p_wc2: 0.05,
|
||||
},
|
||||
"St. Louis Blues": {
|
||||
conference: "Western", division: "Central", elo: 1473,
|
||||
p_wc2: 0.02,
|
||||
},
|
||||
"Chicago Blackhawks": {
|
||||
conference: "Western", division: "Central", elo: 1411,
|
||||
// 0% playoff probability — always miss
|
||||
},
|
||||
"Colorado Avalanche": { conference: "Western", division: "Central", elo: 1594 },
|
||||
"Dallas Stars": { conference: "Western", division: "Central", elo: 1581 },
|
||||
"Minnesota Wild": { conference: "Western", division: "Central", elo: 1548 },
|
||||
"Utah Mammoth": { conference: "Western", division: "Central", elo: 1529 },
|
||||
"Nashville Predators": { conference: "Western", division: "Central", elo: 1471 },
|
||||
"Winnipeg Jets": { conference: "Western", division: "Central", elo: 1483 },
|
||||
"St. Louis Blues": { conference: "Western", division: "Central", elo: 1473 },
|
||||
"Chicago Blackhawks": { conference: "Western", division: "Central", elo: 1411 },
|
||||
|
||||
// ── Western Conference — Pacific ───────────────────────────────────────────
|
||||
// ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds.
|
||||
// LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble.
|
||||
// CGY 73 pts (0%), VAN 63 pts (0%) — eliminated.
|
||||
|
||||
"Anaheim Ducks": {
|
||||
conference: "Western", division: "Pacific", elo: 1490,
|
||||
p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01,
|
||||
},
|
||||
"Edmonton Oilers": {
|
||||
conference: "Western", division: "Pacific", elo: 1531,
|
||||
p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04,
|
||||
},
|
||||
"Vegas Golden Knights": {
|
||||
conference: "Western", division: "Pacific", elo: 1519,
|
||||
p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06,
|
||||
},
|
||||
"Los Angeles Kings": {
|
||||
conference: "Western", division: "Pacific", elo: 1482,
|
||||
p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12,
|
||||
},
|
||||
"San Jose Sharks": {
|
||||
conference: "Western", division: "Pacific", elo: 1455,
|
||||
p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12,
|
||||
},
|
||||
"Seattle Kraken": {
|
||||
conference: "Western", division: "Pacific", elo: 1469,
|
||||
p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10,
|
||||
},
|
||||
"Calgary Flames": {
|
||||
conference: "Western", division: "Pacific", elo: 1436,
|
||||
// 0% playoff probability — always miss
|
||||
},
|
||||
"Vancouver Canucks": {
|
||||
conference: "Western", division: "Pacific", elo: 1403,
|
||||
// 0% playoff probability — always miss
|
||||
},
|
||||
"Anaheim Ducks": { conference: "Western", division: "Pacific", elo: 1490 },
|
||||
"Edmonton Oilers": { conference: "Western", division: "Pacific", elo: 1531 },
|
||||
"Vegas Golden Knights": { conference: "Western", division: "Pacific", elo: 1519 },
|
||||
"Los Angeles Kings": { conference: "Western", division: "Pacific", elo: 1482 },
|
||||
"San Jose Sharks": { conference: "Western", division: "Pacific", elo: 1455 },
|
||||
"Seattle Kraken": { conference: "Western", division: "Pacific", elo: 1469 },
|
||||
"Calgary Flames": { conference: "Western", division: "Pacific", elo: 1436 },
|
||||
"Vancouver Canucks": { conference: "Western", division: "Pacific", elo: 1403 },
|
||||
};
|
||||
|
||||
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
||||
|
|
@ -305,43 +192,60 @@ interface TeamEntry {
|
|||
id: string;
|
||||
name: string;
|
||||
data: NhlTeamData | undefined;
|
||||
conference: "Eastern" | "Western";
|
||||
division: string;
|
||||
/** Current regular season points: wins × 2 + OT losses × 1. */
|
||||
currentPoints: number;
|
||||
/** Remaining regular season games = 82 − gamesPlayed. */
|
||||
remainingGames: number;
|
||||
/** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
|
||||
winProb: number;
|
||||
}
|
||||
|
||||
type DivisionSeedings = {
|
||||
first: TeamEntry;
|
||||
second: TeamEntry;
|
||||
third: TeamEntry;
|
||||
};
|
||||
|
||||
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
|
||||
function elo(entry: TeamEntry): number {
|
||||
return entry.data?.elo ?? 1400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted pick without replacement.
|
||||
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
|
||||
* Returns undefined if no eligible team has positive weight — the caller should
|
||||
* treat that as a degenerate draw and skip the iteration.
|
||||
* Simulate remaining regular season games for one team.
|
||||
* Each game:
|
||||
* - Win (2 pts) with probability winProb
|
||||
* - OT/SO loss (1 pt) with probability (1 − winProb) × NHL_OT_RATE
|
||||
* - Regulation loss (0 pts) otherwise
|
||||
* Returns projected total points for the season.
|
||||
*/
|
||||
function weightedPick(
|
||||
pool: TeamEntry[],
|
||||
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
|
||||
excluded: Set<TeamEntry>
|
||||
): TeamEntry | undefined {
|
||||
const eligible = pool.filter((t) => !excluded.has(t));
|
||||
if (eligible.length === 0) return undefined;
|
||||
|
||||
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
|
||||
const total = weights.reduce((s, w) => s + w, 0);
|
||||
if (total === 0) return undefined;
|
||||
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < eligible.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r <= 0) return eligible[i];
|
||||
export function simulateProjectedPoints(entry: TeamEntry): number {
|
||||
let extra = 0;
|
||||
const { winProb, remainingGames } = entry;
|
||||
const otlProb = (1 - winProb) * NHL_OT_RATE;
|
||||
for (let g = 0; g < remainingGames; g++) {
|
||||
const r = Math.random();
|
||||
if (r < winProb) {
|
||||
extra += 2;
|
||||
} else if (r < winProb + otlProb) {
|
||||
extra += 1;
|
||||
}
|
||||
return eligible[eligible.length - 1];
|
||||
}
|
||||
return entry.currentPoints + extra;
|
||||
}
|
||||
|
||||
// ─── Projected-points helpers (module-level for hot-loop efficiency) ──────────
|
||||
|
||||
interface ProjectedTeam {
|
||||
team: TeamEntry;
|
||||
pts: number;
|
||||
tb: number;
|
||||
}
|
||||
|
||||
/** Project one team's end-of-season point total with a random tiebreaker. */
|
||||
function projectTeam(t: TeamEntry): ProjectedTeam {
|
||||
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
|
||||
}
|
||||
|
||||
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
|
||||
function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
|
||||
return arr.toSorted((a, b) => b.pts - a.pts || b.tb - a.tb);
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -350,11 +254,14 @@ export class NHLSimulator implements Simulator {
|
|||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// 1. Load all participants for this sports season.
|
||||
const participantRows = await db
|
||||
// 1. Load participants and standings in parallel.
|
||||
const [participantRows, standings] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
|
||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
|
||||
getRegularSeasonStandings(sportsSeasonId),
|
||||
]);
|
||||
|
||||
if (participantRows.length === 0) {
|
||||
throw new Error(
|
||||
|
|
@ -365,44 +272,78 @@ export class NHLSimulator implements Simulator {
|
|||
|
||||
const participantIds = participantRows.map((r) => r.id);
|
||||
|
||||
const teams: TeamEntry[] = participantRows.map((r) => ({
|
||||
// 2. Build standings lookup and construct team entries.
|
||||
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
|
||||
|
||||
if (standings.length === 0) {
|
||||
logger.warn(
|
||||
`[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
|
||||
`Simulation will use 0 current points and 82 remaining games for all teams. ` +
|
||||
`Sync standings before running for accurate results.`
|
||||
);
|
||||
}
|
||||
|
||||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||||
const standing = standingsMap.get(r.id);
|
||||
const data = getTeamData(r.name);
|
||||
|
||||
// Conference and division: prefer standings table, fall back to TEAMS_DATA.
|
||||
const rawConf = standing?.conference;
|
||||
const conference: "Eastern" | "Western" =
|
||||
rawConf === "Eastern" || rawConf === "Western"
|
||||
? rawConf
|
||||
: (data?.conference ?? "Eastern");
|
||||
|
||||
const division: string = standing?.division ?? data?.division ?? "";
|
||||
|
||||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||||
const wins = standing?.wins ?? 0;
|
||||
const otLosses = standing?.otLosses ?? 0;
|
||||
const currentPoints = wins * 2 + otLosses;
|
||||
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
data: getTeamData(r.name),
|
||||
}));
|
||||
data,
|
||||
conference,
|
||||
division,
|
||||
currentPoints,
|
||||
remainingGames,
|
||||
winProb: eloWinProbability(data?.elo ?? 1400, 1500),
|
||||
};
|
||||
});
|
||||
|
||||
// Warn about participants that don't match any hardcoded team.
|
||||
// These are excluded entirely rather than silently landing in the wildcard pool.
|
||||
const unrecognized = teams.filter((t) => !t.data);
|
||||
if (unrecognized.length > 0) {
|
||||
logger.warn(
|
||||
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
|
||||
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will use fallback Elo 1400: ` +
|
||||
unrecognized.map((t) => t.name).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Separate recognized teams by conference + division.
|
||||
const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic");
|
||||
const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan");
|
||||
const westernCentral = teams.filter((t) => t.data?.division === "Central");
|
||||
const westernPacific = teams.filter((t) => t.data?.division === "Pacific");
|
||||
const easternAtlantic = teams.filter((t) => t.conference === "Eastern" && t.division === "Atlantic");
|
||||
const easternMetro = teams.filter((t) => t.conference === "Eastern" && t.division === "Metropolitan");
|
||||
const westernCentral = teams.filter((t) => t.conference === "Western" && t.division === "Central");
|
||||
const westernPacific = teams.filter((t) => t.conference === "Western" && t.division === "Pacific");
|
||||
|
||||
// Only recognized teams enter the conference wildcard pools.
|
||||
const easternTeams = teams.filter((t) => t.data?.conference === "Eastern");
|
||||
const westernTeams = teams.filter((t) => t.data?.conference === "Western");
|
||||
|
||||
if (easternTeams.length < 8 || westernTeams.length < 8) {
|
||||
if (
|
||||
easternAtlantic.length < 3 ||
|
||||
easternMetro.length < 3 ||
|
||||
westernCentral.length < 3 ||
|
||||
westernPacific.length < 3
|
||||
) {
|
||||
throw new Error(
|
||||
`Each conference needs at least 8 recognized participants ` +
|
||||
`(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` +
|
||||
`Each division needs at least 3 participants ` +
|
||||
`(got Eastern/Atlantic: ${easternAtlantic.length}, Eastern/Metro: ${easternMetro.length}, ` +
|
||||
`Western/Central: ${westernCentral.length}, Western/Pacific: ${westernPacific.length}). ` +
|
||||
`Add all 32 NHL teams before running simulation.`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
||||
// Load sourceOdds (American format) from participantExpectedValues.
|
||||
// If any odds are present, blend them with Elo for per-game win probability.
|
||||
// Falls back to Elo-only when no odds are stored.
|
||||
|
||||
const evRows = await db
|
||||
.select({
|
||||
|
|
@ -418,7 +359,6 @@ export class NHLSimulator implements Simulator {
|
|||
);
|
||||
const hasOdds = oddsRows.length > 0;
|
||||
|
||||
// Build vig-removed win-probability map keyed by participant ID.
|
||||
const normalizedOddsMap = new Map<string, number>();
|
||||
if (hasOdds) {
|
||||
const rawProbs = oddsRows.map((r) =>
|
||||
|
|
@ -435,7 +375,6 @@ export class NHLSimulator implements Simulator {
|
|||
/**
|
||||
* Blended per-game win probability for team A over team B.
|
||||
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
|
||||
* Falls back to pure Elo when no odds are stored.
|
||||
*/
|
||||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
||||
const eloProb = eloWinProbability(elo(a), elo(b));
|
||||
|
|
@ -458,73 +397,52 @@ export class NHLSimulator implements Simulator {
|
|||
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw the top-3 playoff seeds for a single division.
|
||||
* Returns undefined for each position if no team has weight for that slot
|
||||
* (shouldn't happen with correct data, but handled gracefully).
|
||||
*/
|
||||
const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => {
|
||||
const taken = new Set<TeamEntry>();
|
||||
|
||||
const first = weightedPick(divTeams, "p_div1", taken);
|
||||
if (!first) return undefined;
|
||||
taken.add(first);
|
||||
|
||||
const second = weightedPick(divTeams, "p_div2", taken);
|
||||
if (!second) return undefined;
|
||||
taken.add(second);
|
||||
|
||||
const third = weightedPick(divTeams, "p_div3", taken);
|
||||
if (!third) return undefined;
|
||||
|
||||
return { first, second, third };
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the 8-team playoff bracket for one conference.
|
||||
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
|
||||
*
|
||||
* Bracket:
|
||||
* m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard)
|
||||
* m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard)
|
||||
* m2: topDiv_2nd vs topDiv_3rd
|
||||
* m3: otherDiv_2nd vs otherDiv_3rd
|
||||
* For each sim iteration:
|
||||
* 1. Simulate projected points for all teams in both divisions.
|
||||
* 2. Top 3 per division (by projected pts + random tiebreaker) → division seeds.
|
||||
* 3. Top 2 remaining conference teams → WC1 (more pts) and WC2 (fewer pts).
|
||||
* 4. Division winner with more pts = 1st seed (faces WC2);
|
||||
* other division winner = 2nd seed (faces WC1).
|
||||
*
|
||||
* "Top division" = whichever division winner has higher Elo. The actual NHL rule
|
||||
* uses regular season points, but Elo is a reasonable per-simulation proxy since
|
||||
* we don't simulate the regular season — it approximates the same ordering.
|
||||
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed],
|
||||
* or undefined if the conference doesn't have enough teams for a valid bracket.
|
||||
*/
|
||||
const buildConferenceBracket = (
|
||||
divATeams: TeamEntry[],
|
||||
divBTeams: TeamEntry[],
|
||||
allConfTeams: TeamEntry[]
|
||||
divBTeams: TeamEntry[]
|
||||
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
|
||||
const divA = drawDivisionSeedings(divATeams);
|
||||
const divB = drawDivisionSeedings(divBTeams);
|
||||
if (!divA || !divB) return undefined;
|
||||
if (divATeams.length < 3 || divBTeams.length < 3) return undefined;
|
||||
|
||||
const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]);
|
||||
const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t));
|
||||
const wcTaken = new Set<TeamEntry>();
|
||||
// Project all conference teams exactly once (single simulateProjectedPoints call per team).
|
||||
const divASet = new Set(divATeams.map((t) => t.id));
|
||||
const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
|
||||
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
|
||||
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
|
||||
|
||||
const wc1 = weightedPick(wcPool, "p_wc1", wcTaken);
|
||||
if (!wc1) return undefined;
|
||||
wcTaken.add(wc1);
|
||||
const divASeeds = divAProjected.slice(0, 3);
|
||||
const divBSeeds = divBProjected.slice(0, 3);
|
||||
|
||||
const wc2 = weightedPick(wcPool, "p_wc2", wcTaken);
|
||||
if (!wc2) return undefined;
|
||||
// Wildcard pool: non-division-seed teams, already sorted by projected pts.
|
||||
const divisionSeedIds = new Set([...divASeeds, ...divBSeeds].map((p) => p.team.id));
|
||||
const wcPool = allProjected.filter((p) => !divisionSeedIds.has(p.team.id));
|
||||
|
||||
// Higher-Elo division winner faces WC2 (the weaker wildcard).
|
||||
if (wcPool.length < 2) return undefined;
|
||||
const [wc1, wc2] = wcPool; // wc1 has more pts → stronger wildcard
|
||||
|
||||
// Division winner with more projected points is the top seed (plays WC2).
|
||||
const [topDiv, otherDiv] =
|
||||
elo(divA.first) >= elo(divB.first)
|
||||
? [divA, divB]
|
||||
: [divB, divA];
|
||||
divASeeds[0].pts >= divBSeeds[0].pts
|
||||
? [divASeeds, divBSeeds]
|
||||
: [divBSeeds, divASeeds];
|
||||
|
||||
return [
|
||||
[topDiv.first, wc2], // m0
|
||||
[otherDiv.first, wc1], // m1
|
||||
[topDiv.second, topDiv.third], // m2
|
||||
[otherDiv.second, otherDiv.third], // m3
|
||||
[topDiv[0].team, wc2.team], // m0: 1st seed vs WC2
|
||||
[otherDiv[0].team, wc1.team], // m1: 2nd seed vs WC1
|
||||
[topDiv[1].team, topDiv[2].team], // m2: top div 2nd vs 3rd
|
||||
[otherDiv[1].team, otherDiv[2].team], // m3: other div 2nd vs 3rd
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -537,28 +455,18 @@ export class NHLSimulator implements Simulator {
|
|||
|
||||
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
|
||||
|
||||
// Track effective iterations separately: degenerate bracket draws are skipped
|
||||
// and must not count toward the probability denominators.
|
||||
let effectiveN = 0;
|
||||
|
||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||
// Build conference brackets.
|
||||
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams);
|
||||
const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams);
|
||||
if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
|
||||
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
|
||||
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
|
||||
if (!eastBracket || !westBracket) continue;
|
||||
effectiveN++;
|
||||
|
||||
// ── Eastern Conference ────────────────────────────────────────────────────
|
||||
// Round 1: 4 series (Wild Card round).
|
||||
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
|
||||
|
||||
// Round 2 (Division Finals):
|
||||
// m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner)
|
||||
// m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner)
|
||||
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
|
||||
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
|
||||
|
||||
// Conference Final.
|
||||
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
|
||||
const eastChamp = eastCF.winner;
|
||||
confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1);
|
||||
|
|
@ -576,7 +484,6 @@ export class NHLSimulator implements Simulator {
|
|||
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
|
||||
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
|
||||
|
||||
// Round 2 losers (4 per sim). Round 1 losers are not counted (0 points).
|
||||
for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
|
||||
r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
|
||||
}
|
||||
|
|
@ -585,16 +492,12 @@ export class NHLSimulator implements Simulator {
|
|||
if (effectiveN === 0) {
|
||||
throw new Error(
|
||||
"All simulations produced degenerate brackets. " +
|
||||
"Check that each division has teams with positive seeding probabilities."
|
||||
"Check that each division has at least 3 participants with standings data."
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Convert counts to probability distributions ───────────────────────────
|
||||
//
|
||||
// probFirst/Second → count / N (1 team per sim)
|
||||
// probThird/Fourth → count / (2 * N) (2 conf final losers per sim)
|
||||
// probFifth–Eighth → count / (4 * N) (4 R2 losers per sim)
|
||||
//
|
||||
|
||||
const N = effectiveN;
|
||||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||||
const c = championCounts.get(participantId) ?? 0;
|
||||
|
|
@ -618,10 +521,7 @@ export class NHLSimulator implements Simulator {
|
|||
});
|
||||
|
||||
// ─── Per-position normalization ────────────────────────────────────────────
|
||||
//
|
||||
// Belt-and-suspenders guard against floating-point residuals.
|
||||
// Columns are already near-exactly 1.0 by construction above.
|
||||
//
|
||||
|
||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue