The previous commit did not build. `simulatorInputLabel` was called from the rendered component for the Base Elo Source select, which defeated the treeshaking that keeps the simulator manifest — and through it the registry, every simulator, and ~/database/context — out of the browser. Vite fails the client build outright with "Server-only module referenced by client". The label is now resolved in the loader alongside inputColumns, which is the pattern the loader comment already documents. Typecheck, lint and the unit suite all passed on the broken commit; none of them run a production build. A stale projection now yields to Elo instead of clamping to an extreme. seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a 96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team projected for 95 was simulated to win out. A target outside (0, 1) is proof the projection has gone stale, not a reason to bet everything on it, so it falls back to the Elo rate — the same escape hatch nll-simulator uses. The blend weight is also clamped inside the helper now, so a stray config value cannot turn it into an extrapolation. Seeding only applies a projection that actually produced the resolved Elo, gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw projection drove seeding regardless of the input policy: with the default Elo-first ordering a projection was ignored as the Elo source yet still dictated the standings, and with futures odds blended in at oddsWeight, seeding and playoff matchups ran on two different strength scales. The simulator page's preview resolved its Elo and rating maps only for required inputs, but renders those columns solely from the maps, so stored values displayed as "—" for profiles that treat the input as optional (playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key off the same required-plus-optional set the columns do. The metadata upsert's CASE branches were mutually exclusive, so supplying any metadata skipped the stale-flag clearing: a bulk row carrying both a direct rating and a projection kept a stale ratingMethod and hid the rating it had just set. Stripping now always runs, with the caller's metadata merged over the result. Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo. That is the final-win-total semantics chosen for this work; the stale-target fallback removes its pathological case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
965 lines
44 KiB
TypeScript
965 lines
44 KiB
TypeScript
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,
|
||
resolveRatings,
|
||
resolveSourceElos,
|
||
type MissingEloStrategy,
|
||
type MissingRatingStrategy,
|
||
} from "~/services/simulations/input-policy";
|
||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||
import {
|
||
parseBaseEloPriorityChoice,
|
||
projectionMethodMetadata,
|
||
resolvedInputMethodLabel,
|
||
} from "./admin.sports-seasons.$id.simulator.helpers";
|
||
|
||
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<SimulatorInputKey>([
|
||
...config.profile.requiredInputs,
|
||
...config.profile.optionalInputs,
|
||
]);
|
||
|
||
// The Elo each participant will actually run with, and which source produced it.
|
||
// Without this the preview is misleading: getParticipantSimulatorInputs blanks a
|
||
// generated Elo (so it is re-derived rather than frozen), which reads as "nothing
|
||
// saved" — and a raw Elo silently beating a projection is invisible.
|
||
//
|
||
// Keyed off `relevantInputs`, not requiredInputs: the preview renders these
|
||
// columns solely from these maps, so gating on "required" would blank a stored
|
||
// value for every simulator that treats the input as optional (playoff_bracket
|
||
// and ncaam_bracket for Elo, golf_qualifying_points for rating).
|
||
const resolvedEloRows = Object.fromEntries(
|
||
relevantInputs.has("sourceElo")
|
||
? [...resolveSourceElos(inputs, config.profile, config.config).values()].map((resolved) => [
|
||
resolved.participantId,
|
||
{ sourceElo: resolved.sourceElo, method: resolved.method },
|
||
])
|
||
: []
|
||
);
|
||
// Same for ratings, which are blanked by the same rule when generated. The
|
||
// preview's "missing a required input" marker reads both, so it agrees with
|
||
// readiness instead of flagging every participant a projection resolved.
|
||
const resolvedRatingRows = Object.fromEntries(
|
||
relevantInputs.has("rating")
|
||
? [...resolveRatings(inputs, config.profile, config.config).values()].map((resolved) => [
|
||
resolved.participantId,
|
||
{ rating: resolved.rating, method: resolved.method },
|
||
])
|
||
: []
|
||
);
|
||
const inputColumns = DISPLAY_INPUT_ORDER.filter((key) => relevantInputs.has(key)).map((key) => ({
|
||
key,
|
||
label: simulatorInputLabel(key),
|
||
required: config.profile.requiredInputs.includes(key),
|
||
}));
|
||
|
||
// The projection this simulator can derive Elo from, labelled here for the same
|
||
// reason as inputColumns: calling simulatorInputLabel from the rendered component
|
||
// would pull the manifest (and through it the registry and every simulator) into
|
||
// the client bundle.
|
||
const projectionEloKey = (config.profile.derivableInputs?.sourceElo ?? []).find(
|
||
(key) => key === "projectedWins" || key === "projectedTablePoints"
|
||
);
|
||
const projectionEloOption = projectionEloKey
|
||
? { key: projectionEloKey, label: simulatorInputLabel(projectionEloKey) }
|
||
: null;
|
||
|
||
return {
|
||
sportsSeason,
|
||
participants,
|
||
config,
|
||
inputRows,
|
||
readiness,
|
||
inputPolicy,
|
||
inputColumns,
|
||
resolvedEloRows,
|
||
resolvedRatingRows,
|
||
projectionEloOption,
|
||
};
|
||
}
|
||
|
||
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",
|
||
"projectedWinsWeight",
|
||
]);
|
||
|
||
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<string>();
|
||
|
||
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;
|
||
}
|
||
|
||
const sourceElo = parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined;
|
||
const projectedWins = parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined;
|
||
const projectedTablePoints = parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined;
|
||
|
||
inputs.push({
|
||
participantId,
|
||
sportsSeasonId,
|
||
sourceElo,
|
||
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,
|
||
projectedTablePoints,
|
||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||
region: cols[indexes.get("region") ?? -1] || undefined,
|
||
// A row that supplies a projection but no explicit Elo means "derive the Elo
|
||
// from this projection". Stamping the method flag marks whatever Elo is
|
||
// already stored as generated, so getParticipantSimulatorInputs hides it and
|
||
// resolveSourceElos re-derives from the projection instead of letting a stale
|
||
// Elo win the baseEloPriority race. Mirrors the Elo Ratings page's
|
||
// projections mode.
|
||
metadata: projectionMethodMetadata(sourceElo, projectedWins, projectedTablePoints),
|
||
});
|
||
}
|
||
|
||
return { inputs, unmatched };
|
||
}
|
||
|
||
export async function action({ request, params }: Route.ActionArgs): Promise<ActionData | Response> {
|
||
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<string, unknown>
|
||
: {};
|
||
// 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<string, unknown> = { ...currentConfig.config };
|
||
|
||
// Engine knobs: every numeric field rendered as `engine.<key>`.
|
||
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")),
|
||
baseEloPriority: parseBaseEloPriorityChoice(formData.get("baseEloPriority"), currentPolicy.baseEloPriority),
|
||
// 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<ActionData>();
|
||
const navigation = useNavigation();
|
||
const isSubmitting = navigation.state === "submitting";
|
||
const setupSections = config.profile.setupSections;
|
||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||
const projectionsOutrankElo = inputPolicy.baseEloPriority[0] !== "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, resolvedEloRows, resolvedRatingRows, projectionEloOption } = 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;
|
||
|
||
// A required Elo/rating counts as present when the input policy resolves one,
|
||
// not only when it is stored directly: getParticipantSimulatorInputs deliberately
|
||
// blanks a generated value so it is re-derived each run, so reading the raw input
|
||
// alone would mark every projection-configured participant as missing.
|
||
const isRowIncomplete = (participantId: string, input: (typeof inputRows)[number]["input"]) =>
|
||
requiredInputs.some((key) => {
|
||
if (input?.[key] !== null && input?.[key] !== undefined) return false;
|
||
if (key === "sourceElo") return resolvedEloRows[participantId] === undefined;
|
||
if (key === "rating") return resolvedRatingRows[participantId] === undefined;
|
||
return true;
|
||
});
|
||
|
||
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(participant.id, input)) return false;
|
||
return true;
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [inputRows, search, onlyMissing, requiredInputs, resolvedEloRows, resolvedRatingRows]);
|
||
|
||
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 (
|
||
<div className="container mx-auto p-6 space-y-6">
|
||
<div className="flex items-center gap-4">
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
Back to Sports Season
|
||
</Link>
|
||
</Button>
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link to="/admin/simulators">All Simulators</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<div>
|
||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||
<SlidersHorizontal className="h-7 w-7" />
|
||
Simulator Setup
|
||
</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||
</p>
|
||
</div>
|
||
|
||
{actionData && (
|
||
<Card className={actionData.success ? "border-emerald-500/40" : "border-destructive/40"}>
|
||
<CardContent className="py-4 text-sm">{actionData.message}</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<CardTitle>{config.profile.displayName}</CardTitle>
|
||
<CardDescription>{config.profile.description}</CardDescription>
|
||
</div>
|
||
{readiness.status === "ready" ? (
|
||
<Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="gap-1"><AlertCircle className="h-3 w-3" /> Needs setup</Badge>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
<div className="rounded-md border p-3">
|
||
<div className="text-sm text-muted-foreground">Simulator Type</div>
|
||
<code className="text-sm">{config.simulatorType}</code>
|
||
</div>
|
||
<div className="rounded-md border p-3">
|
||
<div className="text-sm text-muted-foreground">Participant Inputs</div>
|
||
<div className="font-medium">{readiness.participantInputCount}/{readiness.participantCount}</div>
|
||
</div>
|
||
<div className="rounded-md border p-3">
|
||
<div className="text-sm text-muted-foreground">Simulation Status</div>
|
||
<div className="font-medium">{sportsSeason.simulationStatus}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{readiness.missingInputs.length > 0 && (
|
||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm">
|
||
Missing: {readiness.missingInputs.join(", ")}
|
||
</div>
|
||
)}
|
||
|
||
{readiness.warnings.length > 0 && (
|
||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
||
{readiness.warnings.join(" ")}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{setupSections.includes("eloRatings") &&<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
|
||
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
|
||
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
|
||
{setupSections.includes("events") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>Events</Link></Button>}
|
||
<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/expected-values`}>Expected Values</Link></Button>
|
||
</div>
|
||
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="run" />
|
||
<Button type="submit" disabled={!readiness.canRun || sportsSeason.simulationStatus === "running" || isSubmitting}>
|
||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||
Run Simulation
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Simulator Configuration</CardTitle>
|
||
<CardDescription>
|
||
One place for this season's settings. <strong>Engine</strong> controls how the Monte Carlo
|
||
runs; <strong>Input derivation</strong> 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.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<Form method="post" className="space-y-6">
|
||
<input type="hidden" name="intent" value="save-configuration" />
|
||
{showsInputPolicy && <input type="hidden" name="hasInputPolicy" value="1" />}
|
||
|
||
<section className="space-y-3">
|
||
<div>
|
||
<h3 className="text-sm font-semibold">Engine</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
How the simulation runs. A higher <code>parityFactor</code> flattens the finish-position
|
||
distribution (favorites win less often); <code>iterations</code> trades speed for precision.
|
||
</p>
|
||
</div>
|
||
{engineEntries.length > 0 ? (
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{engineEntries.map(([key, value]) => (
|
||
<div key={key} className="space-y-2">
|
||
<Label htmlFor={`engine.${key}`} className="font-mono text-xs">{key}</Label>
|
||
<Input id={`engine.${key}`} name={`engine.${key}`} type="number" step="any" defaultValue={value} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">This simulator exposes no numeric engine knobs.</p>
|
||
)}
|
||
</section>
|
||
|
||
{showsInputPolicy && (
|
||
<section className="space-y-3">
|
||
<div>
|
||
<h3 className="text-sm font-semibold">Input derivation</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
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.
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-5">
|
||
<div className="space-y-2 md:col-span-5">
|
||
<Label htmlFor="oddsWeight">Futures vs. Elo — Odds Blend Weight (0–1)</Label>
|
||
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
||
<p className="text-xs text-muted-foreground">
|
||
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:
|
||
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = 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.
|
||
</p>
|
||
</div>
|
||
{projectionEloOption && (
|
||
<div className="space-y-2 md:col-span-5">
|
||
<Label htmlFor="baseEloPriority">Base Elo Source</Label>
|
||
<select
|
||
id="baseEloPriority"
|
||
name="baseEloPriority"
|
||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||
defaultValue={projectionsOutrankElo ? "projectionsFirst" : "eloFirst"}
|
||
>
|
||
<option value="eloFirst">Entered Elo first, then {projectionEloOption.label}</option>
|
||
<option value="projectionsFirst">{projectionEloOption.label} first, then entered Elo</option>
|
||
</select>
|
||
<p className="text-xs text-muted-foreground">
|
||
Raw Elo and projections are substitutes — the first one a participant has wins, and
|
||
the other is ignored (futures odds are separate and blend on top via the weight above).
|
||
Pick <strong>{projectionEloOption.label} first</strong> when projections are
|
||
the source of truth for this season and a previously entered Elo should not override them.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||
<>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||
<select
|
||
id="missingEloStrategy"
|
||
name="missingEloStrategy"
|
||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||
defaultValue={inputPolicy.missingEloStrategy}
|
||
>
|
||
<option value="block">Block until complete</option>
|
||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||
<option value="averageKnown">Use average known Elo</option>
|
||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="fallbackEloDelta">Worst Elo Delta</Label>
|
||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||
</div>
|
||
</>
|
||
)}
|
||
{config.profile.requiredInputs.includes("rating") && (
|
||
<>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor="missingRatingStrategy">Missing Rating Strategy</Label>
|
||
<select
|
||
id="missingRatingStrategy"
|
||
name="missingRatingStrategy"
|
||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||
defaultValue={inputPolicy.missingRatingStrategy}
|
||
>
|
||
<option value="block">Block until complete</option>
|
||
<option value="fallbackRating">Use fixed fallback rating</option>
|
||
<option value="averageKnown">Use average known rating</option>
|
||
<option value="worstKnownMinus">Use worst known rating minus delta</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="fallbackRating">Fallback Rating</Label>
|
||
<Input id="fallbackRating" name="fallbackRating" type="number" step="0.01" defaultValue={inputPolicy.fallbackRating} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="fallbackRatingDelta">Worst Rating Delta</Label>
|
||
<Input id="fallbackRatingDelta" name="fallbackRatingDelta" type="number" step="0.01" defaultValue={inputPolicy.fallbackRatingDelta} />
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ratingMin">Rating Floor</Label>
|
||
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="ratingMax">Rating Ceiling</Label>
|
||
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
Save Configuration
|
||
</Button>
|
||
</Form>
|
||
|
||
<details>
|
||
<summary className="cursor-pointer text-sm font-medium">Advanced: edit raw config JSON</summary>
|
||
<Form method="post" className="space-y-3 mt-3">
|
||
<input type="hidden" name="intent" value="save-config" />
|
||
<Textarea
|
||
name="config"
|
||
rows={10}
|
||
className="font-mono text-sm"
|
||
defaultValue={JSON.stringify(config.config, null, 2)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Power-user escape hatch for keys not shown above. This is the same stored config the fields
|
||
edit; saving here writes the whole object.
|
||
</p>
|
||
<Button type="submit" variant="outline" disabled={isSubmitting}>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
Save Raw JSON
|
||
</Button>
|
||
</Form>
|
||
</details>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
||
<CardDescription>
|
||
Two ways to paste, then the simulation re-runs automatically on save:
|
||
<strong> CSV</strong> with a header row — supported columns: name, sourceElo, sourceOdds,
|
||
worldRanking, rating, projectedWins, projectedTablePoints, seed, region (values must not
|
||
contain commas); or <strong>futures odds</strong>, one team per line ending in American
|
||
odds (e.g. <code>Kansas City Chiefs +450</code>). Team names are fuzzy-matched to
|
||
participants. A partial paste only updates the columns it includes — other inputs are kept.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Form method="post" className="space-y-4">
|
||
<input type="hidden" name="intent" value="save-inputs" />
|
||
<Textarea
|
||
name="bulkInputs"
|
||
rows={8}
|
||
className="font-mono text-sm"
|
||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5\n\n— or paste futures odds only —\n${inputRows[0]?.participant.name ?? "Team Name"} +2500`}
|
||
/>
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
Save Inputs
|
||
</Button>
|
||
</Form>
|
||
|
||
{externalInputsSection && (
|
||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm flex items-center justify-between gap-4">
|
||
<span>
|
||
This simulator's participant inputs are managed on the{" "}
|
||
<strong>{externalInputsSection.label}</strong> page — the list below is a roster only.
|
||
</span>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link to={externalInputsSection.to}>Go to {externalInputsSection.label}</Link>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Input
|
||
type="search"
|
||
placeholder="Search participants…"
|
||
value={search}
|
||
onChange={(event) => {
|
||
setSearch(event.target.value);
|
||
setPage(0);
|
||
}}
|
||
className="max-w-xs"
|
||
/>
|
||
{requiredInputs.length > 0 && (
|
||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyMissing}
|
||
onChange={(event) => {
|
||
setOnlyMissing(event.target.checked);
|
||
setPage(0);
|
||
}}
|
||
/>
|
||
Only show participants missing a required input
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-md border">
|
||
<div
|
||
className="grid gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground"
|
||
style={{ gridTemplateColumns: gridTemplate }}
|
||
>
|
||
<div>Participant</div>
|
||
{inputColumns.length > 0 ? (
|
||
inputColumns.map((column) => (
|
||
<div key={column.key} className="capitalize">
|
||
{column.label}
|
||
{column.required && <span className="text-amber-500"> *</span>}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div>—</div>
|
||
)}
|
||
</div>
|
||
{pageRows.length === 0 ? (
|
||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||
No participants match.
|
||
</div>
|
||
) : (
|
||
pageRows.map(({ participant, input }) => {
|
||
const incomplete = isRowIncomplete(participant.id, input);
|
||
return (
|
||
<div
|
||
key={participant.id}
|
||
className="grid gap-2 border-b last:border-b-0 px-3 py-2 text-sm"
|
||
style={{ gridTemplateColumns: gridTemplate }}
|
||
>
|
||
<div className="font-medium flex items-center gap-2">
|
||
{incomplete && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" title="Missing a required input" />}
|
||
{participant.name}
|
||
</div>
|
||
{inputColumns.length > 0 ? (
|
||
inputColumns.map((column) => {
|
||
if (column.key === "sourceElo") {
|
||
const resolved = resolvedEloRows[participant.id];
|
||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||
return (
|
||
<div key={column.key} className="flex items-center gap-1.5">
|
||
{resolved ? resolved.sourceElo : "—"}
|
||
{methodLabel && (
|
||
<Badge variant="outline" className="text-[10px] font-normal">
|
||
{methodLabel}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (column.key === "rating") {
|
||
const resolved = resolvedRatingRows[participant.id];
|
||
const methodLabel = resolved ? resolvedInputMethodLabel(resolved.method) : null;
|
||
return (
|
||
<div key={column.key} className="flex items-center gap-1.5">
|
||
{resolved ? resolved.rating : "—"}
|
||
{methodLabel && (
|
||
<Badge variant="outline" className="text-[10px] font-normal">
|
||
{methodLabel}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
const value = input?.[column.key];
|
||
return <div key={column.key}>{typeof value === "number" || typeof value === "string" ? value : "—"}</div>;
|
||
})
|
||
) : (
|
||
<div>—</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
<div className="flex items-center justify-between gap-4 px-3 py-2 text-xs text-muted-foreground">
|
||
<span>
|
||
{filteredRows.length === 0
|
||
? "0 participants"
|
||
: `Showing ${pageStart + 1}–${pageStart + pageRows.length} of ${filteredRows.length}${
|
||
filteredRows.length !== inputRows.length ? ` (${inputRows.length} total)` : ""
|
||
}`}
|
||
</span>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={safePage === 0}
|
||
onClick={() => setPage(safePage - 1)}
|
||
>
|
||
Previous
|
||
</Button>
|
||
<span>
|
||
Page {safePage + 1} of {totalPages}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={safePage >= totalPages - 1}
|
||
onClick={() => setPage(safePage + 1)}
|
||
>
|
||
Next
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|