brackt/app/models/simulator.ts

720 lines
26 KiB
TypeScript
Raw Normal View History

import { and, eq, inArray, 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 {
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;
}
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 === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
}
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
2026-05-13 15:02:23 -07:00
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
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]);
}
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,
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,
};
});
}
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,
},
});
});
}
Fix NCAAW futures odds simulation and admin import UX (#420) - Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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();
const participantIds = inputs.map((input) => input.participantId);
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
await db.transaction(async (tx) => {
Fix futures odds being ignored when stale Elo exists When an admin entered futures (preseason) odds for a season that already had Elo ratings stored, the simulator kept using the old Elo and silently ignored the new odds. This affected any Elo-based simulator (e.g. NHL). Root cause: resolveSourceElos() ranks a direct sourceElo above the sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator() only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale `sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not treated as generated, so it survived and short-circuited the resolver. Fix: - batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips sourceEloMethod (both the pre-update and upsert-conflict paths), so the existing futures -> Elo conversion drives the run. - resolveSourceElos' sourceOdds branch now guards for >= 2 participants (mirroring resolveRatings), so a lone-odds season falls through to the configured missing-Elo strategy instead of getting a flat ~1500. - batchSaveSourceOdds clears the legacy EV sourceElo and marks source as futures_odds so the elo-ratings page won't resurrect a stale rating. Adds unit coverage for odds-derived Elo, the single-participant guard, the post-clear regression, generated-vs-direct sourceElo suppression, and the new clearing behavior in batchSaveFuturesOddsForSimulator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
// Clear ALL ratings AND sourceElo (both manual and generated) so the
// simulation re-derives them from the newly saved futures odds. For
// Elo-based simulators, a stale `sourceElo` (e.g. a manually entered
// "direct" Elo) would otherwise win the resolveSourceElos precedence and
// cause the futures odds to be silently ignored. Nulling it here lets the
// sourceOdds -> convertFuturesToElo branch drive the run.
Fix NCAAW futures odds simulation and admin import UX (#420) - Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:06:16 -07:00
await tx
.update(schema.seasonParticipantSimulatorInputs)
.set({
rating: null,
Fix futures odds being ignored when stale Elo exists When an admin entered futures (preseason) odds for a season that already had Elo ratings stored, the simulator kept using the old Elo and silently ignored the new odds. This affected any Elo-based simulator (e.g. NHL). Root cause: resolveSourceElos() ranks a direct sourceElo above the sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator() only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale `sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not treated as generated, so it survived and short-circuited the resolver. Fix: - batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips sourceEloMethod (both the pre-update and upsert-conflict paths), so the existing futures -> Elo conversion drives the run. - resolveSourceElos' sourceOdds branch now guards for >= 2 participants (mirroring resolveRatings), so a lone-odds season falls through to the configured missing-Elo strategy instead of getting a flat ~1500. - batchSaveSourceOdds clears the legacy EV sourceElo and marks source as futures_odds so the elo-ratings page won't resurrect a stale rating. Adds unit coverage for odds-derived Elo, the single-participant guard, the post-clear regression, generated-vs-direct sourceElo suppression, and the new clearing behavior in batchSaveFuturesOddsForSimulator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
Fix NCAAW futures odds simulation and admin import UX (#420) - Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:06:16 -07:00
updatedAt: now,
})
.where(
and(
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
)
);
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: null,
Fix futures odds being ignored when stale Elo exists When an admin entered futures (preseason) odds for a season that already had Elo ratings stored, the simulator kept using the old Elo and silently ignored the new odds. This affected any Elo-based simulator (e.g. NHL). Root cause: resolveSourceElos() ranks a direct sourceElo above the sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator() only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale `sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not treated as generated, so it survived and short-circuited the resolver. Fix: - batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips sourceEloMethod (both the pre-update and upsert-conflict paths), so the existing futures -> Elo conversion drives the run. - resolveSourceElos' sourceOdds branch now guards for >= 2 participants (mirroring resolveRatings), so a lone-odds season falls through to the configured missing-Elo strategy instead of getting a flat ~1500. - batchSaveSourceOdds clears the legacy EV sourceElo and marks source as futures_odds so the elo-ratings page won't resurrect a stale rating. Adds unit coverage for odds-derived Elo, the single-participant guard, the post-clear regression, generated-vs-direct sourceElo suppression, and the new clearing behavior in batchSaveFuturesOddsForSimulator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
sourceElo: null,
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' - 'sourceEloMethod'`,
Fix NCAAW futures odds simulation and admin import UX (#420) - Revert ncaaw-simulator to Barthag win probability formula; set realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived ratings stay in the range where the formula behaves well - Add batchSaveFuturesOddsForSimulator which clears all ratings (manual and generated) before upserting sourceOdds, so futures odds always drive the simulation rather than being silently overridden by existing Barthag ratings from Simulator Setup - Add clearSourceOddsForParticipants to zero out both tables for participants excluded from a bulk import - Add "Clear existing odds" checkbox to the bulk import card; applies client-side on match and server-side on submit - Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator pre-clear UPDATE (could have wiped ratings across other seasons) - Fix race condition: run batchSaveSourceOdds then batchSaveFuturesOddsForSimulator sequentially so the simulator inputs table always ends in the correct cleared state - Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings to 0 or 1; Elo callers already round after clamping - Handle all-identical-odds edge case in convertFuturesToElo (assign midpoint instead of throwing) - Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest so fallbackRatingDelta is live config, not dead - Log warning in resolveRatings when only 1 participant has odds - Reset clearExisting checkbox after applyMatches Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:06:16 -07:00
updatedAt: now,
},
});
});
}
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;
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) {
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]));
// 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<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"}.`
);
}
}