brackt/app/routes/admin.sports-seasons.$id.simulate.tsx
Claude 7cf37aace8
Force standings recalculation after EV simulation
After persisting updated EVs to participantExpectedValues, call
recalculateAffectedLeagues so that teamStandings.projectedPoints
(and all related standing fields) immediately reflect the new EV
rather than remaining stale until the next scoring event.

https://claude.ai/code/session_01S9Pk4wTSphANMhPWaURPTn
2026-03-20 21:21:47 +00:00

135 lines
5.2 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 { findParticipantsBySportsSeasonId } from "~/models/participant";
import { getSimulator, type SimulatorType } from "~/services/simulations/registry";
import { calculateEV, type ScoringRules } from "~/services/ev-calculator";
import { recalculateAffectedLeagues } from "~/models/scoring-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: 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);
// Force standings recalculation so projectedPoints reflects the new EV
await recalculateAffectedLeagues(sportsSeasonId);
// 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`);
}