import { useMemo, useState } from "react"; import { Form, Link, redirect, useActionData, useNavigation } from "react-router"; import type { Route } from "./+types/admin.sports-seasons.$id.simulator"; import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Play, Save, SlidersHorizontal } from "lucide-react"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Textarea } from "~/components/ui/textarea"; import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; import { findSportsSeasonById } from "~/models/sports-season"; import { batchUpsertParticipantSimulatorInputs, getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig, upsertSportsSeasonSimulatorConfig, validateSimulatorReadiness, type UpsertParticipantSimulatorInput, } from "~/models/simulator"; import { normalizeName } from "~/lib/fuzzy-match"; import { simulatorInputLabel, type SimulatorInputKey, } from "~/services/simulations/manifest"; import { getSimulatorInputPolicy, type MissingEloStrategy, type MissingRatingStrategy, } from "~/services/simulations/input-policy"; import { runSportsSeasonSimulation } from "~/services/simulations/runner"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; } export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); if (!sportsSeason) { throw new Response("Sports season not found", { status: 404 }); } const [participants, config, inputs, readiness] = await Promise.all([ findParticipantsBySportsSeasonId(params.id), getSportsSeasonSimulatorConfig(params.id), getParticipantSimulatorInputs(params.id), validateSimulatorReadiness(params.id), ]); if (!config) { throw new Response("This sports season does not have a simulator configured.", { status: 404 }); } const inputMap = new Map(inputs.map((input) => [input.participantId, input])); const inputRows = participants.map((participant) => ({ participant, input: inputMap.get(participant.id) ?? null, })); const inputPolicy = getSimulatorInputPolicy(config.config); // Sport-aware preview columns: the intersection of the displayable numeric keys // with this simulator's required + optional inputs, so each season shows exactly // the inputs its simulator consumes (F1 = odds, NBA = Elo, NCAA = rating, ...). // Resolved here (server-only) so the client bundle never imports the simulator // manifest/registry, which transitively pulls in `.server` modules. const relevantInputs = new Set([ ...config.profile.requiredInputs, ...config.profile.optionalInputs, ]); const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({ key, label: simulatorInputLabel(key), required: config.profile.requiredInputs.includes(key), })); return { sportsSeason, participants, config, inputRows, readiness, inputPolicy, inputColumns }; } interface ActionData { success?: boolean; message: string; } /** * Input keys the participant preview can render as a numeric column, in the order * they appear. Keys not listed (e.g. `region`, `metadata`) are not shown as * columns; the visible columns for a season are the intersection of this order * with the simulator's required + optional inputs. */ const DISPLAY_INPUT_ORDER: SimulatorInputKey[] = [ "sourceElo", "sourceOdds", "worldRanking", "rating", "projectedWins", "projectedTablePoints", "seed", ]; const PARTICIPANT_PAGE_SIZE = 50; /** * 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); return Number.isFinite(parsed) ? parsed : null; } function parsePolicyNumber(formData: FormData, key: string, fallback: number): number { const value = formData.get(key); if (typeof value !== "string" || value.trim() === "") return fallback; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function parseMissingEloStrategy(value: FormDataEntryValue | null): MissingEloStrategy { return value === "fallbackElo" || value === "averageKnown" || value === "worstKnownMinus" ? value : "block"; } function parseMissingRatingStrategy(value: FormDataEntryValue | null): MissingRatingStrategy { return value === "fallbackRating" || value === "averageKnown" || value === "worstKnownMinus" ? value : "block"; } function findParticipantId(name: string, participants: Array<{ id: string; name: string }>): string | null { const normalizedInput = normalizeName(name); const normalized = participants.map((participant) => ({ participant, normalized: normalizeName(participant.name), })); return ( normalized.find((candidate) => candidate.normalized === normalizedInput)?.participant.id ?? normalized.find((candidate) => candidate.normalized.includes(normalizedInput) || normalizedInput.includes(candidate.normalized) )?.participant.id ?? null ); } const ODDS_LINE_PATTERN = /^(.+?)\s+([+-]\d{2,6})\s*$/; function parseOddsLines( lines: string[], sportsSeasonId: string, participants: Array<{ id: string; name: string }> ): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } { const inputs: UpsertParticipantSimulatorInput[] = []; const unmatched: string[] = []; const seen = new Set(); for (const line of lines) { const match = ODDS_LINE_PATTERN.exec(line); if (!match) continue; const name = match[1].trim(); const sourceOdds = Number(match[2]); if (!Number.isFinite(sourceOdds)) continue; const participantId = findParticipantId(name, participants); if (!participantId) { unmatched.push(name); continue; } if (seen.has(participantId)) continue; seen.add(participantId); inputs.push({ participantId, sportsSeasonId, sourceOdds }); } return { inputs, unmatched }; } function parseInputCsv( text: string, sportsSeasonId: string, participants: Array<{ id: string; name: string }> ): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } { const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); if (lines.length === 0) return { inputs: [], unmatched: [] }; const header = lines[0].split(",").map((value) => value.trim()); const indexes = new Map(header.map((value, index) => [value, index])); const nameIndex = indexes.get("name"); if (nameIndex === undefined) { // No CSV header — treat the paste as sportsbook futures odds, one team per // line ending in American odds (e.g. `Kansas City Chiefs +450`). This is the // friendly bulk-futures path; team names are fuzzy-matched to participants. return parseOddsLines(lines, sportsSeasonId, participants); } const inputs: UpsertParticipantSimulatorInput[] = []; const unmatched: string[] = []; for (const line of lines.slice(1)) { const cols = line.split(",").map((value) => value.trim()); const name = cols[nameIndex]; if (!name) continue; const participantId = findParticipantId(name, participants); if (!participantId) { unmatched.push(name); continue; } inputs.push({ participantId, sportsSeasonId, sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined, sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined, worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined, rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined, projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined, projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined, seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined, region: cols[indexes.get("region") ?? -1] || undefined, }); } return { inputs, unmatched }; } export async function action({ request, params }: Route.ActionArgs): Promise { const formData = await request.formData(); const intent = formData.get("intent"); const sportsSeasonId = params.id; if (intent === "run") { try { await runSportsSeasonSimulation(sportsSeasonId); return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Simulation failed." }; } } if (intent === "save-config") { const rawConfig = formData.get("config"); const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); if (!currentConfig) return { success: false, message: "Simulator config not found." }; try { const parsed = typeof rawConfig === "string" && rawConfig.trim() ? JSON.parse(rawConfig) as Record : {}; // Preserve an existing inputPolicy if the submitted JSON omits it, so // editing other config keys doesn't silently wipe a previously configured // missing-Elo strategy. const config = "inputPolicy" in parsed ? parsed : { ...parsed, ...(currentConfig.config.inputPolicy !== undefined ? { inputPolicy: currentConfig.config.inputPolicy } : {}) }; await upsertSportsSeasonSimulatorConfig({ sportsSeasonId, simulatorType: currentConfig.simulatorType, config, }); return { success: true, message: "Simulator config saved." }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Invalid config JSON." }; } } if (intent === "save-configuration") { const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId); if (!currentConfig) return { success: false, message: "Simulator config not found." }; // Start from the current merged config so keys not exposed as structured // fields (e.g. string knobs) are preserved untouched. const next: Record = { ...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: next, }); return { success: true, message: "Simulator configuration saved." }; } if (intent === "save-inputs") { const rawInputs = formData.get("bulkInputs"); if (typeof rawInputs !== "string" || rawInputs.trim() === "") { return { success: false, message: "Paste at least one input row before saving." }; } const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); try { const parsed = parseInputCsv(rawInputs, sportsSeasonId, participants); if (parsed.inputs.length === 0) { return { success: false, message: "No matching participants found in the pasted inputs." }; } await batchUpsertParticipantSimulatorInputs(parsed.inputs); const suffix = parsed.unmatched.length > 0 ? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.` : ""; const saved = `Saved ${parsed.inputs.length} simulator input row(s).${suffix}`; // Auto-run the simulation so saved inputs immediately drive the standings. // The save itself already succeeded, so a run that never started (e.g. // participants missing required inputs) is reported as success with the // readiness gap — never as a failed save. try { await runSportsSeasonSimulation(sportsSeasonId); return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); } catch (runError) { const reason = runError instanceof Error ? runError.message : "could not run."; // Distinguish "saved but never ran" (readiness/already-running) from // "started running and failed mid-run": the runner only flips the season // to status 'failed' once the simulation itself throws. const season = await findSportsSeasonById(sportsSeasonId); if (season?.simulationStatus === "failed") { return { success: false, message: `${saved} The simulation failed: ${reason}` }; } return { success: true, message: `${saved} Simulation not run yet: ${reason}` }; } } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." }; } } return { success: false, message: "Unknown simulator setup action." }; } export default function AdminSportsSeasonSimulator({ loaderData }: Route.ComponentProps) { const { sportsSeason, config, inputRows, readiness, inputPolicy } = loaderData; const actionData = useActionData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const setupSections = config.profile.setupSections; const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? []; const ratingAlternatives = config.profile.derivableInputs?.rating ?? []; 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]); // Preview columns are resolved server-side in the loader (see note there) and // arrive as plain data, so this client component never imports the manifest. const { inputColumns } = loaderData; const requiredInputs = config.profile.requiredInputs; const gridTemplate = `2fr repeat(${Math.max(inputColumns.length, 1)}, 1fr)`; // For this sport the inputs live on a dedicated page, not the shared bulk paste. const externalInputsSection = requiredInputs.length === 0 ? (setupSections.includes("surfaceElo") ? { label: "Surface Elo", to: `/admin/sports-seasons/${sportsSeason.id}/surface-elo` } : setupSections.includes("golfSkills") ? { label: "Golf Skills", to: `/admin/sports-seasons/${sportsSeason.id}/golf-skills` } : null) : null; const isRowIncomplete = (input: (typeof inputRows)[number]["input"]) => requiredInputs.some((key) => input?.[key] === null || input?.[key] === undefined); const [search, setSearch] = useState(""); const [onlyMissing, setOnlyMissing] = useState(false); const [page, setPage] = useState(0); const filteredRows = useMemo(() => { const normalizedSearch = normalizeName(search); return inputRows.filter(({ participant, input }) => { if (normalizedSearch && !normalizeName(participant.name).includes(normalizedSearch)) return false; if (onlyMissing && !isRowIncomplete(input)) return false; return true; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputRows, search, onlyMissing, requiredInputs]); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PARTICIPANT_PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pageStart = safePage * PARTICIPANT_PAGE_SIZE; const pageRows = filteredRows.slice(pageStart, pageStart + PARTICIPANT_PAGE_SIZE); return (

Simulator Setup

{sportsSeason.sport.name} - {sportsSeason.name}

{actionData && ( {actionData.message} )}
{config.profile.displayName} {config.profile.description}
{readiness.status === "ready" ? ( Ready ) : ( Needs setup )}
Simulator Type
{config.simulatorType}
Participant Inputs
{readiness.participantInputCount}/{readiness.participantCount}
Simulation Status
{sportsSeason.simulationStatus}
{readiness.missingInputs.length > 0 && (
Missing: {readiness.missingInputs.join(", ")}
)} {readiness.warnings.length > 0 && (
{readiness.warnings.join(" ")}
)}
{setupSections.includes("eloRatings") &&} {setupSections.includes("surfaceElo") && } {setupSections.includes("golfSkills") && } {setupSections.includes("regularStandings") && } {setupSections.includes("events") && }
Simulator Configuration 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.
{showsInputPolicy && }

Engine

How the simulation runs. A higher parityFactor flattens the finish-position distribution (favorites win less often); iterations trades speed for precision.

{engineEntries.length > 0 ? (
{engineEntries.map(([key, value]) => (
))}
) : (

This simulator exposes no numeric engine knobs.

)}
{showsInputPolicy && (

Input derivation

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). Odds enter the engine only through this Elo — they are not blended again per game.

{config.profile.requiredInputs.includes("sourceElo") && ( <>
)} {config.profile.requiredInputs.includes("rating") && ( <>
)}
)}
Advanced: edit raw config JSON