diff --git a/app/routes/admin.sports-seasons.$id.simulator.tsx b/app/routes/admin.sports-seasons.$id.simulator.tsx index 578315c..f623c01 100644 --- a/app/routes/admin.sports-seasons.$id.simulator.tsx +++ b/app/routes/admin.sports-seasons.$id.simulator.tsx @@ -232,33 +232,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 +331,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]) => key !== "inputPolicy" && typeof value === "number") + .map(([key, value]) => [key, value as unknown as number] as [string, number]); + return (
@@ -407,130 +429,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. - -
- -