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"; export interface SimulatorProfile extends SimulatorManifestProfile { isActive: boolean; } export interface SportsSeasonSimulatorConfig { sportsSeasonId: string; simulatorType: SimulatorType; config: Record; profile: SimulatorProfile; } export interface ParticipantSimulatorInput { participantId: string; sportsSeasonId: string; sourceOdds: number | null; sourceElo: number | null; worldRanking: number | null; rating: number | null; projectedWins: number | null; projectedTablePoints: number | null; seed: number | null; region: string | null; metadata: Record | null; } export interface UpsertParticipantSimulatorInput { participantId: string; sportsSeasonId: string; sourceOdds?: number | null; sourceElo?: number | null; worldRanking?: number | null; rating?: number | null; projectedWins?: number | null; projectedTablePoints?: number | null; seed?: number | null; region?: string | null; metadata?: Record | null; } export interface SimulatorReadiness { status: "ready" | "not_ready"; canRun: boolean; participantCount: number; participantInputCount: number; derivedInputCount: number; fallbackInputCount: number; missingInputs: string[]; warnings: string[]; } export interface SportsSeasonSimulatorSummary { sportsSeasonId: string; seasonName: string; year: number; seasonStatus: string; simulationStatus: string; fantasySeasonId: string | null; fantasySeasonName: string | null; leagueName: string | null; sportName: string; sportSlug: string; simulatorType: SimulatorType; simulatorName: string; participantCount: number; participantInputCount: number; lastSimulatedDate: string | null; readiness: SimulatorReadiness; /** Whether this simulator exposes a Futures Odds setup section. */ supportsFuturesOdds: boolean; /** Number of participants that currently have stored futures odds. */ oddsParticipantCount: number; /** Weight (0–1) the season's policy gives futures odds when blending into Elo. */ oddsWeight: number; } 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 { const db = database(); const dbProfile = await db.query.simulatorProfiles.findFirst({ where: eq(schema.simulatorProfiles.simulatorType, simulatorType), }); return mergeProfile(simulatorType, dbProfile); } export async function upsertSimulatorProfile( profile: Pick & { inputSchema?: Record; isActive?: boolean; } ): Promise { const db = database(); const now = new Date(); await db .insert(schema.simulatorProfiles) .values({ simulatorType: profile.simulatorType, displayName: profile.displayName, description: profile.description, defaultConfig: profile.defaultConfig, inputSchema: profile.inputSchema ?? {}, isActive: profile.isActive ?? true, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: schema.simulatorProfiles.simulatorType, set: { displayName: sql`excluded.display_name`, description: sql`excluded.description`, defaultConfig: sql`excluded.default_config`, inputSchema: sql`excluded.input_schema`, isActive: sql`excluded.is_active`, updatedAt: now, }, }); } export async function getSportsSeasonSimulatorConfig( sportsSeasonId: string ): Promise { const db = database(); const season = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, sportsSeasonId), with: { sport: true, simulatorConfig: true, }, }); const simulatorType = season?.simulatorConfig?.simulatorType ?? season?.sport?.simulatorType; if (!season || !isSimulatorType(simulatorType)) return null; const profile = await getSimulatorProfile(simulatorType); const config = { ...profile.defaultConfig, ...season.simulatorConfig?.config, }; return { sportsSeasonId, simulatorType, config, profile, }; } export async function upsertSportsSeasonSimulatorConfig(input: { sportsSeasonId: string; simulatorType: SimulatorType; config?: Record; }): Promise { const db = database(); const now = new Date(); await db .insert(schema.sportsSeasonSimulatorConfigs) .values({ sportsSeasonId: input.sportsSeasonId, simulatorType: input.simulatorType, config: input.config ?? {}, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: schema.sportsSeasonSimulatorConfigs.sportsSeasonId, set: { simulatorType: sql`excluded.simulator_type`, config: sql`excluded.config`, updatedAt: now, }, }); } export async function getParticipantSimulatorInputs( sportsSeasonId: string ): Promise { const db = database(); const [seasonParticipants, simulatorInputs, legacyEvs] = await Promise.all([ db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId), columns: { id: true }, }), db.query.seasonParticipantSimulatorInputs.findMany({ where: eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId), }), db.query.seasonParticipantExpectedValues.findMany({ where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId), columns: { participantId: true, sourceOdds: true, sourceElo: true, worldRanking: true, }, }), ]); const simulatorByParticipant = new Map(simulatorInputs.map((row) => [row.participantId, row])); const legacyByParticipant = new Map(legacyEvs.map((row) => [row.participantId, row])); return seasonParticipants.map((participant) => { const input = simulatorByParticipant.get(participant.id); const legacy = legacyByParticipant.get(participant.id); return { participantId: participant.id, sportsSeasonId, sourceOdds: input?.sourceOdds ?? legacy?.sourceOdds ?? null, sourceElo: input ? 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, }; }); } 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 { 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, }, }); }); } export async function batchSaveFuturesOddsForSimulator( inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); // Persist the odds non-destructively. How much these odds move a stored // Elo/rating is now governed by the season's blend weight (see resolveSourceElos // / SimulatorInputPolicy.oddsWeight), so we no longer null out manually entered // Elo here — that policy decides at run time. await db .insert(schema.seasonParticipantSimulatorInputs) .values( inputs.map((input) => ({ participantId: input.participantId, sportsSeasonId: input.sportsSeasonId, sourceOdds: input.sourceOdds, createdAt: now, updatedAt: now, })) ) .onConflictDoUpdate({ target: [ schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.sportsSeasonId, ], set: { sourceOdds: sql`excluded.source_odds`, updatedAt: now, }, }); } export async function batchUpsertParticipantSimulatorInputs( inputs: UpsertParticipantSimulatorInput[] ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); await db.transaction(async (tx) => { await tx .insert(schema.seasonParticipantSimulatorInputs) .values( inputs.map((input) => ({ participantId: input.participantId, sportsSeasonId: input.sportsSeasonId, sourceOdds: input.sourceOdds ?? null, sourceElo: input.sourceElo ?? null, worldRanking: input.worldRanking ?? null, rating: input.rating?.toString() ?? null, projectedWins: input.projectedWins?.toString() ?? null, projectedTablePoints: input.projectedTablePoints?.toString() ?? null, seed: input.seed ?? null, region: input.region ?? null, metadata: input.metadata ?? null, createdAt: now, updatedAt: now, })) ) .onConflictDoUpdate({ target: [ schema.seasonParticipantSimulatorInputs.participantId, schema.seasonParticipantSimulatorInputs.sportsSeasonId, ], set: { sourceOdds: sql`excluded.source_odds`, sourceElo: sql`excluded.source_elo`, worldRanking: sql`excluded.world_ranking`, rating: sql`excluded.rating`, projectedWins: sql`excluded.projected_wins`, projectedTablePoints: sql`excluded.projected_table_points`, seed: sql`excluded.seed`, region: sql`excluded.region`, metadata: sql`excluded.metadata`, updatedAt: sql`excluded.updated_at`, }, }); // Compatibility bridge: keep legacy simulator metadata columns populated until // every simulator has been migrated to season_participant_simulator_inputs. await tx .insert(schema.seasonParticipantExpectedValues) .values( inputs.map((input) => ({ participantId: input.participantId, sportsSeasonId: input.sportsSeasonId, probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0", probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0", expectedValue: "0", source: input.sourceOdds !== null && input.sourceOdds !== undefined ? "futures_odds" as const : "elo_simulation" as const, sourceOdds: input.sourceOdds ?? null, sourceElo: input.sourceElo ?? null, worldRanking: input.worldRanking ?? null, calculatedAt: now, updatedAt: now, })) ) .onConflictDoUpdate({ target: [ schema.seasonParticipantExpectedValues.participantId, schema.seasonParticipantExpectedValues.sportsSeasonId, ], set: { sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantExpectedValues.sourceOdds})`, sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantExpectedValues.sourceElo})`, worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantExpectedValues.worldRanking})`, updatedAt: now, }, }); }); } export async function validateSimulatorReadiness( sportsSeasonId: string ): Promise { const config = await getSportsSeasonSimulatorConfig(sportsSeasonId); const inputs = await getParticipantSimulatorInputs(sportsSeasonId); const missingInputs: string[] = []; const warnings: string[] = []; let derivedInputCount = 0; let fallbackInputCount = 0; if (!config) { return { status: "not_ready", canRun: false, participantCount: inputs.length, participantInputCount: 0, derivedInputCount, fallbackInputCount, missingInputs: ["simulator type"], warnings, }; } const participantCount = inputs.length; if (participantCount === 0) { missingInputs.push("participants"); } const requiredInputs = config.profile.requiredInputs; const resolvedSourceElos = requiredInputs.includes("sourceElo") ? resolveSourceElos(inputs, config.profile, config.config) : new Map(); const resolvedRatings = requiredInputs.includes("rating") ? resolveRatings(inputs, config.profile, config.config) : new Map(); derivedInputCount = [...resolvedSourceElos.values()].filter((input) => input.method !== "direct").length + [...resolvedRatings.values()].filter((input) => input.method !== "direct").length; fallbackInputCount = [...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length + [...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 (config.profile.setupSections.includes("regularStandings")) { warnings.push("Regular-season standings may be needed for in-season accuracy."); } if (config.profile.setupSections.includes("bracket")) { warnings.push("Bracket-aware simulators use completed bracket results when available."); } const status = missingInputs.length === 0 ? "ready" : "not_ready"; return { status, canRun: status === "ready", participantCount, participantInputCount, derivedInputCount, fallbackInputCount, missingInputs, warnings, }; } export async function prepareSimulatorInputsForRun(sportsSeasonId: string): Promise { const config = await getSportsSeasonSimulatorConfig(sportsSeasonId); if (!config) return; const inputs = await getParticipantSimulatorInputs(sportsSeasonId); const resolvedSourceElos = config.profile.requiredInputs.includes("sourceElo") ? resolveSourceElos(inputs, config.profile, config.config) : new Map(); const resolvedRatings = config.profile.requiredInputs.includes("rating") ? resolveRatings(inputs, config.profile, config.config) : new Map(); const rowsToUpsert: UpsertParticipantSimulatorInput[] = []; for (const input of inputs) { const resolvedElo = resolvedSourceElos.get(input.participantId); const resolvedRating = resolvedRatings.get(input.participantId); if (!resolvedElo && !resolvedRating) continue; rowsToUpsert.push({ participantId: input.participantId, sportsSeasonId, sourceOdds: input.sourceOdds, sourceElo: resolvedElo?.sourceElo ?? input.sourceElo, worldRanking: input.worldRanking, rating: resolvedRating?.rating ?? input.rating, projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints, seed: input.seed, region: input.region, metadata: { ...input.metadata, ...(resolvedElo ? { sourceEloMethod: resolvedElo.method } : {}), ...(resolvedRating ? { ratingMethod: resolvedRating.method } : {}), }, }); } await batchUpsertParticipantSimulatorInputs(rowsToUpsert); } export async function listSportsSeasonSimulatorSummaries(): Promise { const db = database(); const seasons = await db.query.sportsSeasons.findMany({ with: { sport: true, fantasySeason: { with: { league: true } }, participants: { columns: { id: true } }, simulatorConfig: true, }, }); const simulatorSeasons = seasons.filter((season) => { const simulatorType = season.simulatorConfig?.simulatorType ?? season.sport.simulatorType; if (!isSimulatorType(simulatorType)) return false; // Brackt's public sports season is a draftable template. The runnable // simulations are the per-league private Brackt seasons. if (season.sport.slug === "brackt") return season.fantasySeasonId !== null; return season.fantasySeasonId === null; }); const seasonIds = simulatorSeasons.map((season) => season.id); const snapshotRows = seasonIds.length > 0 ? await db .select({ sportsSeasonId: schema.participantEvSnapshots.sportsSeasonId, lastSimulatedDate: sql`max(${schema.participantEvSnapshots.snapshotDate})`, }) .from(schema.participantEvSnapshots) .where(inArray(schema.participantEvSnapshots.sportsSeasonId, seasonIds)) .groupBy(schema.participantEvSnapshots.sportsSeasonId) : []; const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate])); const oddsCountRows = seasonIds.length > 0 ? await db .select({ sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId, oddsCount: sql`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 { 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"}.` ); } }