claude/afl-ev-calc-issue-8kcmlh #145
7 changed files with 209 additions and 37 deletions
|
|
@ -319,6 +319,22 @@ describe("processMatchResult", () => {
|
||||||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||||
|
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||||
|
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||||
|
// than once per match. Standings and the announcement still happen per match.
|
||||||
|
const { db } = makeDb();
|
||||||
|
|
||||||
|
await processMatchResult(
|
||||||
|
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||||
|
db
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||||
|
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||||
|
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not throw even if probability update fails", async () => {
|
it("does not throw even if probability update fails", async () => {
|
||||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
new Error("network error")
|
new Error("network error")
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,19 @@ export async function processMatchResult(
|
||||||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||||
matchId?: string;
|
matchId?: string;
|
||||||
skipSideEffects?: boolean;
|
skipSideEffects?: boolean;
|
||||||
|
/**
|
||||||
|
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||||
|
*
|
||||||
|
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||||
|
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||||
|
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||||
|
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||||
|
* announcements then project from the previous probabilities until that final call.
|
||||||
|
*
|
||||||
|
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||||
|
* the announcement.
|
||||||
|
*/
|
||||||
|
skipProbabilities?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true, the loser of this non-scoring round advances to another match
|
* When true, the loser of this non-scoring round advances to another match
|
||||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||||
|
|
@ -557,7 +570,7 @@ export async function processMatchResult(
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
|
@ -637,13 +650,15 @@ export async function processMatchResult(
|
||||||
: undefined;
|
: undefined;
|
||||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||||
// projected points reflect the new result.
|
// projected points reflect the new result.
|
||||||
try {
|
if (!skipProbabilities) {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
try {
|
||||||
} catch (error) {
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
logger.error(
|
} catch (error) {
|
||||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
logger.error(
|
||||||
error
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||||
);
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
||||||
vi.mock("~/models/participant-result");
|
vi.mock("~/models/participant-result");
|
||||||
vi.mock("~/models/participant-expected-value");
|
vi.mock("~/models/participant-expected-value");
|
||||||
vi.mock("~/models/simulator");
|
vi.mock("~/models/simulator");
|
||||||
|
vi.mock("~/models/sports-season");
|
||||||
vi.mock("~/services/simulations/runner");
|
vi.mock("~/services/simulations/runner");
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
database: () => ({
|
database: () => ({
|
||||||
|
|
@ -373,10 +374,17 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||||
evSource: string;
|
evSource: string;
|
||||||
simulatorType: string | null;
|
simulatorType: string | null;
|
||||||
results?: ReturnType<typeof finishedResult>[];
|
results?: ReturnType<typeof finishedResult>[];
|
||||||
|
seasonStatus?: string;
|
||||||
}) {
|
}) {
|
||||||
const simulatorModel = await import("~/models/simulator");
|
const simulatorModel = await import("~/models/simulator");
|
||||||
|
const sportsSeasonModel = await import("~/models/sports-season");
|
||||||
const runner = await import("~/services/simulations/runner");
|
const runner = await import("~/services/simulations/runner");
|
||||||
|
|
||||||
|
vi.mocked(sportsSeasonModel.findSportsSeasonById).mockResolvedValue({
|
||||||
|
id: "season-1",
|
||||||
|
status: opts.seasonStatus ?? "active",
|
||||||
|
} as never);
|
||||||
|
|
||||||
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
vi.mocked(participantResultModel.findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||||
opts.results ?? []
|
opts.results ?? []
|
||||||
);
|
);
|
||||||
|
|
@ -407,11 +415,43 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||||
|
|
||||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||||
expect(icmWrites()).toHaveLength(0);
|
expect(icmWrites()).toHaveLength(0);
|
||||||
expect(result.errors).toEqual([]);
|
expect(result.errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("asks the run for probabilities only, leaving standings and snapshots to the caller", async () => {
|
||||||
|
// recalculateAffectedLeagues detects change by diffing teamStandings across its own
|
||||||
|
// recalculation, and that diff gates the Discord standings post. A recalculation in here
|
||||||
|
// runs before it takes its "before" snapshot, so the diff comes back empty and the post is
|
||||||
|
// silently dropped — and previousRank gets rolled forward twice, erasing rank movement.
|
||||||
|
const { runSim } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||||
|
|
||||||
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runSim).toHaveBeenCalledWith("season-1", {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to ICM on a completed season rather than failing every time", async () => {
|
||||||
|
// finalizeQualifyingPoints marks the season completed immediately before calling here, and
|
||||||
|
// runSportsSeasonSimulation rejects a completed season outright. Treating that as a failure
|
||||||
|
// would strand anyone still unfinished on stale probabilities forever.
|
||||||
|
const { runner } = await setup({
|
||||||
|
evSource: "elo_simulation",
|
||||||
|
simulatorType: "cs2_major_qualifying_points",
|
||||||
|
seasonStatus: "completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
|
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||||
|
expect(icmWrites().length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("still pins finished participants before re-running the simulator", async () => {
|
it("still pins finished participants before re-running the simulator", async () => {
|
||||||
const { runner } = await setup({
|
const { runner } = await setup({
|
||||||
evSource: "elo_simulation",
|
evSource: "elo_simulation",
|
||||||
|
|
@ -464,7 +504,7 @@ describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||||
|
|
||||||
await updateProbabilitiesAfterResult("season-1", true);
|
await updateProbabilitiesAfterResult("season-1", true);
|
||||||
|
|
||||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1");
|
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||||
expect(icmWrites()).toHaveLength(0);
|
expect(icmWrites()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
processQualifyingBracketEvent,
|
processQualifyingBracketEvent,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||||
import {
|
import {
|
||||||
|
|
@ -283,6 +284,11 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
eventName: event.name ?? undefined,
|
||||||
matchId: playoffMatch.id,
|
matchId: playoffMatch.id,
|
||||||
|
// The probability refresh is season-wide and idempotent, and for a bracket-aware
|
||||||
|
// sport it is a full Monte Carlo run — doing it per match would repeat that for
|
||||||
|
// every match in the sync. It runs once after the loop instead. Standings and the
|
||||||
|
// per-match Discord post still happen here as before.
|
||||||
|
skipProbabilities: true,
|
||||||
loserAdvances: event.bracketTemplateId
|
loserAdvances: event.bracketTemplateId
|
||||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||||
: false,
|
: false,
|
||||||
|
|
@ -300,6 +306,15 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The refresh skipped inside the loop, run once for the whole sync.
|
||||||
|
if (playoffUpdated > 0) {
|
||||||
|
try {
|
||||||
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[match-sync] Error updating probabilities after bracket sync:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||||
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
|
|
@ -119,6 +120,15 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
||||||
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
||||||
*/
|
*/
|
||||||
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
async function shouldRerunSimulator(sportsSeasonId: string): Promise<boolean> {
|
||||||
|
// A completed season cannot be simulated — runSportsSeasonSimulation rejects it outright —
|
||||||
|
// and finalizeQualifyingPoints marks the season completed immediately before calling here,
|
||||||
|
// so taking this branch there would fail every single time and leave anyone still in the
|
||||||
|
// unfinished set on permanently stale probabilities. It is not a failure, it is not this
|
||||||
|
// branch's case: the season is over, every placement is final, and the floor this branch
|
||||||
|
// exists to protect can no longer be contradicted. Fall through to ICM as before.
|
||||||
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
|
if (sportsSeason?.status === "completed") return false;
|
||||||
|
|
||||||
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
const simulatorConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
|
||||||
if (!simulatorConfig) return false;
|
if (!simulatorConfig) return false;
|
||||||
|
|
||||||
|
|
@ -185,13 +195,23 @@ export async function updateProbabilitiesAfterResult(
|
||||||
// undefined at module-init time.
|
// undefined at module-init time.
|
||||||
try {
|
try {
|
||||||
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
||||||
await runSportsSeasonSimulation(sportsSeasonId);
|
// Probabilities only. Our callers recalculate standings themselves right after this,
|
||||||
|
// and recalculateAffectedLeagues detects change by diffing teamStandings across its
|
||||||
|
// own recalculation — a recalculation slipped in here empties that diff and silently
|
||||||
|
// suppresses the Discord standings post, and rolls previousRank forward a second time
|
||||||
|
// so rank movement disappears. The daily EV snapshot is not ours to write either: it
|
||||||
|
// is keyed by date, so writing it per result overwrites the day with intra-day values.
|
||||||
|
await runSportsSeasonSimulation(sportsSeasonId, {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
updated += unfinishedEVs.length;
|
updated += unfinishedEVs.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// runSportsSeasonSimulation throws on a completed season, on a run already in
|
// A run already in flight, failed readiness, or a bracket the simulator refuses to
|
||||||
// flight, and on failed readiness. Leave the existing probabilities alone rather
|
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
|
||||||
// than falling back to ICM: for these seasons ICM is the thing being replaced, and
|
// alone rather than falling back to ICM — for these seasons ICM is precisely the
|
||||||
// a completed season has nothing unfinished left to recalculate anyway.
|
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
|
||||||
|
// Completed seasons never reach this: shouldRerunSimulator excludes them.
|
||||||
logger.error(
|
logger.error(
|
||||||
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||||
`leaving existing probabilities in place:`,
|
`leaving existing probabilities in place:`,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ import {
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||||
|
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||||
|
import { database } from "~/database/context";
|
||||||
import { getSimulator } from "~/services/simulations/registry";
|
import { getSimulator } from "~/services/simulations/registry";
|
||||||
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "~/services/simulations/simulation-probabilities";
|
||||||
|
|
||||||
|
|
@ -126,6 +128,42 @@ describe("runSportsSeasonSimulation", () => {
|
||||||
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
expect(vi.mocked(updateSportsSeason).mock.calls[1]).toEqual(["season-1", { simulationStatus: "idle" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The default mock has no linked leagues, so nothing to recalculate. Give it one. */
|
||||||
|
function withLinkedLeague() {
|
||||||
|
vi.mocked(database).mockReturnValue({
|
||||||
|
query: {
|
||||||
|
seasonSports: { findMany: vi.fn().mockResolvedValue([{ seasonId: "fantasy-1" }]) },
|
||||||
|
seasons: { findFirst: vi.fn() },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("recalculates standings and writes the daily snapshot by default", async () => {
|
||||||
|
withLinkedLeague();
|
||||||
|
|
||||||
|
await runSportsSeasonSimulation("season-1");
|
||||||
|
|
||||||
|
expect(recalculateStandings).toHaveBeenCalledWith("fantasy-1");
|
||||||
|
expect(batchUpsertParticipantEvSnapshots).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips standings and snapshots when the caller owns them", async () => {
|
||||||
|
withLinkedLeague();
|
||||||
|
|
||||||
|
// updateProbabilitiesAfterResult runs inside the result path, where the caller
|
||||||
|
// recalculates standings straight afterwards. A recalculation here lands before
|
||||||
|
// recalculateAffectedLeagues takes its "before" snapshot, emptying the diff that gates the
|
||||||
|
// Discord standings post and rolling previousRank forward twice. EVs are still written.
|
||||||
|
await runSportsSeasonSimulation("season-1", {
|
||||||
|
skipStandingsRecalc: true,
|
||||||
|
skipSnapshots: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recalculateStandings).not.toHaveBeenCalled();
|
||||||
|
expect(batchUpsertParticipantEvSnapshots).not.toHaveBeenCalled();
|
||||||
|
expect(batchUpsertParticipantEVs).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when the sports season is not found", async () => {
|
it("throws when the sports season is not found", async () => {
|
||||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,29 @@ async function getPersistenceContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side effects a caller can opt out of.
|
||||||
|
*
|
||||||
|
* A simulation run does three jobs — recompute probabilities, recalculate standings, and record
|
||||||
|
* the day's EV snapshot. `updateProbabilitiesAfterResult` wants only the first: it runs inside
|
||||||
|
* the result path, where the caller recalculates standings itself immediately afterwards.
|
||||||
|
*
|
||||||
|
* Letting the run recalculate there is not merely redundant, it is wrong.
|
||||||
|
* recalculateAffectedLeagues detects change by snapshotting teamStandings, recalculating, then
|
||||||
|
* diffing, and that diff gates the Discord standings post; a recalculation slipped in
|
||||||
|
* beforehand makes the diff empty and silently suppresses the notification. recalculateStandings
|
||||||
|
* also rolls previousRank forward on every call, so an extra one erases rank movement.
|
||||||
|
*/
|
||||||
|
export interface RunSportsSeasonSimulationOptions {
|
||||||
|
/** Leave standings to the caller. */
|
||||||
|
skipStandingsRecalc?: boolean;
|
||||||
|
/**
|
||||||
|
* Skip the daily EV snapshot. The snapshot is a per-day series keyed by snapshotDate, so
|
||||||
|
* writing it on every match result just overwrites the day's row with intra-day values.
|
||||||
|
*/
|
||||||
|
skipSnapshots?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunSportsSeasonSimulationResult {
|
export interface RunSportsSeasonSimulationResult {
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
simulatorType: SimulatorType;
|
simulatorType: SimulatorType;
|
||||||
|
|
@ -71,7 +94,8 @@ export interface RunSportsSeasonSimulationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSportsSeasonSimulation(
|
export async function runSportsSeasonSimulation(
|
||||||
sportsSeasonId: string
|
sportsSeasonId: string,
|
||||||
|
options: RunSportsSeasonSimulationOptions = {}
|
||||||
): Promise<RunSportsSeasonSimulationResult> {
|
): Promise<RunSportsSeasonSimulationResult> {
|
||||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
if (!sportsSeason) {
|
if (!sportsSeason) {
|
||||||
|
|
@ -135,29 +159,33 @@ export async function runSportsSeasonSimulation(
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const seasonSports = await database().query.seasonSports.findMany({
|
if (!options.skipStandingsRecalc) {
|
||||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
const seasonSports = await database().query.seasonSports.findMany({
|
||||||
});
|
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
});
|
||||||
|
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||||
|
}
|
||||||
|
|
||||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||||
await batchUpsertParticipantEvSnapshots(
|
if (!options.skipSnapshots) {
|
||||||
results.map((r) => ({
|
await batchUpsertParticipantEvSnapshots(
|
||||||
participantId: r.participantId,
|
results.map((r) => ({
|
||||||
sportsSeasonId,
|
participantId: r.participantId,
|
||||||
snapshotDate,
|
sportsSeasonId,
|
||||||
probFirst: r.probabilities.probFirst,
|
snapshotDate,
|
||||||
probSecond: r.probabilities.probSecond,
|
probFirst: r.probabilities.probFirst,
|
||||||
probThird: r.probabilities.probThird,
|
probSecond: r.probabilities.probSecond,
|
||||||
probFourth: r.probabilities.probFourth,
|
probThird: r.probabilities.probThird,
|
||||||
probFifth: r.probabilities.probFifth,
|
probFourth: r.probabilities.probFourth,
|
||||||
probSixth: r.probabilities.probSixth,
|
probFifth: r.probabilities.probFifth,
|
||||||
probSeventh: r.probabilities.probSeventh,
|
probSixth: r.probabilities.probSixth,
|
||||||
probEighth: r.probabilities.probEighth,
|
probSeventh: r.probabilities.probSeventh,
|
||||||
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
probEighth: r.probabilities.probEighth,
|
||||||
source: r.source,
|
calculatedEV: calculateEV(r.probabilities, persistence.scoringRules),
|
||||||
}))
|
source: r.source,
|
||||||
);
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue