Entering projected wins for an in-progress MLB season did not behave as expected: the entered numbers came back changed, and the simulation appeared to ignore them in favour of whatever Elo was already stored. Four separate defects were involved. Projections are now stored and shown verbatim. The Elo Ratings page never kept the number typed into it — the field was a display derived from Elo, so a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to an integer Elo) and drifted again after each run, because a run re-resolves that Elo through the input policy. The loader now reads back the stored projection and the paste flow keeps the pasted value as-is; the derived round-trip survives only as a prefill for seasons that have never had a projection saved. A stale Elo no longer silently outranks a projection. baseEloPriority takes the first available base source, and the simulator page's bulk CSV wrote projectedWins without stamping metadata.sourceEloMethod, so the non-destructive upsert left the old Elo in place as a trusted direct value and it won the race — the projection was stored and then ignored on every run. The CSV path now stamps the flag like the Elo Ratings page does, the metadata upsert merges rather than replaces so a flag-only write keeps unrelated keys, and Base Elo Source is editable per season for the case where a genuine hand-entered Elo should still lose to projections. Projected wins now act as a projected final total. The value was baked into a flat season-long rate (projectedWins / 162) applied to every remaining game, so a team at 60-50 projected for 95 finished around 90.5 and the projection was never reached mid-season. seedingWinRateFor spreads the difference over the games still to play, which is a no-op pre-season where the two rates coincide; projectedWinsWeight blends it back toward the Elo-implied rate. Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so any team with an Elo skipped the compression every hardcoded-rdif team gets: a 95-win projection became RDif +686 and played playoff games at .586 instead of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140 alongside the Dodgers' hardcoded +137. Also fixes the preview table's "missing a required input" marker, which flagged every projection-configured participant because a generated Elo or rating is deliberately hidden from getParticipantSimulatorInputs. It now consults the resolved values, so it agrees with readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { getSimulatorInfo, SIMULATOR_TYPES, type SimulatorType } from "./registry";
|
|
|
|
export type SimulatorInputKey =
|
|
| "sourceOdds"
|
|
| "sourceElo"
|
|
| "worldRanking"
|
|
| "rating"
|
|
| "projectedWins"
|
|
| "projectedTablePoints"
|
|
| "seed"
|
|
| "region"
|
|
| "metadata";
|
|
|
|
export type SimulatorSetupSection =
|
|
| "futuresOdds"
|
|
| "eloRatings"
|
|
| "rankings"
|
|
| "ratings"
|
|
| "regularStandings"
|
|
| "bracket"
|
|
| "events"
|
|
| "golfSkills"
|
|
| "surfaceElo"
|
|
| "cs2Setup"
|
|
| "participants";
|
|
|
|
export interface SimulatorManifestProfile {
|
|
simulatorType: SimulatorType;
|
|
displayName: string;
|
|
description: string;
|
|
defaultConfig: Record<string, unknown>;
|
|
requiredInputs: SimulatorInputKey[];
|
|
optionalInputs: SimulatorInputKey[];
|
|
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
|
setupSections: SimulatorSetupSection[];
|
|
minParticipantInputs?: number;
|
|
/**
|
|
* The simulator reads the season's generated bracket: it seeds from the real draw and
|
|
* replays completed matches from their recorded result, rather than re-drawing the field
|
|
* and re-playing decided games every iteration.
|
|
*
|
|
* updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
|
|
* by re-running the simulator or by the generic ICM recalculation. Re-running is both more
|
|
* accurate and the only option that respects a banked placement floor, but it is only safe
|
|
* here: re-running a bracket-blind simulator would re-draw the field and hand equity back
|
|
* to teams already knocked out.
|
|
*
|
|
* Both halves are required. A simulator that reads the draw but re-simulates games already
|
|
* played is NOT bracket-aware for this purpose — it resurrects eliminated teams just the
|
|
* same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator.
|
|
*
|
|
* This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin
|
|
* links and a readiness warning and does not track this accurately in either direction.
|
|
*/
|
|
bracketAware?: boolean;
|
|
}
|
|
|
|
const BASE_CONFIG = {
|
|
iterations: 50_000,
|
|
};
|
|
|
|
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
|
f1_standings: {
|
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: [],
|
|
setupSections: ["participants", "futuresOdds", "events"],
|
|
},
|
|
indycar_standings: {
|
|
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: [],
|
|
setupSections: ["participants", "futuresOdds", "events"],
|
|
},
|
|
golf_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, fieldSize: 156, plBeta: 1.5 },
|
|
requiredInputs: [],
|
|
optionalInputs: ["sourceOdds", "rating"],
|
|
setupSections: ["participants", "events", "golfSkills"],
|
|
},
|
|
playoff_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: ["sourceElo"],
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
},
|
|
ucl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, inputPolicy: { oddsWeight: 0.3 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
ncaam_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
|
requiredInputs: ["rating"],
|
|
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
|
derivableInputs: { rating: ["sourceOdds"] },
|
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
ncaaw_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
|
requiredInputs: ["rating"],
|
|
optionalInputs: ["sourceOdds", "seed", "region"],
|
|
derivableInputs: { rating: ["sourceOdds"] },
|
|
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
nba_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
nhl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
nfl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
afl_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 450, seasonGames: 23 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins"] },
|
|
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
|
// it is drawn the simulator seeds from it and honors completed results.
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
epl_standings: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
iterations: 10_000,
|
|
seasonGames: 38,
|
|
parityFactor: 400,
|
|
matchParityFactor: 400,
|
|
averageOpponentElo: 1500,
|
|
baseDrawRate: 0.26,
|
|
drawDecay: 0.002,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedTablePoints"],
|
|
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
snooker_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["worldRanking", "seed"],
|
|
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
tennis_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
|
requiredInputs: [],
|
|
optionalInputs: ["worldRanking"],
|
|
setupSections: ["participants", "surfaceElo", "events"],
|
|
},
|
|
mlb_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, seasonGames: 162, projectedWinsWeight: 1, inputPolicy: { oddsWeight: 0.3 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
wnba_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 44, srsEloScale: 30 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedWins"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
world_cup: {
|
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, inputPolicy: { oddsWeight: 0.3 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
|
bracketAware: true,
|
|
},
|
|
darts_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
|
requiredInputs: ["sourceElo", "worldRanking"],
|
|
optionalInputs: ["seed"],
|
|
setupSections: ["participants", "eloRatings", "rankings"],
|
|
bracketAware: true,
|
|
},
|
|
cs2_major_qualifying_points: {
|
|
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["worldRanking", "metadata"],
|
|
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
|
bracketAware: true,
|
|
},
|
|
ncaa_football_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
|
},
|
|
llws_bracket: {
|
|
defaultConfig: { ...BASE_CONFIG, parityFactor: 550, usTeamCount: 10, internationalTeamCount: 10 },
|
|
requiredInputs: ["sourceOdds"],
|
|
optionalInputs: ["metadata"],
|
|
// The bracket is optional — without one the draw is randomized — but once it
|
|
// exists the simulator reads the real draw and honors completed results from it.
|
|
setupSections: ["participants", "futuresOdds", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
college_hockey_bracket: {
|
|
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
|
// which the central resolver can't represent). oddsWeight 0 makes the central
|
|
// resolver leave a present base Elo untouched (no double-count), while still
|
|
// letting odds resolve a participant's Elo when they are the only source — so
|
|
// readiness and the sim's own internal odds blend both keep working.
|
|
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850, inputPolicy: { oddsWeight: 0 } },
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "worldRanking"],
|
|
derivableInputs: { sourceElo: ["sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
brackt: {
|
|
defaultConfig: { iterations: 20_000 },
|
|
requiredInputs: [],
|
|
optionalInputs: [],
|
|
setupSections: ["participants"],
|
|
},
|
|
nll_bracket: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
seasonGames: 18,
|
|
parityFactor: 400,
|
|
bracketSize: 8,
|
|
playoffTeams: 8,
|
|
regularSeasonTeamCount: 14,
|
|
homeFieldElo: 0,
|
|
regularSeasonMode: "project_remaining_games",
|
|
regularSeasonNoise: 0.9,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
|
bracketAware: true,
|
|
},
|
|
mls_bracket: {
|
|
defaultConfig: {
|
|
...BASE_CONFIG,
|
|
seasonGames: 34,
|
|
parityFactor: 400,
|
|
matchParityFactor: 400,
|
|
averageOpponentElo: 1500,
|
|
baseDrawRate: 0.26,
|
|
drawDecay: 0.002,
|
|
playoffTeamsPerConference: 9,
|
|
},
|
|
requiredInputs: ["sourceElo"],
|
|
optionalInputs: ["sourceOdds", "projectedTablePoints", "seed", "region"],
|
|
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
|
},
|
|
};
|
|
|
|
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =
|
|
Object.fromEntries(
|
|
SIMULATOR_TYPES.map((simulatorType) => {
|
|
const info = getSimulatorInfo(simulatorType);
|
|
const profile = PROFILES[simulatorType];
|
|
return [
|
|
simulatorType,
|
|
{
|
|
simulatorType,
|
|
displayName: info?.name ?? simulatorType,
|
|
description: info?.description ?? "",
|
|
...profile,
|
|
minParticipantInputs: profile.minParticipantInputs ?? 1,
|
|
},
|
|
];
|
|
})
|
|
) as Record<SimulatorType, SimulatorManifestProfile>;
|
|
|
|
export function getManifestSimulatorProfile(
|
|
simulatorType: SimulatorType
|
|
): SimulatorManifestProfile {
|
|
return SIMULATOR_MANIFEST[simulatorType];
|
|
}
|
|
|
|
export function simulatorInputLabel(key: SimulatorInputKey): string {
|
|
const labels: Record<SimulatorInputKey, string> = {
|
|
sourceOdds: "futures odds",
|
|
sourceElo: "Elo rating",
|
|
worldRanking: "ranking",
|
|
rating: "rating",
|
|
projectedWins: "projected wins",
|
|
projectedTablePoints: "projected table points",
|
|
seed: "seed",
|
|
region: "region",
|
|
metadata: "metadata",
|
|
};
|
|
return labels[key];
|
|
}
|