From e5295812f63ada2c0cc191a234cf104db785891a Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Mon, 11 May 2026 21:09:53 -0700 Subject: [PATCH] Formalize simulator system with manifest, input-policy, runner, and admin UI (#409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/components/ui/select.tsx | 2 +- .../__tests__/sports-season.clone.test.ts | 32 +- app/models/participant-expected-value.ts | 18 + app/models/simulator.ts | 584 ++ app/models/sports-season.ts | 70 +- app/routes.ts | 5 + app/routes/__tests__/admin.simulators.test.ts | 86 + app/routes/__tests__/admin.sports.$id.test.ts | 2 +- app/routes/__tests__/admin.sports.new.test.ts | 2 +- app/routes/admin.simulators.tsx | 282 + app/routes/admin.sports-seasons.$id.clone.tsx | 69 +- .../admin.sports-seasons.$id.elo-ratings.tsx | 78 +- .../admin.sports-seasons.$id.futures-odds.tsx | 67 +- .../admin.sports-seasons.$id.simulate.tsx | 118 +- .../admin.sports-seasons.$id.simulator.tsx | 474 ++ app/routes/admin.sports-seasons.$id.tsx | 95 +- app/routes/admin.sports-seasons.new.tsx | 82 +- app/routes/admin.sports.$id.tsx | 60 +- app/routes/admin.sports.new.tsx | 23 +- app/routes/admin.tsx | 7 + .../__tests__/input-policy.test.ts | 68 + .../simulations/__tests__/manifest.test.ts | 42 + .../simulations/__tests__/runner.test.ts | 169 + app/services/simulations/input-policy.ts | 231 + app/services/simulations/manifest.ts | 233 + app/services/simulations/ncaam-simulator.ts | 17 +- app/services/simulations/ncaaw-simulator.ts | 17 +- app/services/simulations/runner.ts | 172 + database/schema.ts | 81 + docs/agents/simulators.md | 123 + drizzle/0102_wild_wiccan.sql | 59 + drizzle/meta/0102_snapshot.json | 6111 +++++++++++++++++ drizzle/meta/_journal.json | 7 + 33 files changed, 9029 insertions(+), 457 deletions(-) create mode 100644 app/models/simulator.ts create mode 100644 app/routes/__tests__/admin.simulators.test.ts create mode 100644 app/routes/admin.simulators.tsx create mode 100644 app/routes/admin.sports-seasons.$id.simulator.tsx create mode 100644 app/services/simulations/__tests__/input-policy.test.ts create mode 100644 app/services/simulations/__tests__/manifest.test.ts create mode 100644 app/services/simulations/__tests__/runner.test.ts create mode 100644 app/services/simulations/input-policy.ts create mode 100644 app/services/simulations/manifest.ts create mode 100644 app/services/simulations/runner.ts create mode 100644 docs/agents/simulators.md create mode 100644 drizzle/0102_wild_wiccan.sql create mode 100644 drizzle/meta/0102_snapshot.json diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx index 034fc02..215282a 100644 --- a/app/components/ui/select.tsx +++ b/app/components/ui/select.tsx @@ -35,7 +35,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} diff --git a/app/models/__tests__/sports-season.clone.test.ts b/app/models/__tests__/sports-season.clone.test.ts index c0acef8..be2d5cf 100644 --- a/app/models/__tests__/sports-season.clone.test.ts +++ b/app/models/__tests__/sports-season.clone.test.ts @@ -9,6 +9,8 @@ vi.mock("~/database/schema", () => ({ seasonParticipants: { sportsSeasonId: "p.sports_season_id" }, scoringEvents: { sportsSeasonId: "se.sports_season_id" }, seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" }, + sportsSeasonSimulatorConfigs: { sportsSeasonId: "ssc.sports_season_id" }, + seasonParticipantSimulatorInputs: { sportsSeasonId: "spi.sports_season_id" }, })); vi.mock("drizzle-orm", () => ({ @@ -76,6 +78,8 @@ function makeMockDb({ participants = [] as object[], scoringEvents = [] as object[], sourceEvRows = [] as object[], + sourceSimulatorConfig = null as object | null, + sourceSimulatorInputs = [] as object[], insertedSeason = { ...newSeasonData, id: NEW_ID } as object, // New participants returned by the INSERT ... RETURNING — mirrors sources with new IDs by default newParticipantRows = (participants as Array>).map((p, i) => ({ @@ -94,6 +98,8 @@ function makeMockDb({ seasonParticipants: { findMany: vi.fn().mockResolvedValue(participants) }, scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) }, seasonParticipantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) }, + sportsSeasonSimulatorConfigs: { findFirst: vi.fn().mockResolvedValue(sourceSimulatorConfig) }, + seasonParticipantSimulatorInputs: { findMany: vi.fn().mockResolvedValue(sourceSimulatorInputs) }, }, insert: vi.fn().mockImplementation((table: object) => { let key: string; @@ -104,6 +110,10 @@ function makeMockDb({ key = "participants"; returnRows = newParticipantRows; } else if (table === schema.scoringEvents) { key = "scoringEvents"; returnRows = []; + } else if (table === schema.sportsSeasonSimulatorConfigs) { + key = "sportsSeasonSimulatorConfigs"; returnRows = []; + } else if (table === schema.seasonParticipantSimulatorInputs) { + key = "seasonParticipantSimulatorInputs"; returnRows = []; } else { key = "participantExpectedValues"; returnRows = []; } @@ -131,7 +141,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb(); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); expect(db._insertedRows.sportsSeasons).toMatchObject({ name: "2026 F1 Season", @@ -147,7 +157,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb(); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); expect(db._insertedRows.sportsSeasons).toMatchObject({ eloCalibrationExponent: "0.33", @@ -164,7 +174,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const rows = db._insertedRows.participants as object[]; expect(rows).toHaveLength(2); @@ -182,7 +192,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants: [] }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); expect(db._insertedRows.participants).toBeUndefined(); }); @@ -199,7 +209,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ scoringEvents }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const rows = db._insertedRows.scoringEvents as object[]; expect(rows).toHaveLength(2); @@ -224,7 +234,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ scoringEvents }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const rows = db._insertedRows.scoringEvents as Array<{ eventDate: unknown }>; expect(rows[0].eventDate).toBeNull(); @@ -240,7 +250,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ scoringEvents }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const rows = db._insertedRows.scoringEvents as Array<{ eventStartsAt: Date }>; const shiftedDate = rows[0].eventStartsAt; @@ -267,7 +277,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants, sourceEvRows, newParticipantRows }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const evRows = db._insertedRows.participantExpectedValues as Array>; expect(evRows).toHaveLength(2); @@ -297,7 +307,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants, sourceEvRows, newParticipantRows }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const evRows = db._insertedRows.participantExpectedValues as Array>; expect(evRows).toHaveLength(1); @@ -324,7 +334,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants, sourceEvRows, newParticipantRows }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); const evRows = db._insertedRows.participantExpectedValues as Array>; expect(evRows).toHaveLength(1); @@ -343,7 +353,7 @@ describe("cloneSportsSeason", () => { const db = makeMockDb({ participants, sourceEvRows }); vi.mocked(database).mockReturnValue(db as never); - await cloneSportsSeason(SOURCE_ID, newSeasonData); + await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true }); expect(db._insertedRows.participantExpectedValues).toBeUndefined(); }); diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 2ae8e4c..c139993 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -10,6 +10,7 @@ import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/ import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator"; +import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator"; export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model"; @@ -416,6 +417,14 @@ export async function batchSaveSourceOdds( } } }); + + await batchUpsertParticipantSimulatorInputs( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceOdds: input.sourceOdds, + })) + ); } /** @@ -461,6 +470,15 @@ export async function batchSaveSourceElos( updatedAt: sql`excluded.updated_at`, }, }); + + await batchUpsertParticipantSimulatorInputs( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceElo: input.sourceElo, + worldRanking: input.worldRanking ?? null, + })) + ); } /** diff --git a/app/models/simulator.ts b/app/models/simulator.ts new file mode 100644 index 0000000..75b0656 --- /dev/null +++ b/app/models/simulator.ts @@ -0,0 +1,584 @@ +import { eq, inArray, sql } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { + getManifestSimulatorProfile, + simulatorInputLabel, + type SimulatorInputKey, + type SimulatorManifestProfile, +} from "~/services/simulations/manifest"; +import { + ratingRequirementLabel, + resolveRatings, + resolveSourceElos, + sourceEloRequirementLabel, +} from "~/services/simulations/input-policy"; +import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry"; + +export interface SimulatorProfile extends SimulatorManifestProfile { + isActive: boolean; +} + +export interface SportsSeasonSimulatorConfig { + sportsSeasonId: string; + simulatorType: SimulatorType; + config: Record; + profile: SimulatorProfile; +} + +export interface ParticipantSimulatorInput { + participantId: string; + sportsSeasonId: string; + sourceOdds: number | null; + sourceElo: number | null; + worldRanking: number | null; + rating: number | null; + projectedWins: number | null; + projectedTablePoints: number | null; + seed: number | null; + region: string | null; + metadata: Record | null; +} + +export interface UpsertParticipantSimulatorInput { + participantId: string; + sportsSeasonId: string; + sourceOdds?: number | null; + sourceElo?: number | null; + worldRanking?: number | null; + rating?: number | null; + projectedWins?: number | null; + projectedTablePoints?: number | null; + seed?: number | null; + region?: string | null; + metadata?: Record | null; +} + +export interface SimulatorReadiness { + status: "ready" | "not_ready"; + canRun: boolean; + participantCount: number; + participantInputCount: number; + derivedInputCount: number; + fallbackInputCount: number; + missingInputs: string[]; + warnings: string[]; +} + +export interface SportsSeasonSimulatorSummary { + sportsSeasonId: string; + seasonName: string; + year: number; + seasonStatus: string; + simulationStatus: string; + fantasySeasonId: string | null; + fantasySeasonName: string | null; + leagueName: string | null; + sportName: string; + sportSlug: string; + simulatorType: SimulatorType; + simulatorName: string; + participantCount: number; + participantInputCount: number; + lastSimulatedDate: string | null; + readiness: SimulatorReadiness; +} + +function parseDecimal(value: string | null | undefined): number | null { + if (value === null || value === undefined) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isSimulatorType(value: string | null | undefined): value is SimulatorType { + return typeof value === "string" && (SIMULATOR_TYPES as readonly string[]).includes(value); +} + +function mergeProfile( + simulatorType: SimulatorType, + dbProfile?: typeof schema.simulatorProfiles.$inferSelect | null +): SimulatorProfile { + const manifest = getManifestSimulatorProfile(simulatorType); + if (!dbProfile) return { ...manifest, isActive: true }; + + return { + ...manifest, + displayName: dbProfile.displayName, + description: dbProfile.description ?? manifest.description, + defaultConfig: { + ...manifest.defaultConfig, + ...dbProfile.defaultConfig, + }, + isActive: dbProfile.isActive, + }; +} + +function hasInputValue(input: ParticipantSimulatorInput, key: SimulatorInputKey): boolean { + const value = input[key]; + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +function isFallbackMethod(method: string): boolean { + return method === "fallbackElo" || method === "averageKnown" || method === "worstKnownMinus"; +} + +function inputRequirementLabel( + key: SimulatorInputKey, + profile: SimulatorManifestProfile +): string { + if (key === "sourceElo" && profile.derivableInputs?.sourceElo?.length) { + return sourceEloRequirementLabel(profile); + } + if (key === "rating" && profile.derivableInputs?.rating?.length) { + return ratingRequirementLabel(profile); + } + return simulatorInputLabel(key); +} + +export async function getSimulatorProfile(simulatorType: SimulatorType): Promise { + const db = database(); + const dbProfile = await db.query.simulatorProfiles.findFirst({ + where: eq(schema.simulatorProfiles.simulatorType, simulatorType), + }); + return mergeProfile(simulatorType, dbProfile); +} + +export async function upsertSimulatorProfile( + profile: Pick & { + inputSchema?: Record; + isActive?: boolean; + } +): Promise { + const db = database(); + const now = new Date(); + await db + .insert(schema.simulatorProfiles) + .values({ + simulatorType: profile.simulatorType, + displayName: profile.displayName, + description: profile.description, + defaultConfig: profile.defaultConfig, + inputSchema: profile.inputSchema ?? {}, + isActive: profile.isActive ?? true, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: schema.simulatorProfiles.simulatorType, + set: { + displayName: sql`excluded.display_name`, + description: sql`excluded.description`, + defaultConfig: sql`excluded.default_config`, + inputSchema: sql`excluded.input_schema`, + isActive: sql`excluded.is_active`, + updatedAt: now, + }, + }); +} + +export async function getSportsSeasonSimulatorConfig( + sportsSeasonId: string +): Promise { + const db = database(); + const season = await db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, sportsSeasonId), + with: { + sport: true, + simulatorConfig: true, + }, + }); + + const simulatorType = season?.simulatorConfig?.simulatorType ?? season?.sport?.simulatorType; + if (!season || !isSimulatorType(simulatorType)) return null; + + const profile = await getSimulatorProfile(simulatorType); + const config = { + ...profile.defaultConfig, + ...season.simulatorConfig?.config, + }; + + return { + sportsSeasonId, + simulatorType, + config, + profile, + }; +} + +export async function upsertSportsSeasonSimulatorConfig(input: { + sportsSeasonId: string; + simulatorType: SimulatorType; + config?: Record; +}): Promise { + const db = database(); + const now = new Date(); + await db + .insert(schema.sportsSeasonSimulatorConfigs) + .values({ + sportsSeasonId: input.sportsSeasonId, + simulatorType: input.simulatorType, + config: input.config ?? {}, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: schema.sportsSeasonSimulatorConfigs.sportsSeasonId, + set: { + simulatorType: sql`excluded.simulator_type`, + config: sql`excluded.config`, + updatedAt: now, + }, + }); +} + +export async function getParticipantSimulatorInputs( + sportsSeasonId: string +): Promise { + const db = database(); + const [seasonParticipants, simulatorInputs, legacyEvs] = await Promise.all([ + db.query.seasonParticipants.findMany({ + where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId), + columns: { id: true }, + }), + db.query.seasonParticipantSimulatorInputs.findMany({ + where: eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId), + }), + db.query.seasonParticipantExpectedValues.findMany({ + where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId), + columns: { + participantId: true, + sourceOdds: true, + sourceElo: true, + worldRanking: true, + }, + }), + ]); + + const simulatorByParticipant = new Map(simulatorInputs.map((row) => [row.participantId, row])); + const legacyByParticipant = new Map(legacyEvs.map((row) => [row.participantId, row])); + + return seasonParticipants.map((participant) => { + const input = simulatorByParticipant.get(participant.id); + const legacy = legacyByParticipant.get(participant.id); + return { + participantId: participant.id, + sportsSeasonId, + sourceOdds: input?.sourceOdds ?? legacy?.sourceOdds ?? null, + sourceElo: input?.sourceElo ?? legacy?.sourceElo ?? null, + worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null, + rating: parseDecimal(input?.rating), + projectedWins: parseDecimal(input?.projectedWins), + projectedTablePoints: parseDecimal(input?.projectedTablePoints), + seed: input?.seed ?? null, + region: input?.region ?? null, + metadata: input?.metadata ?? null, + }; + }); +} + +export async function batchUpsertParticipantSimulatorInputs( + inputs: UpsertParticipantSimulatorInput[] +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + + await db.transaction(async (tx) => { + await tx + .insert(schema.seasonParticipantSimulatorInputs) + .values( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + sourceOdds: input.sourceOdds ?? null, + sourceElo: input.sourceElo ?? null, + worldRanking: input.worldRanking ?? null, + rating: input.rating?.toString() ?? null, + projectedWins: input.projectedWins?.toString() ?? null, + projectedTablePoints: input.projectedTablePoints?.toString() ?? null, + seed: input.seed ?? null, + region: input.region ?? null, + metadata: input.metadata ?? null, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [ + schema.seasonParticipantSimulatorInputs.participantId, + schema.seasonParticipantSimulatorInputs.sportsSeasonId, + ], + set: { + sourceOdds: sql`excluded.source_odds`, + sourceElo: sql`excluded.source_elo`, + worldRanking: sql`excluded.world_ranking`, + rating: sql`excluded.rating`, + projectedWins: sql`excluded.projected_wins`, + projectedTablePoints: sql`excluded.projected_table_points`, + seed: sql`excluded.seed`, + region: sql`excluded.region`, + metadata: sql`excluded.metadata`, + updatedAt: sql`excluded.updated_at`, + }, + }); + + // Compatibility bridge: keep legacy simulator metadata columns populated until + // every simulator has been migrated to season_participant_simulator_inputs. + await tx + .insert(schema.seasonParticipantExpectedValues) + .values( + inputs.map((input) => ({ + participantId: input.participantId, + sportsSeasonId: input.sportsSeasonId, + probFirst: "0", + probSecond: "0", + probThird: "0", + probFourth: "0", + probFifth: "0", + probSixth: "0", + probSeventh: "0", + probEighth: "0", + expectedValue: "0", + source: input.sourceOdds !== null && input.sourceOdds !== undefined ? "futures_odds" as const : "elo_simulation" as const, + sourceOdds: input.sourceOdds ?? null, + sourceElo: input.sourceElo ?? null, + worldRanking: input.worldRanking ?? null, + calculatedAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [ + schema.seasonParticipantExpectedValues.participantId, + schema.seasonParticipantExpectedValues.sportsSeasonId, + ], + set: { + sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantExpectedValues.sourceOdds})`, + sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantExpectedValues.sourceElo})`, + worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantExpectedValues.worldRanking})`, + updatedAt: now, + }, + }); + }); +} + +export async function validateSimulatorReadiness( + sportsSeasonId: string +): Promise { + const config = await getSportsSeasonSimulatorConfig(sportsSeasonId); + const inputs = await getParticipantSimulatorInputs(sportsSeasonId); + const missingInputs: string[] = []; + const warnings: string[] = []; + let derivedInputCount = 0; + let fallbackInputCount = 0; + + if (!config) { + return { + status: "not_ready", + canRun: false, + participantCount: inputs.length, + participantInputCount: 0, + derivedInputCount, + fallbackInputCount, + missingInputs: ["simulator type"], + warnings, + }; + } + + const participantCount = inputs.length; + if (participantCount === 0) { + missingInputs.push("participants"); + } + + const requiredInputs = config.profile.requiredInputs; + const resolvedSourceElos = requiredInputs.includes("sourceElo") + ? resolveSourceElos(inputs, config.profile, config.config) + : new Map(); + const resolvedRatings = requiredInputs.includes("rating") + ? resolveRatings(inputs, config.profile, config.config) + : new Map(); + derivedInputCount = + [...resolvedSourceElos.values()].filter((input) => input.method !== "direct").length + + [...resolvedRatings.values()].filter((input) => input.method !== "direct").length; + fallbackInputCount = [...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length; + + function hasRequiredInput(input: ParticipantSimulatorInput, key: SimulatorInputKey): boolean { + if (hasInputValue(input, key)) return true; + if (key === "sourceElo") return resolvedSourceElos.has(input.participantId); + if (key === "rating") return resolvedRatings.has(input.participantId); + return false; + } + + const participantInputCount = requiredInputs.length === 0 + ? participantCount + : inputs.filter((input) => requiredInputs.every((key) => hasRequiredInput(input, key))).length; + + if (requiredInputs.length > 0 && participantInputCount < participantCount) { + for (const key of requiredInputs) { + const missingCount = inputs.filter((input) => !hasRequiredInput(input, key)).length; + if (missingCount > 0) { + missingInputs.push(`${inputRequirementLabel(key, config.profile)} for ${missingCount} participant(s)`); + } + } + } + + if (derivedInputCount > 0) { + warnings.push(`Derived simulator inputs will be used for ${derivedInputCount} participant(s).`); + } + if (fallbackInputCount > 0) { + warnings.push(`Configured fallback Elo will be used for ${fallbackInputCount} participant(s).`); + } + if (config.simulatorType === "cs2_major_qualifying_points") { + const missingRankCount = inputs.filter((input) => !hasInputValue(input, "worldRanking")).length; + if (missingRankCount > 0) { + warnings.push( + `CS2 will use rank 9999 for ${missingRankCount} participant(s) without world ranking; this is acceptable for deep-tail teams but less precise.` + ); + } + } + + if (config.profile.setupSections.includes("regularStandings")) { + warnings.push("Regular-season standings may be needed for in-season accuracy."); + } + if (config.profile.setupSections.includes("bracket")) { + warnings.push("Bracket-aware simulators use completed bracket results when available."); + } + + const status = missingInputs.length === 0 ? "ready" : "not_ready"; + return { + status, + canRun: status === "ready", + participantCount, + participantInputCount, + derivedInputCount, + fallbackInputCount, + missingInputs, + warnings, + }; +} + +export async function prepareSimulatorInputsForRun(sportsSeasonId: string): Promise { + const config = await getSportsSeasonSimulatorConfig(sportsSeasonId); + if (!config) return; + + const inputs = await getParticipantSimulatorInputs(sportsSeasonId); + const resolvedSourceElos = config.profile.requiredInputs.includes("sourceElo") + ? resolveSourceElos(inputs, config.profile, config.config) + : new Map(); + const resolvedRatings = config.profile.requiredInputs.includes("rating") + ? resolveRatings(inputs, config.profile, config.config) + : new Map(); + const rowsToUpsert: UpsertParticipantSimulatorInput[] = []; + for (const input of inputs) { + const resolvedElo = resolvedSourceElos.get(input.participantId); + const resolvedRating = resolvedRatings.get(input.participantId); + if (!resolvedElo && !resolvedRating) continue; + + rowsToUpsert.push({ + participantId: input.participantId, + sportsSeasonId, + sourceOdds: input.sourceOdds, + sourceElo: resolvedElo?.sourceElo ?? input.sourceElo, + worldRanking: input.worldRanking, + rating: resolvedRating?.rating ?? input.rating, + projectedWins: input.projectedWins, + projectedTablePoints: input.projectedTablePoints, + seed: input.seed, + region: input.region, + metadata: { + ...input.metadata, + ...(resolvedElo ? { sourceEloMethod: resolvedElo.method } : {}), + ...(resolvedRating ? { ratingMethod: resolvedRating.method } : {}), + }, + }); + } + + await batchUpsertParticipantSimulatorInputs(rowsToUpsert); +} + +export async function listSportsSeasonSimulatorSummaries(): Promise { + const db = database(); + const seasons = await db.query.sportsSeasons.findMany({ + with: { + sport: true, + fantasySeason: { with: { league: true } }, + participants: { columns: { id: true } }, + simulatorConfig: true, + }, + }); + + const simulatorSeasons = seasons.filter((season) => { + const simulatorType = season.simulatorConfig?.simulatorType ?? season.sport.simulatorType; + if (!isSimulatorType(simulatorType)) return false; + + // Brackt's public sports season is a draftable template. The runnable + // simulations are the per-league private Brackt seasons. + if (season.sport.slug === "brackt") return season.fantasySeasonId !== null; + + return season.fantasySeasonId === null; + }); + + const seasonIds = simulatorSeasons.map((season) => season.id); + const snapshotRows = seasonIds.length > 0 + ? await db + .select({ + sportsSeasonId: schema.participantEvSnapshots.sportsSeasonId, + lastSimulatedDate: sql`max(${schema.participantEvSnapshots.snapshotDate})`, + }) + .from(schema.participantEvSnapshots) + .where(inArray(schema.participantEvSnapshots.sportsSeasonId, seasonIds)) + .groupBy(schema.participantEvSnapshots.sportsSeasonId) + : []; + const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate])); + + // Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each). + // Acceptable for an admin-only page; revisit if season counts grow past ~50. + const summaries = await Promise.all( + simulatorSeasons.map(async (season) => { + const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType; + const profile = await getSimulatorProfile(simulatorType); + const readiness = await validateSimulatorReadiness(season.id); + return { + sportsSeasonId: season.id, + seasonName: season.name, + year: season.year, + seasonStatus: season.status, + simulationStatus: season.simulationStatus, + fantasySeasonId: season.fantasySeasonId, + fantasySeasonName: season.fantasySeason ? `${season.fantasySeason.year} Season` : null, + leagueName: season.fantasySeason?.league?.name ?? null, + sportName: season.sport.name, + sportSlug: season.sport.slug, + simulatorType, + simulatorName: profile.displayName, + participantCount: season.participants.length, + participantInputCount: readiness.participantInputCount, + lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null, + readiness, + }; + }) + ); + + return summaries.toSorted((a, b) => + a.sportName.localeCompare(b.sportName) || + (a.leagueName ?? "").localeCompare(b.leagueName ?? "") || + b.year - a.year || + a.seasonName.localeCompare(b.seasonName) + ); +} + +export async function assertRegistrySchemaDriftFree(): Promise { + const schemaValues = schema.simulatorTypeEnum.enumValues; + const missingFromSchema = SIMULATOR_TYPES.filter((type) => !schemaValues.includes(type)); + const missingFromRegistry = schemaValues.filter((type) => !(SIMULATOR_TYPES as readonly string[]).includes(type)); + if (missingFromSchema.length > 0 || missingFromRegistry.length > 0) { + throw new Error( + `Simulator registry/schema drift detected. ` + + `Missing from schema: ${missingFromSchema.join(", ") || "none"}. ` + + `Missing from registry: ${missingFromRegistry.join(", ") || "none"}.` + ); + } +} diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index 97467fc..eb53760 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -199,13 +199,15 @@ function shiftTimestampByYears(date: Date, years: number): Date { /** * Clone a sports season: creates a new season from newData, then copies - * participants (name/shortName/externalId only), scoring events (dates shifted - * by yearDelta), futures odds + Elo ratings (as stubs for re-simulation), - * and QP config (for qualifying_points seasons). + * participants (name/shortName/externalId only), simulator structure/config, + * scoring events (dates shifted by yearDelta), and QP config. Volatile simulator + * inputs such as futures odds and Elo ratings are copied only when explicitly + * requested. */ export async function cloneSportsSeason( sourceId: string, - newData: NewSportsSeason + newData: NewSportsSeason, + options: { copySimulatorInputs?: boolean } = {} ): Promise { const db = database(); @@ -241,11 +243,22 @@ export async function cloneSportsSeason( ? await getQPConfig(sourceId, db) : null; - // Read source futures odds and Elo ratings - const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({ - where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId), + const sourceSimulatorConfig = await db.query.sportsSeasonSimulatorConfigs.findFirst({ + where: eq(schema.sportsSeasonSimulatorConfigs.sportsSeasonId, sourceId), }); + const sourceEvRows = options.copySimulatorInputs + ? await db.query.seasonParticipantExpectedValues.findMany({ + where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId), + }) + : []; + + const sourceSimulatorInputs = options.copySimulatorInputs + ? await db.query.seasonParticipantSimulatorInputs.findMany({ + where: eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sourceId), + }) + : []; + // Build lookup: (externalId ?? name) → source EV, only for rows that have // actual input data (odds or Elo) worth carrying forward type SourceEv = typeof sourceEvRows[number]; @@ -282,7 +295,15 @@ export async function cloneSportsSeason( : []; // Copy futures odds / Elo stubs matched by externalId then name - if (sourceEvByKey.size > 0 && newParticipants.length > 0) { + if (sourceSimulatorConfig) { + await tx.insert(schema.sportsSeasonSimulatorConfigs).values({ + sportsSeasonId: newSeason.id, + simulatorType: sourceSimulatorConfig.simulatorType, + config: sourceSimulatorConfig.config, + }); + } + + if (options.copySimulatorInputs && sourceEvByKey.size > 0 && newParticipants.length > 0) { const now = new Date(); const evRows = newParticipants .map((newP: typeof newParticipants[number]) => { @@ -310,6 +331,39 @@ export async function cloneSportsSeason( } } + if (options.copySimulatorInputs && sourceSimulatorInputs.length > 0 && newParticipants.length > 0) { + const sourceParticipantById = new Map(sourceParticipants.map((p) => [p.id, p])); + const sourceInputByKey = new Map( + sourceSimulatorInputs.flatMap((input) => { + const sourceParticipant = sourceParticipantById.get(input.participantId); + return sourceParticipant ? [[sourceParticipant.externalId ?? sourceParticipant.name, input] as const] : []; + }) + ); + const simulatorInputRows = newParticipants + .map((newP: typeof newParticipants[number]) => { + const sourceInput = sourceInputByKey.get(newP.externalId ?? newP.name); + if (!sourceInput) return null; + return { + participantId: newP.id, + sportsSeasonId: newSeason.id, + sourceOdds: sourceInput.sourceOdds, + sourceElo: sourceInput.sourceElo, + worldRanking: sourceInput.worldRanking, + rating: sourceInput.rating, + projectedWins: sourceInput.projectedWins, + projectedTablePoints: sourceInput.projectedTablePoints, + seed: sourceInput.seed, + region: sourceInput.region, + metadata: sourceInput.metadata, + }; + }) + .filter((row): row is NonNullable => row !== null); + + if (simulatorInputRows.length > 0) { + await tx.insert(schema.seasonParticipantSimulatorInputs).values(simulatorInputRows); + } + } + if (sourceEvents.length > 0) { await tx.insert(schema.scoringEvents).values( sourceEvents.map((e: typeof sourceEvents[number]) => ({ diff --git a/app/routes.ts b/app/routes.ts index f6628c7..f9b2712 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -73,6 +73,7 @@ export default [ route("admin", "routes/admin.tsx", [ index("routes/admin._index.tsx"), route("sports", "routes/admin.sports.tsx"), + route("simulators", "routes/admin.simulators.tsx"), route("sports/new", "routes/admin.sports.new.tsx"), route("sports/:id", "routes/admin.sports.$id.tsx"), route("sports-seasons", "routes/admin.sports-seasons.tsx"), @@ -102,6 +103,10 @@ export default [ "sports-seasons/:id/expected-values", "routes/admin.sports-seasons.$id.expected-values.tsx" ), + route( + "sports-seasons/:id/simulator", + "routes/admin.sports-seasons.$id.simulator.tsx" + ), route( "sports-seasons/:id/futures-odds", "routes/admin.sports-seasons.$id.futures-odds.tsx" diff --git a/app/routes/__tests__/admin.simulators.test.ts b/app/routes/__tests__/admin.simulators.test.ts new file mode 100644 index 0000000..476b845 --- /dev/null +++ b/app/routes/__tests__/admin.simulators.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/models/simulator", () => ({ + listSportsSeasonSimulatorSummaries: vi.fn(), +})); + +vi.mock("~/services/simulations/runner", () => ({ + runSportsSeasonSimulation: vi.fn(), +})); + +import { action } from "../admin.simulators"; +import { runSportsSeasonSimulation } from "~/services/simulations/runner"; + +function postForm(entries: Record) { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(entries)) { + if (Array.isArray(value)) { + for (const item of value) body.append(key, item); + } else { + body.set(key, value); + } + } + return new Request("http://test/admin/simulators", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("admin simulators action", () => { + it("runs one simulator", async () => { + vi.mocked(runSportsSeasonSimulation).mockResolvedValue({ + sportsSeasonId: "season-1", + simulatorType: "nba_bracket", + simulatedParticipants: 30, + zeroedParticipants: 0, + snapshotDate: "2026-05-11", + }); + + const response = await action({ + request: postForm({ intent: "run-one", sportsSeasonId: "season-1" }), + params: {}, + context: {}, + } as never); + + expect(runSportsSeasonSimulation).toHaveBeenCalledWith("season-1"); + expect(response).toEqual({ + success: true, + message: "Simulation run complete: 1/1 succeeded.", + results: [{ sportsSeasonId: "season-1", ok: true, message: "Simulated 30 participant(s)." }], + }); + }); + + it("runs selected simulators sequentially and reports failures", async () => { + vi.mocked(runSportsSeasonSimulation) + .mockResolvedValueOnce({ + sportsSeasonId: "season-1", + simulatorType: "nba_bracket", + simulatedParticipants: 30, + zeroedParticipants: 0, + snapshotDate: "2026-05-11", + }) + .mockRejectedValueOnce(new Error("Simulator is not ready: source odds.")); + + const response = await action({ + request: postForm({ intent: "run-selected", sportsSeasonId: ["season-1", "season-2"] }), + params: {}, + context: {}, + } as never); + + expect(runSportsSeasonSimulation).toHaveBeenNthCalledWith(1, "season-1"); + expect(runSportsSeasonSimulation).toHaveBeenNthCalledWith(2, "season-2"); + expect(response).toEqual({ + success: false, + message: "Simulation run complete: 1/2 succeeded.", + results: [ + { sportsSeasonId: "season-1", ok: true, message: "Simulated 30 participant(s)." }, + { sportsSeasonId: "season-2", ok: false, message: "Simulator is not ready: source odds." }, + ], + }); + }); +}); diff --git a/app/routes/__tests__/admin.sports.$id.test.ts b/app/routes/__tests__/admin.sports.$id.test.ts index df09b04..9d48b05 100644 --- a/app/routes/__tests__/admin.sports.$id.test.ts +++ b/app/routes/__tests__/admin.sports.$id.test.ts @@ -33,7 +33,7 @@ function makeRequest(iconUrl: string) { return new Request("http://test/admin/sports/sport-1", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: formData, + body: formData.toString(), }); } diff --git a/app/routes/__tests__/admin.sports.new.test.ts b/app/routes/__tests__/admin.sports.new.test.ts index 7ec18a7..458e61f 100644 --- a/app/routes/__tests__/admin.sports.new.test.ts +++ b/app/routes/__tests__/admin.sports.new.test.ts @@ -27,7 +27,7 @@ function makeRequest(iconUrl: string) { return new Request("http://test/admin/sports/new", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: formData, + body: formData.toString(), }); } diff --git a/app/routes/admin.simulators.tsx b/app/routes/admin.simulators.tsx new file mode 100644 index 0000000..f0346ee --- /dev/null +++ b/app/routes/admin.simulators.tsx @@ -0,0 +1,282 @@ +import { Form, Link, useActionData, useNavigation, useSearchParams } from "react-router"; +import type { Route } from "./+types/admin.simulators"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { listSportsSeasonSimulatorSummaries } from "~/models/simulator"; +import { runSportsSeasonSimulation } from "~/services/simulations/runner"; +import { Activity, CheckCircle2, Loader2, Play, SlidersHorizontal, XCircle } from "lucide-react"; + +export function meta(): Route.MetaDescriptors { + return [{ title: "Simulators - Brackt Admin" }]; +} + +export async function loader() { + const simulators = await listSportsSeasonSimulatorSummaries(); + const sports = [...new Set(simulators.map((sim) => sim.sportName))].toSorted(); + const simulatorTypes = [...new Set(simulators.map((sim) => sim.simulatorType))].toSorted(); + return { simulators, sports, simulatorTypes }; +} + +interface ActionData { + success?: boolean; + message: string; + results?: Array<{ sportsSeasonId: string; ok: boolean; message: string }>; +} + +export async function action({ request }: Route.ActionArgs): Promise { + const formData = await request.formData(); + const intent = formData.get("intent"); + const ids = intent === "run-selected" + ? formData.getAll("sportsSeasonId").filter((id): id is string => typeof id === "string") + : [formData.get("sportsSeasonId")].filter((id): id is string => typeof id === "string"); + + if (ids.length === 0) { + return { success: false, message: "Select at least one simulator to run." }; + } + + const results: ActionData["results"] = []; + for (const sportsSeasonId of ids) { + try { + const result = await runSportsSeasonSimulation(sportsSeasonId); + results.push({ + sportsSeasonId, + ok: true, + message: `Simulated ${result.simulatedParticipants} participant(s).`, + }); + } catch (error) { + results.push({ + sportsSeasonId, + ok: false, + message: error instanceof Error ? error.message : "Simulation failed.", + }); + } + } + + const succeeded = results.filter((result) => result.ok).length; + return { + success: succeeded > 0 && succeeded === results.length, + message: `Simulation run complete: ${succeeded}/${results.length} succeeded.`, + results, + }; +} + +function statusBadge(status: string) { + if (status === "ready") return Ready; + return Needs setup; +} + +export default function AdminSimulators({ loaderData }: Route.ComponentProps) { + const { simulators, sports, simulatorTypes } = loaderData; + const actionData = useActionData(); + const navigation = useNavigation(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedSport = searchParams.get("sport") ?? "all"; + const selectedType = searchParams.get("type") ?? "all"; + const selectedReadiness = searchParams.get("readiness") ?? "all"; + const selectedStatus = searchParams.get("status") ?? "all"; + const isSubmitting = navigation.state === "submitting"; + + const filtered = simulators.filter((sim) => { + if (selectedSport !== "all" && sim.sportName !== selectedSport) return false; + if (selectedType !== "all" && sim.simulatorType !== selectedType) return false; + if (selectedReadiness !== "all" && sim.readiness.status !== selectedReadiness) return false; + if (selectedStatus !== "all" && sim.seasonStatus !== selectedStatus) return false; + return true; + }); + + function updateFilter(key: string, value: string) { + const next = new URLSearchParams(searchParams); + if (value === "all") next.delete(key); + else next.set(key, value); + setSearchParams(next); + } + + return ( +
+
+

+ + Simulators +

+

+ Inventory, validate, and run every sports-season EV simulator from one place. +

+
+ + {actionData && ( + + + {actionData.message} + + {actionData.results && ( + + {actionData.results.map((result) => ( +
+ {result.sportsSeasonId}: {result.message} +
+ ))} +
+ )} +
+ )} + + + + Filters + {filtered.length} of {simulators.length} simulator seasons shown + + + + + + + + + + + +
+
+ Sports-Season Simulators + + Bulk runs execute sequentially and skip nothing silently: failures are reported per season. + +
+
+ + +
+
+
+ + + + + + Season + Simulator + Status + Readiness + Inputs + Last Run + Actions + + + + {filtered.map((sim) => { + const canRun = sim.readiness.canRun && sim.simulationStatus !== "running"; + return ( + + + + + +
{sim.sportName} {sim.year}
+
{sim.seasonName}
+ {sim.leagueName && ( +
League: {sim.leagueName}
+ )} +
+ +
{sim.simulatorName}
+ {sim.simulatorType} +
+ +
+ {sim.seasonStatus} + {sim.simulationStatus} +
+
+ +
+ {statusBadge(sim.readiness.status)} + {sim.readiness.missingInputs.length > 0 && ( +
+ Missing {sim.readiness.missingInputs.join(", ")} +
+ )} + {sim.readiness.warnings.some((warning) => warning.includes("Derived") || warning.includes("fallback")) && ( +
+ {sim.readiness.warnings + .filter((warning) => warning.includes("Derived") || warning.includes("fallback")) + .join(" ")} +
+ )} +
+
+ + {sim.participantInputCount}/{sim.participantCount} + + + {sim.lastSimulatedDate ?? "Never"} + + +
+ + +
+ + + + +
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/app/routes/admin.sports-seasons.$id.clone.tsx b/app/routes/admin.sports-seasons.$id.clone.tsx index 1aac565..855ce1f 100644 --- a/app/routes/admin.sports-seasons.$id.clone.tsx +++ b/app/routes/admin.sports-seasons.$id.clone.tsx @@ -15,17 +15,12 @@ import { CardHeader, CardTitle, } from "~/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Badge } from "~/components/ui/badge"; import { Copy } from "lucide-react"; import { useState } from "react"; +const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"; + export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Clone ${data?.sourceSeason?.name ?? "Sports Season"} - Brackt Admin` }]; } @@ -75,6 +70,7 @@ export async function action(args: Route.ActionArgs) { const totalMajors = formData.get("totalMajors"); const draftOn = formData.get("draftOn"); const draftOff = formData.get("draftOff"); + const copySimulatorInputs = formData.get("copySimulatorInputs") === "on"; // Validation if (typeof sportId !== "string" || !sportId) { @@ -152,7 +148,9 @@ export async function action(args: Route.ActionArgs) { let newSeason; try { - newSeason = await cloneSportsSeason(params.id, newSeasonData as NewSportsSeason); + newSeason = await cloneSportsSeason(params.id, newSeasonData as NewSportsSeason, { + copySimulatorInputs, + }); } catch (error) { logger.error("Error cloning sports season:", error); return { error: "Failed to clone sports season. Please try again." }; @@ -185,7 +183,7 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp New Sports Season Details - Review and adjust the pre-filled settings. Participants, events, futures odds, and Elo ratings will be copied automatically. + Review and adjust the pre-filled settings. Participants, events, simulator structure, and qualifying-points rules will be copied automatically. @@ -248,16 +246,17 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
- +

Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.

@@ -265,16 +264,18 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
- +
{scoringPattern === "qualifying_points" && ( @@ -328,6 +329,16 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
)} +
+
+ + +
+

+ Leave unchecked for a fresh season. Check only when prior-season odds, Elo ratings, rankings, and simulator input metadata should intentionally carry forward. +

+
+
+ +
+ +
+

+ + Simulator Setup +

+

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

+
+ + {actionData && ( + + {actionData.message} + + )} + + + +
+
+ {config.profile.displayName} + {config.profile.description} +
+ {readiness.status === "ready" ? ( + Ready + ) : ( + Needs setup + )} +
+
+ +
+
+
Simulator Type
+ {config.simulatorType} +
+
+
Participant Inputs
+
{readiness.participantInputCount}/{readiness.participantCount}
+
+
+
Simulation Status
+
{sportsSeason.simulationStatus}
+
+
+ + {readiness.missingInputs.length > 0 && ( +
+ Missing: {readiness.missingInputs.join(", ")} +
+ )} + + {readiness.warnings.length > 0 && ( +
+ {readiness.warnings.join(" ")} +
+ )} + +
+ {setupSections.includes("futuresOdds") && } + {setupSections.includes("eloRatings") && } + {setupSections.includes("surfaceElo") && } + {setupSections.includes("golfSkills") && } + {setupSections.includes("regularStandings") && } + {setupSections.includes("events") && } + +
+ +
+ + +
+
+
+ + {config.profile.requiredInputs.includes("sourceElo") && ( + + + Input Policy + + Direct inputs win. When present, this simulator can derive Elo from{" "} + {sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate inputs"}. + Rating-based simulators may also derive preseason ratings from futures odds. Tail fallbacks are explicit + so low-impact missing participants do not silently get invented ratings. + + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ )} + + + + Season Config + + JSON overrides for this specific sports season. Defaults come from the simulator profile. + + + +
+ +