Entering projected wins for an in-progress MLB season did not behave as expected: the entered numbers came back changed, and the simulation appeared to ignore them in favour of whatever Elo was already stored. Four separate defects were involved. Projections are now stored and shown verbatim. The Elo Ratings page never kept the number typed into it — the field was a display derived from Elo, so a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to an integer Elo) and drifted again after each run, because a run re-resolves that Elo through the input policy. The loader now reads back the stored projection and the paste flow keeps the pasted value as-is; the derived round-trip survives only as a prefill for seasons that have never had a projection saved. A stale Elo no longer silently outranks a projection. baseEloPriority takes the first available base source, and the simulator page's bulk CSV wrote projectedWins without stamping metadata.sourceEloMethod, so the non-destructive upsert left the old Elo in place as a trusted direct value and it won the race — the projection was stored and then ignored on every run. The CSV path now stamps the flag like the Elo Ratings page does, the metadata upsert merges rather than replaces so a flag-only write keeps unrelated keys, and Base Elo Source is editable per season for the case where a genuine hand-entered Elo should still lose to projections. Projected wins now act as a projected final total. The value was baked into a flat season-long rate (projectedWins / 162) applied to every remaining game, so a team at 60-50 projected for 95 finished around 90.5 and the projection was never reached mid-season. seedingWinRateFor spreads the difference over the games still to play, which is a no-op pre-season where the two rates coincide; projectedWinsWeight blends it back toward the Elo-implied rate. Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so any team with an Elo skipped the compression every hardcoded-rdif team gets: a 95-win projection became RDif +686 and played playoff games at .586 instead of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140 alongside the Dodgers' hardcoded +137. Also fixes the preview table's "missing a required input" marker, which flagged every projection-configured participant because a generated Elo or rating is deliberately hidden from getParticipantSimulatorInputs. It now consults the resolved values, so it agrees with readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
746 lines
33 KiB
TypeScript
746 lines
33 KiB
TypeScript
/**
|
||
* MLB Playoff Simulator
|
||
*
|
||
* Monte Carlo simulation of the MLB season and playoffs including seeding
|
||
* projection for the current season (2026).
|
||
*
|
||
* Algorithm:
|
||
* 1. Load all participants for the sports season from DB
|
||
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
||
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
||
* 4. Load raw projected win totals from seasonParticipantSimulatorInputs
|
||
* 5. Match participant names to hardcoded team data (RDif + league/division)
|
||
* 6. For each simulation:
|
||
* a. For each league (AL/NL), simulate remaining regular season games for
|
||
* every team using Binomial sampling, giving final projected wins.
|
||
* b. Division winner = best record in each division (3 per league).
|
||
* Wild card = next 3 best records among non-division-winners per league.
|
||
* c. Seed division winners 1–3 by final wins (best = seed 1);
|
||
* WC teams 4–6 by final wins.
|
||
* d. Run the playoff bracket per league:
|
||
* - Wildcard Round (best-of-3): 3 vs 6, 4 vs 5 (seeds 1 & 2 get byes)
|
||
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
||
* - League Championship Series (best-of-7)
|
||
* e. World Series (best-of-7): AL champ vs NL champ
|
||
* 7. Track placement counts per scoring tier
|
||
* 8. Convert counts to probability distributions
|
||
*
|
||
* Win probability (log5 formula):
|
||
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
||
* winRate = clamp(0.5 + rdif / RDIF_DIVISOR, 0.01, 0.99)
|
||
* RDIF_DIVISOR compresses team strengths toward .500 for playoff parity.
|
||
* Step 2 — Bill James log5 head-to-head probability:
|
||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||
*
|
||
* Regular season simulation (seeding):
|
||
* Each team's base per-game win rate is derived from sourceElo (if set) or
|
||
* from the hardcoded RDif using SEEDING_RDIF_SCALE ≈ 10 runs/win × 162 games.
|
||
* When the team also has a user-entered projected win total, that base rate is
|
||
* replaced by the rest-of-season rate that actually reaches the projection:
|
||
* target = (projectedWins − currentWins) / remainingGames
|
||
* (see seedingWinRateFor; config `projectedWinsWeight` blends it back toward the
|
||
* base rate, and is a no-op pre-season where the two rates coincide).
|
||
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
|
||
* Binomial distribution. This makes playoff seeding respond to both current
|
||
* standings and user-entered projected wins.
|
||
*
|
||
* Input resolution:
|
||
* sourceElo is the single Elo produced by the shared input policy — already a
|
||
* blend of any raw Elo / projections / futures odds, written by
|
||
* prepareSimulatorInputsForRun before the run. This simulator does not blend
|
||
* futures odds itself.
|
||
*
|
||
* Placement tiers → SimulationProbabilities mapping:
|
||
* probFirst = World Series champion (1 per sim)
|
||
* probSecond = World Series loser (1 per sim)
|
||
* probThird / probFourth = LCS losers (2 per sim — AL + NL, split evenly)
|
||
* probFifth–probEighth = Division Series losers (4 per sim, split evenly)
|
||
* Wildcard Round losers → all 0 (score 0 points, same as non-playoff teams)
|
||
* Missed playoffs → all 0
|
||
*
|
||
* Team data keys:
|
||
* rdif: FanGraphs Depth Charts projected run differential for 2026.
|
||
* Used as fallback for seeding win rate and for playoff series win probability.
|
||
* league/division: Static MLB structure — does not change season-to-season.
|
||
*
|
||
* Sources:
|
||
* rdif: https://www.fangraphs.com/standings/projected-standings
|
||
* Update rdif at the start of each season.
|
||
*
|
||
* Divisions:
|
||
* AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays
|
||
* AL Central: Royals, Guardians, Twins, Tigers, White Sox
|
||
* AL West: Astros, Mariners, Rangers, Angels, Athletics
|
||
* NL East: Phillies, Braves, Mets, Nationals, Marlins
|
||
* NL Central: Brewers, Cubs, Cardinals, Reds, Pirates
|
||
* NL West: Dodgers, Padres, Diamondbacks, Giants, Rockies
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
import { configNumber, positiveConfigNumber } from "./config-access";
|
||
import { logger } from "~/lib/logger";
|
||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||
const TOTAL_SEASON_GAMES = 162;
|
||
|
||
/**
|
||
* Controls how much projected run differential spreads teams away from .500
|
||
* for single-game playoff matchups.
|
||
*
|
||
* Effect on Dodgers (RDif +137):
|
||
* 1620 → win rate 0.585 (raw Pythagorean — too dominant for playoff matchups)
|
||
* 8000 → win rate 0.517 (current — near coin-flip vs any playoff team)
|
||
*/
|
||
const RDIF_DIVISOR = 8000;
|
||
|
||
/**
|
||
* Scale for converting RDif to a raw per-game win rate for regular-season
|
||
* seeding simulation. Unlike RDIF_DIVISOR (which compresses for playoff
|
||
* parity), this uses ~10 runs/win × 162 games to give realistic season win%.
|
||
*/
|
||
const SEEDING_RDIF_SCALE = 1620;
|
||
|
||
/**
|
||
* Default weight given to a user-entered projected win total when deriving the
|
||
* rest-of-season win rate. 1 = the projection is authoritative; 0 = ignore it and
|
||
* use the Elo-implied rate. Overridable per season via config `projectedWinsWeight`.
|
||
*/
|
||
const DEFAULT_PROJECTED_WINS_WEIGHT = 1;
|
||
|
||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||
//
|
||
// rdif: Projected run differential from FanGraphs Depth Charts.
|
||
// Source: https://www.fangraphs.com/standings/projected-standings
|
||
//
|
||
// league/division: Static MLB structure — update only if teams change leagues.
|
||
//
|
||
// Update rdif at the start of each season.
|
||
|
||
interface MlbTeamData {
|
||
league: "AL" | "NL";
|
||
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
|
||
rdif: number; // 2026 FanGraphs Depth Charts projected run differential
|
||
}
|
||
|
||
const TEAMS_DATA: Record<string, MlbTeamData> = {
|
||
// ── American League East ───────────────────────────────────────────────────
|
||
"New York Yankees": { league: "AL", division: "AL East", rdif: 67 },
|
||
"Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23 },
|
||
"Boston Red Sox": { league: "AL", division: "AL East", rdif: 48 },
|
||
"Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2 },
|
||
"Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37 },
|
||
|
||
// ── American League Central ────────────────────────────────────────────────
|
||
"Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8 },
|
||
"Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38 },
|
||
"Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15 },
|
||
"Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26 },
|
||
"Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112 },
|
||
|
||
// ── American League West ───────────────────────────────────────────────────
|
||
"Houston Astros": { league: "AL", division: "AL West", rdif: -1 },
|
||
"Seattle Mariners": { league: "AL", division: "AL West", rdif: 66 },
|
||
"Texas Rangers": { league: "AL", division: "AL West", rdif: 20 },
|
||
"Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65 },
|
||
"Athletics": { league: "AL", division: "AL West", rdif: -17 },
|
||
|
||
// ── National League East ───────────────────────────────────────────────────
|
||
"Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51 },
|
||
"Atlanta Braves": { league: "NL", division: "NL East", rdif: 67 },
|
||
"New York Mets": { league: "NL", division: "NL East", rdif: 71 },
|
||
"Washington Nationals": { league: "NL", division: "NL East", rdif: -113 },
|
||
"Miami Marlins": { league: "NL", division: "NL East", rdif: -48 },
|
||
|
||
// ── National League Central ────────────────────────────────────────────────
|
||
"Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9 },
|
||
"Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23 },
|
||
"St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55 },
|
||
"Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31 },
|
||
"Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13 },
|
||
|
||
// ── National League West ───────────────────────────────────────────────────
|
||
"Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137 },
|
||
"San Diego Padres": { league: "NL", division: "NL West", rdif: -9 },
|
||
"Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5 },
|
||
"San Francisco Giants": { league: "NL", division: "NL West", rdif: 3 },
|
||
"Colorado Rockies": { league: "NL", division: "NL West", rdif: -173 },
|
||
};
|
||
|
||
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
||
|
||
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
|
||
export function normalizeTeamName(name: string): string {
|
||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||
}
|
||
|
||
/** Look up team data by participant name (case-insensitive). */
|
||
export function getTeamData(name: string): MlbTeamData | undefined {
|
||
const normalized = normalizeTeamName(name);
|
||
for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
|
||
if (normalizeTeamName(teamName) === normalized) return data;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Convert projected run differential to a compressed win rate for playoff matchup probability.
|
||
* Uses RDIF_DIVISOR to control how much team strength spreads away from .500.
|
||
* Clamped to [0.01, 0.99] to avoid degenerate log5 values.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function winRateFromRDif(rdif: number): number {
|
||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
|
||
}
|
||
|
||
/**
|
||
* Convert projected run differential to a raw per-game win rate for regular-season
|
||
* seeding simulation. Uses SEEDING_RDIF_SCALE (~10 runs/win × 162 games) which
|
||
* gives a realistic season win percentage rather than the playoff-compressed value.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rawWinRateFromRDif(rdif: number): number {
|
||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / SEEDING_RDIF_SCALE));
|
||
}
|
||
|
||
/**
|
||
* Derive a raw per-game win rate directly from an Elo rating.
|
||
* This is the inverse Elo formula, returning the same win probability
|
||
* that was originally used to compute the Elo from projected wins.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rawWinRateFromElo(elo: number): number {
|
||
return 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||
}
|
||
|
||
/**
|
||
* Convert an Elo rating to an equivalent projected run differential, on the same
|
||
* scale as the hardcoded TEAMS_DATA.rdif values.
|
||
*
|
||
* Uses the standard Elo win probability formula (parity factor 400, average Elo
|
||
* 1500) and inverts rawWinRateFromRDif: rdif = (winRate − 0.5) × SEEDING_RDIF_SCALE.
|
||
*
|
||
* SEEDING_RDIF_SCALE — not RDIF_DIVISOR — is deliberate. Scaling by RDIF_DIVISOR
|
||
* would make this the exact algebraic inverse of winRateFromRDif, so a team with
|
||
* an Elo would skip the playoff-parity compression that every hardcoded-rdif team
|
||
* gets: a 95-win projection (Elo ≈ 1561) mapped to RDif +686 and played playoff
|
||
* games at .586 instead of the ~.517 documented on RDIF_DIVISOR. On this scale it
|
||
* maps to ≈ +140 — right alongside the Dodgers' hardcoded +137 — and
|
||
* winRateFromRDif then compresses it to ≈ .5175 like any other team.
|
||
*
|
||
* Exported for unit testing.
|
||
*/
|
||
export function eloToRDif(elo: number): number {
|
||
return (rawWinRateFromElo(elo) - 0.5) * SEEDING_RDIF_SCALE;
|
||
}
|
||
|
||
/**
|
||
* Per-game win rate to use for a team's remaining regular-season games.
|
||
*
|
||
* A user-entered `projectedWins` is a projected *final* season win total, so the
|
||
* rate that reproduces it is spread over the games still to play:
|
||
*
|
||
* target = (projectedWins − currentWins) / remainingGames
|
||
*
|
||
* Pre-season this is a no-op — with currentWins 0 and remainingGames 162 the
|
||
* target equals projectedWins / 162, which is exactly the rate the Elo derived
|
||
* from that projection already encodes. Mid-season it is what makes the
|
||
* simulation actually land on the projection: a team at 60-50 projected for 95
|
||
* needs .673 over its last 52 games, not the .586 its season-long Elo implies.
|
||
*
|
||
* `weight` (config `projectedWinsWeight`, default 1) blends the target back
|
||
* toward the Elo-implied rate. At 1 the projection is treated as authoritative;
|
||
* lower values hedge it. Note that at weight 1 a team that has already passed its
|
||
* projection is clamped to a .01 rest-of-season rate — lower the weight if that
|
||
* proves too rigid for in-season use.
|
||
*
|
||
* Exported for unit testing.
|
||
*/
|
||
export function seedingWinRateFor(
|
||
eloRate: number,
|
||
projectedWins: number | null,
|
||
currentWins: number,
|
||
remainingGames: number,
|
||
weight: number = DEFAULT_PROJECTED_WINS_WEIGHT
|
||
): number {
|
||
if (projectedWins === null || remainingGames <= 0 || weight <= 0) return eloRate;
|
||
const target = (projectedWins - currentWins) / remainingGames;
|
||
const rate = weight * target + (1 - weight) * eloRate;
|
||
return Math.min(0.99, Math.max(0.01, rate));
|
||
}
|
||
|
||
/**
|
||
* Bill James log5 head-to-head win probability for team A over team B,
|
||
* given their projected run differentials.
|
||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rdifWinProbability(rdifA: number, rdifB: number): number {
|
||
const wA = winRateFromRDif(rdifA);
|
||
const wB = winRateFromRDif(rdifB);
|
||
// Denominator is always > 0 when wA and wB are in (0,1).
|
||
return (wA - wA * wB) / (wA + wB - 2 * wA * wB);
|
||
}
|
||
|
||
/**
|
||
* Sample the number of wins from n independent Bernoulli trials each with
|
||
* probability p. Used to project remaining regular-season wins per team.
|
||
*
|
||
* For n ≥ 30 (where CLT applies well: n·p ≥ 5 and n·(1-p) ≥ 5 for any
|
||
* realistic win rate), uses a Box-Muller normal approximation — 2 Math.random()
|
||
* calls per team instead of n, cutting the seeding phase from ~243M to ~3M
|
||
* Math.random() calls per 50K-simulation run. For small n the exact Bernoulli
|
||
* loop is used. Both paths produce integer output clamped to [0, n].
|
||
* Exported for unit testing.
|
||
*/
|
||
export function sampleBinomial(n: number, p: number): number {
|
||
if (n <= 0) return 0;
|
||
if (p <= 0) return 0;
|
||
if (p >= 1) return n;
|
||
|
||
if (n >= 30) {
|
||
// Normal approximation via Box-Muller transform.
|
||
// Guard u1 > 0 to avoid log(0) = -Infinity.
|
||
const u1 = Math.max(Number.EPSILON, Math.random());
|
||
const u2 = Math.random();
|
||
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||
return Math.round(Math.min(n, Math.max(0, n * p + Math.sqrt(n * p * (1 - p)) * z)));
|
||
}
|
||
|
||
// Exact Bernoulli trials for small n.
|
||
let wins = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
if (Math.random() < p) wins++;
|
||
}
|
||
return wins;
|
||
}
|
||
|
||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||
|
||
interface TeamEntry {
|
||
id: string;
|
||
name: string;
|
||
data: MlbTeamData | undefined;
|
||
originalSeed?: number;
|
||
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
||
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
|
||
/** User-entered projected *final* season win total, or null when not set. */
|
||
projectedWins: number | null;
|
||
}
|
||
|
||
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
|
||
function getEntryRDif(entry: TeamEntry): number {
|
||
return entry.data?.rdif ?? 0;
|
||
}
|
||
|
||
// ─── Series simulators ─────────────────────────────────────────────────────────
|
||
|
||
type SeriesResult = { winner: TeamEntry; loser: TeamEntry };
|
||
|
||
/** Simulate a series where the winner must reach `winsNeeded` wins. */
|
||
function simSeries(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
winsNeeded: number,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
const prob = gameWinProb(a, b);
|
||
let winsA = 0;
|
||
let winsB = 0;
|
||
while (winsA < winsNeeded && winsB < winsNeeded) {
|
||
if (Math.random() < prob) winsA++; else winsB++;
|
||
}
|
||
return winsA === winsNeeded ? { winner: a, loser: b } : { winner: b, loser: a };
|
||
}
|
||
|
||
/** Wildcard Round: best-of-3 (first to 2 wins). */
|
||
export function simBo3(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 2, gameWinProb);
|
||
}
|
||
|
||
/** Division Series: best-of-5 (first to 3 wins). */
|
||
export function simBo5(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 3, gameWinProb);
|
||
}
|
||
|
||
/** League Championship + World Series: best-of-7 (first to 4 wins). */
|
||
export function simBo7(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 4, gameWinProb);
|
||
}
|
||
|
||
// ─── League bracket builder ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Draws the 6-team playoff field for one league (AL or NL) by simulating the
|
||
* remaining regular season for each team.
|
||
*
|
||
* Steps:
|
||
* 1. For each team, sample remaining wins from Binomial(remainingGames, seedingWinRate).
|
||
* A tiny uniform noise [0, 0.001) is added to break integer ties randomly.
|
||
* 2. Division winner = team with highest final wins in each division (3 per league).
|
||
* 3. Wild card = next 3 highest final wins among non-division-winners.
|
||
* 4. Seed division winners 1–3 by final wins descending (best = seed 1).
|
||
* 5. Seed WC teams 4–6 by final wins descending.
|
||
*
|
||
* Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated
|
||
* with originalSeed, or undefined if the league has fewer than 3 eligible WC teams.
|
||
*/
|
||
function drawLeaguePlayoffField(
|
||
leagueTeams: TeamEntry[],
|
||
getSeedingWinRate: (t: TeamEntry) => number
|
||
): TeamEntry[] | undefined {
|
||
// Group by division
|
||
const divMap = new Map<string, TeamEntry[]>();
|
||
for (const t of leagueTeams) {
|
||
const div = t.data?.division ?? "Unknown";
|
||
if (!divMap.has(div)) divMap.set(div, []);
|
||
divMap.get(div)?.push(t);
|
||
}
|
||
|
||
// Simulate remaining games for each team; tiny noise breaks integer win ties
|
||
const finalWins = new Map<string, number>();
|
||
for (const t of leagueTeams) {
|
||
finalWins.set(
|
||
t.id,
|
||
t.currentWins + sampleBinomial(t.remainingGames, getSeedingWinRate(t)) + Math.random() * 0.001
|
||
);
|
||
}
|
||
|
||
// Division winners: best record per division
|
||
const divisionWinners: TeamEntry[] = [];
|
||
const divWinnerSet = new Set<TeamEntry>();
|
||
|
||
for (const divTeams of divMap.values()) {
|
||
const winner = divTeams.reduce((best, t) =>
|
||
(finalWins.get(t.id) ?? 0) > (finalWins.get(best.id) ?? 0) ? t : best
|
||
);
|
||
divisionWinners.push(winner);
|
||
divWinnerSet.add(winner);
|
||
}
|
||
|
||
// Wild card: top 3 non-division-winners by final wins
|
||
const wcTeams = leagueTeams
|
||
.filter((t) => !divWinnerSet.has(t))
|
||
.toSorted((a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0))
|
||
.slice(0, 3);
|
||
|
||
if (wcTeams.length < 3) return undefined;
|
||
|
||
// Seed: div winners 1–3 and WC teams 4–6, both by final wins descending
|
||
const sortedDivWinners = divisionWinners.toSorted(
|
||
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
|
||
);
|
||
const sortedWcTeams = wcTeams.toSorted(
|
||
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
|
||
);
|
||
|
||
const seeds = [...sortedDivWinners, ...sortedWcTeams];
|
||
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
|
||
}
|
||
|
||
/**
|
||
* Simulate the full playoff bracket for one league.
|
||
*
|
||
* Bracket structure:
|
||
* Wildcard Round (best-of-3): seeds 3v6, 4v5 — seeds 1 & 2 get byes
|
||
* Division Series (best-of-5): 1 vs lowest-seeded WC survivor; 2 vs other
|
||
* League Championship Series (best-of-7)
|
||
*
|
||
* Returns { lcWinner, lcLoser, dsLosers[2], wcLosers[2] }
|
||
*/
|
||
function simLeagueBracket(
|
||
seeds: TeamEntry[],
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): {
|
||
lcWinner: TeamEntry;
|
||
lcLoser: TeamEntry;
|
||
dsLosers: [TeamEntry, TeamEntry];
|
||
wcLosers: [TeamEntry, TeamEntry];
|
||
} {
|
||
const [s1, s2, s3, s4, s5, s6] = seeds;
|
||
|
||
// Wildcard Round (best-of-3)
|
||
const wc1 = simBo3(s3, s6, gameWinProb);
|
||
const wc2 = simBo3(s4, s5, gameWinProb);
|
||
|
||
// Division Series: re-seed — seed 1 plays the worse WC survivor, seed 2 plays the better one.
|
||
// Sort by originalSeed ascending: [0] = lower seed number = better team, [1] = worse team.
|
||
const survivors = [wc1.winner, wc2.winner].toSorted(
|
||
(a, b) => (a.originalSeed ?? 0) - (b.originalSeed ?? 0)
|
||
);
|
||
const ds1 = simBo5(s1, survivors[1], gameWinProb); // 1 vs worse remaining (higher seed number)
|
||
const ds2 = simBo5(s2, survivors[0], gameWinProb); // 2 vs better remaining (lower seed number)
|
||
|
||
// League Championship Series (best-of-7)
|
||
const lcs = simBo7(ds1.winner, ds2.winner, gameWinProb);
|
||
|
||
return {
|
||
lcWinner: lcs.winner,
|
||
lcLoser: lcs.loser,
|
||
dsLosers: [ds1.loser, ds2.loser],
|
||
wcLosers: [wc1.loser, wc2.loser],
|
||
};
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class MLBSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
// configNumber (not positiveConfigNumber) so an explicit 0 — ignore projections,
|
||
// use the Elo-implied rate — is honored rather than falling back to the default.
|
||
const projectedWinsWeight = configNumber(config, "projectedWinsWeight", DEFAULT_PROJECTED_WINS_WEIGHT);
|
||
const db = database();
|
||
|
||
// 1. Load all participants for this sports season.
|
||
const participantRows = await db
|
||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
|
||
|
||
if (participantRows.length === 0) {
|
||
throw new Error(
|
||
`No participants found for sports season ${sportsSeasonId}. ` +
|
||
`Add MLB teams as participants before running simulation.`
|
||
);
|
||
}
|
||
|
||
const participantIds = participantRows.map((r) => r.id);
|
||
const participantIdSet = new Set(participantIds);
|
||
|
||
// 2. Load current standings (wins + games played) to seed the regular-season simulation.
|
||
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
||
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
|
||
|
||
// 3. Load the raw projected win totals. The resolved Elo already encodes the
|
||
// projection as a season-long rate, but the raw total is what lets seeding
|
||
// spread the *remaining* wins correctly once games have been played.
|
||
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||
const projectedWinsMap = new Map(
|
||
simInputs.map((input) => [input.participantId, input.projectedWins])
|
||
);
|
||
|
||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||
const standing = standingsByParticipantId.get(r.id);
|
||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||
return {
|
||
id: r.id,
|
||
name: r.name,
|
||
data: getTeamData(r.name),
|
||
currentWins: standing?.wins ?? 0,
|
||
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
|
||
projectedWins: projectedWinsMap.get(r.id) ?? null,
|
||
};
|
||
});
|
||
|
||
// Warn about participants that don't match any hardcoded team.
|
||
const unrecognized = teams.filter((t) => !t.data);
|
||
if (unrecognized.length > 0) {
|
||
logger.warn(
|
||
`[MLBSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
|
||
unrecognized.map((t) => t.name).join(", ")
|
||
);
|
||
}
|
||
|
||
// Warn when standings exist for some teams but not all recognized ones — this
|
||
// usually means a partial sync. Missing teams fall back to 0 wins / 162
|
||
// remaining games (league-average strength), which distorts mid-season seeding.
|
||
if (standings.length > 0) {
|
||
const missingStandings = teams.filter(
|
||
(t) => t.data && !standingsByParticipantId.has(t.id)
|
||
);
|
||
if (missingStandings.length > 0) {
|
||
logger.warn(
|
||
`[MLBSimulator] ${missingStandings.length} recognized team(s) have no standings row — ` +
|
||
`seeding will use 0 wins / 162 remaining games for: ` +
|
||
missingStandings.map((t) => t.name).join(", ")
|
||
);
|
||
}
|
||
}
|
||
|
||
const alTeams = teams.filter((t) => t.data?.league === "AL");
|
||
const nlTeams = teams.filter((t) => t.data?.league === "NL");
|
||
|
||
if (alTeams.length < 6 || nlTeams.length < 6) {
|
||
throw new Error(
|
||
`Each league needs at least 6 recognized participants (3 division winners + 3 wild cards) ` +
|
||
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
|
||
`Add MLB teams before running simulation.`
|
||
);
|
||
}
|
||
|
||
// ─── Strength from the resolved Elo ────────────────────────────────────────
|
||
// sourceElo is the single Elo produced by the input policy — already a blend
|
||
// of any raw Elo / projections / futures odds — so the simulator just reads it
|
||
// and no longer blends odds itself.
|
||
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
|
||
// for playoff series win probability.
|
||
const sourceEloRDifMap = new Map<string, number>();
|
||
// Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding.
|
||
const rawWinRateMap = new Map<string, number>();
|
||
for (const r of evRows) {
|
||
if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) {
|
||
sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo));
|
||
rawWinRateMap.set(r.participantId, rawWinRateFromElo(r.sourceElo));
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/** Effective RDif for playoff series: prefer sourceElo-derived value over hardcoded rdif. */
|
||
const effectiveRDif = (entry: TeamEntry): number =>
|
||
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
|
||
|
||
/**
|
||
* Raw per-game win rate for regular-season seeding simulation.
|
||
*
|
||
* The base rate comes from sourceElo when available, else from the hardcoded
|
||
* rdif via SEEDING_RDIF_SCALE (Pythagorean approximation). A user-entered
|
||
* projected win total then re-expresses that as a rest-of-season target so the
|
||
* projection is actually reached mid-season — see seedingWinRateFor.
|
||
*
|
||
* The result depends only on fixed per-team inputs, so it is resolved once here
|
||
* rather than on every one of the ~1.5M calls the seeding loop makes.
|
||
*/
|
||
const seedingWinRateMap = new Map(
|
||
teams.map((team) => [
|
||
team.id,
|
||
seedingWinRateFor(
|
||
rawWinRateMap.get(team.id) ?? rawWinRateFromRDif(getEntryRDif(team)),
|
||
team.projectedWins,
|
||
team.currentWins,
|
||
team.remainingGames,
|
||
projectedWinsWeight
|
||
),
|
||
])
|
||
);
|
||
const seedingWinRate = (entry: TeamEntry): number => seedingWinRateMap.get(entry.id) ?? 0.5;
|
||
|
||
/**
|
||
* Per-game win probability for team A over team B in a playoff series, from
|
||
* the (resolved-Elo-derived) run differential.
|
||
*/
|
||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
|
||
rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
||
|
||
// ─── Placement count maps ──────────────────────────────────────────────────
|
||
|
||
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const lcsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const dsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
// WC losers are not tracked — they score 0 points
|
||
|
||
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
|
||
|
||
let effectiveN = 0;
|
||
|
||
for (let s = 0; s < numSimulations; s++) {
|
||
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
|
||
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
|
||
if (!alField || !nlField) continue; // degenerate draw — skip
|
||
|
||
effectiveN++;
|
||
|
||
// Simulate both league brackets
|
||
const alResult = simLeagueBracket(alField, gameWinProb);
|
||
const nlResult = simLeagueBracket(nlField, gameWinProb);
|
||
|
||
// LCS losers (3rd/4th tier)
|
||
lcsLoserCounts.set(alResult.lcLoser.id, (lcsLoserCounts.get(alResult.lcLoser.id) ?? 0) + 1);
|
||
lcsLoserCounts.set(nlResult.lcLoser.id, (lcsLoserCounts.get(nlResult.lcLoser.id) ?? 0) + 1);
|
||
|
||
// DS losers (5th–8th tier)
|
||
for (const loser of [...alResult.dsLosers, ...nlResult.dsLosers]) {
|
||
dsLoserCounts.set(loser.id, (dsLoserCounts.get(loser.id) ?? 0) + 1);
|
||
}
|
||
|
||
// World Series (best-of-7)
|
||
const ws = simBo7(alResult.lcWinner, nlResult.lcWinner, gameWinProb);
|
||
championCounts.set(ws.winner.id, (championCounts.get(ws.winner.id) ?? 0) + 1);
|
||
finalistCounts.set(ws.loser.id, (finalistCounts.get(ws.loser.id) ?? 0) + 1);
|
||
}
|
||
|
||
if (effectiveN === 0) {
|
||
throw new Error(
|
||
"All simulations produced degenerate brackets. " +
|
||
"Check that each division has teams with non-degenerate RDif values."
|
||
);
|
||
}
|
||
|
||
// ─── Convert counts to probability distributions ───────────────────────────
|
||
//
|
||
// probFirst/Second → count / N (1 team per sim)
|
||
// probThird/Fourth → count / (2 * N) (2 LCS losers per sim: AL + NL)
|
||
// probFifth–Eighth → count / (4 * N) (4 DS losers per sim: 2 per league)
|
||
// WC losers → 0 (all probs zero)
|
||
|
||
const N = effectiveN;
|
||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||
const c = championCounts.get(participantId) ?? 0;
|
||
const f = finalistCounts.get(participantId) ?? 0;
|
||
const lcs = lcsLoserCounts.get(participantId) ?? 0;
|
||
const ds = dsLoserCounts.get(participantId) ?? 0;
|
||
return {
|
||
participantId,
|
||
probabilities: {
|
||
probFirst: c / N,
|
||
probSecond: f / N,
|
||
probThird: lcs / (2 * N),
|
||
probFourth: lcs / (2 * N),
|
||
probFifth: ds / (4 * N),
|
||
probSixth: ds / (4 * N),
|
||
probSeventh: ds / (4 * N),
|
||
probEighth: ds / (4 * N),
|
||
},
|
||
source: "mlb_bracket_monte_carlo",
|
||
};
|
||
});
|
||
|
||
// ─── Per-position normalization ────────────────────────────────────────────
|
||
|
||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||
"probFirst", "probSecond", "probThird", "probFourth",
|
||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||
];
|
||
for (const key of positionKeys) {
|
||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||
const residual = 1.0 - colSum;
|
||
if (residual !== 0) {
|
||
const maxResult = results.reduce((best, r) =>
|
||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||
);
|
||
maxResult.probabilities[key] += residual;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
}
|