brackt/app/models/simulator.ts
Claude adceda3aca
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.

`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.

- Add `countSeasonRaces` in a new leaf model. A race is every event except
  `final_standings`, and completion is inferred from the event date, since
  nobody marks rows labelled "Non-Scoring" complete. Its own module because
  `scoring-event.ts` reaches back into `simulator.ts` through
  `scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
  a finished season reverted to an odds draw instead of reporting the final
  standings.
- Warn when a season has championship points but no calendar, in the
  simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
  by the same book sum guts the favourite in a 27-driver market: a 75%
  implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
  gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
  market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
  without tripping the manifest/registry import cycle.

The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00

671 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
getManifestSimulatorProfile,
simulatorInputLabel,
type SimulatorInputKey,
type SimulatorManifestProfile,
} from "~/services/simulations/manifest";
import {
getSimulatorInputPolicy,
ratingRequirementLabel,
resolveRatings,
resolveSourceElos,
sourceEloRequirementLabel,
} from "~/services/simulations/input-policy";
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
import { countSeasonRaces } from "~/models/season-races";
/** Simulator types driven by a race calendar plus championship standings. */
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
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;
/** Whether this simulator exposes a Futures Odds setup section. */
supportsFuturesOdds: boolean;
/** Number of participants that currently have stored futures odds. */
oddsParticipantCount: number;
/** Weight (01) the season's policy gives futures odds when blending into Elo. */
oddsWeight: number;
}
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", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "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]);
}
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);
}
function isGeneratedSourceEloInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
return isGeneratedSourceEloMethod(input?.metadata?.sourceEloMethod);
}
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,
sourceElo: input
? isGeneratedSourceEloInput(input) ? null : input.sourceElo
: legacy?.sourceElo ?? null,
worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null,
rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.rating),
projectedWins: parseDecimal(input?.projectedWins),
projectedTablePoints: parseDecimal(input?.projectedTablePoints),
seed: input?.seed ?? null,
region: input?.region ?? null,
metadata: input?.metadata ?? null,
};
});
}
export async function batchUpsertParticipantSimulatorInputs(
inputs: UpsertParticipantSimulatorInput[]
): Promise<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,
],
// Non-destructive: only overwrite a column when the incoming value is
// non-null. This lets a partial import (e.g. odds-only) update just the
// columns it provides without clobbering previously stored inputs like a
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
// legacy EV table below.
set: {
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
// direct value. When a caller supplies explicit metadata, use it as-is
// (prepareSimulatorInputsForRun and the projection importer set the
// correct flags). Otherwise preserve existing metadata, but drop the
// method flag for any column receiving a fresh direct value — otherwise a
// stale "generated" flag would cause that newly-entered Elo/rating to be
// filtered out as derived (see getParticipantSimulatorInputs).
metadata: sql`CASE
WHEN excluded.metadata IS NOT NULL THEN excluded.metadata
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
END`,
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;
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).`);
}
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 (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
// Without a calendar the simulator cannot tell how many races are left, so
// it falls back to futures odds and ignores the championship standings
// entirely. A warning, not a blocker — a season drafted before the schedule
// is published still needs to run.
const races = await countSeasonRaces(sportsSeasonId);
if (races.total === 0) {
warnings.push(
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
);
}
}
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]));
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)]));
// 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);
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
const policy = getSimulatorInputPolicy(mergedConfig);
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,
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
oddsWeight: policy.oddsWeight,
};
})
);
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"}.`
);
}
}