brackt/app/services/simulations/mls-simulator.ts

540 lines
23 KiB
TypeScript
Raw Normal View History

Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
/**
* MLS Season + Playoffs Simulator
*
* Monte Carlo simulation of the MLS regular season and MLS Cup Playoffs.
*
* Two modes, auto-detected at runtime:
*
* Mode 1: Known-Seed
* Used when all 18 playoff seeds (19 East, 19 West) are set via the `seed`
* input and a `region` ("Eastern"/"Western") is present for every playoff team.
* Simulates the playoff bracket directly from those seeds.
*
* Mode 2: Regular-Season Projection (default)
* Simulates remaining regular-season games per team using Elo vs the average
* opponent (1500), per conference. Top 9 teams per conference qualify.
* Then simulates the MLS Cup Playoffs bracket.
*
* Conference assignment resolution order:
* 1. regularSeasonStandings.conference (populated by standings sync)
* 2. seasonParticipantSimulatorInputs.region ("Eastern" or "Western")
Add MLS standings sync and fix simulator conference resolution (#423) * Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
* 3. seasonParticipant.externalId set to "Eastern" or "Western" (pre-sync bootstrap;
* note: this prevents externalId-based matching in future syncs for that participant,
* which falls back to slower name-matching acceptable tradeoff for bootstrapping)
* 4. Error conference must be known for all teams
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
*
* MLS Cup Playoffs format (18 teams: 9 East + 9 West):
* Wild Card : E8 vs E9, W8 vs W9 single game (no draws; PKs if tied)
* Round 1 (Conf Quarters): best-of-3 E1 vs WC winner, E2 vs E7, E3 vs E6, E4 vs E5
* (same structure West)
* Conference Semifinals : single game 4 per conference 2 per conference
* Conference Finals : single game 2 per conference 1 per conference
* MLS Cup : single game East champion vs West champion
*
* Probability mapping:
* probFirst = MLS Cup champion (1 per sim)
* probSecond = MLS Cup finalist (1 per sim)
* probThird/Fourth = Conference Finals losers (2 per sim split evenly)
* probFifthEighth = Conference Semifinals losers (4 per sim split evenly)
* Wild Card losers, Round 1 losers, non-qualifiers all 0
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import type { EloSoccerMatchOptions } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { logger } from "~/lib/logger";
import { configNumber } from "./config-access";
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
// ─── Default constants (overridable via season simulator config) ───────────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
const DEFAULT_SEASON_GAMES = 34;
const DEFAULT_PARITY_FACTOR = 400;
const DEFAULT_AVERAGE_OPPONENT_ELO = 1500;
const DEFAULT_BASE_DRAW_RATE = 0.26;
const DEFAULT_DRAW_DECAY = 0.002;
const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
Add MLS standings sync and fix simulator conference resolution (#423) * Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
/**
* Infer MLS conference from seasonParticipant.externalId.
* Admins can set externalId to "Eastern" or "Western" before the first standings
* sync, mirroring how LLWS uses externalId to encode pool assignment.
*
* Side-effect: once externalId holds a conference name instead of an ESPN team ID,
* the standings-sync write-back won't overwrite it (write-back only fires when
* externalId is null). That participant will always match by name, never by ID.
*/
export function parseConferenceFromExternalId(externalId: string | null | undefined): MlsConference | null {
if (!externalId) return null;
const upper = externalId.toUpperCase();
if (upper === "EASTERN") return "Eastern";
if (upper === "WESTERN") return "Western";
return null;
}
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
// ─── Public types ─────────────────────────────────────────────────────────────
export type MlsConference = "Eastern" | "Western";
export interface MlsTeamEntry {
participantId: string;
elo: number;
conference: MlsConference;
currentPoints: number;
currentGoalsFor: number;
currentGoalDifference: number;
remainingGames: number;
}
export interface MlsConferenceSeed {
participantId: string;
elo: number;
conference: MlsConference;
seed: number; // 19 within conference
}
export interface MlsPlayoffResult {
champion: string;
finalist: string;
confFinalsLosers: [string, string];
confSemiLosers: [string, string, string, string];
}
// ─── Match helpers ────────────────────────────────────────────────────────────
export function mlsWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
const DEFAULT_MATCH_OPTIONS: EloSoccerMatchOptions = {
baseDrawRate: DEFAULT_BASE_DRAW_RATE,
drawDecay: DEFAULT_DRAW_DECAY,
parityFactor: DEFAULT_PARITY_FACTOR,
};
/**
* Simulate a single MLS knockout game (no draws allowed in result).
* If the Elo match produces a draw, a penalty shootout is simulated
* using the same Elo win probability.
*/
export function simulateMlsKnockoutGame(
teamA: MlsConferenceSeed,
teamB: MlsConferenceSeed,
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS
): { winner: MlsConferenceSeed; loser: MlsConferenceSeed } {
const result = simulateEloSoccerMatch(teamA.elo, teamB.elo, matchOptions);
if (result === "win") return { winner: teamA, loser: teamB };
if (result === "loss") return { winner: teamB, loser: teamA };
// Draw → penalty shootout (Elo-biased coin flip)
const pA = mlsWinProbability(teamA.elo, teamB.elo, matchOptions.parityFactor ?? DEFAULT_PARITY_FACTOR);
return Math.random() < pA
? { winner: teamA, loser: teamB }
: { winner: teamB, loser: teamA };
}
/**
* Simulate a best-of-3 MLS Round 1 series.
* Individual games use knockout rules (no draws; PKs decide ties).
* Higher seed is home for games 1 and 3.
*/
export function simulateMlsBestOfThree(
higherSeed: MlsConferenceSeed,
lowerSeed: MlsConferenceSeed,
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS,
existing: { winsHigher: number; winsLower: number } = { winsHigher: 0, winsLower: 0 }
): { winner: MlsConferenceSeed; loser: MlsConferenceSeed; gamesPlayed: number } {
let wH = existing.winsHigher;
let wL = existing.winsLower;
let gamesPlayed = 0;
while (wH < 2 && wL < 2) {
const { winner } = simulateMlsKnockoutGame(higherSeed, lowerSeed, matchOptions);
if (winner.participantId === higherSeed.participantId) wH++; else wL++;
gamesPlayed++;
}
return wH === 2
? { winner: higherSeed, loser: lowerSeed, gamesPlayed }
: { winner: lowerSeed, loser: higherSeed, gamesPlayed };
}
// ─── Regular-season simulation ────────────────────────────────────────────────
/**
* Simulate remaining regular season games per conference and return the
* top-9 seeds for each conference sorted by points GD GF random tiebreak.
*/
export function simulateMlsRegularSeason(
teams: MlsTeamEntry[],
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS,
averageOpponentElo = DEFAULT_AVERAGE_OPPONENT_ELO
): { east: MlsConferenceSeed[]; west: MlsConferenceSeed[] } {
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
const rows = teams.map((t) => {
let points = t.currentPoints;
let gd = t.currentGoalDifference;
let gf = t.currentGoalsFor;
for (let g = 0; g < t.remainingGames; g++) {
const result = simulateEloSoccerMatch(t.elo, averageOpponentElo, matchOptions);
const goals = simulateSimpleSoccerGoals(result);
gf += goals.gf;
gd += goals.gf - goals.ga;
if (result === "win") points += 3;
else if (result === "draw") points += 1;
}
return { participantId: t.participantId, elo: t.elo, conference: t.conference, points, gd, gf };
});
function seedConference(conf: MlsConference): MlsConferenceSeed[] {
const confRows = rows.filter((r) => r.conference === conf);
const sorted = confRows.toSorted(
(a, b) =>
b.points - a.points ||
b.gd - a.gd ||
b.gf - a.gf ||
(jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0)
);
return sorted.slice(0, MLS_PLAYOFF_TEAMS_PER_CONF).map((r, i) => ({
participantId: r.participantId,
elo: r.elo,
conference: conf,
seed: i + 1,
}));
}
return { east: seedConference("Eastern"), west: seedConference("Western") };
}
// ─── Playoff simulation ────────────────────────────────────────────────────────
function getBySeeds(
seeds: MlsConferenceSeed[],
...seedNums: number[]
): MlsConferenceSeed[] {
const byNum = new Map(seeds.map((s) => [s.seed, s]));
return seedNums.map((n) => {
const entry = byNum.get(n);
if (!entry) throw new Error(`MLS playoff seed ${n} not found in conference bracket.`);
return entry;
});
}
/**
* Simulate the full MLS Cup Playoffs from 9+9 conference seeds.
*/
export function simulateMlsPlayoffs(
east: MlsConferenceSeed[],
west: MlsConferenceSeed[],
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS
): MlsPlayoffResult {
function simConference(seeds: MlsConferenceSeed[]): {
champion: MlsConferenceSeed;
finalist: MlsConferenceSeed;
confSemiLosers: [string, string];
} {
const [s1, s2, s3, s4, s5, s6, s7, s8, s9] = getBySeeds(seeds, 1, 2, 3, 4, 5, 6, 7, 8, 9);
// Wild Card: 8 vs 9
const wc = simulateMlsKnockoutGame(s8, s9, matchOptions);
// Round 1 (best-of-3): 1 vs WC winner, 2 vs 7, 3 vs 6, 4 vs 5
const r1a = simulateMlsBestOfThree(s1, wc.winner, matchOptions);
const r1b = simulateMlsBestOfThree(s2, s7, matchOptions);
const r1c = simulateMlsBestOfThree(s3, s6, matchOptions);
const r1d = simulateMlsBestOfThree(s4, s5, matchOptions);
// Conference Semifinals: single game
// Arm A: r1a winner vs r1d winner; Arm B: r1b winner vs r1c winner
const sfA = simulateMlsKnockoutGame(r1a.winner, r1d.winner, matchOptions);
const sfB = simulateMlsKnockoutGame(r1b.winner, r1c.winner, matchOptions);
// Conference Finals: single game
const confFinal = simulateMlsKnockoutGame(sfA.winner, sfB.winner, matchOptions);
return {
champion: confFinal.winner,
finalist: confFinal.loser,
confSemiLosers: [sfA.loser.participantId, sfB.loser.participantId],
};
}
const eastResult = simConference(east);
const westResult = simConference(west);
// MLS Cup: East champion vs West champion
const cup = simulateMlsKnockoutGame(eastResult.champion, westResult.champion, matchOptions);
return {
champion: cup.winner.participantId,
finalist: cup.loser.participantId,
confFinalsLosers: [eastResult.finalist.participantId, westResult.finalist.participantId],
confSemiLosers: [
...eastResult.confSemiLosers,
...westResult.confSemiLosers,
] as [string, string, string, string],
};
}
// ─── Simulator class ──────────────────────────────────────────────────────────
export class MLSSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const [participants, evRows, standingsRows, simInputs, simulatorConfig] = await Promise.all([
db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
}),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
getParticipantSimulatorInputs(sportsSeasonId),
getSportsSeasonSimulatorConfig(sportsSeasonId),
]);
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// Extract config values (admin-overridable; fall back to defaults)
const config = simulatorConfig?.config;
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions: EloSoccerMatchOptions = {
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
parityFactor: configNumber(config, "matchParityFactor", DEFAULT_PARITY_FACTOR),
};
// Build Elo map: prefer sourceElo; fall back to converting sourceOdds
const eloMap = new Map<string, number>();
const hasSourceElo = evRows.some((r) => r.sourceElo !== null);
if (hasSourceElo) {
for (const r of evRows) {
if (r.sourceElo !== null) eloMap.set(r.participantId, r.sourceElo);
}
} else {
const hasOdds = evRows.some((r) => r.sourceOdds !== null);
if (!hasOdds) {
throw new Error(
`No Elo ratings or futures odds found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo, projectedTablePoints, or sourceOdds via Admin → Elo Ratings before simulating.`
);
}
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds as number }));
const converted = convertFuturesToElo(oddsInput);
for (const [id, elo] of converted) eloMap.set(id, elo);
}
// Build simulator input maps (seed + region)
const seedMap = new Map<string, number>();
const regionMap = new Map<string, string>();
for (const input of simInputs) {
if (input.seed !== null && input.seed !== undefined) {
seedMap.set(input.participantId, input.seed);
}
if (input.region !== null && input.region !== undefined) {
regionMap.set(input.participantId, input.region);
}
}
Add MLS standings sync and fix simulator conference resolution (#423) * Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
// Resolve conference for every participant.
// Priority: (1) regularSeasonStandings.conference, (2) simulatorInputs.region,
// (3) seasonParticipant.externalId set to "Eastern"/"Western" (pre-sync bootstrap).
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
const conferenceMap = new Map<string, MlsConference>();
for (const p of participants) {
const standing = standingsMap.get(p.id);
const raw =
(standing?.conference as string | null | undefined) ??
regionMap.get(p.id) ??
Add MLS standings sync and fix simulator conference resolution (#423) * Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
parseConferenceFromExternalId(p.externalId) ??
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
null;
if (raw === "Eastern" || raw === "Western") {
conferenceMap.set(p.id, raw);
}
}
const missingConf = participants.filter((p) => !conferenceMap.has(p.id));
if (missingConf.length > 0) {
throw new Error(
`MLSSimulator: Conference assignment missing for ${missingConf.length} team(s): ` +
`${missingConf.map((p) => p.name).join(", ")}. ` +
Add MLS standings sync and fix simulator conference resolution (#423) * Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 15:39:40 -07:00
`Options: sync standings (populates conference automatically), set region to "Eastern"/"Western" ` +
`via Admin → Simulator Inputs, or set externalId to "Eastern"/"Western" on the participant.`
Add MLS sport simulator (mls_bracket) (#422) * Add MLS sport simulator (mls_bracket) Adds Major League Soccer as a draftable sport with a full-season Monte Carlo simulator. Models both preseason regular-season projection (34 games across Eastern and Western conferences) and the MLS Cup Playoffs bracket, including the Wild Card (single game + PKs), Round 1 best-of-3 series, Conference Semis/Finals (single game), and MLS Cup. P1–P8 mapping: MLS Cup winner, finalist, Conference Finals losers, Conference Semifinals losers. Conference assignment reads from regularSeasonStandings.conference or falls back to the region simulator input ("Eastern"/"Western"). Admin inputs: projectedTablePoints (primary, max 102 for 34×3), with derivation chain to sourceElo via existing input-policy; sourceOdds as alternative. No hardcoded team data — all inputs are admin-managed per season. - database/schema.ts: add mls_bracket to simulatorTypeEnum - drizzle/0104_chief_boom_boom.sql: migration for the new enum value - mls-simulator.ts: MLSSimulator + exported pure helpers for testability - registry.ts / manifest.ts / simulator-config.ts: register mls_bracket - mls-simulator.test.ts: 38 unit tests covering all helpers and sync checks https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Fix MLS simulator: config loading, sourceOdds fallback, normalization Three issues found in code review comparing against EPL and NFL simulators: 1. Load simulator config from DB via getSportsSeasonSimulatorConfig so admins can override iterations, seasonGames, parityFactor, drawRates per season without code changes. Previously all constants were hardcoded. 2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is present. EPL and NFL both do this; MLS was throwing immediately instead of attempting the odds conversion that the manifest declares as optional. 3. Call normalizeSimulationResultColumns before returning results, matching EPL's local normalization pattern for consistency (runner also normalizes globally, but EPL calls it locally too). https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa * Polish MLS simulator: configNumber zero, logger warning, Map lookup Three small fixes from secondary code review: 1. configNumber: allow value >= 0 (not just > 0) so admins can legitimately set baseDrawRate or drawDecay to 0 without the value being silently discarded and replaced with the default. 2. Add logger.warn when a participant is excluded from simulation due to a missing Elo rating, matching the NLL bracket-aware pattern. Gives admins a visible signal instead of a silent exclusion. 3. getBySeeds: build a Map once instead of calling Array.find() per seed. Eliminates 4M linear scans across 50k iterations for a 9- element array — trivially fast either way, but Map is the right tool. https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 12:45:53 -07:00
);
}
const allIds = participants.map((p) => p.id);
// ── Mode detection ─────────────────────────────────────────────────────────
// Mode 1: Known-seed — all 18 playoff seeds (1-9 East, 1-9 West) are explicitly set
const eastSeeds: MlsConferenceSeed[] = [];
const westSeeds: MlsConferenceSeed[] = [];
for (const p of participants) {
const seed = seedMap.get(p.id);
const elo = eloMap.get(p.id);
const conf = conferenceMap.get(p.id);
if (seed === undefined || elo === undefined || conf === undefined) continue;
if (seed < 1 || seed > MLS_PLAYOFF_TEAMS_PER_CONF) continue;
if (conf === "Eastern") eastSeeds.push({ participantId: p.id, elo, conference: conf, seed });
else westSeeds.push({ participantId: p.id, elo, conference: conf, seed });
}
const eastSeedSet = new Set(eastSeeds.map((s) => s.seed));
const westSeedSet = new Set(westSeeds.map((s) => s.seed));
const isKnownSeed =
eastSeeds.length === MLS_PLAYOFF_TEAMS_PER_CONF &&
westSeeds.length === MLS_PLAYOFF_TEAMS_PER_CONF &&
eastSeedSet.size === MLS_PLAYOFF_TEAMS_PER_CONF &&
westSeedSet.size === MLS_PLAYOFF_TEAMS_PER_CONF;
if (isKnownSeed) {
return this.simulateKnownSeeds(allIds, eastSeeds, westSeeds, numSimulations, matchOptions);
}
// Mode 2: Regular-season projection (default, including preseason)
const teamsWithElo = participants.flatMap((p): MlsTeamEntry[] => {
const elo = eloMap.get(p.id);
const conf = conferenceMap.get(p.id);
if (elo === undefined || conf === undefined) {
logger.warn(
`MLSSimulator: no Elo rating for participant "${p.name}" (${p.id}). ` +
`They will be excluded from simulation. Enter sourceElo or projectedTablePoints via Admin → Elo Ratings.`
);
return [];
}
const standing = standingsMap.get(p.id);
const wins = standing?.wins ?? 0;
const draws = standing?.ties ?? 0;
const gamesPlayed = standing?.gamesPlayed ?? wins + draws + (standing?.losses ?? 0);
return [{
participantId: p.id,
elo,
conference: conf,
currentPoints: standing?.tablePoints ?? wins * 3 + draws,
currentGoalsFor: standing?.goalsFor ?? 0,
currentGoalDifference: standing?.goalDifference ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed),
}];
});
if (teamsWithElo.length === 0) {
throw new Error(`No participants with Elo ratings found for season ${sportsSeasonId}.`);
}
return this.simulateRegularSeason(allIds, teamsWithElo, numSimulations, matchOptions, averageOpponentElo);
}
// ── Mode 1: Known-Seed ──────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
east: MlsConferenceSeed[],
west: MlsConferenceSeed[],
numSimulations: number,
matchOptions: EloSoccerMatchOptions
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < numSimulations; s++) {
const result = simulateMlsPlayoffs(east, west, matchOptions);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts, numSimulations);
}
// ── Mode 2: Regular-Season Projection ──────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: MlsTeamEntry[],
numSimulations: number,
matchOptions: EloSoccerMatchOptions,
averageOpponentElo: number
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < numSimulations; s++) {
const { east, west } = simulateMlsRegularSeason(teams, matchOptions, averageOpponentElo);
if (east.length < MLS_PLAYOFF_TEAMS_PER_CONF || west.length < MLS_PLAYOFF_TEAMS_PER_CONF) {
// Not enough teams in a conference — skip this iteration
continue;
}
const result = simulateMlsPlayoffs(east, west, matchOptions);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts, numSimulations);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
private zeroCounts(allIds: string[]) {
return {
champion: new Map(allIds.map((id) => [id, 0])),
finalist: new Map(allIds.map((id) => [id, 0])),
confFinalsLoser: new Map(allIds.map((id) => [id, 0])),
confSemiLoser: new Map(allIds.map((id) => [id, 0])),
};
}
private recordResult(
counts: ReturnType<MLSSimulator["zeroCounts"]>,
result: MlsPlayoffResult
) {
counts.champion.set(result.champion, (counts.champion.get(result.champion) ?? 0) + 1);
counts.finalist.set(result.finalist, (counts.finalist.get(result.finalist) ?? 0) + 1);
for (const id of result.confFinalsLosers) {
counts.confFinalsLoser.set(id, (counts.confFinalsLoser.get(id) ?? 0) + 1);
}
for (const id of result.confSemiLosers) {
counts.confSemiLoser.set(id, (counts.confSemiLoser.get(id) ?? 0) + 1);
}
}
private buildResults(
allIds: string[],
counts: ReturnType<MLSSimulator["zeroCounts"]>,
numSimulations: number
): SimulationResult[] {
const N = numSimulations;
const results: SimulationResult[] = allIds.map((id) => ({
participantId: id,
probabilities: {
probFirst: (counts.champion.get(id) ?? 0) / N,
probSecond: (counts.finalist.get(id) ?? 0) / N,
// 2 conf finals losers per sim — split evenly across 3rd/4th
probThird: (counts.confFinalsLoser.get(id) ?? 0) / N / 2,
probFourth: (counts.confFinalsLoser.get(id) ?? 0) / N / 2,
// 4 conf semi losers per sim — split evenly across 5th8th
probFifth: (counts.confSemiLoser.get(id) ?? 0) / N / 4,
probSixth: (counts.confSemiLoser.get(id) ?? 0) / N / 4,
probSeventh: (counts.confSemiLoser.get(id) ?? 0) / N / 4,
probEighth: (counts.confSemiLoser.get(id) ?? 0) / N / 4,
},
source: "mls_bracket_monte_carlo",
}));
normalizeSimulationResultColumns(results);
return results;
}
}