Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles, sports_season_simulator_configs, season_participant_simulator_inputs), a central model layer (app/models/simulator.ts), and a single runner entry point so every simulator run follows the same prepare → simulate → persist → snapshot → recalculate flow. Key additions: - manifest.ts: per-simulator display names, default configs, required/ optional inputs, derivable-input declarations, and setup sections - input-policy.ts: resolves sourceElo from projectedWins, projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds; supports block / fallbackElo / averageKnown / worstKnownMinus strategies - runner.ts: single entry point for admin simulation runs; materialises derived inputs, normalises result columns, zeroes omitted participants, snapshots EVs, and recalculates linked fantasy standings - /admin/simulators: inventory page with per-season readiness and bulk run - /admin/sports-seasons/:id/simulator: per-season setup page with readiness summary, input-policy editor, raw JSON config override, and CSV bulk input - NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs, falling back to the hardcoded name-keyed maps while DB data is being populated - Clone flow copies simulator config by default; volatile inputs (odds, Elo) only copied when explicitly requested Code-review fixes included in this commit: - source field in compatibility bridge checked with !== null instead of !== undefined - sourceEloRequirementLabel no longer appends "configured fallback" when the participant is already excluded from all resolved sources - Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel - save-config preserves existing inputPolicy when the submitted JSON omits it - Input table truncation label added (Showing 20 of N) - CSV description notes values must not contain commas - N+1 comment added to listSportsSeasonSimulatorSummaries - assertRegistrySchemaDriftFree called in manifest tests - Runner test suite added covering happy path, already-running guard, readiness failure, empty results, and error recovery with status reset Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
190a6e1fe7
commit
e5295812f6
33 changed files with 9029 additions and 457 deletions
|
|
@ -35,7 +35,7 @@ function SelectTrigger({
|
|||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ vi.mock("~/database/schema", () => ({
|
|||
seasonParticipants: { sportsSeasonId: "p.sports_season_id" },
|
||||
scoringEvents: { sportsSeasonId: "se.sports_season_id" },
|
||||
seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
|
||||
sportsSeasonSimulatorConfigs: { sportsSeasonId: "ssc.sports_season_id" },
|
||||
seasonParticipantSimulatorInputs: { sportsSeasonId: "spi.sports_season_id" },
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
|
|
@ -76,6 +78,8 @@ function makeMockDb({
|
|||
participants = [] as object[],
|
||||
scoringEvents = [] as object[],
|
||||
sourceEvRows = [] as object[],
|
||||
sourceSimulatorConfig = null as object | null,
|
||||
sourceSimulatorInputs = [] as object[],
|
||||
insertedSeason = { ...newSeasonData, id: NEW_ID } as object,
|
||||
// New participants returned by the INSERT ... RETURNING — mirrors sources with new IDs by default
|
||||
newParticipantRows = (participants as Array<Record<string, unknown>>).map((p, i) => ({
|
||||
|
|
@ -94,6 +98,8 @@ function makeMockDb({
|
|||
seasonParticipants: { findMany: vi.fn().mockResolvedValue(participants) },
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) },
|
||||
seasonParticipantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
|
||||
sportsSeasonSimulatorConfigs: { findFirst: vi.fn().mockResolvedValue(sourceSimulatorConfig) },
|
||||
seasonParticipantSimulatorInputs: { findMany: vi.fn().mockResolvedValue(sourceSimulatorInputs) },
|
||||
},
|
||||
insert: vi.fn().mockImplementation((table: object) => {
|
||||
let key: string;
|
||||
|
|
@ -104,6 +110,10 @@ function makeMockDb({
|
|||
key = "participants"; returnRows = newParticipantRows;
|
||||
} else if (table === schema.scoringEvents) {
|
||||
key = "scoringEvents"; returnRows = [];
|
||||
} else if (table === schema.sportsSeasonSimulatorConfigs) {
|
||||
key = "sportsSeasonSimulatorConfigs"; returnRows = [];
|
||||
} else if (table === schema.seasonParticipantSimulatorInputs) {
|
||||
key = "seasonParticipantSimulatorInputs"; returnRows = [];
|
||||
} else {
|
||||
key = "participantExpectedValues"; returnRows = [];
|
||||
}
|
||||
|
|
@ -131,7 +141,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
expect(db._insertedRows.sportsSeasons).toMatchObject({
|
||||
name: "2026 F1 Season",
|
||||
|
|
@ -147,7 +157,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
expect(db._insertedRows.sportsSeasons).toMatchObject({
|
||||
eloCalibrationExponent: "0.33",
|
||||
|
|
@ -164,7 +174,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const rows = db._insertedRows.participants as object[];
|
||||
expect(rows).toHaveLength(2);
|
||||
|
|
@ -182,7 +192,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants: [] });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
expect(db._insertedRows.participants).toBeUndefined();
|
||||
});
|
||||
|
|
@ -199,7 +209,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as object[];
|
||||
expect(rows).toHaveLength(2);
|
||||
|
|
@ -224,7 +234,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as Array<{ eventDate: unknown }>;
|
||||
expect(rows[0].eventDate).toBeNull();
|
||||
|
|
@ -240,7 +250,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as Array<{ eventStartsAt: Date }>;
|
||||
const shiftedDate = rows[0].eventStartsAt;
|
||||
|
|
@ -267,7 +277,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(2);
|
||||
|
|
@ -297,7 +307,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(1);
|
||||
|
|
@ -324,7 +334,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(1);
|
||||
|
|
@ -343,7 +353,7 @@ describe("cloneSportsSeason", () => {
|
|||
const db = makeMockDb({ participants, sourceEvRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData, { copySimulatorInputs: true });
|
||||
|
||||
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/
|
|||
import { eq, and, count, sql } from "drizzle-orm";
|
||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||
|
||||
|
|
@ -416,6 +417,14 @@ export async function batchSaveSourceOdds(
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
await batchUpsertParticipantSimulatorInputs(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceOdds: input.sourceOdds,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -461,6 +470,15 @@ export async function batchSaveSourceElos(
|
|||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
||||
await batchUpsertParticipantSimulatorInputs(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceElo: input.sourceElo,
|
||||
worldRanking: input.worldRanking ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
584
app/models/simulator.ts
Normal file
584
app/models/simulator.ts
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
import { eq, inArray, 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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 === "averageKnown" || method === "worstKnownMinus";
|
||||
}
|
||||
|
||||
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?.sourceElo ?? legacy?.sourceElo ?? null,
|
||||
worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null,
|
||||
rating: 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,
|
||||
],
|
||||
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;
|
||||
|
||||
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 Elo 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]));
|
||||
|
||||
// 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);
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
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"}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -199,13 +199,15 @@ function shiftTimestampByYears(date: Date, years: number): Date {
|
|||
|
||||
/**
|
||||
* Clone a sports season: creates a new season from newData, then copies
|
||||
* participants (name/shortName/externalId only), scoring events (dates shifted
|
||||
* by yearDelta), futures odds + Elo ratings (as stubs for re-simulation),
|
||||
* and QP config (for qualifying_points seasons).
|
||||
* participants (name/shortName/externalId only), simulator structure/config,
|
||||
* scoring events (dates shifted by yearDelta), and QP config. Volatile simulator
|
||||
* inputs such as futures odds and Elo ratings are copied only when explicitly
|
||||
* requested.
|
||||
*/
|
||||
export async function cloneSportsSeason(
|
||||
sourceId: string,
|
||||
newData: NewSportsSeason
|
||||
newData: NewSportsSeason,
|
||||
options: { copySimulatorInputs?: boolean } = {}
|
||||
): Promise<SportsSeason> {
|
||||
const db = database();
|
||||
|
||||
|
|
@ -241,11 +243,22 @@ export async function cloneSportsSeason(
|
|||
? await getQPConfig(sourceId, db)
|
||||
: null;
|
||||
|
||||
// Read source futures odds and Elo ratings
|
||||
const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({
|
||||
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId),
|
||||
const sourceSimulatorConfig = await db.query.sportsSeasonSimulatorConfigs.findFirst({
|
||||
where: eq(schema.sportsSeasonSimulatorConfigs.sportsSeasonId, sourceId),
|
||||
});
|
||||
|
||||
const sourceEvRows = options.copySimulatorInputs
|
||||
? await db.query.seasonParticipantExpectedValues.findMany({
|
||||
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId),
|
||||
})
|
||||
: [];
|
||||
|
||||
const sourceSimulatorInputs = options.copySimulatorInputs
|
||||
? await db.query.seasonParticipantSimulatorInputs.findMany({
|
||||
where: eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sourceId),
|
||||
})
|
||||
: [];
|
||||
|
||||
// Build lookup: (externalId ?? name) → source EV, only for rows that have
|
||||
// actual input data (odds or Elo) worth carrying forward
|
||||
type SourceEv = typeof sourceEvRows[number];
|
||||
|
|
@ -282,7 +295,15 @@ export async function cloneSportsSeason(
|
|||
: [];
|
||||
|
||||
// Copy futures odds / Elo stubs matched by externalId then name
|
||||
if (sourceEvByKey.size > 0 && newParticipants.length > 0) {
|
||||
if (sourceSimulatorConfig) {
|
||||
await tx.insert(schema.sportsSeasonSimulatorConfigs).values({
|
||||
sportsSeasonId: newSeason.id,
|
||||
simulatorType: sourceSimulatorConfig.simulatorType,
|
||||
config: sourceSimulatorConfig.config,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.copySimulatorInputs && sourceEvByKey.size > 0 && newParticipants.length > 0) {
|
||||
const now = new Date();
|
||||
const evRows = newParticipants
|
||||
.map((newP: typeof newParticipants[number]) => {
|
||||
|
|
@ -310,6 +331,39 @@ export async function cloneSportsSeason(
|
|||
}
|
||||
}
|
||||
|
||||
if (options.copySimulatorInputs && sourceSimulatorInputs.length > 0 && newParticipants.length > 0) {
|
||||
const sourceParticipantById = new Map(sourceParticipants.map((p) => [p.id, p]));
|
||||
const sourceInputByKey = new Map(
|
||||
sourceSimulatorInputs.flatMap((input) => {
|
||||
const sourceParticipant = sourceParticipantById.get(input.participantId);
|
||||
return sourceParticipant ? [[sourceParticipant.externalId ?? sourceParticipant.name, input] as const] : [];
|
||||
})
|
||||
);
|
||||
const simulatorInputRows = newParticipants
|
||||
.map((newP: typeof newParticipants[number]) => {
|
||||
const sourceInput = sourceInputByKey.get(newP.externalId ?? newP.name);
|
||||
if (!sourceInput) return null;
|
||||
return {
|
||||
participantId: newP.id,
|
||||
sportsSeasonId: newSeason.id,
|
||||
sourceOdds: sourceInput.sourceOdds,
|
||||
sourceElo: sourceInput.sourceElo,
|
||||
worldRanking: sourceInput.worldRanking,
|
||||
rating: sourceInput.rating,
|
||||
projectedWins: sourceInput.projectedWins,
|
||||
projectedTablePoints: sourceInput.projectedTablePoints,
|
||||
seed: sourceInput.seed,
|
||||
region: sourceInput.region,
|
||||
metadata: sourceInput.metadata,
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
if (simulatorInputRows.length > 0) {
|
||||
await tx.insert(schema.seasonParticipantSimulatorInputs).values(simulatorInputRows);
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceEvents.length > 0) {
|
||||
await tx.insert(schema.scoringEvents).values(
|
||||
sourceEvents.map((e: typeof sourceEvents[number]) => ({
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default [
|
|||
route("admin", "routes/admin.tsx", [
|
||||
index("routes/admin._index.tsx"),
|
||||
route("sports", "routes/admin.sports.tsx"),
|
||||
route("simulators", "routes/admin.simulators.tsx"),
|
||||
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||
route("sports/:id", "routes/admin.sports.$id.tsx"),
|
||||
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
|
||||
|
|
@ -102,6 +103,10 @@ export default [
|
|||
"sports-seasons/:id/expected-values",
|
||||
"routes/admin.sports-seasons.$id.expected-values.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/simulator",
|
||||
"routes/admin.sports-seasons.$id.simulator.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/futures-odds",
|
||||
"routes/admin.sports-seasons.$id.futures-odds.tsx"
|
||||
|
|
|
|||
86
app/routes/__tests__/admin.simulators.test.ts
Normal file
86
app/routes/__tests__/admin.simulators.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/models/simulator", () => ({
|
||||
listSportsSeasonSimulatorSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/services/simulations/runner", () => ({
|
||||
runSportsSeasonSimulation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { action } from "../admin.simulators";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
|
||||
function postForm(entries: Record<string, string | string[]>) {
|
||||
const body = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) body.append(key, item);
|
||||
} else {
|
||||
body.set(key, value);
|
||||
}
|
||||
}
|
||||
return new Request("http://test/admin/simulators", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("admin simulators action", () => {
|
||||
it("runs one simulator", async () => {
|
||||
vi.mocked(runSportsSeasonSimulation).mockResolvedValue({
|
||||
sportsSeasonId: "season-1",
|
||||
simulatorType: "nba_bracket",
|
||||
simulatedParticipants: 30,
|
||||
zeroedParticipants: 0,
|
||||
snapshotDate: "2026-05-11",
|
||||
});
|
||||
|
||||
const response = await action({
|
||||
request: postForm({ intent: "run-one", sportsSeasonId: "season-1" }),
|
||||
params: {},
|
||||
context: {},
|
||||
} as never);
|
||||
|
||||
expect(runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
||||
expect(response).toEqual({
|
||||
success: true,
|
||||
message: "Simulation run complete: 1/1 succeeded.",
|
||||
results: [{ sportsSeasonId: "season-1", ok: true, message: "Simulated 30 participant(s)." }],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs selected simulators sequentially and reports failures", async () => {
|
||||
vi.mocked(runSportsSeasonSimulation)
|
||||
.mockResolvedValueOnce({
|
||||
sportsSeasonId: "season-1",
|
||||
simulatorType: "nba_bracket",
|
||||
simulatedParticipants: 30,
|
||||
zeroedParticipants: 0,
|
||||
snapshotDate: "2026-05-11",
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("Simulator is not ready: source odds."));
|
||||
|
||||
const response = await action({
|
||||
request: postForm({ intent: "run-selected", sportsSeasonId: ["season-1", "season-2"] }),
|
||||
params: {},
|
||||
context: {},
|
||||
} as never);
|
||||
|
||||
expect(runSportsSeasonSimulation).toHaveBeenNthCalledWith(1, "season-1");
|
||||
expect(runSportsSeasonSimulation).toHaveBeenNthCalledWith(2, "season-2");
|
||||
expect(response).toEqual({
|
||||
success: false,
|
||||
message: "Simulation run complete: 1/2 succeeded.",
|
||||
results: [
|
||||
{ sportsSeasonId: "season-1", ok: true, message: "Simulated 30 participant(s)." },
|
||||
{ sportsSeasonId: "season-2", ok: false, message: "Simulator is not ready: source odds." },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ function makeRequest(iconUrl: string) {
|
|||
return new Request("http://test/admin/sports/sport-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData,
|
||||
body: formData.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function makeRequest(iconUrl: string) {
|
|||
return new Request("http://test/admin/sports/new", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData,
|
||||
body: formData.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
282
app/routes/admin.simulators.tsx
Normal file
282
app/routes/admin.simulators.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { Form, Link, useActionData, useNavigation, useSearchParams } from "react-router";
|
||||
import type { Route } from "./+types/admin.simulators";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
import { Activity, CheckCircle2, Loader2, Play, SlidersHorizontal, XCircle } from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Simulators - Brackt Admin" }];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const simulators = await listSportsSeasonSimulatorSummaries();
|
||||
const sports = [...new Set(simulators.map((sim) => sim.sportName))].toSorted();
|
||||
const simulatorTypes = [...new Set(simulators.map((sim) => sim.simulatorType))].toSorted();
|
||||
return { simulators, sports, simulatorTypes };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
message: string;
|
||||
results?: Array<{ sportsSeasonId: string; ok: boolean; message: string }>;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
const ids = intent === "run-selected"
|
||||
? formData.getAll("sportsSeasonId").filter((id): id is string => typeof id === "string")
|
||||
: [formData.get("sportsSeasonId")].filter((id): id is string => typeof id === "string");
|
||||
|
||||
if (ids.length === 0) {
|
||||
return { success: false, message: "Select at least one simulator to run." };
|
||||
}
|
||||
|
||||
const results: ActionData["results"] = [];
|
||||
for (const sportsSeasonId of ids) {
|
||||
try {
|
||||
const result = await runSportsSeasonSimulation(sportsSeasonId);
|
||||
results.push({
|
||||
sportsSeasonId,
|
||||
ok: true,
|
||||
message: `Simulated ${result.simulatedParticipants} participant(s).`,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
sportsSeasonId,
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Simulation failed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((result) => result.ok).length;
|
||||
return {
|
||||
success: succeeded > 0 && succeeded === results.length,
|
||||
message: `Simulation run complete: ${succeeded}/${results.length} succeeded.`,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
||||
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
||||
}
|
||||
|
||||
export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
||||
const { simulators, sports, simulatorTypes } = loaderData;
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedSport = searchParams.get("sport") ?? "all";
|
||||
const selectedType = searchParams.get("type") ?? "all";
|
||||
const selectedReadiness = searchParams.get("readiness") ?? "all";
|
||||
const selectedStatus = searchParams.get("status") ?? "all";
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const filtered = simulators.filter((sim) => {
|
||||
if (selectedSport !== "all" && sim.sportName !== selectedSport) return false;
|
||||
if (selectedType !== "all" && sim.simulatorType !== selectedType) return false;
|
||||
if (selectedReadiness !== "all" && sim.readiness.status !== selectedReadiness) return false;
|
||||
if (selectedStatus !== "all" && sim.seasonStatus !== selectedStatus) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function updateFilter(key: string, value: string) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value === "all") next.delete(key);
|
||||
else next.set(key, value);
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<Activity className="h-7 w-7" />
|
||||
Simulators
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Inventory, validate, and run every sports-season EV simulator from one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionData && (
|
||||
<Card className={actionData.success ? "border-emerald-500/40" : "border-amber-500/40"}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{actionData.message}</CardTitle>
|
||||
</CardHeader>
|
||||
{actionData.results && (
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
{actionData.results.map((result) => (
|
||||
<div key={result.sportsSeasonId} className={result.ok ? "text-emerald-600" : "text-destructive"}>
|
||||
{result.sportsSeasonId}: {result.message}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>{filtered.length} of {simulators.length} simulator seasons shown</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedSport} onChange={(e) => updateFilter("sport", e.target.value)}>
|
||||
<option value="all">All sports</option>
|
||||
{sports.map((sport) => <option key={sport} value={sport}>{sport}</option>)}
|
||||
</select>
|
||||
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedType} onChange={(e) => updateFilter("type", e.target.value)}>
|
||||
<option value="all">All simulator types</option>
|
||||
{simulatorTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedReadiness} onChange={(e) => updateFilter("readiness", e.target.value)}>
|
||||
<option value="all">All readiness</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="not_ready">Needs setup</option>
|
||||
</select>
|
||||
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedStatus} onChange={(e) => updateFilter("status", e.target.value)}>
|
||||
<option value="all">All season statuses</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Sports-Season Simulators</CardTitle>
|
||||
<CardDescription>
|
||||
Bulk runs execute sequentially and skip nothing silently: failures are reported per season.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Form method="post" id="bulk-run-simulators">
|
||||
<input type="hidden" name="intent" value="run-selected" />
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||||
Run Selected
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>Season</TableHead>
|
||||
<TableHead>Simulator</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Readiness</TableHead>
|
||||
<TableHead>Inputs</TableHead>
|
||||
<TableHead>Last Run</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((sim) => {
|
||||
const canRun = sim.readiness.canRun && sim.simulationStatus !== "running";
|
||||
return (
|
||||
<TableRow key={sim.sportsSeasonId}>
|
||||
<TableCell>
|
||||
<input
|
||||
form="bulk-run-simulators"
|
||||
type="checkbox"
|
||||
name="sportsSeasonId"
|
||||
value={sim.sportsSeasonId}
|
||||
disabled={!canRun}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{sim.sportName} {sim.year}</div>
|
||||
<div className="text-xs text-muted-foreground">{sim.seasonName}</div>
|
||||
{sim.leagueName && (
|
||||
<div className="text-xs text-muted-foreground">League: {sim.leagueName}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{sim.simulatorName}</div>
|
||||
<code className="text-xs text-muted-foreground">{sim.simulatorType}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={sim.seasonStatus === "active" ? "default" : "outline"}>{sim.seasonStatus}</Badge>
|
||||
<Badge variant={sim.simulationStatus === "failed" ? "destructive" : "secondary"}>{sim.simulationStatus}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{statusBadge(sim.readiness.status)}
|
||||
{sim.readiness.missingInputs.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground max-w-64">
|
||||
Missing {sim.readiness.missingInputs.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sim.readiness.warnings.some((warning) => warning.includes("Derived") || warning.includes("fallback")) && (
|
||||
<div className="text-xs text-muted-foreground max-w-64">
|
||||
{sim.readiness.warnings
|
||||
.filter((warning) => warning.includes("Derived") || warning.includes("fallback"))
|
||||
.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{sim.participantInputCount}/{sim.participantCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{sim.lastSimulatedDate ?? "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/simulator`}>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
Setup
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
||||
</Button>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="run-one" />
|
||||
<input type="hidden" name="sportsSeasonId" value={sim.sportsSeasonId} />
|
||||
<Button type="submit" size="sm" disabled={!canRun || isSubmitting}>
|
||||
Run
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,17 +15,12 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Clone ${data?.sourceSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
|
@ -75,6 +70,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
const totalMajors = formData.get("totalMajors");
|
||||
const draftOn = formData.get("draftOn");
|
||||
const draftOff = formData.get("draftOff");
|
||||
const copySimulatorInputs = formData.get("copySimulatorInputs") === "on";
|
||||
|
||||
// Validation
|
||||
if (typeof sportId !== "string" || !sportId) {
|
||||
|
|
@ -152,7 +148,9 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
let newSeason;
|
||||
try {
|
||||
newSeason = await cloneSportsSeason(params.id, newSeasonData as NewSportsSeason);
|
||||
newSeason = await cloneSportsSeason(params.id, newSeasonData as NewSportsSeason, {
|
||||
copySimulatorInputs,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error cloning sports season:", error);
|
||||
return { error: "Failed to clone sports season. Please try again." };
|
||||
|
|
@ -185,7 +183,7 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
|
|||
<CardHeader>
|
||||
<CardTitle>New Sports Season Details</CardTitle>
|
||||
<CardDescription>
|
||||
Review and adjust the pre-filled settings. Participants, events, futures odds, and Elo ratings will be copied automatically.
|
||||
Review and adjust the pre-filled settings. Participants, events, simulator structure, and qualifying-points rules will be copied automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -248,16 +246,17 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||
<Select name="scoringType" defaultValue={defaults.scoringType} required>
|
||||
<SelectTrigger id="scoringType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||
<SelectItem value="majors">Majors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="scoringType"
|
||||
name="scoringType"
|
||||
defaultValue={defaults.scoringType}
|
||||
required
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="playoffs">Playoffs</option>
|
||||
<option value="regular_season">Regular Season</option>
|
||||
<option value="majors">Majors</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||||
</p>
|
||||
|
|
@ -265,16 +264,18 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||||
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
|
||||
<SelectTrigger id="scoringPattern">
|
||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="scoringPattern"
|
||||
name="scoringPattern"
|
||||
value={scoringPattern}
|
||||
onChange={(event) => setScoringPattern(event.target.value)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="">Select scoring pattern (optional)</option>
|
||||
<option value="playoff_bracket">Playoff Bracket</option>
|
||||
<option value="season_standings">Season Standings</option>
|
||||
<option value="qualifying_points">Qualifying Points (Golf/Tennis)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{scoringPattern === "qualifying_points" && (
|
||||
|
|
@ -328,6 +329,16 @@ export default function CloneSportsSeason({ loaderData, actionData }: Route.Comp
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="copySimulatorInputs" name="copySimulatorInputs" type="checkbox" className="h-4 w-4" />
|
||||
<Label htmlFor="copySimulatorInputs">Copy volatile simulator inputs</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave unchecked for a fresh season. Check only when prior-season odds, Elo ratings, rankings, and simulator input metadata should intentionally carry forward.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="flex-1">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -2,21 +2,13 @@ import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'rea
|
|||
import type { Route } from './+types/admin.sports-seasons.$id.elo-ratings';
|
||||
|
||||
import { logger } from '~/lib/logger';
|
||||
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import {
|
||||
getAllParticipantEVsForSeason,
|
||||
batchSaveSourceElos,
|
||||
batchUpsertParticipantEVs,
|
||||
} from '~/models/participant-expected-value';
|
||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types';
|
||||
import { recalculateStandings } from '~/models/scoring-calculator';
|
||||
import { database } from '~/database/context';
|
||||
import * as schema from '~/database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { SimulatorType } from '~/services/simulations/registry';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Input } from '~/components/ui/input';
|
||||
import { Label } from '~/components/ui/label';
|
||||
|
|
@ -38,6 +30,7 @@ import {
|
|||
projectedTablePointsToElo,
|
||||
projectedWinsToElo,
|
||||
} from '~/services/probability-engine';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
|
||||
// Simulator types that use worldRanking in addition to sourceElo
|
||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||||
|
|
@ -168,71 +161,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||||
}
|
||||
|
||||
await batchSaveSourceElos(eloInputs);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
|
||||
|
||||
try {
|
||||
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
||||
const results = await simulator.simulate(sportsSeasonId);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error('Simulation returned no results.');
|
||||
}
|
||||
|
||||
const simulatedIds = new Set(results.map(r => r.participantId));
|
||||
const ZERO_PROBS = {
|
||||
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
|
||||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||||
};
|
||||
const evInputs = [
|
||||
...results.map(r => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
probabilities: r.probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'elo_simulation' as const,
|
||||
})),
|
||||
...participants
|
||||
.filter(p => !simulatedIds.has(p.id))
|
||||
.map(p => ({
|
||||
participantId: p.id,
|
||||
sportsSeasonId,
|
||||
probabilities: ZERO_PROBS,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'elo_simulation' as const,
|
||||
})),
|
||||
];
|
||||
|
||||
await batchUpsertParticipantEVs(evInputs);
|
||||
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map(r => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate: today,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
|
||||
await batchSaveSourceElos(eloInputs);
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
} catch (error) {
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
|
||||
logger.error('Error running simulation:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@ import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'rea
|
|||
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
||||
|
||||
import { logger } from '~/lib/logger';
|
||||
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { getAllParticipantEVsForSeason, batchSaveSourceOdds } from '~/models/participant-expected-value';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Input } from '~/components/ui/input';
|
||||
import { Label } from '~/components/ui/label';
|
||||
|
|
@ -21,6 +18,7 @@ import {
|
|||
} from '~/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Futures Odds — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -98,64 +96,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||||
}
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
// Persist the odds first so the simulator can read them
|
||||
await batchSaveSourceOdds(
|
||||
futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
|
||||
|
||||
try {
|
||||
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
||||
const results = await simulator.simulate(sportsSeasonId);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error('Simulation returned no results.');
|
||||
}
|
||||
|
||||
await batchUpsertParticipantEVs(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
probabilities: r.probabilities,
|
||||
scoringRules,
|
||||
source: 'elo_simulation' as const,
|
||||
}))
|
||||
// Persist the odds first so the simulator can read them.
|
||||
await batchSaveSourceOdds(
|
||||
futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }))
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate: today,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
} catch (error) {
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
|
||||
logger.error('Error running simulation:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -2,128 +2,22 @@
|
|||
* Admin: Run EV Simulation for a Sports Season
|
||||
*
|
||||
* Action-only route. POSTing here:
|
||||
* 1. Guards against concurrent runs via simulationStatus
|
||||
* 2. Runs the appropriate simulator based on the sport's simulatorType
|
||||
* 3. Persists updated EVs to participantExpectedValues (transactional)
|
||||
* 4. Refreshes cached projectedPoints in teamStandings for all affected fantasy seasons
|
||||
* 5. Takes an EV snapshot from simulation output (upsert for today's date)
|
||||
* 1. Delegates validation + execution to the shared simulator runner
|
||||
* 6. Redirects back to the sports season admin page
|
||||
*/
|
||||
|
||||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulate";
|
||||
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
|
||||
export async function action({ params }: Route.ActionArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (!sportsSeason) {
|
||||
throw new Response("Sports season not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (!sportsSeason.sport?.simulatorType) {
|
||||
throw new Response(
|
||||
"This sport has no simulator type configured. Set a simulator type on the sport in the admin panel before running a simulation.",
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Guard against concurrent simulation runs
|
||||
if (sportsSeason.simulationStatus === "running") {
|
||||
throw new Response(
|
||||
"A simulation is already running for this sports season. Please wait for it to complete.",
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as running
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "running" });
|
||||
|
||||
try {
|
||||
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
||||
|
||||
const results = await simulator.simulate(sportsSeasonId);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error("Simulation returned no results. Check that participants have EV data.");
|
||||
}
|
||||
|
||||
// Build a complete set of EV inputs covering ALL season participants.
|
||||
// Participants not included in simulation results (e.g., teams in the season
|
||||
// but not in the bracket) get zeroed out so stale EVs from prior runs don't
|
||||
// inflate the total. Without this, extra participants with old non-zero EVs
|
||||
// would cause the total EV to exceed the expected sum of scoring values (340).
|
||||
const allSeasonParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const simulatedIds = new Set(results.map((r) => r.participantId));
|
||||
const ZERO_PROBS = {
|
||||
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
|
||||
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
|
||||
};
|
||||
const evInputs = [
|
||||
...results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
probabilities: r.probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: "elo_simulation" as const,
|
||||
})),
|
||||
...allSeasonParticipants
|
||||
.filter((p) => !simulatedIds.has(p.id))
|
||||
.map((p) => ({
|
||||
participantId: p.id,
|
||||
sportsSeasonId,
|
||||
probabilities: ZERO_PROBS,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: "elo_simulation" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
// Persist updated EVs (transactional)
|
||||
await batchUpsertParticipantEVs(evInputs);
|
||||
|
||||
// Refresh cached projectedPoints in teamStandings for all affected fantasy seasons
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
|
||||
// Take EV snapshot from simulation output (not re-read from DB)
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate: today,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
// Mark as idle
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
} catch (error) {
|
||||
// Mark as failed so the UI shows the error state
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "failed" });
|
||||
throw error;
|
||||
throw new Response(error instanceof Error ? error.message : "Simulation failed", {
|
||||
status: error instanceof Error && error.message.includes("already running") ? 409 : 400,
|
||||
});
|
||||
}
|
||||
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
|
|
|
|||
474
app/routes/admin.sports-seasons.$id.simulator.tsx
Normal file
474
app/routes/admin.sports-seasons.$id.simulator.tsx
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Play, Save, SlidersHorizontal } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import {
|
||||
batchUpsertParticipantSimulatorInputs,
|
||||
getParticipantSimulatorInputs,
|
||||
getSportsSeasonSimulatorConfig,
|
||||
upsertSportsSeasonSimulatorConfig,
|
||||
validateSimulatorReadiness,
|
||||
type UpsertParticipantSimulatorInput,
|
||||
} from "~/models/simulator";
|
||||
import { normalizeName } from "~/lib/fuzzy-match";
|
||||
import { getSimulatorInputPolicy, type MissingEloStrategy } from "~/services/simulations/input-policy";
|
||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Simulator Setup - ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
if (!sportsSeason) {
|
||||
throw new Response("Sports season not found", { status: 404 });
|
||||
}
|
||||
|
||||
const [participants, config, inputs, readiness] = await Promise.all([
|
||||
findParticipantsBySportsSeasonId(params.id),
|
||||
getSportsSeasonSimulatorConfig(params.id),
|
||||
getParticipantSimulatorInputs(params.id),
|
||||
validateSimulatorReadiness(params.id),
|
||||
]);
|
||||
|
||||
if (!config) {
|
||||
throw new Response("This sports season does not have a simulator configured.", { status: 404 });
|
||||
}
|
||||
|
||||
const inputMap = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
const inputRows = participants.map((participant) => ({
|
||||
participant,
|
||||
input: inputMap.get(participant.id) ?? null,
|
||||
}));
|
||||
|
||||
return { sportsSeason, participants, config, inputRows, readiness };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
success?: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parsePolicyNumber(formData: FormData, key: string, fallback: number): number {
|
||||
const value = formData.get(key);
|
||||
if (typeof value !== "string" || value.trim() === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseMissingEloStrategy(value: FormDataEntryValue | null): MissingEloStrategy {
|
||||
return value === "fallbackElo" || value === "averageKnown" || value === "worstKnownMinus"
|
||||
? value
|
||||
: "block";
|
||||
}
|
||||
|
||||
function findParticipantId(name: string, participants: Array<{ id: string; name: string }>): string | null {
|
||||
const normalizedInput = normalizeName(name);
|
||||
const normalized = participants.map((participant) => ({
|
||||
participant,
|
||||
normalized: normalizeName(participant.name),
|
||||
}));
|
||||
|
||||
return (
|
||||
normalized.find((candidate) => candidate.normalized === normalizedInput)?.participant.id ??
|
||||
normalized.find((candidate) =>
|
||||
candidate.normalized.includes(normalizedInput) || normalizedInput.includes(candidate.normalized)
|
||||
)?.participant.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function parseInputCsv(
|
||||
text: string,
|
||||
sportsSeasonId: string,
|
||||
participants: Array<{ id: string; name: string }>
|
||||
): { inputs: UpsertParticipantSimulatorInput[]; unmatched: string[] } {
|
||||
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return { inputs: [], unmatched: [] };
|
||||
|
||||
const header = lines[0].split(",").map((value) => value.trim());
|
||||
const indexes = new Map(header.map((value, index) => [value, index]));
|
||||
const nameIndex = indexes.get("name");
|
||||
if (nameIndex === undefined) {
|
||||
throw new Error("Bulk input CSV must include a `name` column.");
|
||||
}
|
||||
|
||||
const inputs: UpsertParticipantSimulatorInput[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line.split(",").map((value) => value.trim());
|
||||
const name = cols[nameIndex];
|
||||
if (!name) continue;
|
||||
|
||||
const participantId = findParticipantId(name, participants);
|
||||
if (!participantId) {
|
||||
unmatched.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
inputs.push({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
sourceElo: parseOptionalNumber(cols[indexes.get("sourceElo") ?? -1]) ?? undefined,
|
||||
sourceOdds: parseOptionalNumber(cols[indexes.get("sourceOdds") ?? -1]) ?? undefined,
|
||||
worldRanking: parseOptionalNumber(cols[indexes.get("worldRanking") ?? -1]) ?? undefined,
|
||||
rating: parseOptionalNumber(cols[indexes.get("rating") ?? -1]) ?? undefined,
|
||||
projectedWins: parseOptionalNumber(cols[indexes.get("projectedWins") ?? -1]) ?? undefined,
|
||||
projectedTablePoints: parseOptionalNumber(cols[indexes.get("projectedTablePoints") ?? -1]) ?? undefined,
|
||||
seed: parseOptionalNumber(cols[indexes.get("seed") ?? -1]) ?? undefined,
|
||||
region: cols[indexes.get("region") ?? -1] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { inputs, unmatched };
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs): Promise<ActionData | Response> {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
const sportsSeasonId = params.id;
|
||||
|
||||
if (intent === "run") {
|
||||
try {
|
||||
await runSportsSeasonSimulation(sportsSeasonId);
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Simulation failed." };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "save-config") {
|
||||
const rawConfig = formData.get("config");
|
||||
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
||||
|
||||
try {
|
||||
const parsed = typeof rawConfig === "string" && rawConfig.trim()
|
||||
? JSON.parse(rawConfig) as Record<string, unknown>
|
||||
: {};
|
||||
// Preserve an existing inputPolicy if the submitted JSON omits it, so
|
||||
// editing other config keys doesn't silently wipe a previously configured
|
||||
// missing-Elo strategy.
|
||||
const config = "inputPolicy" in parsed
|
||||
? parsed
|
||||
: { ...parsed, ...(currentConfig.config.inputPolicy !== undefined ? { inputPolicy: currentConfig.config.inputPolicy } : {}) };
|
||||
await upsertSportsSeasonSimulatorConfig({
|
||||
sportsSeasonId,
|
||||
simulatorType: currentConfig.simulatorType,
|
||||
config,
|
||||
});
|
||||
return { success: true, message: "Simulator config saved." };
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Invalid config JSON." };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "save-input-policy") {
|
||||
const currentConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!currentConfig) return { success: false, message: "Simulator config not found." };
|
||||
|
||||
const currentPolicy = getSimulatorInputPolicy(currentConfig.config);
|
||||
await upsertSportsSeasonSimulatorConfig({
|
||||
sportsSeasonId,
|
||||
simulatorType: currentConfig.simulatorType,
|
||||
config: {
|
||||
...currentConfig.config,
|
||||
inputPolicy: {
|
||||
missingEloStrategy: parseMissingEloStrategy(formData.get("missingEloStrategy")),
|
||||
fallbackElo: parsePolicyNumber(formData, "fallbackElo", currentPolicy.fallbackElo),
|
||||
fallbackEloDelta: parsePolicyNumber(formData, "fallbackEloDelta", currentPolicy.fallbackEloDelta),
|
||||
eloMin: parsePolicyNumber(formData, "eloMin", currentPolicy.eloMin),
|
||||
eloMax: parsePolicyNumber(formData, "eloMax", currentPolicy.eloMax),
|
||||
ratingMin: parsePolicyNumber(formData, "ratingMin", currentPolicy.ratingMin),
|
||||
ratingMax: parsePolicyNumber(formData, "ratingMax", currentPolicy.ratingMax),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { success: true, message: "Simulator input policy saved." };
|
||||
}
|
||||
|
||||
if (intent === "save-inputs") {
|
||||
const rawInputs = formData.get("bulkInputs");
|
||||
if (typeof rawInputs !== "string" || rawInputs.trim() === "") {
|
||||
return { success: false, message: "Paste at least one input row before saving." };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
try {
|
||||
const parsed = parseInputCsv(rawInputs, sportsSeasonId, participants);
|
||||
if (parsed.inputs.length === 0) {
|
||||
return { success: false, message: "No matching participants found in the pasted inputs." };
|
||||
}
|
||||
await batchUpsertParticipantSimulatorInputs(parsed.inputs);
|
||||
const suffix = parsed.unmatched.length > 0
|
||||
? ` ${parsed.unmatched.length} row(s) were unmatched: ${parsed.unmatched.join(", ")}.`
|
||||
: "";
|
||||
return { success: true, message: `Saved ${parsed.inputs.length} simulator input row(s).${suffix}` };
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : "Failed to save simulator inputs." };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: "Unknown simulator setup action." };
|
||||
}
|
||||
|
||||
export default function AdminSportsSeasonSimulator({ loaderData }: Route.ComponentProps) {
|
||||
const { sportsSeason, config, inputRows, readiness } = loaderData;
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
const setupSections = config.profile.setupSections;
|
||||
const inputPolicy = getSimulatorInputPolicy(config.config);
|
||||
const sourceEloAlternatives = config.profile.derivableInputs?.sourceElo ?? [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Sports Season
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/admin/simulators">All Simulators</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-7 w-7" />
|
||||
Simulator Setup
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionData && (
|
||||
<Card className={actionData.success ? "border-emerald-500/40" : "border-destructive/40"}>
|
||||
<CardContent className="py-4 text-sm">{actionData.message}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{config.profile.displayName}</CardTitle>
|
||||
<CardDescription>{config.profile.description}</CardDescription>
|
||||
</div>
|
||||
{readiness.status === "ready" ? (
|
||||
<Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1"><AlertCircle className="h-3 w-3" /> Needs setup</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">Simulator Type</div>
|
||||
<code className="text-sm">{config.simulatorType}</code>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">Participant Inputs</div>
|
||||
<div className="font-medium">{readiness.participantInputCount}/{readiness.participantCount}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">Simulation Status</div>
|
||||
<div className="font-medium">{sportsSeason.simulationStatus}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readiness.missingInputs.length > 0 && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm">
|
||||
Missing: {readiness.missingInputs.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readiness.warnings.length > 0 && (
|
||||
<div className="rounded-md border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
||||
{readiness.warnings.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{setupSections.includes("futuresOdds") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}>Futures Odds</Link></Button>}
|
||||
{setupSections.includes("eloRatings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`}>Elo Ratings</Link></Button>}
|
||||
{setupSections.includes("surfaceElo") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/surface-elo`}>Surface Elo</Link></Button>}
|
||||
{setupSections.includes("golfSkills") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/golf-skills`}>Golf Skills</Link></Button>}
|
||||
{setupSections.includes("regularStandings") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/regular-standings`}>Regular Standings</Link></Button>}
|
||||
{setupSections.includes("events") && <Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>Events</Link></Button>}
|
||||
<Button variant="outline" size="sm" asChild><Link to={`/admin/sports-seasons/${sportsSeason.id}/expected-values`}>Expected Values</Link></Button>
|
||||
</div>
|
||||
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="run" />
|
||||
<Button type="submit" disabled={!readiness.canRun || sportsSeason.simulationStatus === "running" || isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
||||
Run Simulation
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input Policy</CardTitle>
|
||||
<CardDescription>
|
||||
Direct inputs win. When present, this simulator can derive Elo from{" "}
|
||||
{sourceEloAlternatives.length > 0 ? sourceEloAlternatives.join(", ") : "no alternate inputs"}.
|
||||
Rating-based simulators may also derive preseason ratings from futures odds. Tail fallbacks are explicit
|
||||
so low-impact missing participants do not silently get invented ratings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||
<input type="hidden" name="intent" value="save-input-policy" />
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="missingEloStrategy">Missing Elo Strategy</Label>
|
||||
<select
|
||||
id="missingEloStrategy"
|
||||
name="missingEloStrategy"
|
||||
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
|
||||
defaultValue={inputPolicy.missingEloStrategy}
|
||||
>
|
||||
<option value="block">Block until complete</option>
|
||||
<option value="fallbackElo">Use fixed fallback Elo</option>
|
||||
<option value="averageKnown">Use average known Elo</option>
|
||||
<option value="worstKnownMinus">Use worst known Elo minus delta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackElo">Fallback Elo</Label>
|
||||
<Input id="fallbackElo" name="fallbackElo" type="number" defaultValue={inputPolicy.fallbackElo} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallbackEloDelta">Worst Delta</Label>
|
||||
<Input id="fallbackEloDelta" name="fallbackEloDelta" type="number" defaultValue={inputPolicy.fallbackEloDelta} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMin">Elo Floor</Label>
|
||||
<Input id="eloMin" name="eloMin" type="number" defaultValue={inputPolicy.eloMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eloMax">Elo Ceiling</Label>
|
||||
<Input id="eloMax" name="eloMax" type="number" defaultValue={inputPolicy.eloMax} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMin">Rating Floor</Label>
|
||||
<Input id="ratingMin" name="ratingMin" type="number" step="0.01" defaultValue={inputPolicy.ratingMin} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ratingMax">Rating Ceiling</Label>
|
||||
<Input id="ratingMax" name="ratingMax" type="number" step="0.01" defaultValue={inputPolicy.ratingMax} />
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Input Policy
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Season Config</CardTitle>
|
||||
<CardDescription>
|
||||
JSON overrides for this specific sports season. Defaults come from the simulator profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="save-config" />
|
||||
<Textarea
|
||||
name="config"
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
defaultValue={JSON.stringify(config.config, null, 2)}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Config
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bulk Simulator Inputs</CardTitle>
|
||||
<CardDescription>
|
||||
Paste CSV with a header row. Supported columns: name, sourceElo, sourceOdds, worldRanking, rating,
|
||||
projectedWins, projectedTablePoints, seed, region. Values must not contain commas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="save-inputs" />
|
||||
<Textarea
|
||||
name="bulkInputs"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
placeholder={`name,sourceElo,sourceOdds,worldRanking,rating\n${inputRows[0]?.participant.name ?? "Team Name"},1500,+2500,12,28.5`}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Inputs
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-6 gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-2">Participant</div>
|
||||
<div>Elo</div>
|
||||
<div>Odds</div>
|
||||
<div>Rank</div>
|
||||
<div>Rating</div>
|
||||
</div>
|
||||
{inputRows.slice(0, 20).map(({ participant, input }) => (
|
||||
<div key={participant.id} className="grid grid-cols-6 gap-2 border-b last:border-b-0 px-3 py-2 text-sm">
|
||||
<div className="col-span-2 font-medium">{participant.name}</div>
|
||||
<div>{input?.sourceElo ?? "—"}</div>
|
||||
<div>{input?.sourceOdds ?? "—"}</div>
|
||||
<div>{input?.worldRanking ?? "—"}</div>
|
||||
<div>{input?.rating ?? "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
{inputRows.length > 20 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Showing 20 of {inputRows.length} participants
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,13 +28,6 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -50,6 +43,8 @@ import { Badge } from "~/components/ui/badge";
|
|||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
|
@ -382,30 +377,26 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select name="status" defaultValue={sportsSeason.status} required>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="status" name="status" defaultValue={sportsSeason.status} required className={SELECT_CLASS}>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||
<Select name="scoringType" defaultValue={sportsSeason.scoringType} required>
|
||||
<SelectTrigger id="scoringType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||
<SelectItem value="majors">Majors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="scoringType"
|
||||
name="scoringType"
|
||||
defaultValue={sportsSeason.scoringType}
|
||||
required
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="playoffs">Playoffs</option>
|
||||
<option value="regular_season">Regular Season</option>
|
||||
<option value="majors">Majors</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||||
</p>
|
||||
|
|
@ -413,16 +404,18 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||||
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
|
||||
<SelectTrigger id="scoringPattern">
|
||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="scoringPattern"
|
||||
name="scoringPattern"
|
||||
value={scoringPattern}
|
||||
onChange={(event) => setScoringPattern(event.target.value)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="">Select scoring pattern (optional)</option>
|
||||
<option value="playoff_bracket">Playoff Bracket</option>
|
||||
<option value="season_standings">Season Standings</option>
|
||||
<option value="qualifying_points">Qualifying Points (Golf/Tennis)</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
|
||||
</p>
|
||||
|
|
@ -555,6 +548,14 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
Last run failed
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/simulator`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Simulator Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -760,18 +761,14 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
<p className="text-sm font-medium truncate">{mapping.teamName}</p>
|
||||
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p>
|
||||
</div>
|
||||
<Select name="participantId" required>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Select participant…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participants.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select name="participantId" required defaultValue="" className={`${SELECT_CLASS} w-56`}>
|
||||
<option value="" disabled>Select participant...</option>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm">
|
||||
Resolve
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -14,15 +14,10 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useState } from "react";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "New Sports Season - Brackt Admin" }];
|
||||
}
|
||||
|
|
@ -148,18 +143,14 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
|
|||
<Form method="post" className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sportId">Sport</Label>
|
||||
<Select name="sportId" required>
|
||||
<SelectTrigger id="sportId">
|
||||
<SelectValue placeholder="Select a sport" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sports.map((sport) => (
|
||||
<SelectItem key={sport.id} value={sport.id}>
|
||||
{sport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="sportId" name="sportId" required defaultValue="" className={SELECT_CLASS}>
|
||||
<option value="" disabled>Select a sport</option>
|
||||
{sports.map((sport) => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{sports.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No sports available. <Link to="/admin/sports/new" className="underline">Create a sport first</Link>.
|
||||
|
|
@ -213,30 +204,21 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select name="status" defaultValue="upcoming" required>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="status" name="status" defaultValue="upcoming" required className={SELECT_CLASS}>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||
<Select name="scoringType" required>
|
||||
<SelectTrigger id="scoringType">
|
||||
<SelectValue placeholder="Select scoring type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||
<SelectItem value="majors">Majors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="scoringType" name="scoringType" required defaultValue="" className={SELECT_CLASS}>
|
||||
<option value="" disabled>Select scoring type</option>
|
||||
<option value="playoffs">Playoffs</option>
|
||||
<option value="regular_season">Regular Season</option>
|
||||
<option value="majors">Majors</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||||
</p>
|
||||
|
|
@ -244,16 +226,18 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||||
<Select name="scoringPattern" onValueChange={setScoringPattern}>
|
||||
<SelectTrigger id="scoringPattern">
|
||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="scoringPattern"
|
||||
name="scoringPattern"
|
||||
value={scoringPattern}
|
||||
onChange={(event) => setScoringPattern(event.target.value)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="">Select scoring pattern (optional)</option>
|
||||
<option value="playoff_bracket">Playoff Bracket</option>
|
||||
<option value="season_standings">Season Standings</option>
|
||||
<option value="qualifying_points">Qualifying Points (Golf/Tennis)</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -17,15 +17,10 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { SIMULATOR_TYPES, getSimulatorInfo } from "~/services/simulations/registry";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `${data?.sport?.name ?? "Sport"} - Brackt Admin` }];
|
||||
}
|
||||
|
|
@ -156,15 +151,10 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select name="type" defaultValue={sport.type} required>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue placeholder="Select sport type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team">Team Sport</SelectItem>
|
||||
<SelectItem value="individual">Individual Sport</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="type" name="type" defaultValue={sport.type} required className={SELECT_CLASS}>
|
||||
<option value="team">Team Sport</option>
|
||||
<option value="individual">Individual Sport</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Team sports have teams, individual sports have players
|
||||
</p>
|
||||
|
|
@ -188,25 +178,25 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
|
||||
<Select name="simulatorType" defaultValue={sport.simulatorType ?? "none"}>
|
||||
<SelectTrigger id="simulatorType">
|
||||
<SelectValue placeholder="No simulator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No simulator</SelectItem>
|
||||
{[...SIMULATOR_TYPES]
|
||||
.toSorted((a, b) => {
|
||||
const nameA = getSimulatorInfo(a)?.name ?? a;
|
||||
const nameB = getSimulatorInfo(b)?.name ?? b;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getSimulatorInfo(type)?.name ?? type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
id="simulatorType"
|
||||
name="simulatorType"
|
||||
defaultValue={sport.simulatorType ?? "none"}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="none">No simulator</option>
|
||||
{[...SIMULATOR_TYPES]
|
||||
.toSorted((a, b) => {
|
||||
const nameA = getSimulatorInfo(a)?.name ?? a;
|
||||
const nameB = getSimulatorInfo(b)?.name ?? b;
|
||||
return nameA.localeCompare(nameB);
|
||||
})
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getSimulatorInfo(type)?.name ?? type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Algorithm used when running EV simulations for this sport
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -17,13 +17,8 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "New Sport - Brackt Admin" }];
|
||||
|
|
@ -121,15 +116,11 @@ export default function NewSport({ loaderData, actionData }: Route.ComponentProp
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select name="type" required>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue placeholder="Select sport type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team">Team Sport</SelectItem>
|
||||
<SelectItem value="individual">Individual Sport</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select id="type" name="type" required defaultValue="" className={SELECT_CLASS}>
|
||||
<option value="" disabled>Select sport type</option>
|
||||
<option value="team">Team Sport</option>
|
||||
<option value="individual">Individual Sport</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Team sports have teams, individual sports have players
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
FolderKanban,
|
||||
RefreshCw,
|
||||
Award,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
|
|
@ -60,6 +61,12 @@ export default function AdminLayout() {
|
|||
Sports Seasons
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to="/admin/simulators">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Simulators
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to="/admin/tournaments">
|
||||
<Award className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
68
app/services/simulations/__tests__/input-policy.test.ts
Normal file
68
app/services/simulations/__tests__/input-policy.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRatings, resolveSourceElos } from "../input-policy";
|
||||
import type { SimulatorManifestProfile } from "../manifest";
|
||||
|
||||
const profile = {
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||
|
||||
describe("simulator input policy", () => {
|
||||
it("keeps direct Elo ahead of derived values", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[{ participantId: "team-1", sourceElo: 1600, rating: null, sourceOdds: 2000, projectedWins: 20, projectedTablePoints: null }],
|
||||
profile,
|
||||
{ seasonGames: 82 }
|
||||
);
|
||||
|
||||
expect(resolved.get("team-1")).toMatchObject({ sourceElo: 1600, method: "direct" });
|
||||
});
|
||||
|
||||
it("derives Elo from projected wins when Elo is missing", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: 60, projectedTablePoints: null }],
|
||||
profile,
|
||||
{ seasonGames: 82, parityFactor: 400 }
|
||||
);
|
||||
|
||||
expect(resolved.get("team-1")?.method).toBe("projectedWins");
|
||||
expect(resolved.get("team-1")?.sourceElo).toBeGreaterThan(1500);
|
||||
});
|
||||
|
||||
it("blocks missing Elo unless a fallback strategy is configured", () => {
|
||||
const inputs = [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }];
|
||||
|
||||
expect(resolveSourceElos(inputs, profile, {}).has("team-1")).toBe(false);
|
||||
expect(resolveSourceElos(inputs, profile, {
|
||||
inputPolicy: { missingEloStrategy: "fallbackElo", fallbackElo: 1375 },
|
||||
}).get("team-1")).toMatchObject({ sourceElo: 1375, method: "fallbackElo" });
|
||||
});
|
||||
|
||||
it("supports worst-known-minus tail fallback", () => {
|
||||
const resolved = resolveSourceElos(
|
||||
[
|
||||
{ participantId: "known-1", sourceElo: 1500, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "known-2", sourceElo: 1430, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "tail", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
profile,
|
||||
{ inputPolicy: { missingEloStrategy: "worstKnownMinus", fallbackEloDelta: 30 } }
|
||||
);
|
||||
|
||||
expect(resolved.get("tail")).toMatchObject({ sourceElo: 1400, method: "worstKnownMinus" });
|
||||
});
|
||||
|
||||
it("derives ratings from futures odds when the profile allows it", () => {
|
||||
const resolved = resolveRatings(
|
||||
[
|
||||
{ participantId: "favorite", sourceElo: null, rating: null, sourceOdds: 300, projectedWins: null, projectedTablePoints: null },
|
||||
{ participantId: "longshot", sourceElo: null, rating: null, sourceOdds: 20000, projectedWins: null, projectedTablePoints: null },
|
||||
],
|
||||
{ derivableInputs: { rating: ["sourceOdds"] } },
|
||||
{ inputPolicy: { ratingMin: -10, ratingMax: 35 } }
|
||||
);
|
||||
|
||||
expect(resolved.get("favorite")?.method).toBe("sourceOdds");
|
||||
expect(resolved.get("longshot")?.method).toBe("sourceOdds");
|
||||
expect(resolved.get("favorite")?.rating).toBeGreaterThan(resolved.get("longshot")?.rating ?? 999);
|
||||
});
|
||||
});
|
||||
42
app/services/simulations/__tests__/manifest.test.ts
Normal file
42
app/services/simulations/__tests__/manifest.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as schema from "~/database/schema";
|
||||
import { SIMULATOR_MANIFEST } from "../manifest";
|
||||
import { SIMULATOR_TYPES } from "../registry";
|
||||
import { assertRegistrySchemaDriftFree } from "~/models/simulator";
|
||||
|
||||
describe("simulator manifest", () => {
|
||||
it("has a manifest profile for every registered simulator type", () => {
|
||||
expect(Object.keys(SIMULATOR_MANIFEST).toSorted()).toEqual([...SIMULATOR_TYPES].toSorted());
|
||||
});
|
||||
|
||||
it("keeps the registry and database enum in sync", () => {
|
||||
expect([...schema.simulatorTypeEnum.enumValues].toSorted()).toEqual([...SIMULATOR_TYPES].toSorted());
|
||||
});
|
||||
|
||||
it("assertRegistrySchemaDriftFree does not throw when registry and schema are aligned", async () => {
|
||||
await expect(assertRegistrySchemaDriftFree()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents admin setup requirements for all profiles", () => {
|
||||
for (const simulatorType of SIMULATOR_TYPES) {
|
||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||
expect(profile.displayName).toBeTruthy();
|
||||
expect(profile.description).toBeTruthy();
|
||||
expect(Array.isArray(profile.requiredInputs)).toBe(true);
|
||||
expect(Array.isArray(profile.optionalInputs)).toBe(true);
|
||||
expect(profile.setupSections.length).toBeGreaterThan(0);
|
||||
expect(profile.defaultConfig).toBeTypeOf("object");
|
||||
}
|
||||
});
|
||||
|
||||
it("only derives inputs from declared optional inputs", () => {
|
||||
for (const simulatorType of SIMULATOR_TYPES) {
|
||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||
for (const alternatives of Object.values(profile.derivableInputs ?? {})) {
|
||||
for (const alternative of alternatives ?? []) {
|
||||
expect(profile.optionalInputs).toContain(alternative);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
169
app/services/simulations/__tests__/runner.test.ts
Normal file
169
app/services/simulations/__tests__/runner.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/models/sports-season", () => ({
|
||||
findSportsSeasonById: vi.fn(),
|
||||
updateSportsSeason: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/simulator", () => ({
|
||||
getSportsSeasonSimulatorConfig: vi.fn(),
|
||||
validateSimulatorReadiness: vi.fn(),
|
||||
prepareSimulatorInputsForRun: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", () => ({
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-expected-value", () => ({
|
||||
batchUpsertParticipantEVs: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/ev-snapshot", () => ({
|
||||
batchUpsertParticipantEvSnapshots: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", () => ({
|
||||
recalculateStandings: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/simulations/registry", () => ({
|
||||
getSimulator: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/simulations/simulation-probabilities", () => ({
|
||||
normalizeSimulationResultColumns: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(() => ({
|
||||
query: {
|
||||
seasonSports: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
seasons: { findFirst: vi.fn() },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { runSportsSeasonSimulation } from "../runner";
|
||||
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
|
||||
import {
|
||||
getSportsSeasonSimulatorConfig,
|
||||
validateSimulatorReadiness,
|
||||
prepareSimulatorInputsForRun,
|
||||
} from "~/models/simulator";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { getSimulator } from "~/services/simulations/registry";
|
||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||
|
||||
const SEASON = {
|
||||
id: "season-1",
|
||||
simulationStatus: "idle",
|
||||
fantasySeasonId: null,
|
||||
};
|
||||
|
||||
const CONFIG = {
|
||||
sportsSeasonId: "season-1",
|
||||
simulatorType: "nba_bracket" as const,
|
||||
config: {},
|
||||
profile: { displayName: "NBA Bracket", requiredInputs: [], optionalInputs: [], setupSections: [] },
|
||||
};
|
||||
|
||||
const READY = { canRun: true, missingInputs: [], status: "ready" as const };
|
||||
|
||||
const PARTICIPANTS = [{ id: "p1" }, { id: "p2" }];
|
||||
|
||||
const RESULTS = [
|
||||
{
|
||||
participantId: "p1",
|
||||
probabilities: {
|
||||
probFirst: 0.5, probSecond: 0.2, probThird: 0.1, probFourth: 0.1,
|
||||
probFifth: 0.05, probSixth: 0.03, probSeventh: 0.01, probEighth: 0.01,
|
||||
},
|
||||
source: "elo_simulation" as const,
|
||||
},
|
||||
{
|
||||
participantId: "p2",
|
||||
probabilities: {
|
||||
probFirst: 0.5, probSecond: 0.2, probThird: 0.1, probFourth: 0.1,
|
||||
probFifth: 0.05, probSixth: 0.03, probSeventh: 0.01, probEighth: 0.01,
|
||||
},
|
||||
source: "elo_simulation" as const,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(SEASON as never);
|
||||
vi.mocked(getSportsSeasonSimulatorConfig).mockResolvedValue(CONFIG as never);
|
||||
vi.mocked(validateSimulatorReadiness).mockResolvedValue(READY as never);
|
||||
vi.mocked(prepareSimulatorInputsForRun).mockResolvedValue(undefined);
|
||||
vi.mocked(updateSportsSeason).mockResolvedValue(undefined as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(PARTICIPANTS as never);
|
||||
vi.mocked(batchUpsertParticipantEVs).mockResolvedValue([] as never);
|
||||
vi.mocked(batchUpsertParticipantEvSnapshots).mockResolvedValue(undefined as never);
|
||||
vi.mocked(getSimulator).mockReturnValue({ simulate: vi.fn().mockResolvedValue(RESULTS) } as never);
|
||||
vi.mocked(normalizeSimulationResultColumns).mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
describe("runSportsSeasonSimulation", () => {
|
||||
it("runs and returns a summary with zeroed participants for those not in results", async () => {
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
...PARTICIPANTS,
|
||||
{ id: "p3" },
|
||||
] as never);
|
||||
|
||||
const result = await runSportsSeasonSimulation("season-1");
|
||||
|
||||
expect(result.simulatedParticipants).toBe(2);
|
||||
expect(result.zeroedParticipants).toBe(1);
|
||||
expect(result.simulatorType).toBe("nba_bracket");
|
||||
expect(result.snapshotDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("sets simulationStatus to running before simulate and back to idle after", async () => {
|
||||
await runSportsSeasonSimulation("season-1");
|
||||
|
||||
expect(vi.mocked(updateSportsSeason).mock.calls[0]).toEqual(["season-1", { simulationStatus: "running" }]);
|
||||
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
||||
});
|
||||
|
||||
it("throws when the sports season is not found", async () => {
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||
|
||||
await expect(runSportsSeasonSimulation("missing")).rejects.toThrow("Sports season not found");
|
||||
});
|
||||
|
||||
it("throws when no simulator config is set", async () => {
|
||||
vi.mocked(getSportsSeasonSimulatorConfig).mockResolvedValue(null);
|
||||
|
||||
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("no simulator type configured");
|
||||
});
|
||||
|
||||
it("throws when a simulation is already running", async () => {
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue({ ...SEASON, simulationStatus: "running" } as never);
|
||||
|
||||
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("already running");
|
||||
});
|
||||
|
||||
it("throws when readiness check fails", async () => {
|
||||
vi.mocked(validateSimulatorReadiness).mockResolvedValue({
|
||||
canRun: false,
|
||||
missingInputs: ["Elo rating for 3 participant(s)"],
|
||||
status: "not_ready",
|
||||
} as never);
|
||||
|
||||
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("not ready");
|
||||
});
|
||||
|
||||
it("sets simulationStatus to failed and re-throws when the simulator errors", async () => {
|
||||
vi.mocked(getSimulator).mockReturnValue({
|
||||
simulate: vi.fn().mockRejectedValue(new Error("bracket data missing")),
|
||||
} as never);
|
||||
|
||||
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("bracket data missing");
|
||||
expect(vi.mocked(updateSportsSeason).mock.calls.at(-1)).toEqual(["season-1", { simulationStatus: "failed" }]);
|
||||
});
|
||||
|
||||
it("sets simulationStatus to failed and re-throws when simulate returns no results", async () => {
|
||||
vi.mocked(getSimulator).mockReturnValue({
|
||||
simulate: vi.fn().mockResolvedValue([]),
|
||||
} as never);
|
||||
|
||||
await expect(runSportsSeasonSimulation("season-1")).rejects.toThrow("no results");
|
||||
expect(vi.mocked(updateSportsSeason).mock.calls.at(-1)).toEqual(["season-1", { simulationStatus: "failed" }]);
|
||||
});
|
||||
});
|
||||
231
app/services/simulations/input-policy.ts
Normal file
231
app/services/simulations/input-policy.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { convertFuturesToElo } from "~/services/probability-engine";
|
||||
import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest";
|
||||
|
||||
export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
|
||||
|
||||
export interface SimulatorInputPolicy {
|
||||
missingEloStrategy: MissingEloStrategy;
|
||||
fallbackElo: number;
|
||||
fallbackEloDelta: number;
|
||||
eloMin: number;
|
||||
eloMax: number;
|
||||
ratingMin: number;
|
||||
ratingMax: number;
|
||||
}
|
||||
|
||||
export interface ParticipantInputForPolicy {
|
||||
participantId: string;
|
||||
sourceOdds: number | null;
|
||||
sourceElo: number | null;
|
||||
rating: number | null;
|
||||
projectedWins: number | null;
|
||||
projectedTablePoints: number | null;
|
||||
}
|
||||
|
||||
export interface ResolvedSourceElo {
|
||||
participantId: string;
|
||||
sourceElo: number;
|
||||
method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy;
|
||||
}
|
||||
|
||||
export interface ResolvedRating {
|
||||
participantId: string;
|
||||
rating: number;
|
||||
method: "direct" | "sourceOdds";
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: SimulatorInputPolicy = {
|
||||
missingEloStrategy: "block",
|
||||
fallbackElo: 1400,
|
||||
fallbackEloDelta: 25,
|
||||
eloMin: 1100,
|
||||
eloMax: 1900,
|
||||
ratingMin: -20,
|
||||
ratingMax: 40,
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toWinRate(projection: number, maxProjection: number): number {
|
||||
if (maxProjection <= 0) return 0.5;
|
||||
return clamp(projection / maxProjection, 0.01, 0.99);
|
||||
}
|
||||
|
||||
function projectionToElo(
|
||||
projection: number,
|
||||
maxProjection: number,
|
||||
parityFactor: number,
|
||||
policy: SimulatorInputPolicy
|
||||
): number {
|
||||
const rate = toWinRate(projection, maxProjection);
|
||||
const elo = 1500 + parityFactor * Math.log10(rate / (1 - rate));
|
||||
return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
|
||||
}
|
||||
|
||||
export function getSimulatorInputPolicy(config: Record<string, unknown>): SimulatorInputPolicy {
|
||||
const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
|
||||
const strategy = rawPolicy.missingEloStrategy;
|
||||
|
||||
return {
|
||||
missingEloStrategy:
|
||||
strategy === "fallbackElo" || strategy === "averageKnown" || strategy === "worstKnownMinus"
|
||||
? strategy
|
||||
: "block",
|
||||
fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
|
||||
fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
|
||||
eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
|
||||
eloMax: optionalNumber(rawPolicy.eloMax, DEFAULT_POLICY.eloMax),
|
||||
ratingMin: optionalNumber(rawPolicy.ratingMin, DEFAULT_POLICY.ratingMin),
|
||||
ratingMax: optionalNumber(rawPolicy.ratingMax, DEFAULT_POLICY.ratingMax),
|
||||
};
|
||||
}
|
||||
|
||||
export function sourceEloRequirementLabel(profile: SimulatorManifestProfile): string {
|
||||
const alternatives = profile.derivableInputs?.sourceElo ?? [];
|
||||
// Omit "configured fallback" from the label: if a participant appears in
|
||||
// missingInputs it means no fallback resolved it, so mentioning fallback
|
||||
// as an option would be misleading.
|
||||
return ["Elo rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
|
||||
}
|
||||
|
||||
export function ratingRequirementLabel(profile: SimulatorManifestProfile): string {
|
||||
const alternatives = profile.derivableInputs?.rating ?? [];
|
||||
return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
|
||||
}
|
||||
|
||||
export function resolveSourceElos(
|
||||
inputs: ParticipantInputForPolicy[],
|
||||
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
|
||||
config: Record<string, unknown>
|
||||
): Map<string, ResolvedSourceElo> {
|
||||
const policy = getSimulatorInputPolicy(config);
|
||||
const resolved = new Map<string, ResolvedSourceElo>();
|
||||
const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
|
||||
const parityFactor = optionalNumber(config.parityFactor, 400);
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.sourceElo !== null && input.sourceElo !== undefined) {
|
||||
resolved.set(input.participantId, {
|
||||
participantId: input.participantId,
|
||||
sourceElo: input.sourceElo,
|
||||
method: "direct",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (alternatives.has("projectedWins")) {
|
||||
const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
|
||||
for (const input of inputs) {
|
||||
if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
|
||||
resolved.set(input.participantId, {
|
||||
participantId: input.participantId,
|
||||
sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
|
||||
method: "projectedWins",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (alternatives.has("projectedTablePoints")) {
|
||||
const seasonGames = optionalNumber(config.seasonGames, 38);
|
||||
const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
|
||||
for (const input of inputs) {
|
||||
if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
|
||||
resolved.set(input.participantId, {
|
||||
participantId: input.participantId,
|
||||
sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
|
||||
method: "projectedTablePoints",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (alternatives.has("sourceOdds")) {
|
||||
const oddsInputs = inputs
|
||||
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
|
||||
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
||||
const converted = convertFuturesToElo(oddsInputs);
|
||||
for (const [participantId, elo] of converted) {
|
||||
resolved.set(participantId, {
|
||||
participantId,
|
||||
sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
|
||||
method: "sourceOdds",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const knownElos = [...resolved.values()].map((value) => value.sourceElo);
|
||||
const averageKnown = knownElos.length > 0
|
||||
? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
|
||||
: policy.fallbackElo;
|
||||
const worstKnown = knownElos.length > 0 ? Math.min(...knownElos) : policy.fallbackElo;
|
||||
|
||||
for (const input of inputs) {
|
||||
if (resolved.has(input.participantId) || policy.missingEloStrategy === "block") continue;
|
||||
|
||||
let sourceElo: number;
|
||||
if (policy.missingEloStrategy === "averageKnown") {
|
||||
sourceElo = averageKnown;
|
||||
} else if (policy.missingEloStrategy === "worstKnownMinus") {
|
||||
sourceElo = worstKnown - policy.fallbackEloDelta;
|
||||
} else {
|
||||
sourceElo = policy.fallbackElo;
|
||||
}
|
||||
|
||||
resolved.set(input.participantId, {
|
||||
participantId: input.participantId,
|
||||
sourceElo: clamp(Math.round(sourceElo), policy.eloMin, policy.eloMax),
|
||||
method: policy.missingEloStrategy,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveRatings(
|
||||
inputs: ParticipantInputForPolicy[],
|
||||
profile: Pick<SimulatorManifestProfile, "derivableInputs">,
|
||||
config: Record<string, unknown>
|
||||
): Map<string, ResolvedRating> {
|
||||
const policy = getSimulatorInputPolicy(config);
|
||||
const resolved = new Map<string, ResolvedRating>();
|
||||
const alternatives = new Set(profile.derivableInputs?.rating ?? []);
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.rating !== null && input.rating !== undefined) {
|
||||
resolved.set(input.participantId, {
|
||||
participantId: input.participantId,
|
||||
rating: input.rating,
|
||||
method: "direct",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (alternatives.has("sourceOdds")) {
|
||||
const oddsInputs = inputs
|
||||
.filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
|
||||
.map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
|
||||
const converted = convertFuturesToElo(oddsInputs, "american", {
|
||||
exponent: 0.33,
|
||||
eloMin: policy.ratingMin,
|
||||
eloMax: policy.ratingMax,
|
||||
});
|
||||
for (const [participantId, rating] of converted) {
|
||||
resolved.set(participantId, {
|
||||
participantId,
|
||||
rating,
|
||||
method: "sourceOdds",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
233
app/services/simulations/manifest.ts
Normal file
233
app/services/simulations/manifest.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { getSimulatorInfo, SIMULATOR_TYPES, type SimulatorType } from "./registry";
|
||||
|
||||
export type SimulatorInputKey =
|
||||
| "sourceOdds"
|
||||
| "sourceElo"
|
||||
| "worldRanking"
|
||||
| "rating"
|
||||
| "projectedWins"
|
||||
| "projectedTablePoints"
|
||||
| "seed"
|
||||
| "region"
|
||||
| "metadata";
|
||||
|
||||
export type SimulatorSetupSection =
|
||||
| "futuresOdds"
|
||||
| "eloRatings"
|
||||
| "rankings"
|
||||
| "ratings"
|
||||
| "regularStandings"
|
||||
| "bracket"
|
||||
| "events"
|
||||
| "golfSkills"
|
||||
| "surfaceElo"
|
||||
| "cs2Setup"
|
||||
| "participants";
|
||||
|
||||
export interface SimulatorManifestProfile {
|
||||
simulatorType: SimulatorType;
|
||||
displayName: string;
|
||||
description: string;
|
||||
defaultConfig: Record<string, unknown>;
|
||||
requiredInputs: SimulatorInputKey[];
|
||||
optionalInputs: SimulatorInputKey[];
|
||||
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
||||
setupSections: SimulatorSetupSection[];
|
||||
minParticipantInputs?: number;
|
||||
}
|
||||
|
||||
const BASE_CONFIG = {
|
||||
iterations: 50_000,
|
||||
};
|
||||
|
||||
const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorType" | "displayName" | "description">> = {
|
||||
f1_standings: {
|
||||
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: [],
|
||||
setupSections: ["participants", "futuresOdds", "events"],
|
||||
},
|
||||
indycar_standings: {
|
||||
defaultConfig: { iterations: 10_000, raceNoise: 0.5, participantVolatility: 1.5 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: [],
|
||||
setupSections: ["participants", "futuresOdds", "events"],
|
||||
},
|
||||
golf_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, fieldSize: 156, plBeta: 1.5 },
|
||||
requiredInputs: [],
|
||||
optionalInputs: ["sourceOdds", "rating"],
|
||||
setupSections: ["participants", "events", "golfSkills"],
|
||||
},
|
||||
playoff_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["sourceElo"],
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
},
|
||||
ucl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["sourceElo"],
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
},
|
||||
ncaam_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35 } },
|
||||
requiredInputs: ["rating"],
|
||||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
},
|
||||
ncaaw_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingType: "barthag", inputPolicy: { ratingMin: 0.15, ratingMax: 0.99 } },
|
||||
requiredInputs: ["rating"],
|
||||
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
},
|
||||
nba_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
},
|
||||
nhl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
},
|
||||
nfl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
afl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 450, seasonGames: 23 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
epl_standings: {
|
||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 38, baseDrawRate: 0.26, drawDecay: 0.75 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedTablePoints"],
|
||||
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
snooker_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
||||
},
|
||||
tennis_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
||||
requiredInputs: [],
|
||||
optionalInputs: ["worldRanking"],
|
||||
setupSections: ["participants", "surfaceElo", "events"],
|
||||
},
|
||||
mlb_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, rDiffWeight: 0.7, oddsWeight: 0.3, seasonGames: 162 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
wnba_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, seasonGames: 44, srsEloScale: 30 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
},
|
||||
world_cup: {
|
||||
defaultConfig: { ...BASE_CONFIG, eloWeight: 0.7, oddsWeight: 0.3 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
||||
},
|
||||
darts_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, eloDivisor: 400 },
|
||||
requiredInputs: ["sourceElo", "worldRanking"],
|
||||
optionalInputs: ["seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings"],
|
||||
},
|
||||
cs2_major_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "metadata"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||
},
|
||||
ncaa_football_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 12, eloWeight: 0.6, oddsWeight: 0.4 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||
},
|
||||
llws_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["metadata"],
|
||||
setupSections: ["participants", "futuresOdds"],
|
||||
},
|
||||
college_hockey_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, bracketSize: 16, parityFactor: 850 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
||||
},
|
||||
brackt: {
|
||||
defaultConfig: { iterations: 20_000 },
|
||||
requiredInputs: [],
|
||||
optionalInputs: [],
|
||||
setupSections: ["participants"],
|
||||
},
|
||||
};
|
||||
|
||||
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =
|
||||
Object.fromEntries(
|
||||
SIMULATOR_TYPES.map((simulatorType) => {
|
||||
const info = getSimulatorInfo(simulatorType);
|
||||
const profile = PROFILES[simulatorType];
|
||||
return [
|
||||
simulatorType,
|
||||
{
|
||||
simulatorType,
|
||||
displayName: info?.name ?? simulatorType,
|
||||
description: info?.description ?? "",
|
||||
...profile,
|
||||
minParticipantInputs: profile.minParticipantInputs ?? 1,
|
||||
},
|
||||
];
|
||||
})
|
||||
) as Record<SimulatorType, SimulatorManifestProfile>;
|
||||
|
||||
export function getManifestSimulatorProfile(
|
||||
simulatorType: SimulatorType
|
||||
): SimulatorManifestProfile {
|
||||
return SIMULATOR_MANIFEST[simulatorType];
|
||||
}
|
||||
|
||||
export function simulatorInputLabel(key: SimulatorInputKey): string {
|
||||
const labels: Record<SimulatorInputKey, string> = {
|
||||
sourceOdds: "futures odds",
|
||||
sourceElo: "Elo rating",
|
||||
worldRanking: "ranking",
|
||||
rating: "rating",
|
||||
projectedWins: "projected wins",
|
||||
projectedTablePoints: "projected table points",
|
||||
seed: "seed",
|
||||
region: "region",
|
||||
metadata: "metadata",
|
||||
};
|
||||
return labels[key];
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import type { BracketRegion } from "~/lib/bracket-templates";
|
|||
import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -492,11 +493,14 @@ export class NCAAMSimulator implements Simulator {
|
|||
}
|
||||
const participantIds = [...participantIdSet];
|
||||
|
||||
// 6. Load participant names from DB; build net rating lookup by ID.
|
||||
const participantRows = await db
|
||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||
.from(schema.seasonParticipants)
|
||||
.where(inArray(schema.seasonParticipants.id, participantIds));
|
||||
// 6. Load participant names + admin-managed ratings from DB; build net rating lookup by ID.
|
||||
const [participantRows, simulatorInputs] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||
.from(schema.seasonParticipants)
|
||||
.where(inArray(schema.seasonParticipants.id, participantIds)),
|
||||
getParticipantSimulatorInputs(sportsSeasonId),
|
||||
]);
|
||||
|
||||
if (participantRows.length < participantIds.length) {
|
||||
const foundIds = new Set(participantRows.map((r) => r.id));
|
||||
|
|
@ -507,9 +511,10 @@ export class NCAAMSimulator implements Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input]));
|
||||
const netRatingById = new Map<string, number>();
|
||||
for (const { id, name } of participantRows) {
|
||||
netRatingById.set(id, getNetRating(name));
|
||||
netRatingById.set(id, inputById.get(id)?.rating ?? getNetRating(name));
|
||||
}
|
||||
|
||||
// 7. Build per-round O(1) lookup maps keyed by matchNumber.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import type { BracketRegion } from "~/lib/bracket-templates";
|
|||
import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68 } from "~/lib/bracket-templates";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -455,11 +456,14 @@ export class NCAAWSimulator implements Simulator {
|
|||
}
|
||||
const participantIds = [...participantIdSet];
|
||||
|
||||
// 6. Load participant names from DB; build Barthag lookup by ID.
|
||||
const participantRows = await db
|
||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||
.from(schema.seasonParticipants)
|
||||
.where(inArray(schema.seasonParticipants.id, participantIds));
|
||||
// 6. Load participant names + admin-managed ratings from DB; build Barthag lookup by ID.
|
||||
const [participantRows, simulatorInputs] = await Promise.all([
|
||||
db
|
||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||
.from(schema.seasonParticipants)
|
||||
.where(inArray(schema.seasonParticipants.id, participantIds)),
|
||||
getParticipantSimulatorInputs(sportsSeasonId),
|
||||
]);
|
||||
|
||||
if (participantRows.length < participantIds.length) {
|
||||
const foundIds = new Set(participantRows.map((r) => r.id));
|
||||
|
|
@ -470,9 +474,10 @@ export class NCAAWSimulator implements Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input]));
|
||||
const barthagById = new Map<string, number>();
|
||||
for (const { id, name } of participantRows) {
|
||||
barthagById.set(id, getBarthagRating(name));
|
||||
barthagById.set(id, inputById.get(id)?.rating ?? getBarthagRating(name));
|
||||
}
|
||||
|
||||
// 7. Build per-round O(1) lookup maps keyed by matchNumber.
|
||||
|
|
|
|||
172
app/services/simulations/runner.ts
Normal file
172
app/services/simulations/runner.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import type { ScoringRules } from "~/services/ev-calculator";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||
import {
|
||||
getSportsSeasonSimulatorConfig,
|
||||
prepareSimulatorInputsForRun,
|
||||
validateSimulatorReadiness,
|
||||
} from "~/models/simulator";
|
||||
|
||||
const ZERO_PROBS = {
|
||||
probFirst: 0,
|
||||
probSecond: 0,
|
||||
probThird: 0,
|
||||
probFourth: 0,
|
||||
probFifth: 0,
|
||||
probSixth: 0,
|
||||
probSeventh: 0,
|
||||
probEighth: 0,
|
||||
};
|
||||
|
||||
async function getPersistenceContext(
|
||||
simulatorType: SimulatorType,
|
||||
sportsSeason: Awaited<ReturnType<typeof findSportsSeasonById>>
|
||||
): Promise<{ scoringRules: ScoringRules; source: "elo_simulation" | "performance_model" }> {
|
||||
if (simulatorType !== "brackt") {
|
||||
return { scoringRules: DEFAULT_SCORING_RULES, source: "elo_simulation" };
|
||||
}
|
||||
|
||||
if (!sportsSeason?.fantasySeasonId) {
|
||||
throw new Error("Brackt simulations must run against a private per-league sports season, not the global Brackt template.");
|
||||
}
|
||||
|
||||
const fantasySeason = await database().query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, sportsSeason.fantasySeasonId),
|
||||
});
|
||||
if (!fantasySeason) {
|
||||
throw new Error("Linked fantasy season not found for Brackt simulation.");
|
||||
}
|
||||
|
||||
return {
|
||||
source: "performance_model",
|
||||
scoringRules: {
|
||||
pointsFor1st: fantasySeason.pointsFor1st,
|
||||
pointsFor2nd: fantasySeason.pointsFor2nd,
|
||||
pointsFor3rd: fantasySeason.pointsFor3rd,
|
||||
pointsFor4th: fantasySeason.pointsFor4th,
|
||||
pointsFor5th: fantasySeason.pointsFor5th,
|
||||
pointsFor6th: fantasySeason.pointsFor6th,
|
||||
pointsFor7th: fantasySeason.pointsFor7th,
|
||||
pointsFor8th: fantasySeason.pointsFor8th,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunSportsSeasonSimulationResult {
|
||||
sportsSeasonId: string;
|
||||
simulatorType: SimulatorType;
|
||||
simulatedParticipants: number;
|
||||
zeroedParticipants: number;
|
||||
snapshotDate: string;
|
||||
}
|
||||
|
||||
export async function runSportsSeasonSimulation(
|
||||
sportsSeasonId: string
|
||||
): Promise<RunSportsSeasonSimulationResult> {
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (!sportsSeason) {
|
||||
throw new Error("Sports season not found");
|
||||
}
|
||||
|
||||
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||
if (!simulatorConfig) {
|
||||
throw new Error(
|
||||
"This sport has no simulator type configured. Set a simulator type on the sport in the admin panel before running a simulation."
|
||||
);
|
||||
}
|
||||
|
||||
if (sportsSeason.simulationStatus === "running") {
|
||||
throw new Error("A simulation is already running for this sports season. Please wait for it to complete.");
|
||||
}
|
||||
|
||||
const readiness = await validateSimulatorReadiness(sportsSeasonId);
|
||||
if (!readiness.canRun) {
|
||||
throw new Error(
|
||||
`Simulator is not ready: ${readiness.missingInputs.join(", ") || "missing required setup"}.`
|
||||
);
|
||||
}
|
||||
|
||||
await prepareSimulatorInputsForRun(sportsSeasonId);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "running" });
|
||||
|
||||
try {
|
||||
const simulator = getSimulator(simulatorConfig.simulatorType);
|
||||
const results = await simulator.simulate(sportsSeasonId);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error("Simulation returned no results. Check that participants have simulator input data.");
|
||||
}
|
||||
|
||||
normalizeSimulationResultColumns(results);
|
||||
|
||||
const allSeasonParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const simulatedIds = new Set(results.map((r) => r.participantId));
|
||||
const zeroedParticipants = allSeasonParticipants.filter((p) => !simulatedIds.has(p.id));
|
||||
const persistence = await getPersistenceContext(simulatorConfig.simulatorType, sportsSeason);
|
||||
|
||||
await batchUpsertParticipantEVs([
|
||||
...results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
probabilities: r.probabilities,
|
||||
scoringRules: persistence.scoringRules,
|
||||
source: persistence.source,
|
||||
})),
|
||||
...zeroedParticipants.map((p) => ({
|
||||
participantId: p.id,
|
||||
sportsSeasonId,
|
||||
probabilities: ZERO_PROBS,
|
||||
scoringRules: persistence.scoringRules,
|
||||
source: persistence.source,
|
||||
})),
|
||||
]);
|
||||
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
|
||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
|
||||
return {
|
||||
sportsSeasonId,
|
||||
simulatorType: simulatorConfig.simulatorType,
|
||||
simulatedParticipants: results.length,
|
||||
zeroedParticipants: zeroedParticipants.length,
|
||||
snapshotDate,
|
||||
};
|
||||
} catch (error) {
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "failed" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -390,6 +390,31 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const simulatorProfiles = pgTable("simulator_profiles", {
|
||||
simulatorType: simulatorTypeEnum("simulator_type").primaryKey(),
|
||||
displayName: varchar("display_name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
defaultConfig: jsonb("default_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
inputSchema: jsonb("input_schema").$type<Record<string, unknown>>().notNull().default({}),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const sportsSeasonSimulatorConfigs = pgTable("sports_season_simulator_configs", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sportsSeasonId: uuid("sports_season_id")
|
||||
.notNull()
|
||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||
simulatorType: simulatorTypeEnum("simulator_type").notNull(),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
uniqueSportsSeason: uniqueIndex("sports_season_simulator_config_unique").on(table.sportsSeasonId),
|
||||
simulatorTypeIdx: index("sports_season_simulator_config_type_idx").on(table.simulatorType),
|
||||
}));
|
||||
|
||||
export const seasonParticipants = pgTable("season_participants", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sportsSeasonId: uuid("sports_season_id")
|
||||
|
|
@ -409,6 +434,33 @@ export const seasonParticipants = pgTable("season_participants", {
|
|||
uniqueIndex("participants_sports_season_name_unique").on(table.sportsSeasonId, table.name),
|
||||
]);
|
||||
|
||||
export const seasonParticipantSimulatorInputs = pgTable("season_participant_simulator_inputs", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
participantId: uuid("participant_id")
|
||||
.notNull()
|
||||
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
|
||||
sportsSeasonId: uuid("sports_season_id")
|
||||
.notNull()
|
||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||
sourceOdds: integer("source_odds"),
|
||||
sourceElo: integer("source_elo"),
|
||||
worldRanking: integer("world_ranking"),
|
||||
rating: decimal("rating", { precision: 10, scale: 4 }),
|
||||
projectedWins: decimal("projected_wins", { precision: 6, scale: 2 }),
|
||||
projectedTablePoints: decimal("projected_table_points", { precision: 6, scale: 2 }),
|
||||
seed: integer("seed"),
|
||||
region: varchar("region", { length: 100 }),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
uniqueParticipantSeason: uniqueIndex("season_participant_simulator_inputs_unique").on(
|
||||
table.participantId,
|
||||
table.sportsSeasonId,
|
||||
),
|
||||
sportsSeasonIdx: index("season_participant_simulator_inputs_season_idx").on(table.sportsSeasonId),
|
||||
}));
|
||||
|
||||
export const participants = pgTable(
|
||||
"participants",
|
||||
{
|
||||
|
|
@ -920,6 +972,8 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
|||
references: [seasons.id],
|
||||
}),
|
||||
participants: many(seasonParticipants),
|
||||
simulatorConfig: one(sportsSeasonSimulatorConfigs),
|
||||
simulatorInputs: many(seasonParticipantSimulatorInputs),
|
||||
seasonTemplateSports: many(seasonTemplateSports),
|
||||
seasonSports: many(seasonSports),
|
||||
participantResults: many(seasonParticipantResults),
|
||||
|
|
@ -927,6 +981,21 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
|||
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||
}));
|
||||
|
||||
export const simulatorProfilesRelations = relations(simulatorProfiles, ({ many }) => ({
|
||||
sportsSeasonConfigs: many(sportsSeasonSimulatorConfigs),
|
||||
}));
|
||||
|
||||
export const sportsSeasonSimulatorConfigsRelations = relations(sportsSeasonSimulatorConfigs, ({ one }) => ({
|
||||
sportsSeason: one(sportsSeasons, {
|
||||
fields: [sportsSeasonSimulatorConfigs.sportsSeasonId],
|
||||
references: [sportsSeasons.id],
|
||||
}),
|
||||
profile: one(simulatorProfiles, {
|
||||
fields: [sportsSeasonSimulatorConfigs.simulatorType],
|
||||
references: [simulatorProfiles.simulatorType],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const seasonParticipantsRelations = relations(seasonParticipants, ({ one, many }) => ({
|
||||
sportsSeason: one(sportsSeasons, {
|
||||
fields: [seasonParticipants.sportsSeasonId],
|
||||
|
|
@ -938,6 +1007,18 @@ export const seasonParticipantsRelations = relations(seasonParticipants, ({ one,
|
|||
}),
|
||||
results: many(seasonParticipantResults),
|
||||
regularSeasonStandings: many(regularSeasonStandings),
|
||||
simulatorInput: one(seasonParticipantSimulatorInputs),
|
||||
}));
|
||||
|
||||
export const seasonParticipantSimulatorInputsRelations = relations(seasonParticipantSimulatorInputs, ({ one }) => ({
|
||||
participant: one(seasonParticipants, {
|
||||
fields: [seasonParticipantSimulatorInputs.participantId],
|
||||
references: [seasonParticipants.id],
|
||||
}),
|
||||
sportsSeason: one(sportsSeasons, {
|
||||
fields: [seasonParticipantSimulatorInputs.sportsSeasonId],
|
||||
references: [sportsSeasons.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const participantsRelations = relations(participants, ({ one, many }) => ({
|
||||
|
|
|
|||
123
docs/agents/simulators.md
Normal file
123
docs/agents/simulators.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Simulator Guide
|
||||
|
||||
Brackt EV simulators project real-world sports outcomes into the standard top-eight probability shape used by fantasy expected values.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `app/services/simulations/registry.ts` maps each `simulatorType` to the algorithm implementation.
|
||||
- `app/services/simulations/manifest.ts` describes each simulator for admin/UI use: display name, default config, required inputs, optional inputs, and setup sections.
|
||||
- `app/services/simulations/input-policy.ts` resolves acceptable alternate inputs, such as projected wins or futures odds, into simulator-ready Elo values.
|
||||
- `app/models/simulator.ts` owns simulator profile/config/input queries. Routes should use this model layer and must not query Drizzle directly.
|
||||
- `app/services/simulations/runner.ts` is the only supported way for admin routes to run a sports-season simulation.
|
||||
- `season_participant_expected_values` is an output table. New mutable simulator inputs belong in `season_participant_simulator_inputs`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Admin configures the sport's default `simulatorType` on the sport.
|
||||
2. Each `sports_seasons` row gets its own simulator config through `sports_season_simulator_configs`.
|
||||
3. Admin/import flows save participant inputs to `season_participant_simulator_inputs`.
|
||||
4. The runner validates readiness, runs the registered simulator, normalizes output columns, persists EVs, snapshots probabilities, zeroes omitted participants, and recalculates linked fantasy standings.
|
||||
5. `/admin/simulators` inventories all sports-season simulators and can run ready/idle seasons.
|
||||
|
||||
Before running older Elo-based simulators, the runner materializes resolved Elo values back through `season_participant_simulator_inputs` and the legacy EV compatibility bridge. This lets older simulator algorithms keep reading `sourceElo` while admin setup can use direct Elo, projected wins/table points, futures odds, or an explicit fallback policy.
|
||||
|
||||
The runner also materializes derived `rating` values for rating-based simulators that declare that support, such as preseason NCAAM/NCAAW using futures odds before KenPom/Barttorvik-style ratings are available.
|
||||
|
||||
## Season Scoping
|
||||
|
||||
Never store mutable simulator state on `sports`. A sport can have multiple active seasons at once, such as NHL playoffs for one season and preseason drafting for the next. Each sports season needs independent:
|
||||
|
||||
- Participants
|
||||
- Simulator config overrides
|
||||
- Ratings, rankings, odds, seeds, and metadata
|
||||
- Standings/events/brackets
|
||||
- EV outputs and snapshots
|
||||
|
||||
When cloning a sports season, copy simulator structure/config by default. Do not copy volatile inputs such as odds, Elo ratings, rankings, or standings unless an admin explicitly opts in.
|
||||
|
||||
## Adding A Simulator
|
||||
|
||||
1. Add the simulator type to `database/schema.ts` and generate a Drizzle migration with `npm run db:generate`.
|
||||
2. Implement `Simulator` in `app/services/simulations/`.
|
||||
3. Register the simulator in `app/services/simulations/registry.ts`.
|
||||
4. Add a manifest profile in `app/services/simulations/manifest.ts`.
|
||||
5. Add readiness and setup expectations through required/optional inputs and setup sections.
|
||||
6. If a required input can be derived, add `derivableInputs`. For example, an Elo simulator with projected wins support should declare `derivableInputs: { sourceElo: ["projectedWins"] }`.
|
||||
7. Add unit tests for algorithm helpers and a readiness/manifest regression test.
|
||||
8. Confirm `/admin/simulators` shows the season and the per-season setup page reports actionable missing inputs.
|
||||
|
||||
## Hardcoding Rules
|
||||
|
||||
Do not hardcode refreshable season data in simulator files:
|
||||
|
||||
- No current-season Elo tables
|
||||
- No KenPom/Barttorvik/SRS maps as production inputs
|
||||
- No bookmaker odds
|
||||
- No team rankings/seeds that change per season
|
||||
- No participant-specific metadata that admins may need to change
|
||||
|
||||
It is acceptable to keep stable algorithmic rules in code:
|
||||
|
||||
- Bracket advancement mechanics
|
||||
- Swiss-stage mechanics
|
||||
- Best-of series math
|
||||
- Plackett-Luce, Elo, Log5, and Harville formulas
|
||||
- Sport format rules that are intentionally not admin-editable yet
|
||||
|
||||
If a hardcoded fallback is temporarily retained for backwards compatibility, admin readiness should still require the corresponding DB-managed input before normal admin-triggered runs.
|
||||
|
||||
## Admin Setup
|
||||
|
||||
Use `/admin/sports-seasons/:id/simulator` for per-season setup. The page should show readiness, missing inputs, config JSON, links to sport-specific setup pages, and a generic CSV importer for common inputs:
|
||||
|
||||
```csv
|
||||
name,sourceElo,sourceOdds,worldRanking,rating,projectedWins,projectedTablePoints,seed,region
|
||||
Boston Celtics,1699,+450,1,0.95,58,,1,East
|
||||
```
|
||||
|
||||
Keep specialized pages when they provide real workflow value, such as Golf Skills, Surface Elo, CS2 setup, regular standings, and bracket setup. Those pages should still feed readiness through the simulator model layer.
|
||||
|
||||
## Input Policies
|
||||
|
||||
Direct ratings are always preferred. If a simulator declares derived inputs, readiness may also pass with those alternatives:
|
||||
|
||||
- `projectedWins` can become Elo using `seasonGames` and `parityFactor` from season config.
|
||||
- `projectedTablePoints` can become Elo using `seasonGames`, `maxTablePoints`, and `parityFactor`.
|
||||
- `sourceOdds` can become Elo through the shared futures-to-Elo conversion.
|
||||
- `sourceOdds` can become a generic `rating` when the simulator declares `derivableInputs: { rating: ["sourceOdds"] }`.
|
||||
|
||||
Missing tail participants must remain blocked unless the season config explicitly chooses an `inputPolicy.missingEloStrategy`:
|
||||
|
||||
```json
|
||||
{
|
||||
"inputPolicy": {
|
||||
"missingEloStrategy": "worstKnownMinus",
|
||||
"fallbackElo": 1400,
|
||||
"fallbackEloDelta": 25,
|
||||
"eloMin": 1100,
|
||||
"eloMax": 1900
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported strategies are `block`, `fallbackElo`, `averageKnown`, and `worstKnownMinus`. Use fallbacks only when the tail participants have genuinely tiny EV impact; the setup page will warn when derived or fallback Elo is being used.
|
||||
|
||||
For rating conversions, configure `inputPolicy.ratingMin` and `inputPolicy.ratingMax` to match the target model scale. NCAAM uses a KenPom-like net rating range; NCAAW uses a Barthag-like 0-1 range. Futures-derived ratings are a preseason approximation and should be replaced by real ratings when available.
|
||||
|
||||
## Required Checks
|
||||
|
||||
After simulator changes, run:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
At minimum, add or update tests for:
|
||||
|
||||
- Manifest/schema/registry drift
|
||||
- Readiness validation
|
||||
- Input persistence and legacy compatibility bridge
|
||||
- Shared runner success/failure behavior
|
||||
- Any simulator whose required inputs changed
|
||||
59
drizzle/0102_wild_wiccan.sql
Normal file
59
drizzle/0102_wild_wiccan.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
CREATE TABLE IF NOT EXISTS "season_participant_simulator_inputs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"sports_season_id" uuid NOT NULL,
|
||||
"source_odds" integer,
|
||||
"source_elo" integer,
|
||||
"world_ranking" integer,
|
||||
"rating" numeric(10, 4),
|
||||
"projected_wins" numeric(6, 2),
|
||||
"projected_table_points" numeric(6, 2),
|
||||
"seed" integer,
|
||||
"region" varchar(100),
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "simulator_profiles" (
|
||||
"simulator_type" "simulator_type" PRIMARY KEY NOT NULL,
|
||||
"display_name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"default_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"input_schema" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "sports_season_simulator_configs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"sports_season_id" uuid NOT NULL,
|
||||
"simulator_type" "simulator_type" NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_participant_simulator_inputs" ADD CONSTRAINT "season_participant_simulator_inputs_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "season_participant_simulator_inputs" ADD CONSTRAINT "season_participant_simulator_inputs_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "sports_season_simulator_configs" ADD CONSTRAINT "sports_season_simulator_configs_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "season_participant_simulator_inputs_unique" ON "season_participant_simulator_inputs" USING btree ("participant_id","sports_season_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "season_participant_simulator_inputs_season_idx" ON "season_participant_simulator_inputs" USING btree ("sports_season_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "sports_season_simulator_config_unique" ON "sports_season_simulator_configs" USING btree ("sports_season_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "sports_season_simulator_config_type_idx" ON "sports_season_simulator_configs" USING btree ("simulator_type");
|
||||
6111
drizzle/meta/0102_snapshot.json
Normal file
6111
drizzle/meta/0102_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -715,6 +715,13 @@
|
|||
"when": 1778445560663,
|
||||
"tag": "0101_simple_black_bolt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 102,
|
||||
"version": "7",
|
||||
"when": 1778539362096,
|
||||
"tag": "0102_wild_wiccan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue