Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE
641 lines
24 KiB
TypeScript
641 lines
24 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: sql`COALESCE(excluded.metadata, ${schema.seasonParticipantSimulatorInputs.metadata})`,
|
||
updatedAt: sql`excluded.updated_at`,
|
||
},
|
||
});
|
||
|
||
// Compatibility bridge: keep legacy simulator metadata columns populated until
|
||
// every simulator has been migrated to season_participant_simulator_inputs.
|
||
await tx
|
||
.insert(schema.seasonParticipantExpectedValues)
|
||
.values(
|
||
inputs.map((input) => ({
|
||
participantId: input.participantId,
|
||
sportsSeasonId: input.sportsSeasonId,
|
||
probFirst: "0",
|
||
probSecond: "0",
|
||
probThird: "0",
|
||
probFourth: "0",
|
||
probFifth: "0",
|
||
probSixth: "0",
|
||
probSeventh: "0",
|
||
probEighth: "0",
|
||
expectedValue: "0",
|
||
source: input.sourceOdds !== null && input.sourceOdds !== undefined ? "futures_odds" as const : "elo_simulation" as const,
|
||
sourceOdds: input.sourceOdds ?? null,
|
||
sourceElo: input.sourceElo ?? null,
|
||
worldRanking: input.worldRanking ?? null,
|
||
calculatedAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoUpdate({
|
||
target: [
|
||
schema.seasonParticipantExpectedValues.participantId,
|
||
schema.seasonParticipantExpectedValues.sportsSeasonId,
|
||
],
|
||
set: {
|
||
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantExpectedValues.sourceOdds})`,
|
||
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantExpectedValues.sourceElo})`,
|
||
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantExpectedValues.worldRanking})`,
|
||
updatedAt: now,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
export async function validateSimulatorReadiness(
|
||
sportsSeasonId: string
|
||
): Promise<SimulatorReadiness> {
|
||
const config = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||
const inputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||
const missingInputs: string[] = [];
|
||
const warnings: string[] = [];
|
||
let derivedInputCount = 0;
|
||
let fallbackInputCount = 0;
|
||
|
||
if (!config) {
|
||
return {
|
||
status: "not_ready",
|
||
canRun: false,
|
||
participantCount: inputs.length,
|
||
participantInputCount: 0,
|
||
derivedInputCount,
|
||
fallbackInputCount,
|
||
missingInputs: ["simulator type"],
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
const participantCount = inputs.length;
|
||
if (participantCount === 0) {
|
||
missingInputs.push("participants");
|
||
}
|
||
|
||
const requiredInputs = config.profile.requiredInputs;
|
||
const resolvedSourceElos = requiredInputs.includes("sourceElo")
|
||
? resolveSourceElos(inputs, config.profile, config.config)
|
||
: new Map();
|
||
const resolvedRatings = requiredInputs.includes("rating")
|
||
? resolveRatings(inputs, config.profile, config.config)
|
||
: new Map();
|
||
derivedInputCount =
|
||
[...resolvedSourceElos.values()].filter((input) => input.method !== "direct").length +
|
||
[...resolvedRatings.values()].filter((input) => input.method !== "direct").length;
|
||
fallbackInputCount =
|
||
[...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length +
|
||
[...resolvedRatings.values()].filter((input) => isFallbackMethod(input.method)).length;
|
||
|
||
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"}.`
|
||
);
|
||
}
|
||
}
|