Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
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";
|
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.
Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.
Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
resolve each participant by the configured priority instead of a fixed order.
Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
from the season policy; defaults preserve prior calibration when no context is
passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
context-blend behavioral test, and an updated non-destructive save test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00
|
|
|
|
import {
|
|
|
|
|
|
getSimulatorInputPolicy,
|
|
|
|
|
|
type MissingEloStrategy,
|
|
|
|
|
|
type MissingRatingStrategy,
|
|
|
|
|
|
} from "~/services/simulations/input-policy";
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
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,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-05-11 22:24:29 -07:00
|
|
|
|
const inputPolicy = getSimulatorInputPolicy(config.config);
|
|
|
|
|
|
|
|
|
|
|
|
return { sportsSeason, participants, config, inputRows, readiness, inputPolicy };
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ActionData {
|
|
|
|
|
|
success?: boolean;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 22:26:25 -07:00
|
|
|
|
function parseMissingRatingStrategy(value: FormDataEntryValue | null): MissingRatingStrategy {
|
|
|
|
|
|
return value === "fallbackRating" || value === "averageKnown" || value === "worstKnownMinus"
|
|
|
|
|
|
? value
|
|
|
|
|
|
: "block";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
throw new Error("Bulk input CSV must include a `name` column.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-input-policy") {
|
|
|
|
|
|
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
|
|
|
|
|
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
|
|
|
|
|
|
|
|
|
|
|
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
|
|
|
|
|
|
await upsertSportsSeasonSimulatorConfig({
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
simulatorType: currentConfig.simulatorType,
|
|
|
|
|
|
config: {
|
|
|
|
|
|
...currentConfig.config,
|
|
|
|
|
|
inputPolicy: {
|
|
|
|
|
|
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
2026-05-12 22:26:25 -07:00
|
|
|
|
missingRatingStrategy: parseMissingRatingStrategy(formData.get("missingRatingStrategy")),
|
Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-26 01:39:12 +00:00
|
|
|
|
// Stored as-is; getSimulatorInputPolicy clamps to [0,1] on read.
|
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.
Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.
Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
resolve each participant by the configured priority instead of a fixed order.
Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
from the season policy; defaults preserve prior calibration when no context is
passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
context-blend behavioral test, and an updated non-destructive save test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00
|
|
|
|
oddsWeight: parsePolicyNumber(formData, "oddsWeight", currentPolicy.oddsWeight),
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
|
|
|
|
|
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
|
|
|
|
|
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
|
|
|
|
|
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
|
2026-05-12 22:26:25 -07:00
|
|
|
|
fallbackRating: parsePolicyNumber(formData, "fallbackRating", currentPolicy.fallbackRating),
|
|
|
|
|
|
fallbackRatingDelta: parsePolicyNumber(formData, "fallbackRatingDelta", currentPolicy.fallbackRatingDelta),
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
|
|
|
|
|
|
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
return { success: true, message: "Simulator input policy 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(", ")}.`
|
|
|
|
|
|
: "";
|
|
|
|
|
|
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
|
|
|
|
|
|
} 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) {
|
2026-05-11 22:24:29 -07:00
|
|
|
|
const { sportsSeason, config, inputRows, readiness, inputPolicy } = loaderData;
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
const actionData = useActionData<ActionData>();
|
|
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
|
const isSubmitting = navigation.state === "submitting";
|
|
|
|
|
|
const setupSections = config.profile.setupSections;
|
|
|
|
|
|
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
2026-05-12 22:26:25 -07:00
|
|
|
|
const ratingAlternatives = config.profile.derivableInputs?.rating ?? [];
|
|
|
|
|
|
const showsInputPolicy =
|
|
|
|
|
|
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
|
|
|
|
|
|
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("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
|
2026-05-12 22:26:25 -07:00
|
|
|
|
{showsInputPolicy && (
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Input Policy</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
2026-05-12 22:26:25 -07:00
|
|
|
|
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.
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
|
|
|
|
|
<input type="hidden" name="intent" value="save-input-policy" />
|
Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-26 01:39:12 +00:00
|
|
|
|
<div className="space-y-2 md:col-span-5">
|
|
|
|
|
|
<Label htmlFor="oddsWeight">Futures vs. Elo — Odds Blend Weight (0–1)</Label>
|
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.
Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.
Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
resolve each participant by the configured priority instead of a fixed order.
Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
from the season policy; defaults preserve prior calibration when no context is
passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
context-blend behavioral test, and an updated non-destructive save test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00
|
|
|
|
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
Unify simulator strength on a single blended Elo
Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-26 01:39:12 +00:00
|
|
|
|
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).
|
Unify futures odds with the simulation system
Make the futures-vs-Elo relationship an explicit, configurable rule and fix
futures odds failing to override stored Elo.
Root causes addressed:
- resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo ->
projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo
on futures entry but not projections, which still outranked odds.
- The override relied on a destructive null-on-save hack that also wiped
manually entered Elo.
- Blended simulators buried their Elo/odds weight in module constants.
- Futures odds were not surfaced on the /admin/simulators inventory.
Changes:
- Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight,
parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now
resolve each participant by the configured priority instead of a fixed order.
Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football,
college_hockey) default to a futures-override priority.
- Drop the destructive nulling in batchSaveFuturesOddsForSimulator and
batchSaveSourceOdds; override is now governed by policy, preserving stored Elo.
- Thread an optional SimulationContext (oddsWeight) through the Simulator
interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight
from the season policy; defaults preserve prior calibration when no context is
passed.
- Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the
Simulator Setup input-policy card, persisted via save-input-policy.
- Surface futures on /admin/simulators: a source badge and a Futures Odds quick
link; extend listSportsSeasonSimulatorSummaries with odds source info.
- Tests: configurable priority override (Elo/projections/rating), oddsWeight
parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football
context-blend behavioral test, and an updated non-destructive save test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
2026-06-25 22:11:32 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-12 22:26:25 -07:00
|
|
|
|
{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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
|
|
|
|
<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 className="md:col-span-5">
|
|
|
|
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Save Input Policy
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Season Config</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
JSON overrides for this specific sports season. Defaults come from the simulator profile.
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<Form method="post" className="space-y-4">
|
|
|
|
|
|
<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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Save Config
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
|
|
|
|
|
|
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
|
|
|
|
|
|
</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`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|