* 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 * Use recalculateStandings directly after EV simulation Replaces recalculateAffectedLeagues (which carries Discord notification and daily snapshot side effects) with a direct call to recalculateStandings per affected fantasy season. This updates the cached projectedPoints in teamStandings so the standings page stays in sync with the team page after a simulation, without triggering unrelated side effects. https://claude.ai/code/session_01S9Pk4wTSphANMhPWaURPTn * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
142 lines
5.6 KiB
TypeScript
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`);
|
|
}
|