diff --git a/app/components/sport-season/RegularSeasonStandings.tsx b/app/components/sport-season/RegularSeasonStandings.tsx index f5a1f2b..c941a85 100644 --- a/app/components/sport-season/RegularSeasonStandings.tsx +++ b/app/components/sport-season/RegularSeasonStandings.tsx @@ -44,8 +44,8 @@ interface Props { participantEvs?: Record; /** 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(); 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({
{group.conference && (

- {group.conference} Conference + {group.conference} {group.conferenceLabel}

)} diff --git a/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx b/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx index ddb6d71..cdc325c 100644 --- a/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx +++ b/app/components/sport-season/__tests__/RegularSeasonStandings.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + expect(screen.getAllByText("Wild Card").length).toBeGreaterThan(0); + }); + + it("division section contains only the division winner (divisionRank=1)", () => { + render( + + ); + // 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( + + ); + expect(screen.getAllByText(/Playoff Line/i).length).toBeGreaterThan(0); + }); +}); diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index a01d314..fa0ceb0 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -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} />
@@ -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} /> diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts new file mode 100644 index 0000000..2741d62 --- /dev/null +++ b/app/services/simulations/__tests__/mlb-simulator.test.ts @@ -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); + }); +}); diff --git a/app/services/simulations/__tests__/tennis-simulator.test.ts b/app/services/simulations/__tests__/tennis-simulator.test.ts index 166dbbe..fa23b5c 100644 --- a/app/services/simulations/__tests__/tennis-simulator.test.ts +++ b/app/services/simulations/__tests__/tennis-simulator.test.ts @@ -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%) }); }); diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts new file mode 100644 index 0000000..49a17ff --- /dev/null +++ b/app/services/simulations/mlb-simulator.ts @@ -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 1–3 by Elo (best Elo = seed 1); WC teams 4–6 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) + * 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: + * 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 = { + // ── 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, + 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 1–3 by Elo (best Elo → seed 1). + * 4. Rank WC teams 4–6 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(); + 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(); + + 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(); + 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 1–3 by Elo descending (best Elo = seed 1) + const sortedDivWinners = divisionWinners.toSorted((a, b) => elo(b) - elo(a)); + + // Rank WC teams 4–6 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 { + 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(); + 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(participantIds.map((id) => [id, 0])); + const finalistCounts = new Map(participantIds.map((id) => [id, 0])); + const lcsLoserCounts = new Map(participantIds.map((id) => [id, 0])); + const dsLoserCounts = new Map(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 (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 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) + // 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 = [ + "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; + } +} diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index 3ed1c55..5129cb4 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -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 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 { diff --git a/app/services/standings-sync/__tests__/mlb.test.ts b/app/services/standings-sync/__tests__/mlb.test.ts new file mode 100644 index 0000000..119cc83 --- /dev/null +++ b/app/services/standings-sync/__tests__/mlb.test.ts @@ -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(); + }); +}); diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index ec264df..741c18f 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -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." diff --git a/app/services/standings-sync/mlb.ts b/app/services/standings-sync/mlb.ts new file mode 100644 index 0000000..4bc90ba --- /dev/null +++ b/app/services/standings-sync/mlb.ts @@ -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 { + 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 1–15, NL rank 1–15) 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(); + 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(); + 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, + }; + }); + } +} diff --git a/database/schema.ts b/database/schema.ts index c4cdaba..f7b9212 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -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", [ diff --git a/drizzle/0062_add_mlb_bracket_simulator_type.sql b/drizzle/0062_add_mlb_bracket_simulator_type.sql new file mode 100644 index 0000000..686287a --- /dev/null +++ b/drizzle/0062_add_mlb_bracket_simulator_type.sql @@ -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 $$; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ddbc129..3e428c0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file