Replace the two parallel odds/Elo mechanisms with one model: every strength
source (raw Elo, projected wins, projected table points, futures odds) converts
to an Elo, those blend by weight into a single Elo, and that one Elo feeds every
simulator. This supersedes the earlier source-priority + per-match probability
blend approach.
Resolution (app/services/simulations/input-policy.ts):
- SimulatorInputPolicy gains baseEloPriority (order among the substitutable base
sources: raw Elo / projected wins / projected table points) and oddsWeight
(0–1). resolveSourceElos/resolveRatings now compute baseElo, derive an
odds Elo via convertFuturesToElo, and blend: 0 = base only, 1 = futures
override, between = weighted blend (method "blend").
- Drops the odds-inclusive sourceEloPriority and the prefersFuturesOdds helper.
Simulators consume the single resolved Elo:
- UCL, World Cup, NCAA Football, MLB drop their separate normalized-odds signal,
convertFuturesToElo calls, and per-match probability blend; they read the
resolved sourceElo (preserving each sim's hardcoded fallback Elo table). The
optional SimulationContext oddsWeight plumbing (types.ts/runner.ts) is removed.
- UCL is routed through the central blend (requiredInputs sourceElo, derivable
from sourceOdds) so any entered Elo and futures blend uniformly.
- College hockey already blends odds into Elo internally (and uses NPI rank the
central resolver can't), so its central oddsWeight is set to 0 to avoid
double-counting; the simulator is unchanged.
Manifest: per-profile oddsWeight defaults (World Cup/UCL/MLB 0.3, NCAA FB 0.4,
college hockey 0; global default 0.3).
UI: the Input Policy card exposes one "Futures vs. Elo — Odds Blend Weight"
control; the /admin/simulators inventory badge shows the effective blend
("Elo only" / "NN% blend" / "overrides Elo") with the odds participant count.
Tests: input-policy blend math (0/0.5/1) for Elo and ratings, baseEloPriority
and oddsWeight parsing/clamping, manifest per-profile weights; obsolete
source-priority and oddsWeight-context tests removed/replaced.
Note: this intentionally shifts the calibrated EV outputs of the four sims that
previously blended at the probability level (accepted in design discussion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
723 lines
26 KiB
TypeScript
723 lines
26 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,
|
||
};
|
||
});
|
||
}
|
||
|
||
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<void> {
|
||
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<void> {
|
||
if (inputs.length === 0) return;
|
||
const db = database();
|
||
const now = new Date();
|
||
|
||
// Persist the odds non-destructively. Whether these odds override a stored
|
||
// Elo/rating is now governed by the season's configurable source priority
|
||
// (see resolveSourceElos / SimulatorInputPolicy.sourceEloPriority), 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<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,
|
||
],
|
||
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<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"}.`
|
||
);
|
||
}
|
||
}
|