/** * 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) * 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/participant"; import { getSimulator, type SimulatorType } from "~/services/simulations/registry"; import { calculateEV, type ScoringRules } from "~/services/ev-calculator"; import { recalculateStandings } from "~/models/scoring-calculator"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; // 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: 45, pointsFor4th: 45, pointsFor5th: 20, pointsFor6th: 20, pointsFor7th: 20, pointsFor8th: 20, }; 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" }); } 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`); }