109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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. Takes an EV snapshot from simulation output (upsert for today's date)
|
||
|
|
* 5. 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 { getSimulator, type SimulatorType } from "~/services/simulations/registry";
|
||
|
|
import { calculateEV, type ScoringRules } from "~/services/ev-calculator";
|
||
|
|
|
||
|
|
// Default scoring rules — matches the season table defaults.
|
||
|
|
// Per-league EV is recalculated from probabilities using league-specific rules in standings.
|
||
|
|
const DEFAULT_SCORING_RULES: ScoringRules = {
|
||
|
|
pointsFor1st: 100,
|
||
|
|
pointsFor2nd: 70,
|
||
|
|
pointsFor3rd: 50,
|
||
|
|
pointsFor4th: 40,
|
||
|
|
pointsFor5th: 25,
|
||
|
|
pointsFor6th: 25,
|
||
|
|
pointsFor7th: 15,
|
||
|
|
pointsFor8th: 15,
|
||
|
|
};
|
||
|
|
|
||
|
|
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.");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Persist updated EVs (transactional)
|
||
|
|
await batchUpsertParticipantEVs(
|
||
|
|
results.map((r) => ({
|
||
|
|
participantId: r.participantId,
|
||
|
|
sportsSeasonId,
|
||
|
|
probabilities: r.probabilities,
|
||
|
|
scoringRules: DEFAULT_SCORING_RULES,
|
||
|
|
source: "elo_simulation",
|
||
|
|
}))
|
||
|
|
);
|
||
|
|
|
||
|
|
// 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" });
|
||
|
|
} catch (error) {
|
||
|
|
// Mark as failed so the UI shows the error state
|
||
|
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "failed" });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||
|
|
}
|