666 lines
31 KiB
TypeScript
666 lines
31 KiB
TypeScript
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 {
|
||
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);
|
||
|
||
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
||
}
|
||
|
||
interface ActionData {
|
||
success?: boolean;
|
||
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);
|
||
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;
|
||
}
|
||
|
||
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<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")),
|
||
// 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 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]);
|
||
|
||
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>
|
||
{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>
|
||
|
||
<div className="rounded-md border">
|
||
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||
<div className="col-span-2">Participant</div>
|
||
<div>Elo</div>
|
||
<div>Odds</div>
|
||
<div>Rank</div>
|
||
<div>Rating</div>
|
||
</div>
|
||
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
||
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
|
||
<div className="col-span-2 font-medium">{participant.name}</div>
|
||
<div>{input?.sourceElo ?? "—"}</div>
|
||
<div>{input?.sourceOdds ?? "—"}</div>
|
||
<div>{input?.worldRanking ?? "—"}</div>
|
||
<div>{input?.rating ?? "—"}</div>
|
||
</div>
|
||
))}
|
||
{inputRows.length > 20 && (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||
Showing 20 of {inputRows.length} participants
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|