Entering projected wins for an in-progress MLB season did not behave as expected: the entered numbers came back changed, and the simulation appeared to ignore them in favour of whatever Elo was already stored. Four separate defects were involved. Projections are now stored and shown verbatim. The Elo Ratings page never kept the number typed into it — the field was a display derived from Elo, so a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to an integer Elo) and drifted again after each run, because a run re-resolves that Elo through the input policy. The loader now reads back the stored projection and the paste flow keeps the pasted value as-is; the derived round-trip survives only as a prefill for seasons that have never had a projection saved. A stale Elo no longer silently outranks a projection. baseEloPriority takes the first available base source, and the simulator page's bulk CSV wrote projectedWins without stamping metadata.sourceEloMethod, so the non-destructive upsert left the old Elo in place as a trusted direct value and it won the race — the projection was stored and then ignored on every run. The CSV path now stamps the flag like the Elo Ratings page does, the metadata upsert merges rather than replaces so a flag-only write keeps unrelated keys, and Base Elo Source is editable per season for the case where a genuine hand-entered Elo should still lose to projections. Projected wins now act as a projected final total. The value was baked into a flat season-long rate (projectedWins / 162) applied to every remaining game, so a team at 60-50 projected for 95 finished around 90.5 and the projection was never reached mid-season. seedingWinRateFor spreads the difference over the games still to play, which is a no-op pre-season where the two rates coincide; projectedWinsWeight blends it back toward the Elo-implied rate. Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so any team with an Elo skipped the compression every hardcoded-rdif team gets: a 95-win projection became RDif +686 and played playoff games at .586 instead of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140 alongside the Dodgers' hardcoded +137. Also fixes the preview table's "missing a required input" marker, which flagged every projection-configured participant because a generated Elo or rating is deliberately hidden from getParticipantSimulatorInputs. It now consults the resolved values, so it agrees with readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
674 lines
26 KiB
TypeScript
674 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";
|
||
import { countSeasonRaces } from "~/models/season-races";
|
||
|
||
/** Simulator types driven by a race calendar plus championship standings. */
|
||
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
|
||
|
||
export interface SimulatorProfile extends SimulatorManifestProfile {
|
||
isActive: boolean;
|
||
}
|
||
|
||
export interface SportsSeasonSimulatorConfig {
|
||
sportsSeasonId: string;
|
||
simulatorType: SimulatorType;
|
||
config: Record<string, unknown>;
|
||
profile: SimulatorProfile;
|
||
}
|
||
|
||
export interface ParticipantSimulatorInput {
|
||
participantId: string;
|
||
sportsSeasonId: string;
|
||
sourceOdds: number | null;
|
||
sourceElo: number | null;
|
||
worldRanking: number | null;
|
||
rating: number | null;
|
||
projectedWins: number | null;
|
||
projectedTablePoints: number | null;
|
||
seed: number | null;
|
||
region: string | null;
|
||
metadata: Record<string, unknown> | null;
|
||
}
|
||
|
||
export interface UpsertParticipantSimulatorInput {
|
||
participantId: string;
|
||
sportsSeasonId: string;
|
||
sourceOdds?: number | null;
|
||
sourceElo?: number | null;
|
||
worldRanking?: number | null;
|
||
rating?: number | null;
|
||
projectedWins?: number | null;
|
||
projectedTablePoints?: number | null;
|
||
seed?: number | null;
|
||
region?: string | null;
|
||
metadata?: Record<string, unknown> | null;
|
||
}
|
||
|
||
export interface SimulatorReadiness {
|
||
status: "ready" | "not_ready";
|
||
canRun: boolean;
|
||
participantCount: number;
|
||
participantInputCount: number;
|
||
derivedInputCount: number;
|
||
fallbackInputCount: number;
|
||
missingInputs: string[];
|
||
warnings: string[];
|
||
}
|
||
|
||
export interface SportsSeasonSimulatorSummary {
|
||
sportsSeasonId: string;
|
||
seasonName: string;
|
||
year: number;
|
||
seasonStatus: string;
|
||
simulationStatus: string;
|
||
fantasySeasonId: string | null;
|
||
fantasySeasonName: string | null;
|
||
leagueName: string | null;
|
||
sportName: string;
|
||
sportSlug: string;
|
||
simulatorType: SimulatorType;
|
||
simulatorName: string;
|
||
participantCount: number;
|
||
participantInputCount: number;
|
||
lastSimulatedDate: string | null;
|
||
readiness: SimulatorReadiness;
|
||
/** Whether this simulator exposes a Futures Odds setup section. */
|
||
supportsFuturesOdds: boolean;
|
||
/** Number of participants that currently have stored futures odds. */
|
||
oddsParticipantCount: number;
|
||
/** Weight (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, merge it over
|
||
// what is already stored (prepareSimulatorInputsForRun and the projection
|
||
// importers set the correct flags) — a merge rather than a replace so a
|
||
// caller that only needs to stamp a method flag does not wipe unrelated
|
||
// metadata keys. 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 COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) || excluded.metadata
|
||
ELSE COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||
END`,
|
||
updatedAt: sql`excluded.updated_at`,
|
||
},
|
||
});
|
||
|
||
// Compatibility bridge: keep legacy simulator metadata columns populated until
|
||
// every simulator has been migrated to season_participant_simulator_inputs.
|
||
await tx
|
||
.insert(schema.seasonParticipantExpectedValues)
|
||
.values(
|
||
inputs.map((input) => ({
|
||
participantId: input.participantId,
|
||
sportsSeasonId: input.sportsSeasonId,
|
||
probFirst: "0",
|
||
probSecond: "0",
|
||
probThird: "0",
|
||
probFourth: "0",
|
||
probFifth: "0",
|
||
probSixth: "0",
|
||
probSeventh: "0",
|
||
probEighth: "0",
|
||
expectedValue: "0",
|
||
source: input.sourceOdds !== null && input.sourceOdds !== undefined ? "futures_odds" as const : "elo_simulation" as const,
|
||
sourceOdds: input.sourceOdds ?? null,
|
||
sourceElo: input.sourceElo ?? null,
|
||
worldRanking: input.worldRanking ?? null,
|
||
calculatedAt: now,
|
||
updatedAt: now,
|
||
}))
|
||
)
|
||
.onConflictDoUpdate({
|
||
target: [
|
||
schema.seasonParticipantExpectedValues.participantId,
|
||
schema.seasonParticipantExpectedValues.sportsSeasonId,
|
||
],
|
||
set: {
|
||
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantExpectedValues.sourceOdds})`,
|
||
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantExpectedValues.sourceElo})`,
|
||
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantExpectedValues.worldRanking})`,
|
||
updatedAt: now,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
export async function validateSimulatorReadiness(
|
||
sportsSeasonId: string
|
||
): Promise<SimulatorReadiness> {
|
||
const config = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||
const inputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||
const missingInputs: string[] = [];
|
||
const warnings: string[] = [];
|
||
let derivedInputCount = 0;
|
||
let fallbackInputCount = 0;
|
||
|
||
if (!config) {
|
||
return {
|
||
status: "not_ready",
|
||
canRun: false,
|
||
participantCount: inputs.length,
|
||
participantInputCount: 0,
|
||
derivedInputCount,
|
||
fallbackInputCount,
|
||
missingInputs: ["simulator type"],
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
const participantCount = inputs.length;
|
||
if (participantCount === 0) {
|
||
missingInputs.push("participants");
|
||
}
|
||
|
||
const requiredInputs = config.profile.requiredInputs;
|
||
const resolvedSourceElos = requiredInputs.includes("sourceElo")
|
||
? resolveSourceElos(inputs, config.profile, config.config)
|
||
: new Map();
|
||
const resolvedRatings = requiredInputs.includes("rating")
|
||
? resolveRatings(inputs, config.profile, config.config)
|
||
: new Map();
|
||
derivedInputCount =
|
||
[...resolvedSourceElos.values()].filter((input) => input.method !== "direct").length +
|
||
[...resolvedRatings.values()].filter((input) => input.method !== "direct").length;
|
||
fallbackInputCount =
|
||
[...resolvedSourceElos.values()].filter((input) => isFallbackMethod(input.method)).length +
|
||
[...resolvedRatings.values()].filter((input) => isFallbackMethod(input.method)).length;
|
||
|
||
function hasRequiredInput(input: ParticipantSimulatorInput, key: SimulatorInputKey): boolean {
|
||
if (hasInputValue(input, key)) return true;
|
||
if (key === "sourceElo") return resolvedSourceElos.has(input.participantId);
|
||
if (key === "rating") return resolvedRatings.has(input.participantId);
|
||
return false;
|
||
}
|
||
|
||
const participantInputCount = requiredInputs.length === 0
|
||
? participantCount
|
||
: inputs.filter((input) => requiredInputs.every((key) => hasRequiredInput(input, key))).length;
|
||
|
||
if (requiredInputs.length > 0 && participantInputCount < participantCount) {
|
||
for (const key of requiredInputs) {
|
||
const missingCount = inputs.filter((input) => !hasRequiredInput(input, key)).length;
|
||
if (missingCount > 0) {
|
||
missingInputs.push(`${inputRequirementLabel(key, config.profile)} for ${missingCount} participant(s)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (derivedInputCount > 0) {
|
||
warnings.push(`Derived simulator inputs will be used for ${derivedInputCount} participant(s).`);
|
||
}
|
||
if (fallbackInputCount > 0) {
|
||
warnings.push(`Configured fallback simulator inputs will be used for ${fallbackInputCount} participant(s).`);
|
||
}
|
||
if (config.simulatorType === "cs2_major_qualifying_points") {
|
||
const missingRankCount = inputs.filter((input) => !hasInputValue(input, "worldRanking")).length;
|
||
if (missingRankCount > 0) {
|
||
warnings.push(
|
||
`CS2 will use rank 9999 for ${missingRankCount} participant(s) without world ranking; this is acceptable for deep-tail teams but less precise.`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
|
||
// Without a calendar the simulator cannot tell how many races are left, so
|
||
// it falls back to futures odds and ignores the championship standings
|
||
// entirely. A warning, not a blocker — a season drafted before the schedule
|
||
// is published still needs to run.
|
||
const races = await countSeasonRaces(sportsSeasonId);
|
||
if (races.total === 0) {
|
||
warnings.push(
|
||
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
|
||
);
|
||
}
|
||
}
|
||
|
||
if (config.profile.setupSections.includes("regularStandings")) {
|
||
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
||
}
|
||
if (config.profile.setupSections.includes("bracket")) {
|
||
warnings.push("Bracket-aware simulators use completed bracket results when available.");
|
||
}
|
||
|
||
const status = missingInputs.length === 0 ? "ready" : "not_ready";
|
||
return {
|
||
status,
|
||
canRun: status === "ready",
|
||
participantCount,
|
||
participantInputCount,
|
||
derivedInputCount,
|
||
fallbackInputCount,
|
||
missingInputs,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
export async function prepareSimulatorInputsForRun(sportsSeasonId: string): Promise<void> {
|
||
const config = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||
if (!config) return;
|
||
|
||
const inputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||
const resolvedSourceElos = config.profile.requiredInputs.includes("sourceElo")
|
||
? resolveSourceElos(inputs, config.profile, config.config)
|
||
: new Map();
|
||
const resolvedRatings = config.profile.requiredInputs.includes("rating")
|
||
? resolveRatings(inputs, config.profile, config.config)
|
||
: new Map();
|
||
const rowsToUpsert: UpsertParticipantSimulatorInput[] = [];
|
||
for (const input of inputs) {
|
||
const resolvedElo = resolvedSourceElos.get(input.participantId);
|
||
const resolvedRating = resolvedRatings.get(input.participantId);
|
||
if (!resolvedElo && !resolvedRating) continue;
|
||
|
||
rowsToUpsert.push({
|
||
participantId: input.participantId,
|
||
sportsSeasonId,
|
||
sourceOdds: input.sourceOdds,
|
||
sourceElo: resolvedElo?.sourceElo ?? input.sourceElo,
|
||
worldRanking: input.worldRanking,
|
||
rating: resolvedRating?.rating ?? input.rating,
|
||
projectedWins: input.projectedWins,
|
||
projectedTablePoints: input.projectedTablePoints,
|
||
seed: input.seed,
|
||
region: input.region,
|
||
metadata: {
|
||
...input.metadata,
|
||
...(resolvedElo ? { sourceEloMethod: resolvedElo.method } : {}),
|
||
...(resolvedRating ? { ratingMethod: resolvedRating.method } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
await batchUpsertParticipantSimulatorInputs(rowsToUpsert);
|
||
}
|
||
|
||
export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeasonSimulatorSummary[]> {
|
||
const db = database();
|
||
const seasons = await db.query.sportsSeasons.findMany({
|
||
with: {
|
||
sport: true,
|
||
fantasySeason: { with: { league: true } },
|
||
participants: { columns: { id: true } },
|
||
simulatorConfig: true,
|
||
},
|
||
});
|
||
|
||
const simulatorSeasons = seasons.filter((season) => {
|
||
const simulatorType = season.simulatorConfig?.simulatorType ?? season.sport.simulatorType;
|
||
if (!isSimulatorType(simulatorType)) return false;
|
||
|
||
// Brackt's public sports season is a draftable template. The runnable
|
||
// simulations are the per-league private Brackt seasons.
|
||
if (season.sport.slug === "brackt") return season.fantasySeasonId !== null;
|
||
|
||
return season.fantasySeasonId === null;
|
||
});
|
||
|
||
const seasonIds = simulatorSeasons.map((season) => season.id);
|
||
const snapshotRows = seasonIds.length > 0
|
||
? await db
|
||
.select({
|
||
sportsSeasonId: schema.participantEvSnapshots.sportsSeasonId,
|
||
lastSimulatedDate: sql<string>`max(${schema.participantEvSnapshots.snapshotDate})`,
|
||
})
|
||
.from(schema.participantEvSnapshots)
|
||
.where(inArray(schema.participantEvSnapshots.sportsSeasonId, seasonIds))
|
||
.groupBy(schema.participantEvSnapshots.sportsSeasonId)
|
||
: [];
|
||
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
|
||
|
||
const oddsCountRows = seasonIds.length > 0
|
||
? await db
|
||
.select({
|
||
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||
oddsCount: sql<number>`count(*)::int`,
|
||
})
|
||
.from(schema.seasonParticipantSimulatorInputs)
|
||
.where(
|
||
and(
|
||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
|
||
)
|
||
)
|
||
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
|
||
: [];
|
||
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
|
||
|
||
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
|
||
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
||
const summaries = await Promise.all(
|
||
simulatorSeasons.map(async (season) => {
|
||
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
||
const profile = await getSimulatorProfile(simulatorType);
|
||
const readiness = await validateSimulatorReadiness(season.id);
|
||
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
|
||
const policy = getSimulatorInputPolicy(mergedConfig);
|
||
return {
|
||
sportsSeasonId: season.id,
|
||
seasonName: season.name,
|
||
year: season.year,
|
||
seasonStatus: season.status,
|
||
simulationStatus: season.simulationStatus,
|
||
fantasySeasonId: season.fantasySeasonId,
|
||
fantasySeasonName: season.fantasySeason ? `${season.fantasySeason.year} Season` : null,
|
||
leagueName: season.fantasySeason?.league?.name ?? null,
|
||
sportName: season.sport.name,
|
||
sportSlug: season.sport.slug,
|
||
simulatorType,
|
||
simulatorName: profile.displayName,
|
||
participantCount: season.participants.length,
|
||
participantInputCount: readiness.participantInputCount,
|
||
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
||
readiness,
|
||
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
||
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
||
oddsWeight: policy.oddsWeight,
|
||
};
|
||
})
|
||
);
|
||
|
||
return summaries.toSorted((a, b) =>
|
||
a.sportName.localeCompare(b.sportName) ||
|
||
(a.leagueName ?? "").localeCompare(b.leagueName ?? "") ||
|
||
b.year - a.year ||
|
||
a.seasonName.localeCompare(b.seasonName)
|
||
);
|
||
}
|
||
|
||
export async function assertRegistrySchemaDriftFree(): Promise<void> {
|
||
const schemaValues = schema.simulatorTypeEnum.enumValues;
|
||
const missingFromSchema = SIMULATOR_TYPES.filter((type) => !schemaValues.includes(type));
|
||
const missingFromRegistry = schemaValues.filter((type) => !(SIMULATOR_TYPES as readonly string[]).includes(type));
|
||
if (missingFromSchema.length > 0 || missingFromRegistry.length > 0) {
|
||
throw new Error(
|
||
`Simulator registry/schema drift detected. ` +
|
||
`Missing from schema: ${missingFromSchema.join(", ") || "none"}. ` +
|
||
`Missing from registry: ${missingFromRegistry.join(", ") || "none"}.`
|
||
);
|
||
}
|
||
}
|