brackt/app/routes/admin.sports-seasons.$id.simulate.tsx
Claude b21c553c9c
Polish simulate route after code review
- Remove redundant db variable; use database() inline consistent with
  all other model calls in this file
- Parallelize standings recalculation with Promise.all
- Update file header comment to include the standings refresh step

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

142 lines
5.6 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. 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`);
}