Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)

* Add MLB playoff simulator with AL/NL division standings, fixes #121

- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
  (weighted by p_div) and 3 WC teams per league, then simulating
  WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
  Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
  mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
  winner per division section + Wild Card section with 3-spot playoff line,
  headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
  (divisionSpots + wcSpots params); conferenceLabel now flows through group
  objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix flaky tennis simulator test: increase trials and lower threshold

500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.

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:
Chris Parsons 2026-03-25 08:47:02 -07:00 committed by GitHub
parent 286a9afbbe
commit 2e2d8db777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1479 additions and 19 deletions

View file

@ -44,8 +44,8 @@ interface Props {
participantEvs?: Record<string, string>;
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
playoffSpots?: number;
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. */
displayMode?: "flat" | "nhl-divisions";
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */
displayMode?: "flat" | "nhl-divisions" | "mlb-divisions";
}
type RankMode = "conference" | "division" | "index";
@ -84,16 +84,19 @@ function getRank(row: StandingRow, idx: number, mode: RankMode): number {
// ─── Grouping ─────────────────────────────────────────────────────────────────
type ConferenceGroup = { conference: string; conferenceLabel: string; sections: TableSection[] };
function buildFlatSections(
standings: StandingRow[],
playoffSpots: number
): Array<{ conference: string; sections: TableSection[] }> {
): ConferenceGroup[] {
const hasConferences = standings.some((s) => s.conference);
if (!hasConferences) {
return [
{
conference: "",
conferenceLabel: "Conference",
sections: [
{
rows: [...standings].toSorted(
@ -116,6 +119,7 @@ function buildFlatSections(
return Array.from(confMap.entries()).map(([conference, rows]) => ({
conference,
conferenceLabel: "Conference",
sections: [
{
rows: [...rows].toSorted(
@ -131,9 +135,18 @@ function buildFlatSections(
}));
}
function buildNhlSections(
standings: StandingRow[]
): Array<{ conference: string; sections: TableSection[] }> {
/**
* Shared builder for division-based display modes (NHL and MLB).
* `divisionSpots`: how many teams per division qualify directly (3 for NHL, 1 for MLB).
* `wcSpots`: how many wildcard slots exist per conference (2 for NHL, 3 for MLB).
* `conferenceLabel`: the word shown after the conference name in the heading.
*/
function buildDivisionSections(
standings: StandingRow[],
divisionSpots: number,
wcSpots: number,
conferenceLabel: string
): ConferenceGroup[] {
const confMap = new Map<string, StandingRow[]>();
for (const row of standings) {
const conf = row.conference ?? "Other";
@ -153,7 +166,7 @@ function buildNhlSections(
.map(([name, divRows]) => ({
name,
qualifiers: divRows
.filter((r) => (r.divisionRank ?? 99) <= 3)
.filter((r) => (r.divisionRank ?? 99) <= divisionSpots)
.toSorted((a, b) => (a.divisionRank ?? 99) - (b.divisionRank ?? 99)),
}))
.toSorted(
@ -163,7 +176,7 @@ function buildNhlSections(
);
const wildCard = rows
.filter((r) => (r.divisionRank ?? 99) > 3)
.filter((r) => (r.divisionRank ?? 99) > divisionSpots)
.toSorted(
(a, b) =>
(a.conferenceRank ?? a.leagueRank ?? 99) -
@ -184,17 +197,25 @@ function buildNhlSections(
heading: "Wild Card",
rows: wildCard,
rankMode: "index" as RankMode,
playoffSpots: 2,
playoffSpots: wcSpots,
showDivisionLabel: true,
},
]
: []),
];
return { conference, sections };
return { conference, conferenceLabel, sections };
});
}
function buildNhlSections(standings: StandingRow[]): ConferenceGroup[] {
return buildDivisionSections(standings, 3, 2, "Conference");
}
function buildMlbSections(standings: StandingRow[]): ConferenceGroup[] {
return buildDivisionSections(standings, 1, 3, "League");
}
// ─── Single table that renders all sections ───────────────────────────────────
function StandingsTable({
@ -394,6 +415,8 @@ export function RegularSeasonStandings({
const groups =
displayMode === "nhl-divisions"
? buildNhlSections(standings)
: displayMode === "mlb-divisions"
? buildMlbSections(standings)
: buildFlatSections(standings, playoffSpots);
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, participantEvs, hasEvs };
@ -418,7 +441,7 @@ export function RegularSeasonStandings({
<div key={group.conference} className="mb-6 last:mb-0">
{group.conference && (
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{group.conference} Conference
{group.conference} {group.conferenceLabel}
</h3>
)}
<StandingsTable sections={group.sections} {...tableProps} />

View file

@ -180,3 +180,106 @@ describe("RegularSeasonStandings", () => {
expect(streakEl.className).toContain("destructive");
});
});
// ─── mlb-divisions display mode ───────────────────────────────────────────────
function makeAlDivisionRows() {
return [
// AL East — division winner (divisionRank=1) + 2 WC candidates (divisionRank=2,3)
makeRow({ id: "r1", participantId: "p1", conference: "AL", division: "AL East", divisionRank: 1, conferenceRank: 1, participant: { id: "p1", name: "Baltimore Orioles" } }),
makeRow({ id: "r2", participantId: "p2", conference: "AL", division: "AL East", divisionRank: 2, conferenceRank: 4, participant: { id: "p2", name: "Boston Red Sox" } }),
// AL Central — division winner
makeRow({ id: "r3", participantId: "p3", conference: "AL", division: "AL Central", divisionRank: 1, conferenceRank: 2, participant: { id: "p3", name: "Cleveland Guardians" } }),
makeRow({ id: "r4", participantId: "p4", conference: "AL", division: "AL Central", divisionRank: 2, conferenceRank: 5, participant: { id: "p4", name: "Minnesota Twins" } }),
// AL West — division winner
makeRow({ id: "r5", participantId: "p5", conference: "AL", division: "AL West", divisionRank: 1, conferenceRank: 3, participant: { id: "p5", name: "Houston Astros" } }),
makeRow({ id: "r6", participantId: "p6", conference: "AL", division: "AL West", divisionRank: 2, conferenceRank: 6, participant: { id: "p6", name: "Seattle Mariners" } }),
// NL East
makeRow({ id: "r7", participantId: "p7", conference: "NL", division: "NL East", divisionRank: 1, conferenceRank: 1, participant: { id: "p7", name: "Philadelphia Phillies" } }),
makeRow({ id: "r8", participantId: "p8", conference: "NL", division: "NL East", divisionRank: 2, conferenceRank: 4, participant: { id: "p8", name: "Atlanta Braves" } }),
// NL Central
makeRow({ id: "r9", participantId: "p9", conference: "NL", division: "NL Central", divisionRank: 1, conferenceRank: 2, participant: { id: "p9", name: "Milwaukee Brewers" } }),
makeRow({ id: "r10", participantId: "p10", conference: "NL", division: "NL Central", divisionRank: 2, conferenceRank: 5, participant: { id: "p10", name: "Chicago Cubs" } }),
// NL West
makeRow({ id: "r11", participantId: "p11", conference: "NL", division: "NL West", divisionRank: 1, conferenceRank: 3, participant: { id: "p11", name: "Los Angeles Dodgers" } }),
makeRow({ id: "r12", participantId: "p12", conference: "NL", division: "NL West", divisionRank: 2, conferenceRank: 6, participant: { id: "p12", name: "San Diego Padres" } }),
];
}
describe("RegularSeasonStandings mlb-divisions mode", () => {
it("renders 'AL League' and 'NL League' headings (not Conference)", () => {
render(
<RegularSeasonStandings
standings={makeAlDivisionRows()}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
displayMode="mlb-divisions"
/>
);
expect(screen.getByText(/AL League/i)).toBeInTheDocument();
expect(screen.getByText(/NL League/i)).toBeInTheDocument();
expect(screen.queryByText(/AL Conference/i)).not.toBeInTheDocument();
expect(screen.queryByText(/NL Conference/i)).not.toBeInTheDocument();
});
it("renders division section headings", () => {
render(
<RegularSeasonStandings
standings={makeAlDivisionRows()}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
displayMode="mlb-divisions"
/>
);
expect(screen.getByText("AL East Division")).toBeInTheDocument();
expect(screen.getByText("AL Central Division")).toBeInTheDocument();
expect(screen.getByText("AL West Division")).toBeInTheDocument();
});
it("renders Wild Card section heading", () => {
render(
<RegularSeasonStandings
standings={makeAlDivisionRows()}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
displayMode="mlb-divisions"
/>
);
expect(screen.getAllByText("Wild Card").length).toBeGreaterThan(0);
});
it("division section contains only the division winner (divisionRank=1)", () => {
render(
<RegularSeasonStandings
standings={makeAlDivisionRows()}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
displayMode="mlb-divisions"
/>
);
// Division winners should be rendered
expect(screen.getByText("Baltimore Orioles")).toBeInTheDocument();
expect(screen.getByText("Cleveland Guardians")).toBeInTheDocument();
expect(screen.getByText("Houston Astros")).toBeInTheDocument();
// Wild card teams (rank > 1) also rendered in Wild Card section
expect(screen.getByText("Boston Red Sox")).toBeInTheDocument();
});
it("shows 'Playoff Line' after 3 teams in the Wild Card section", () => {
// Add enough WC teams to trigger the playoff line (need >3 in the WC section)
const standings = [
...makeAlDivisionRows(),
makeRow({ id: "r13", participantId: "p13", conference: "AL", division: "AL East", divisionRank: 3, conferenceRank: 7, participant: { id: "p13", name: "Tampa Bay Rays" } }),
makeRow({ id: "r14", participantId: "p14", conference: "AL", division: "AL Central", divisionRank: 3, conferenceRank: 8, participant: { id: "p14", name: "Detroit Tigers" } }),
];
render(
<RegularSeasonStandings
standings={standings}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
displayMode="mlb-divisions"
/>
);
expect(screen.getAllByText(/Playoff Line/i).length).toBeGreaterThan(0);
});
});

View file

@ -73,10 +73,13 @@ export default function SportSeasonDetail({
const simulatorType = sportsSeason.sport?.simulatorType;
const standingsDisplayMode =
simulatorType === "nhl_bracket" ? "nhl-divisions" : "flat";
simulatorType === "nhl_bracket" ? "nhl-divisions" :
simulatorType === "mlb_bracket" ? "mlb-divisions" :
"flat";
// NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in)
// AFL: top 10 advance to the finals series (Wildcard + QF/EF + SF + PF + GF)
// NHL: handled by the nhl-divisions mode (3 per div + 2 wild cards)
// NHL: handled by nhl-divisions mode (3 per div + 2 wild cards)
// MLB: handled by mlb-divisions mode (1 per div + 3 wild cards)
const playoffSpots =
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
@ -120,7 +123,7 @@ export default function SportSeasonDetail({
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions"}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div>
@ -173,7 +176,7 @@ export default function SportSeasonDetail({
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
participantEvs={participantEvs}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions"}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>
</div>

View file

@ -0,0 +1,211 @@
import { describe, it, expect } from "vitest";
import {
normalizeTeamName,
getTeamData,
eloWinProbability,
simBo3,
simBo5,
simBo7,
} from "../mlb-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Los Angeles Dodgers ")).toBe("los angeles dodgers");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("New York Yankees")).toBe("new york yankees");
});
it("is identity for already-normalized strings", () => {
expect(normalizeTeamName("houston astros")).toBe("houston astros");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Los Angeles Dodgers");
expect(d).toBeDefined();
expect(d?.league).toBe("NL");
expect(d?.division).toBe("NL West");
expect(d?.elo).toBeGreaterThan(1500);
});
it("is case-insensitive", () => {
expect(getTeamData("los angeles dodgers")).toEqual(getTeamData("Los Angeles Dodgers"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
});
it("all 30 MLB teams are present", () => {
const allTeams = [
// AL East
"New York Yankees", "Baltimore Orioles", "Boston Red Sox",
"Tampa Bay Rays", "Toronto Blue Jays",
// AL Central
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins",
"Detroit Tigers", "Chicago White Sox",
// AL West
"Houston Astros", "Seattle Mariners", "Texas Rangers",
"Los Angeles Angels", "Athletics",
// NL East
"Philadelphia Phillies", "Atlanta Braves", "New York Mets",
"Washington Nationals", "Miami Marlins",
// NL Central
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals",
"Cincinnati Reds", "Pittsburgh Pirates",
// NL West
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks",
"San Francisco Giants", "Colorado Rockies",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
it("all teams have the correct league", () => {
const alTeams = [
"New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays",
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox",
"Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics",
];
const nlTeams = [
"Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins",
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates",
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies",
];
for (const name of alTeams) {
expect(getTeamData(name)?.league, `${name} should be AL`).toBe("AL");
}
for (const name of nlTeams) {
expect(getTeamData(name)?.league, `${name} should be NL`).toBe("NL");
}
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability (PARITY_FACTOR = 350)", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1570, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1500, 1570)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1560, 1480);
expect(p + eloWinProbability(1480, 1560)).toBeCloseTo(1.0, 10);
});
it("a 100-pt gap gives ~65.9% win prob per game", () => {
// At 350: P = 1 / (1 + 10^(-100/350)) = 1 / (1 + 0.518) ≈ 0.659
const p = eloWinProbability(1600, 1500);
expect(p).toBeCloseTo(0.659, 2);
});
it("returns a value strictly between 0 and 1", () => {
expect(eloWinProbability(1200, 1800)).toBeGreaterThan(0);
expect(eloWinProbability(1200, 1800)).toBeLessThan(1);
});
});
// ─── Seeding probabilities sanity checks ─────────────────────────────────────
describe("team seeding probabilities", () => {
const DIVISIONS = {
"AL East": ["New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays"],
"AL Central": ["Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox"],
"AL West": ["Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics"],
"NL East": ["Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins"],
"NL Central": ["Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates"],
"NL West": ["Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies"],
};
it("p_div values sum to ~1.0 within each division", () => {
for (const [divName, teams] of Object.entries(DIVISIONS)) {
const sum = teams.reduce((s, name) => s + (getTeamData(name)?.p_div ?? 0), 0);
expect(sum, `${divName} p_div sum should be ~1`).toBeCloseTo(1.0, 1);
}
});
it("p_div + p_wc does not exceed 1.0 per team", () => {
for (const teams of Object.values(DIVISIONS)) {
for (const name of teams) {
const d = getTeamData(name);
expect((d?.p_div ?? 0) + (d?.p_wc ?? 0), `${name} p_div + p_wc > 1`).toBeLessThanOrEqual(1.001);
}
}
});
it("Dodgers have the highest p_div in NL West", () => {
const dodgers = getTeamData("Los Angeles Dodgers");
const padres = getTeamData("San Diego Padres");
const dbacks = getTeamData("Arizona Diamondbacks");
expect(dodgers?.p_div ?? 0).toBeGreaterThan(padres?.p_div ?? 0);
expect(dodgers?.p_div ?? 0).toBeGreaterThan(dbacks?.p_div ?? 0);
});
it("rebuilding teams have low p_div", () => {
const whiteSox = getTeamData("Chicago White Sox");
const rockies = getTeamData("Colorado Rockies");
expect(whiteSox?.p_div ?? 0).toBeLessThan(0.15);
expect(rockies?.p_div ?? 0).toBeLessThan(0.10);
});
});
// ─── Series simulators ────────────────────────────────────────────────────────
const teamA = { id: "a", name: "Team A", data: undefined };
const teamB = { id: "b", name: "Team B", data: undefined };
const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5;
describe("simBo3 (Wildcard Round — first to 2 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo3(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo3(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo3(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect([teamA, teamB]).toContain(result.loser);
expect(result.winner).not.toBe(result.loser);
});
});
describe("simBo5 (Division Series — first to 3 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo5(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo5(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo5(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect(result.winner).not.toBe(result.loser);
});
});
describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo7(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo7(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo7(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect(result.winner).not.toBe(result.loser);
});
});

View file

@ -265,7 +265,7 @@ describe("QP accumulation ranking (unit)", () => {
const eloMap = makeEloMap(IDS, elos);
let seed1Wins = 0;
const TRIALS = 500;
const TRIALS = 2000;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
@ -273,9 +273,10 @@ describe("QP accumulation ranking (unit)", () => {
if (seed1?.qp === 20) seed1Wins++;
}
// Seed 1 has ~3 points advantage per player; against 127 opponents should win
// substantially more than 1/128 ≈ 0.78% of the time
// Seed 1 has ~3 Elo points advantage per seed; should win substantially
// more than 1/128 ≈ 0.78% of the time. Threshold at 0.02 is still well
// above random; 2000 trials keeps std dev small enough to be non-flaky.
const winRate = seed1Wins / TRIALS;
expect(winRate).toBeGreaterThan(0.03); // > 3% (well above random 0.78%)
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
});
});

View file

@ -0,0 +1,652 @@
/**
* 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. Match participant names to hardcoded team data (Elo + seeding probabilities)
* 3. For each simulation:
* a. For each league (AL/NL), draw 1 division winner per division (3 draws),
* then draw 3 wildcard teams from the remaining pool all weighted draws.
* b. Seed division winners 13 by Elo (best Elo = seed 1); WC teams 46 by Elo.
* c. 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)
* d. World Series (best-of-7): AL champ vs NL champ
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 350):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 350))
* MLB uses a lower parity factor than the NBA (400) or NHL (1000) to reflect
* that better teams win series at a somewhat higher rate in baseball. The short
* best-of-3 wildcard round introduces significant upset potential.
*
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
* the per-game win probability is blended:
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
* Normalized odds are vig-removed futures win probabilities.
* ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as NHL simulator).
* Falls back to Elo-only when no odds are stored.
*
* 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)
* probFifthprobEighth = 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:
* elo: Elo rating derived from FanGraphs projected wins (ExpW) via:
* elo = 1500 + 350 * log10(winRate / (1 - winRate))
* where winRate = ExpW / 162
* p_div: Probability of winning own division (FanGraphs divTitle).
* Must sum to ~1.0 within each 5-team division.
* p_wc: Probability of claiming one of 3 wildcard slots (FanGraphs wcTitle).
* Used as relative weights among non-division-winners in each league.
*
* Elo ratings and seeding probabilities are hardcoded below (2026 pre-season).
* Source: https://www.fangraphs.com/standings/playoff-odds/fg/div
* Update at the start of each season using the formula above.
*
* 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 { logger } from "~/lib/logger";
import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
/**
* Elo parity factor. MLB uses 350 (below NHL's 1000 and NBA's 400) to reflect
* that teams of different quality win series at a higher rate in baseball
* compared to hockey, where single-game variance is very high.
*/
const PARITY_FACTOR = 350;
/**
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
* Same calibration as the NHL simulator (0.7 / 0.3).
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2026 pre-season estimates) ────────────────────────────────────
//
// elo: Derived from FanGraphs projected wins (ExpW) via:
// elo = 1500 + 350 * log10(winRate / (1 - winRate))
// where winRate = ExpW / 162
// p_div: FanGraphs divTitle — probability of winning own division.
// Sum within each 5-team division should equal ~1.0.
// p_wc: FanGraphs wcTitle — probability of claiming a wildcard slot.
// Used as relative weights among non-division-winners per league.
// Does not need to sum to any specific value.
//
// Update these at the start of each season using the FanGraphs playoff odds page:
// https://www.fangraphs.com/standings/playoff-odds/fg/div
//
// Quick update formula (JavaScript):
// const winRate = ExpW / 162;
// const elo = Math.round(1500 + 350 * Math.log10(winRate / (1 - winRate)));
interface MlbTeamData {
league: "AL" | "NL";
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
elo: number;
p_div: number;
p_wc: number;
}
const TEAMS_DATA: Record<string, MlbTeamData> = {
// ── American League East ───────────────────────────────────────────────────
"New York Yankees": {
league: "AL", division: "AL East", elo: 1545,
p_div: 0.30, p_wc: 0.42,
},
"Baltimore Orioles": {
league: "AL", division: "AL East", elo: 1535,
p_div: 0.28, p_wc: 0.41,
},
"Boston Red Sox": {
league: "AL", division: "AL East", elo: 1520,
p_div: 0.22, p_wc: 0.35,
},
"Tampa Bay Rays": {
league: "AL", division: "AL East", elo: 1513,
p_div: 0.12, p_wc: 0.25,
},
"Toronto Blue Jays": {
league: "AL", division: "AL East", elo: 1506,
p_div: 0.08, p_wc: 0.18,
},
// ── American League Central ────────────────────────────────────────────────
"Kansas City Royals": {
league: "AL", division: "AL Central", elo: 1519,
p_div: 0.28, p_wc: 0.30,
},
"Cleveland Guardians": {
league: "AL", division: "AL Central", elo: 1516,
p_div: 0.27, p_wc: 0.29,
},
"Minnesota Twins": {
league: "AL", division: "AL Central", elo: 1514,
p_div: 0.23, p_wc: 0.25,
},
"Detroit Tigers": {
league: "AL", division: "AL Central", elo: 1510,
p_div: 0.15, p_wc: 0.18,
},
"Chicago White Sox": {
league: "AL", division: "AL Central", elo: 1440,
p_div: 0.07, p_wc: 0.03,
},
// ── American League West ───────────────────────────────────────────────────
"Houston Astros": {
league: "AL", division: "AL West", elo: 1537,
p_div: 0.35, p_wc: 0.40,
},
"Seattle Mariners": {
league: "AL", division: "AL West", elo: 1528,
p_div: 0.28, p_wc: 0.35,
},
"Texas Rangers": {
league: "AL", division: "AL West", elo: 1522,
p_div: 0.22, p_wc: 0.28,
},
"Los Angeles Angels": {
league: "AL", division: "AL West", elo: 1475,
p_div: 0.09, p_wc: 0.08,
},
"Athletics": {
league: "AL", division: "AL West", elo: 1455,
p_div: 0.06, p_wc: 0.04,
},
// ── National League East ───────────────────────────────────────────────────
"Philadelphia Phillies": {
league: "NL", division: "NL East", elo: 1553,
p_div: 0.35, p_wc: 0.42,
},
"Atlanta Braves": {
league: "NL", division: "NL East", elo: 1550,
p_div: 0.32, p_wc: 0.40,
},
"New York Mets": {
league: "NL", division: "NL East", elo: 1534,
p_div: 0.22, p_wc: 0.33,
},
"Washington Nationals": {
league: "NL", division: "NL East", elo: 1483,
p_div: 0.07, p_wc: 0.07,
},
"Miami Marlins": {
league: "NL", division: "NL East", elo: 1468,
p_div: 0.04, p_wc: 0.03,
},
// ── National League Central ────────────────────────────────────────────────
"Milwaukee Brewers": {
league: "NL", division: "NL Central", elo: 1529,
p_div: 0.35, p_wc: 0.38,
},
"Chicago Cubs": {
league: "NL", division: "NL Central", elo: 1515,
p_div: 0.27, p_wc: 0.28,
},
"St. Louis Cardinals": {
league: "NL", division: "NL Central", elo: 1507,
p_div: 0.20, p_wc: 0.20,
},
"Cincinnati Reds": {
league: "NL", division: "NL Central", elo: 1503,
p_div: 0.12, p_wc: 0.13,
},
"Pittsburgh Pirates": {
league: "NL", division: "NL Central", elo: 1498,
p_div: 0.06, p_wc: 0.07,
},
// ── National League West ───────────────────────────────────────────────────
"Los Angeles Dodgers": {
league: "NL", division: "NL West", elo: 1570,
p_div: 0.65, p_wc: 0.28,
},
"San Diego Padres": {
league: "NL", division: "NL West", elo: 1525,
p_div: 0.15, p_wc: 0.30,
},
"Arizona Diamondbacks": {
league: "NL", division: "NL West", elo: 1520,
p_div: 0.12, p_wc: 0.28,
},
"San Francisco Giants": {
league: "NL", division: "NL West", elo: 1510,
p_div: 0.06, p_wc: 0.20,
},
"Colorado Rockies": {
league: "NL", division: "NL West", elo: 1442,
p_div: 0.02, p_wc: 0.02,
},
};
// ─── 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;
}
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: MlbTeamData | undefined;
originalSeed?: number;
}
/** 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 using the given weight key.
* Returns undefined if no eligible team has positive weight.
*/
function weightedPickByKey(
pool: TeamEntry[],
excluded: Set<TeamEntry>,
getWeight: (t: TeamEntry) => number
): TeamEntry | undefined {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map(getWeight);
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];
}
return eligible[eligible.length - 1];
}
// ─── 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).
*
* Steps:
* 1. For each of the 3 divisions, draw 1 division winner weighted by p_div.
* 2. From the remaining non-division-winners, draw 3 WC teams weighted by p_wc.
* 3. Rank division winners 13 by Elo (best Elo seed 1).
* 4. Rank WC teams 46 by Elo (best Elo seed 4).
*
* Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated
* with originalSeed, or undefined if any draw is degenerate (no eligible team
* with positive weight).
*/
function drawLeaguePlayoffField(
leagueTeams: TeamEntry[]
): 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);
}
const divisionWinners: TeamEntry[] = [];
const allDivisionWinnerSet = new Set<TeamEntry>();
for (const divTeams of divMap.values()) {
const winner = weightedPickByKey(divTeams, new Set(), (t) => t.data?.p_div ?? 0);
if (!winner) return undefined;
divisionWinners.push(winner);
allDivisionWinnerSet.add(winner);
}
// Draw 3 WC teams from non-division-winners
const wcPool = leagueTeams.filter((t) => !allDivisionWinnerSet.has(t));
const wcTaken = new Set<TeamEntry>();
const wcTeams: TeamEntry[] = [];
for (let i = 0; i < 3; i++) {
const wc = weightedPickByKey(wcPool, wcTaken, (t) => t.data?.p_wc ?? 0);
if (!wc) return undefined;
wcTeams.push(wc);
wcTaken.add(wc);
}
// Rank division winners 13 by Elo descending (best Elo = seed 1)
const sortedDivWinners = divisionWinners.toSorted((a, b) => elo(b) - elo(a));
// Rank WC teams 46 by Elo descending (best Elo = seed 4)
const sortedWcTeams = wcTeams.toSorted((a, b) => elo(b) - elo(a));
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): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.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 teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// 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(", ")
);
}
const alTeams = teams.filter((t) => t.data?.league === "AL");
const nlTeams = teams.filter((t) => t.data?.league === "NL");
if (alTeams.length < 5 || nlTeams.length < 5) {
throw new Error(
`Each league needs at least 5 recognized participants ` +
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
`Add MLB teams before running simulation.`
);
}
// ─── Futures odds blending ─────────────────────────────────────────────────
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) =>
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
);
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const eloProb = eloWinProbability(elo(a), elo(b));
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(a.id);
const o2 = normalizedOddsMap.get(b.id);
// Fall back to Elo if either team lacks odds — avoids inflating one side to 100%.
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return eloProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
// ─── 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 < NUM_SIMULATIONS; s++) {
const alField = drawLeaguePlayoffField(alTeams);
const nlField = drawLeaguePlayoffField(nlTeams);
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 (5th8th 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 positive p_div 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)
// probFifthEighth → 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;
}
}

View file

@ -18,6 +18,7 @@ import { NHLSimulator } from "./nhl-simulator";
import { AFLSimulator } from "./afl-simulator";
import { SnookerSimulator } from "./snooker-simulator";
import { TennisSimulator } from "./tennis-simulator";
import { MLBSimulator } from "./mlb-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -32,6 +33,7 @@ export const SIMULATOR_TYPES = [
"afl_bracket",
"snooker_bracket",
"tennis_qualifying_points",
"mlb_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -104,6 +106,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "Tennis Grand Slam Monte Carlo", description: "Simulates all 4 Grand Slam majors (128-player seeded bracket) using surface-specific Elo ratings. Accumulates qualifying points per round (tie-split applied) and ranks players by total QP across the season." },
create: () => new TennisSimulator(),
},
mlb_bracket: {
info: { name: "MLB Playoff Monte Carlo", description: "Simulates MLB division races + full playoff bracket (WC best-of-3, DS best-of-5, LCS/WS best-of-7) using Elo ratings calibrated from FanGraphs projected wins" },
create: () => new MLBSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MlbStandingsAdapter } from "../mlb";
const SAMPLE_MLB_RESPONSE = {
records: [
{
// AL East division record
teamRecords: [
{
team: {
id: 110,
name: "Baltimore Orioles",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 47,
losses: 34,
gamesPlayed: 81,
winningPercentage: ".580",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W", streakNumber: 3 },
records: {
splitRecords: [
{ type: "home", wins: 24, losses: 15 },
{ type: "away", wins: 23, losses: 19 },
{ type: "lastTen", wins: 7, losses: 3 },
],
},
},
{
team: {
id: 111,
name: "Boston Red Sox",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 42,
losses: 39,
gamesPlayed: 81,
winningPercentage: ".519",
divisionRank: "2",
leagueRank: "4",
gamesBack: "5.0",
streak: { streakCode: "L", streakNumber: 2 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 18 },
{ type: "away", wins: 20, losses: 21 },
{ type: "lastTen", wins: 4, losses: 6 },
],
},
},
],
},
{
// NL East division record
teamRecords: [
{
team: {
id: 121,
name: "New York Mets",
league: { name: "National League" },
division: { name: "NL East" },
},
wins: 43,
losses: 38,
gamesPlayed: 81,
winningPercentage: ".531",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W", streakNumber: 1 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 17 },
{ type: "away", wins: 21, losses: 21 },
{ type: "lastTen", wins: 6, losses: 4 },
],
},
},
],
},
],
};
describe("MlbStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("maps AL teams to conference 'AL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
expect(bos?.conference).toBe("AL");
});
it("maps NL teams to conference 'NL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const mets = records.find((r) => r.teamName === "New York Mets");
expect(mets?.conference).toBe("NL");
});
it("maps division names correctly", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
const mets = records.find((r) => r.teamName === "New York Mets");
expect(bos?.division).toBe("AL East");
expect(mets?.division).toBe("NL East");
});
it("maps wins, losses, gamesPlayed, and winPct", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.wins).toBe(47);
expect(ori?.losses).toBe(34);
expect(ori?.gamesPlayed).toBe(81);
expect(ori?.winPct).toBeCloseTo(0.58, 2);
});
it("formats streak as 'W3' or 'L2'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(ori?.streak).toBe("W3");
expect(bos?.streak).toBe("L2");
});
it("extracts lastTen from splitRecords", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.lastTen).toBe("7-3");
});
it("extracts homeRecord and awayRecord from splitRecords", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.homeRecord).toBe("24-15");
expect(ori?.awayRecord).toBe("23-19");
});
it("parses gamesBack as null for the division leader ('-')", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.gamesBack).toBeUndefined();
});
it("parses gamesBack as a number for non-leaders", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(bos?.gamesBack).toBeCloseTo(5.0, 1);
});
it("computes leagueRank sorted by wins desc across all 30 teams", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
// Baltimore (47W) should rank #1 overall, Boston (42W) should rank #2
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(ori?.leagueRank).toBe(1);
expect(bos?.leagueRank).toBeGreaterThan(1);
});
it("never sets otLosses (MLB has no OT losses)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
for (const record of records) {
expect(record.otLosses).toBeUndefined();
}
});
it("uses team numeric id as externalTeamId", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.externalTeamId).toBe("110");
});
it("throws on non-ok HTTP response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("503");
});
it("throws on unexpected response shape", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ unexpected: true }),
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow();
});
});

View file

@ -8,6 +8,7 @@ import { findMatchingTeamName } from "~/lib/normalize-team-name";
import { NhlStandingsAdapter } from "./nhl";
import { NbaStandingsAdapter } from "./nba";
import { AflStandingsAdapter } from "./afl";
import { MlbStandingsAdapter } from "./mlb";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
/**
@ -23,6 +24,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
return new NhlStandingsAdapter();
case "afl_bracket":
return new AflStandingsAdapter();
case "mlb_bracket":
return new MlbStandingsAdapter();
case "f1_standings":
throw new Error(
"F1 standings sync is not yet implemented. Use the manual standings page."

View file

@ -0,0 +1,160 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
// MLB Stats API — free, no key required.
// leagueId 103 = American League, 104 = National League
const MLB_STANDINGS_URL =
"https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason&hydrate=team(division,league)";
interface MlbSplitRecord {
type: string; // "home", "away", "lastTen", etc.
wins: number;
losses: number;
}
interface MlbTeamRecord {
team: {
id: number;
name: string;
league?: { name?: string };
division?: { name?: string };
};
wins: number;
losses: number;
gamesPlayed: number;
winningPercentage: string; // e.g. ".617"
divisionRank?: string; // e.g. "1""5" — rank within the 5-team division (all teams)
wildCardRank?: string; // e.g. "1" — only set for WC-ranked teams
leagueRank?: string; // rank within the 15-team AL or NL
gamesBack: string; // "-" for leader, "2.0" otherwise
wildCardGamesBack?: string;
streak?: { streakCode: string; streakNumber: number };
records?: { splitRecords?: MlbSplitRecord[] };
}
interface MlbDivisionRecord {
teamRecords: MlbTeamRecord[];
}
interface MlbStandingsResponse {
records: MlbDivisionRecord[];
}
function sortByRecord(a: MlbTeamRecord, b: MlbTeamRecord): number {
return b.wins !== a.wins ? b.wins - a.wins : a.losses - b.losses;
}
export class MlbStandingsAdapter implements StandingsSyncAdapter {
constructor(private season: number = new Date().getFullYear()) {}
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const url = `${MLB_STANDINGS_URL}&season=${this.season}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as MlbStandingsResponse;
if (!json.records || !Array.isArray(json.records)) {
throw new Error("Unexpected MLB standings API response shape");
}
// Collect all team records from all division groups
const allTeamRecords: MlbTeamRecord[] = json.records.flatMap(
(divRecord) => divRecord.teamRecords ?? []
);
if (allTeamRecords.length === 0) {
throw new Error("MLB standings API returned no team records");
}
// Compute leagueRank by sorting all teams by wins desc, losses asc within each league.
// We'll assign ranks per league (AL rank 115, NL rank 115) separately since
// the API's leagueRank field may reflect conference-level rank.
const alTeams = allTeamRecords.filter(
(t) => t.team.league?.name?.includes("American")
);
const nlTeams = allTeamRecords.filter(
(t) => t.team.league?.name?.includes("National")
);
const alSorted = [...alTeams].toSorted(sortByRecord);
const nlSorted = [...nlTeams].toSorted(sortByRecord);
const conferenceRankMap = new Map<number, number>();
for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
// Compute overall league rank across all 30 teams for leagueRank field
const allSorted = [...allTeamRecords].toSorted(sortByRecord);
const overallRankMap = new Map<number, number>();
for (const [i, t] of allSorted.entries()) overallRankMap.set(t.team.id, i + 1);
return allTeamRecords.map((team): FetchedStandingsRecord => {
// Conference: "American League" → "AL", "National League" → "NL"
const leagueName = team.team.league?.name ?? "";
const conference = leagueName.startsWith("American")
? "AL"
: leagueName.startsWith("National")
? "NL"
: leagueName;
// Division: "AL East", "AL Central", etc.
const division = team.team.division?.name ?? null;
// Win percentage
const winPct = parseFloat(team.winningPercentage) || 0;
// Games back — "-" means leader; use null. Use isNaN guard so GB=0 (tied for first) is kept.
const gbParsed = parseFloat(team.gamesBack);
const gbStr = (team.gamesBack === "-" || isNaN(gbParsed)) ? null : gbParsed;
// Division rank and conference rank
const divisionRank = team.divisionRank ? parseInt(team.divisionRank, 10) : null;
// Conference rank: use the per-league rank computed above
const conferenceRank = conferenceRankMap.get(team.team.id) ?? null;
// Streak: e.g. "W3" or "L2"
const streak = team.streak
? `${team.streak.streakCode}${team.streak.streakNumber}`
: undefined;
// Split records
const splits = team.records?.splitRecords ?? [];
const homeSplit = splits.find((s) => s.type === "home");
const awaySplit = splits.find((s) => s.type === "away");
const lastTenSplit = splits.find((s) => s.type === "lastTen");
const homeRecord = homeSplit
? `${homeSplit.wins}-${homeSplit.losses}`
: undefined;
const awayRecord = awaySplit
? `${awaySplit.wins}-${awaySplit.losses}`
: undefined;
const lastTen = lastTenSplit
? `${lastTenSplit.wins}-${lastTenSplit.losses}`
: undefined;
return {
teamName: team.team.name,
externalTeamId: String(team.team.id),
wins: team.wins,
losses: team.losses,
gamesPlayed: team.gamesPlayed,
winPct,
// MLB has no OT losses
conference,
division: division ?? undefined,
divisionRank: divisionRank ?? undefined,
conferenceRank: conferenceRank ?? undefined,
leagueRank: overallRankMap.get(team.team.id) ?? 1,
gamesBack: gbStr ?? undefined,
streak,
lastTen,
homeRecord,
awayRecord,
};
});
}
}

View file

@ -95,6 +95,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"afl_bracket",
"snooker_bracket",
"tennis_qualifying_points",
"mlb_bracket",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [

View file

@ -0,0 +1,9 @@
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'mlb_bracket'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type')
) THEN
ALTER TYPE "public"."simulator_type" ADD VALUE 'mlb_bracket';
END IF;
END $$;

View file

@ -435,6 +435,13 @@
"when": 1774410244601,
"tag": "0061_violet_mephistopheles",
"breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1774500000000,
"tag": "0062_add_mlb_bracket_simulator_type",
"breakpoints": true
}
]
}