2026-06-26 05:16:54 +00:00
|
|
|
|
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
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 { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getManifestSimulatorProfile,
|
|
|
|
|
|
simulatorInputLabel,
|
|
|
|
|
|
type SimulatorInputKey,
|
|
|
|
|
|
type SimulatorManifestProfile,
|
|
|
|
|
|
} from "~/services/simulations/manifest";
|
|
|
|
|
|
import {
|
2026-06-26 05:16:54 +00:00
|
|
|
|
getSimulatorInputPolicy,
|
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
|
|
|
|
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<string, unknown>;
|
|
|
|
|
|
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<string, unknown> | 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<string, unknown> | 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;
|
2026-06-26 05:16:54 +00:00
|
|
|
|
/** Whether this simulator exposes a Futures Odds setup section. */
|
|
|
|
|
|
supportsFuturesOdds: boolean;
|
|
|
|
|
|
/** Number of participants that currently have stored futures odds. */
|
|
|
|
|
|
oddsParticipantCount: number;
|
|
|
|
|
|
/** Weight (0–1) the season's policy gives futures odds when blending into Elo. */
|
|
|
|
|
|
oddsWeight: number;
|
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 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 {
|
2026-05-12 22:26:25 -07:00
|
|
|
|
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 05:16:54 +00:00
|
|
|
|
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
|
|
|
|
|
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
2026-05-12 22:26:25 -07:00
|
|
|
|
|
|
|
|
|
|
function isGeneratedRatingMethod(method: unknown): boolean {
|
|
|
|
|
|
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
function isGeneratedSourceEloMethod(method: unknown): boolean {
|
|
|
|
|
|
return typeof method === "string" && GENERATED_SOURCE_ELO_METHODS.includes(method as (typeof GENERATED_SOURCE_ELO_METHODS)[number]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 22:26:25 -07:00
|
|
|
|
function isGeneratedRatingInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
|
|
|
|
|
|
return isGeneratedRatingMethod(input?.metadata?.ratingMethod);
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
function isGeneratedSourceEloInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
|
|
|
|
|
|
return isGeneratedSourceEloMethod(input?.metadata?.sourceEloMethod);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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<SimulatorProfile> {
|
|
|
|
|
|
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<SimulatorProfile, "simulatorType" | "displayName" | "description" | "defaultConfig"> & {
|
|
|
|
|
|
inputSchema?: Record<string, unknown>;
|
|
|
|
|
|
isActive?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
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<SportsSeasonSimulatorConfig | null> {
|
|
|
|
|
|
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<string, unknown>;
|
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
|
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<ParticipantSimulatorInput[]> {
|
|
|
|
|
|
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,
|
2026-05-13 15:02:23 -07:00
|
|
|
|
sourceElo: input
|
|
|
|
|
|
? isGeneratedSourceEloInput(input) ? null : input.sourceElo
|
|
|
|
|
|
: legacy?.sourceElo ?? null,
|
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
|
|
|
|
worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null,
|
2026-05-12 22:26:25 -07:00
|
|
|
|
rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.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
|
|
|
|
projectedWins: parseDecimal(input?.projectedWins),
|
|
|
|
|
|
projectedTablePoints: parseDecimal(input?.projectedTablePoints),
|
|
|
|
|
|
seed: input?.seed ?? null,
|
|
|
|
|
|
region: input?.region ?? null,
|
|
|
|
|
|
metadata: input?.metadata ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 22:26:25 -07:00
|
|
|
|
function generatedRatingMethodSql() {
|
|
|
|
|
|
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function batchSaveParticipantSimulatorSourceOdds(
|
|
|
|
|
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
if (inputs.length === 0) return;
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
|
|
|
|
|
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.update(schema.seasonParticipantSimulatorInputs)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
rating: null,
|
|
|
|
|
|
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
|
|
|
|
|
generatedRatingMethodSql()
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await tx
|
|
|
|
|
|
.insert(schema.seasonParticipantSimulatorInputs)
|
|
|
|
|
|
.values(
|
|
|
|
|
|
inputs.map((input) => ({
|
|
|
|
|
|
participantId: input.participantId,
|
|
|
|
|
|
sportsSeasonId: input.sportsSeasonId,
|
|
|
|
|
|
sourceOdds: input.sourceOdds,
|
|
|
|
|
|
createdAt: now,
|
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
|
target: [
|
|
|
|
|
|
schema.seasonParticipantSimulatorInputs.participantId,
|
|
|
|
|
|
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
|
|
|
|
|
],
|
|
|
|
|
|
set: {
|
|
|
|
|
|
sourceOdds: sql`excluded.source_odds`,
|
|
|
|
|
|
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
|
|
|
|
|
|
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
|
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 01:06:16 -07:00
|
|
|
|
export async function batchSaveFuturesOddsForSimulator(
|
|
|
|
|
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
if (inputs.length === 0) return;
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
2026-06-26 05:16:54 +00:00
|
|
|
|
// Persist the odds non-destructively. How much these odds move a stored
|
|
|
|
|
|
// Elo/rating is now governed by the season's blend weight (see resolveSourceElos
|
|
|
|
|
|
// / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered
|
|
|
|
|
|
// Elo here — that policy decides at run time.
|
|
|
|
|
|
await db
|
|
|
|
|
|
.insert(schema.seasonParticipantSimulatorInputs)
|
|
|
|
|
|
.values(
|
|
|
|
|
|
inputs.map((input) => ({
|
|
|
|
|
|
participantId: input.participantId,
|
|
|
|
|
|
sportsSeasonId: input.sportsSeasonId,
|
|
|
|
|
|
sourceOdds: input.sourceOdds,
|
|
|
|
|
|
createdAt: now,
|
2026-05-13 01:06:16 -07:00
|
|
|
|
updatedAt: now,
|
2026-06-26 05:16:54 +00:00
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
|
target: [
|
|
|
|
|
|
schema.seasonParticipantSimulatorInputs.participantId,
|
|
|
|
|
|
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
|
|
|
|
|
],
|
|
|
|
|
|
set: {
|
|
|
|
|
|
sourceOdds: sql`excluded.source_odds`,
|
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-05-13 01:06:16 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
export async function batchUpsertParticipantSimulatorInputs(
|
|
|
|
|
|
inputs: UpsertParticipantSimulatorInput[]
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
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<SimulatorReadiness> {
|
|
|
|
|
|
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;
|
2026-05-12 22:26:25 -07:00
|
|
|
|
fallbackInputCount =
|
|
|
|
|
|
[...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length +
|
|
|
|
|
|
[...resolvedRatings.values()].filter((input) => isFallbackMethod(input.method)).length;
|
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 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) {
|
2026-05-12 22:26:25 -07:00
|
|
|
|
warnings.push(`Configured fallback simulator inputs will be used for ${fallbackInputCount} participant(s).`);
|
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
|
|
|
|
}
|
|
|
|
|
|
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<void> {
|
|
|
|
|
|
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<SportsSeasonSimulatorSummary[]> {
|
|
|
|
|
|
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<string>`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]));
|
|
|
|
|
|
|
2026-06-26 05:16:54 +00:00
|
|
|
|
const oddsCountRows = seasonIds.length > 0
|
|
|
|
|
|
? await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
|
|
|
|
|
oddsCount: sql<number>`count(*)::int`,
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.seasonParticipantSimulatorInputs)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
|
|
|
|
|
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
|
|
|
|
|
|
|
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
|
|
|
|
// 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);
|
2026-06-26 05:16:54 +00:00
|
|
|
|
const mergedConfig = { ...profile.defaultConfig, ...(season.simulatorConfig?.config ?? {}) };
|
|
|
|
|
|
const policy = getSimulatorInputPolicy(mergedConfig);
|
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 {
|
|
|
|
|
|
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,
|
2026-06-26 05:16:54 +00:00
|
|
|
|
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
|
|
|
|
|
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
|
|
|
|
|
oddsWeight: policy.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
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
|
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"}.`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|