645 lines
25 KiB
TypeScript
645 lines
25 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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)
|
|||
|
|
* probFifth–Eighth = 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";
|
|||
|
|
|
|||
|
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const 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>
|
|||
|
|
): { 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 }
|
|||
|
|
);
|
|||
|
|
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
|
|||
|
|
): { 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 },
|
|||
|
|
PARITY_FACTOR,
|
|||
|
|
{ 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): Promise<SimulationResult[]> {
|
|||
|
|
const db = database();
|
|||
|
|
|
|||
|
|
// ── 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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
private async simulateBracketAware(
|
|||
|
|
allIds: string[],
|
|||
|
|
eloMap: Map<string, number>,
|
|||
|
|
allMatches: typeof schema.playoffMatches.$inferSelect[]
|
|||
|
|
): 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 < NUM_SIMULATIONS; s++) {
|
|||
|
|
const r_qf1 = resolveQfMatch(qf1, eloMap);
|
|||
|
|
const r_qf2 = resolveQfMatch(qf2, eloMap);
|
|||
|
|
const r_qf3 = resolveQfMatch(qf3, eloMap);
|
|||
|
|
const r_qf4 = resolveQfMatch(qf4, eloMap);
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
// 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);
|
|||
|
|
|
|||
|
|
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
private simulateKnownSeeds(
|
|||
|
|
allIds: string[],
|
|||
|
|
seeds: SeedEntry[]
|
|||
|
|
): 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 < NUM_SIMULATIONS; s++) {
|
|||
|
|
const result = simulateNllPlayoffs(seeds);
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
|
|||
|
|
|
|||
|
|
private simulateRegularSeason(
|
|||
|
|
allIds: string[],
|
|||
|
|
teams: TeamProjection[]
|
|||
|
|
): 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 < NUM_SIMULATIONS; s++) {
|
|||
|
|
const seeds = simulateRegularSeasonSeeds(teams);
|
|||
|
|
const result = simulateNllPlayoffs(seeds);
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Shared result builder ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
private buildResults(
|
|||
|
|
allIds: string[],
|
|||
|
|
championCounts: Map<string, number>,
|
|||
|
|
finalistCounts: Map<string, number>,
|
|||
|
|
sfLoserCounts: Map<string, number>,
|
|||
|
|
qfLoserCounts: Map<string, number>
|
|||
|
|
): SimulationResult[] {
|
|||
|
|
const N = NUM_SIMULATIONS;
|
|||
|
|
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 5th–8th
|
|||
|
|
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",
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
}
|