brackt/app/models/simulator.ts
chrisp 8edb4293c5
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m28s
🚀 Deploy / 🚀 Deploy (push) Successful in 25s
claude/indycar-ev-sims-probability-fpzfx6 (#139)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #139
2026-08-17 22:50:35 +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"}.`
);
}
}