Address two issues from code review: - batchUpsertParticipantSimulatorInputs blindly COALESCE'd the metadata column, preserving a stale sourceEloMethod/ratingMethod flag. A freshly entered direct Elo/rating (Elo-ratings page, or the bulk CSV importer) was then misclassified as generated and filtered out by getParticipantSimulatorInputs, so the entered value was silently ignored. Metadata now uses explicit caller metadata when given, otherwise preserves existing flags but drops the method flag for any column receiving a fresh direct value. - The save-inputs auto-run catch reported success with "Simulation not run yet" even when the simulation started and failed mid-run (leaving the season in status 'failed'). It now re-checks the season status and reports a real run failure distinctly from a not-ready/never-started run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE
654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
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<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 (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<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 (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"}.`
|
||
);
|
||
}
|
||
}
|