diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 578315c..e6d2f87 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -69,6 +69,25 @@ interface ActionData { message: string; } +/** + * Engine knobs that a simulator (or the shared input-policy resolver) actually + * reads from config. The structured Engine fields are limited to these so the UI + * never shows a control that silently does nothing — bespoke per-sim constants + * (e.g. homeFieldElo, eloDivisor, srsEloScale, raceNoise) that live in a profile + * but are not read from config stay editable only via the raw-JSON escape hatch. + */ +const HONORED_ENGINE_KNOBS = new Set([ + "iterations", + "parityFactor", + "seasonGames", + "overtimeRate", + "matchParityFactor", + "averageOpponentElo", + "baseDrawRate", + "drawDecay", + "ratingScaleFactor", +]); + function parseOptionalNumber(value: string | undefined): number | null { if (value === undefined || value.trim() === "") return null; const parsed = Number(value); @@ -232,33 +251,48 @@ export async function action({ request, params }: Route.ActionArgs): Promise = { ...currentConfig.config }; + + // Engine knobs: every numeric field rendered as `engine.`. + for (const [field, value] of formData.entries()) { + if (typeof value !== "string" || !field.startsWith("engine.")) continue; + const key = field.slice("engine.".length); + const parsed = Number(value); + if (value.trim() !== "" && Number.isFinite(parsed)) next[key] = parsed; + } + + // Input-derivation policy (only when the simulator consumes Elo/ratings). + if (formData.get("hasInputPolicy") === "1") { + const currentPolicy = getSimulatorInputPolicy(currentConfig.config); + next.inputPolicy = { + ...currentPolicy, + missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), + missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), + // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. + oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), + fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo), + fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta), + eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin), + eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax), + fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating), + fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta), + ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin), + ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax), + }; + } + await upsertSportsSeasonSimulatorConfig({ sportsSeasonId, simulatorType: currentConfig.simulatorType, - config: { - ...currentConfig.config, - inputPolicy: { - missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")), - missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")), - // Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read. - oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight), - fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo), - fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta), - eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin), - eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax), - fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating), - fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta), - ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin), - ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax), - }, - }, + config: next, }); - return { success: true, message: "Simulator input policy saved." }; + return { success: true, message: "Simulator configuration saved." }; } if (intent === "save-inputs") { @@ -316,6 +350,13 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone const showsInputPolicy = config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating"); + // Structured engine knobs: every top-level numeric config key (inputPolicy is a + // nested object edited in its own section). Driving the fields from the merged + // config means each simulator shows exactly the knobs it actually reads. + const engineEntries = Object.entries(config.config) + .filter(([key, value]) => HONORED_ENGINE_KNOBS.has(key) && typeof value === "number") + .map(([key, value]) => [key, value as unknown as number] as [string, number]); + return (
@@ -407,130 +448,164 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone - {showsInputPolicy && ( - - - Input Policy - - Direct inputs win. This simulator can derive Elo from{" "} - {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate Elo inputs"} - {ratingAlternatives.length > 0 ? ` and ratings from ${ratingAlternatives.join(", ")}` : ""}. - Tail fallbacks are explicit so low-impact missing participants do not silently get invented ratings. - - - -
- -
- - -

- Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into - the single Elo that feeds the simulator. This is the weight given to futures odds: - 0 = Elo / projections only, 1 = futures fully override, - in between = blend (e.g. 0.3 = 70% Elo / 30% futures). -

-
- {config.profile.requiredInputs.includes("sourceElo") && ( - <> -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - )} - {config.profile.requiredInputs.includes("rating") && ( - <> -
- - -
-
- - -
-
- - -
- - )} -
- - -
-
- - -
-
- -
-
-
-
- )} - - Season Config + Simulator Configuration - JSON overrides for this specific sports season. Defaults come from the simulator profile. + One place for this season's settings. Engine controls how the Monte Carlo + runs; Input derivation controls how raw inputs become the single Elo/rating + the engine consumes. Defaults come from the simulator profile; values set here override them + for this season only. Both sections write the same stored config. - -
- -