brackt/app/services/simulations/nll-simulator.ts
Claude 4142b21c55
Honor engine knobs across simulators, de-dupe odds, unify config UI
Two problems addressed:

1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied).
   - The NHL simulator hardcoded its parity factor (1000) and ignored the
     season config's parityFactor, so the knob meant to flatten the
     distribution did nothing. It also re-blended raw futures odds into every
     game on top of the odds->Elo conversion, double-counting the same signal.
   - NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config
     and no longer re-blends odds per game (odds enter once, via the central
     odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to
     ~13% title odds.

2. "Season Config" and "Input Policy" were two forms over the same stored
   object that didn't reflect each other, and the engine-knob half was inert
   for many simulators.
   - Every simulator now reads its engine knobs (iterations everywhere;
     parityFactor for all Elo-based sims) from the merged config, passed in by
     the runner via the Simulator interface. Defaults equal the former
     hardcoded constants, so behavior is unchanged unless a season overrides.
   - The admin simulator page is now a single "Simulator Configuration" card
     with structured Engine and Input-derivation sections (profile-driven, so
     each sport shows only the knobs it honors) plus an Advanced raw-JSON
     escape hatch — all writing the same config.

Also: centralized the duplicated configNumber helpers into config-access.ts;
the central odds->Elo resolver now maps onto the configured Elo floor/ceiling
so those bounds set the odds-derived spread (a real flattening dial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf
2026-06-30 22:00:33 +00:00

657 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NLL Season + Playoffs Simulator
*
* Monte Carlo simulation of the NLL regular season and playoffs.
*
* Three modes, auto-detected at runtime:
*
* ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────────
* Used when a playoff_game scoring event with generated matches exists.
* Reads completed QF/SF/Finals results; simulates remaining games and series.
* Best-of-3 series respect completed playoff_match_games rows.
*
* ── Mode 2: Known-Seed ────────────────────────────────────────────────────────
* Used when no bracket exists but all 8 seeds 1-8 are set via the seed input.
* Simulates the playoff bracket directly from those seeds.
*
* ── Mode 3: Regular-Season Projection (default) ───────────────────────────────
* Simulates remaining regular season games per team using Elo vs average (1500),
* blending with projectedWins when provided. Top 8 teams qualify. Then simulates
* the NLL playoff bracket.
*
* NLL playoff bracket format:
* Quarterfinals (single game): 1v8, 2v7, 3v6, 4v5
* Semifinal arms: (1/8 winner vs 4/5 winner), (2/7 winner vs 3/6 winner)
* Semifinals (best-of-3) and Finals (best-of-3)
*
* Probability mapping:
* probFirst = NLL champion (1 per sim)
* probSecond = Finals loser (1 per sim)
* probThird/Fourth = Semifinal losers (2 per sim — split evenly)
* probFifthEighth = Quarterfinal losers (4 per sim — split evenly)
* Missed playoffs → all 0
*
* Round name constants (ROUND_*) must match the bracket template used to
* generate playoff matches. If the template changes round names, update these.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;
/** Maximum preseason weight given to projectedWins in the blend. Decays to 0 by season end. */
const PROJECTED_WINS_WEIGHT = 0.65;
/** Fallback Elo used when a bracket participant has no sourceElo on record. */
const FALLBACK_ELO = 1400;
// ─── Round name constants ─────────────────────────────────────────────────────
// These must match the round strings generated by the nll_bracket bracket template.
export const ROUND_QUARTERFINALS = "Quarterfinals" as const;
export const ROUND_SEMIFINALS = "Semifinals" as const;
export const ROUND_FINALS = "Finals" as const;
// ─── Structural types used by resolve helpers ─────────────────────────────────
/** Minimal match fields needed by the resolve helpers. */
export interface MatchSnapshot {
id: string;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
/** Minimal game result needed by the resolve helpers. */
export interface GameSnapshot {
winnerId: string | null;
}
// ─── Public helpers (exported for testability) ────────────────────────────────
export function nllGameWinProbability(
eloA: number,
eloB: number,
parityFactor = PARITY_FACTOR
): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
export interface SimTeam {
participantId: string;
elo: number;
seed?: number;
}
export function simulateNllGame(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR
): { winner: SimTeam; loser: SimTeam } {
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
if (Math.random() < pA) return { winner: teamA, loser: teamB };
return { winner: teamB, loser: teamA };
}
export interface BestOfThreeState {
winsA: number;
winsB: number;
}
/** Simulate a best-of-3 series from an optional existing state. First to 2 wins. */
export function simulateBestOfThree(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR,
existing: BestOfThreeState = { winsA: 0, winsB: 0 }
): { winner: SimTeam; loser: SimTeam; gamesPlayed: number } {
let wA = existing.winsA;
let wB = existing.winsB;
let gamesPlayed = 0;
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
while (wA < 2 && wB < 2) {
if (Math.random() < pA) wA++; else wB++;
gamesPlayed++;
}
return wA === 2
? { winner: teamA, loser: teamB, gamesPlayed }
: { winner: teamB, loser: teamA, gamesPlayed };
}
export interface SeedEntry {
participantId: string;
elo: number;
seed: number;
}
/**
* Build the 4 NLL Quarterfinal matchups from seeds 1-8.
* Bracket arms are fixed:
* QF1: 1 vs 8 → SF arm A
* QF2: 4 vs 5 → SF arm A
* QF3: 2 vs 7 → SF arm B
* QF4: 3 vs 6 → SF arm B
*/
export function buildNllBracketFromSeeds(seeds: SeedEntry[]): [
[SeedEntry, SeedEntry], // QF1: 1 vs 8
[SeedEntry, SeedEntry], // QF2: 4 vs 5
[SeedEntry, SeedEntry], // QF3: 2 vs 7
[SeedEntry, SeedEntry], // QF4: 3 vs 6
] {
const bySeeds = new Map(seeds.map((s) => [s.seed, s]));
const s = (n: number) => {
const entry = bySeeds.get(n);
if (!entry) throw new Error(`Seed ${n} not found in bracket seeds.`);
return entry;
};
return [
[s(1), s(8)],
[s(4), s(5)],
[s(2), s(7)],
[s(3), s(6)],
];
}
export interface NllPlayoffResult {
champion: string;
finalist: string;
sfLosers: [string, string];
qfLosers: [string, string, string, string];
}
/** Simulate the full NLL playoff bracket from 8 seeded teams. */
export function simulateNllPlayoffs(
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR
): NllPlayoffResult {
const [[qf1a, qf1b], [qf2a, qf2b], [qf3a, qf3b], [qf4a, qf4b]] =
buildNllBracketFromSeeds(seeds);
const qf1 = simulateNllGame(qf1a, qf1b, parityFactor);
const qf2 = simulateNllGame(qf2a, qf2b, parityFactor);
const qf3 = simulateNllGame(qf3a, qf3b, parityFactor);
const qf4 = simulateNllGame(qf4a, qf4b, parityFactor);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5)
const sfA = simulateBestOfThree(qf1.winner, qf2.winner, parityFactor);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6)
const sfB = simulateBestOfThree(qf3.winner, qf4.winner, parityFactor);
const final = simulateBestOfThree(sfA.winner, sfB.winner, parityFactor);
return {
champion: final.winner.participantId,
finalist: final.loser.participantId,
sfLosers: [sfA.loser.participantId, sfB.loser.participantId],
qfLosers: [
qf1.loser.participantId,
qf2.loser.participantId,
qf3.loser.participantId,
qf4.loser.participantId,
],
};
}
// ─── Bracket-aware resolve helpers (exported for testability) ─────────────────
/**
* Resolve a single-game QF match.
* Returns the stored result deterministically when the match is complete;
* simulates a single game otherwise.
*/
export function resolveQfMatch(
match: MatchSnapshot | undefined,
eloMap: Map<string, number>,
parityFactor = PARITY_FACTOR
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id;
const p2 = match?.participant2Id;
if (!p1 || !p2) {
throw new Error(
`NLL QF match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor
);
return { winner: result.winner.participantId, loser: result.loser.participantId };
}
/**
* Resolve a best-of-3 SF or Finals match.
* Returns the stored result deterministically when the match is complete.
* Counts completed game rows (GameSnapshot[]) for partial series state; if a
* team already has 2 wins in the game rows, treats the series as done even if
* playoffMatches.isComplete has not been toggled yet.
* Simulates remaining games from the current series score otherwise.
*/
export function resolveBo3Match(
match: MatchSnapshot | undefined,
games: GameSnapshot[],
eloMap: Map<string, number>,
p1Override?: string,
p2Override?: string,
parityFactor = PARITY_FACTOR
): { 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(
`NLL series match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
let winsP1 = 0;
let winsP2 = 0;
for (const g of games) {
if (g.winnerId === null) continue;
if (g.winnerId === p1) winsP1++;
else if (g.winnerId === p2) winsP2++;
}
// A team with 2 game wins has clinched the series.
if (winsP1 >= 2) return { winner: p1, loser: p2 };
if (winsP2 >= 2) return { winner: p2, loser: p1 };
const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
parityFactor,
{ winsA: winsP1, winsB: winsP2 }
);
return { winner: winner.participantId, loser: loser.participantId };
}
// ─── Regular-season projection helper ────────────────────────────────────────
export interface TeamProjection {
participantId: string;
elo: number;
currentWins: number;
gamesPlayed: number;
projectedWins: number | null;
}
/** Simulate remaining regular season games and return the top-8 seeds. */
export function simulateRegularSeasonSeeds(
teams: TeamProjection[],
parityFactor = PARITY_FACTOR
): SeedEntry[] {
// Pre-compute tiebreaker jitter once per simulation for sort stability.
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
const projected = teams.map((t) => {
const remaining = Math.max(0, NLL_REGULAR_SEASON_GAMES - t.gamesPlayed);
const eloRate = eloWinProbabilityWithParity(t.elo, AVERAGE_OPPONENT_ELO, parityFactor);
// Decay the preseason projectedWins prior linearly as the season progresses.
// At gamesPlayed=0 it carries PROJECTED_WINS_WEIGHT (65%); by gamesPlayed=18
// it carries 0% so late-season projections rely purely on Elo. This prevents
// a stale preseason estimate from dominating when actual standings are available.
//
// The prior rate is computed over *remaining* games (not the full season) so
// that wins already accumulated are subtracted from the preseason expectation.
// If currentWins already meets or exceeds the projection, the prior is clamped
// to 0 and Elo takes over fully.
const completionFraction = t.gamesPlayed / NLL_REGULAR_SEASON_GAMES;
const projectedBlend =
t.projectedWins !== null ? PROJECTED_WINS_WEIGHT * (1 - completionFraction) : 0;
const priorRate =
t.projectedWins !== null && remaining > 0
? Math.min(1, Math.max(0, t.projectedWins - t.currentWins) / remaining)
: 0;
const winRate =
projectedBlend > 0 && priorRate > 0
? projectedBlend * priorRate + (1 - projectedBlend) * eloRate
: eloRate;
let additionalWins = 0;
for (let g = 0; g < remaining; g++) {
if (Math.random() < winRate) additionalWins++;
}
return {
participantId: t.participantId,
elo: t.elo,
finalWins: t.currentWins + additionalWins,
};
});
const sorted = projected.toSorted(
(a, b) =>
b.finalWins - a.finalWins ||
(jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0)
);
return sorted.slice(0, NLL_PLAYOFF_TEAMS).map((t, i) => ({
participantId: t.participantId,
elo: t.elo,
seed: i + 1,
}));
}
// ─── Simulator class ──────────────────────────────────────────────────────────
export class NLLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
// ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// ── Load Elo ratings from EV table (populated by prepareSimulatorInputsForRun) ──
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
}
}
if (eloMap.size === 0) {
throw new Error(
`No Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
);
}
// ── Load simulator inputs (projectedWins + seed) ──────────────────────────
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map<string, number | null>();
const seedMap = new Map<string, number>();
for (const input of simInputs) {
projectedWinsMap.set(input.participantId, input.projectedWins);
if (input.seed !== null && input.seed !== undefined) {
seedMap.set(input.participantId, input.seed);
}
}
// ── All participant IDs for counting ─────────────────────────────────────
const allIds = participants.map((p) => p.id);
// ── Mode detection ────────────────────────────────────────────────────────
// Mode 1: bracket-aware — a playoff_game scoring 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(allIds, eloMap, bracketMatches, parityFactor, numSimulations);
}
}
// Mode 2: known-seed — all 8 seeds 1-8 are explicitly set.
const knownSeeds: SeedEntry[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
const seed = seedMap.get(p.id);
if (elo !== undefined && seed !== undefined && seed >= 1 && seed <= 8) {
knownSeeds.push({ participantId: p.id, elo, seed });
}
}
if (
knownSeeds.length === NLL_PLAYOFF_TEAMS &&
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
) {
return this.simulateKnownSeeds(allIds, knownSeeds, parityFactor, numSimulations);
}
// Mode 3: regular-season projection (default, including preseason).
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
const teams: TeamProjection[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
if (elo === undefined) continue;
const standing = standingsMap.get(p.id);
teams.push({
participantId: p.id,
elo,
currentWins: standing?.wins ?? 0,
gamesPlayed: standing?.gamesPlayed ?? 0,
projectedWins: projectedWinsMap.get(p.id) ?? null,
});
}
if (teams.length === 0) {
throw new Error(
`No participants with Elo ratings found for season ${sportsSeasonId}.`
);
}
return this.simulateRegularSeason(allIds, teams, parityFactor, numSimulations);
}
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
private async simulateBracketAware(
allIds: string[],
eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): Promise<SimulationResult[]> {
const db = database();
const qfMatches = allMatches
.filter((m) => m.round === ROUND_QUARTERFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const sfMatches = allMatches
.filter((m) => m.round === ROUND_SEMIFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const finalMatches = allMatches.filter((m) => m.round === ROUND_FINALS);
if (qfMatches.length !== 4 || sfMatches.length !== 2 || finalMatches.length !== 1) {
throw new Error(
`NLL bracket has unexpected structure. Expected QF×4, SF×2, Finals×1. ` +
`Got QF×${qfMatches.length}, SF×${sfMatches.length}, Finals×${finalMatches.length}. ` +
`Ensure the bracket was generated with the nll_bracket template.`
);
}
// Warn about any bracket participants without Elo ratings before the hot loop.
const bracketParticipantIds = new Set<string>();
for (const m of allMatches) {
if (m.participant1Id) bracketParticipantIds.add(m.participant1Id);
if (m.participant2Id) bracketParticipantIds.add(m.participant2Id);
if (m.winnerId) bracketParticipantIds.add(m.winnerId);
if (m.loserId) bracketParticipantIds.add(m.loserId);
}
for (const id of bracketParticipantIds) {
if (!eloMap.has(id)) {
logger.warn(
`NLL bracket simulator: no Elo rating for participant ${id}. ` +
`Using fallback Elo ${FALLBACK_ELO}. Enter sourceElo via Admin → Elo Ratings.`
);
}
}
// Load playoff_match_games for best-of-3 series state.
const sfAndFinalIds = [...sfMatches, ...finalMatches].map((m) => m.id);
const allGames = sfAndFinalIds.length > 0
? await db.query.playoffMatchGames.findMany({
where: (g, { inArray }) => inArray(g.playoffMatchId, sfAndFinalIds),
})
: [];
const gamesByMatch = new Map<string, typeof schema.playoffMatchGames.$inferSelect[]>();
for (const game of allGames) {
const list = gamesByMatch.get(game.playoffMatchId) ?? [];
list.push(game);
gamesByMatch.set(game.playoffMatchId, list);
}
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const [qf1, qf2, qf3, qf4] = qfMatches;
const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0];
for (let s = 0; s < numSimulations; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap, parityFactor);
const r_qf2 = resolveQfMatch(qf2, eloMap, parityFactor);
const r_qf3 = resolveQfMatch(qf3, eloMap, parityFactor);
const r_qf4 = resolveQfMatch(qf4, eloMap, parityFactor);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner, parityFactor);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner, parityFactor);
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner, parityFactor);
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf1.loser, (sfLoserCounts.get(r_sf1.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf2.loser, (sfLoserCounts.get(r_sf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf1.loser, (qfLoserCounts.get(r_qf1.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf2.loser, (qfLoserCounts.get(r_qf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf3.loser, (qfLoserCounts.get(r_qf3.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
}
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) {
const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
}
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: TeamProjection[],
parityFactor = PARITY_FACTOR,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < numSimulations; s++) {
const seeds = simulateRegularSeasonSeeds(teams, parityFactor);
const result = simulateNllPlayoffs(seeds, parityFactor);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts, numSimulations);
}
// ── Shared result builder ─────────────────────────────────────────────────
private buildResults(
allIds: string[],
championCounts: Map<string, number>,
finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>,
numSimulations = DEFAULT_NUM_SIMULATIONS
): SimulationResult[] {
const N = numSimulations;
return allIds.map((id) => ({
participantId: id,
probabilities: {
probFirst: (championCounts.get(id) ?? 0) / N,
probSecond: (finalistCounts.get(id) ?? 0) / N,
// 2 SF losers per sim — split evenly across 3rd/4th
probThird: (sfLoserCounts.get(id) ?? 0) / N / 2,
probFourth: (sfLoserCounts.get(id) ?? 0) / N / 2,
// 4 QF losers per sim — split evenly across 5th8th
probFifth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSixth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSeventh: (qfLoserCounts.get(id) ?? 0) / N / 4,
probEighth: (qfLoserCounts.get(id) ?? 0) / N / 4,
},
source: "nll_bracket_monte_carlo",
}));
}
}