Merge pull request 'claude/afl-ev-calc-issue-8kcmlh' (#145) from claude/afl-ev-calc-issue-8kcmlh into main
Reviewed-on: #145
This commit is contained in:
commit
758166dd46
11 changed files with 1081 additions and 146 deletions
|
|
@ -319,6 +319,22 @@ describe("processMatchResult", () => {
|
|||
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 () => {
|
||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
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). */
|
||||
matchId?: string;
|
||||
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
|
||||
* (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>
|
||||
): Promise<void> {
|
||||
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) {
|
||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||
|
|
@ -637,6 +650,7 @@ export async function processMatchResult(
|
|||
: undefined;
|
||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||
// projected points reflect the new result.
|
||||
if (!skipProbabilities) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
|
|
@ -645,6 +659,7 @@ export async function processMatchResult(
|
|||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import * as participantEVModel from "~/models/participant-expected-value";
|
|||
// Mock the dependencies
|
||||
vi.mock("~/models/participant-result");
|
||||
vi.mock("~/models/participant-expected-value");
|
||||
vi.mock("~/models/simulator");
|
||||
vi.mock("~/models/sports-season");
|
||||
vi.mock("~/services/simulations/runner");
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
query: {
|
||||
|
|
@ -18,6 +21,9 @@ vi.mock("~/database/context", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// vi.mock above is hoisted over the imports, so this is already the mocked function.
|
||||
const upsertEV = vi.mocked(participantEVModel.upsertParticipantEV);
|
||||
|
||||
describe("probability-updater", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -320,3 +326,208 @@ describe("probability-updater", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bracket-aware simulator seasons ──────────────────────────────────────────
|
||||
//
|
||||
// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about
|
||||
// who is playing whom or what has already been decided, so it cannot see the placement floors
|
||||
// an afl_10 seeding or a non-scoring-round win has already banked — it will happily value a
|
||||
// team below points the league has paid out. Whenever the season has a simulator that reads
|
||||
// its bracket, that simulator is the better answer and is re-run instead. Only a season whose
|
||||
// simulator is bracket-blind (or has none) still goes through ICM.
|
||||
|
||||
const evRow = (participantId: string, source: string) => ({
|
||||
id: `ev-${participantId}`,
|
||||
participantId,
|
||||
sportsSeasonId: "season-1",
|
||||
probFirst: "0.1000",
|
||||
probSecond: "0.1000",
|
||||
probThird: "0.1000",
|
||||
probFourth: "0.1000",
|
||||
probFifth: "0.1000",
|
||||
probSixth: "0.1000",
|
||||
probSeventh: "0.1000",
|
||||
probEighth: "0.1000",
|
||||
expectedValue: "34.00",
|
||||
source,
|
||||
sourceOdds: null,
|
||||
calculatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const finishedResult = (participantId: string, finalPosition: number) => ({
|
||||
id: `result-${participantId}`,
|
||||
participantId,
|
||||
sportsSeasonId: "season-1",
|
||||
finalPosition,
|
||||
isPartialScore: false,
|
||||
qualifyingPoints: null,
|
||||
notes: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participant: null,
|
||||
});
|
||||
|
||||
describe("updateProbabilitiesAfterResult — simulator-backed seasons", () => {
|
||||
/** Wire up a season: which teams are done, what wrote the EVs, which simulator it has. */
|
||||
async function setup(opts: {
|
||||
evSource: string;
|
||||
simulatorType: string | null;
|
||||
results?: ReturnType<typeof finishedResult>[];
|
||||
seasonStatus?: string;
|
||||
}) {
|
||||
const simulatorModel = await import("~/models/simulator");
|
||||
const sportsSeasonModel = await import("~/models/sports-season");
|
||||
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(
|
||||
opts.results ?? []
|
||||
);
|
||||
vi.mocked(participantEVModel.getAllParticipantEVsForSeason).mockResolvedValue([
|
||||
evRow("alive-1", opts.evSource),
|
||||
evRow("alive-2", opts.evSource),
|
||||
] as never);
|
||||
vi.mocked(participantEVModel.upsertParticipantEV).mockResolvedValue({} as never);
|
||||
vi.mocked(simulatorModel.getSportsSeasonSimulatorConfig).mockResolvedValue(
|
||||
opts.simulatorType ? ({ simulatorType: opts.simulatorType, config: {} } as never) : null
|
||||
);
|
||||
const runSim = vi.mocked(runner.runSportsSeasonSimulation);
|
||||
runSim.mockResolvedValue({} as never);
|
||||
|
||||
return { runner, runSim };
|
||||
}
|
||||
|
||||
/** The ICM branch is the only thing that writes unfinished rows with this source. */
|
||||
const icmWrites = () =>
|
||||
upsertEV.mock.calls.filter(([arg]) => arg.source === "futures_odds");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-runs a bracket-aware simulator instead of recalculating ICM", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
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 () => {
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "afl_bracket",
|
||||
results: [finishedResult("done-1", 2)],
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
const pinned = upsertEV.mock.calls.find(([arg]) => arg.participantId === "done-1");
|
||||
expect(pinned?.[0].probabilities.probSecond).toBe(1.0);
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes a finalized pin after the re-run, so the pin wins over the simulation", async () => {
|
||||
// runSportsSeasonSimulation rewrites every participant in the season, finalized ones
|
||||
// included. A finalized placement is a fact, not a projection, so it has to land last.
|
||||
const { runSim } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "afl_bracket",
|
||||
results: [finishedResult("done-1", 0)],
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
const pinIndex = upsertEV.mock.calls.findIndex(([arg]) => arg.participantId === "done-1");
|
||||
expect(pinIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(upsertEV.mock.invocationCallOrder[pinIndex]).toBeGreaterThan(
|
||||
runSim.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves probabilities alone, and does not fall back to ICM, when the re-run fails", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "afl_bracket" });
|
||||
vi.mocked(runner.runSportsSeasonSimulation).mockRejectedValue(
|
||||
new Error("A simulation is already running for this sports season.")
|
||||
);
|
||||
|
||||
const result = await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatch(/Failed to re-run simulator/);
|
||||
});
|
||||
|
||||
it("re-runs the simulator whatever wrote the EVs originally", async () => {
|
||||
// The alternative is not leaving them alone — ICM would overwrite them either way — so
|
||||
// futures-odds EVs are no reason to prefer the bracket-blind overwrite.
|
||||
const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_bracket" });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).toHaveBeenCalledWith("season-1", expect.anything());
|
||||
expect(icmWrites()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the ICM path for a bracket-blind simulator", async () => {
|
||||
// ncaa_football_bracket declares a "bracket" setup section but never reads playoff_matches,
|
||||
// so re-running it would re-draw the field and hand equity back to eliminated teams.
|
||||
const { runner } = await setup({
|
||||
evSource: "elo_simulation",
|
||||
simulatorType: "ncaa_football_bracket",
|
||||
});
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the ICM path when the season has no simulator configured", async () => {
|
||||
const { runner } = await setup({ evSource: "elo_simulation", simulatorType: null });
|
||||
|
||||
await updateProbabilitiesAfterResult("season-1", true);
|
||||
|
||||
expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled();
|
||||
expect(icmWrites().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import {
|
||||
|
|
@ -283,6 +284,11 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
|
|||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
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
|
||||
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||
: 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 };
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
/**
|
||||
* Result of probability update operation
|
||||
|
|
@ -97,6 +101,40 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
|||
return probs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this season's still-alive participants should be refreshed by re-running its
|
||||
* simulator instead of by the ICM recalculation below.
|
||||
*
|
||||
* If the season has a simulator that reads its bracket, that simulator is simply a better
|
||||
* answer than ICM to "what happens from here": it seeds from the real draw and replays every
|
||||
* completed match, where ICM re-derives a whole distribution from P(1st) alone and knows
|
||||
* nothing about who is playing whom or what has already been decided. That blindness is what
|
||||
* makes ICM report a placement floor the league has already paid out as worth less than its
|
||||
* awarded points.
|
||||
*
|
||||
* Where the EVs originally came from is not consulted, because the alternative here is not
|
||||
* leaving them alone — the ICM branch overwrites them either way. Given the choice between
|
||||
* two overwrites, the bracket-aware one wins.
|
||||
*
|
||||
* The gate is `bracketAware`, not merely "has a simulator": re-running a bracket-blind
|
||||
* simulator would re-draw the field and hand equity back to teams already knocked out.
|
||||
*/
|
||||
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);
|
||||
if (!simulatorConfig) return false;
|
||||
|
||||
return getManifestSimulatorProfile(simulatorConfig.simulatorType)?.bracketAware === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update probabilities for a sports season after results come in
|
||||
*
|
||||
|
|
@ -104,7 +142,8 @@ function createFinishedProbabilities(finalPosition: number): number[] {
|
|||
* 1. Get all participant results (finished participants)
|
||||
* 2. Get all existing participant EVs
|
||||
* 3. For finished participants: set 100% at their placement
|
||||
* 4. For unfinished participants: recalculate using ICM with remaining participants
|
||||
* 4. For unfinished participants: re-run the season's bracket-aware simulator if it has one,
|
||||
* otherwise recalculate using ICM with remaining participants
|
||||
*
|
||||
* @param sportsSeasonId Sports season to update
|
||||
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
|
||||
|
|
@ -138,39 +177,49 @@ export async function updateProbabilitiesAfterResult(
|
|||
.map(r => [r.participantId, r.finalPosition ?? 0])
|
||||
);
|
||||
|
||||
// Update finished participants. The shared default table is used because we only
|
||||
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||
|
||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||
// Running these in parallel would race on that shared state.
|
||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||
try {
|
||||
const probs = createFinishedProbabilities(finalPosition);
|
||||
const probabilities = arrayToProbabilityDistribution(probs);
|
||||
|
||||
await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'manual', // Result is from actual outcome
|
||||
});
|
||||
|
||||
updated++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate unfinished participants if requested
|
||||
if (recalculateUnfinished) {
|
||||
const unfinishedEVs = existingEVs.filter(
|
||||
ev => !finishedMap.has(ev.participantId)
|
||||
);
|
||||
|
||||
if (unfinishedEVs.length > 0) {
|
||||
if (unfinishedEVs.length > 0 && (await shouldRerunSimulator(sportsSeasonId))) {
|
||||
// The simulator reads the bracket, so it already knows this result: it seeds from the
|
||||
// real draw and replays every completed match. Re-running it keeps each participant's
|
||||
// distribution consistent with the games actually played — including the placement
|
||||
// floors a bracket entry or a non-scoring-round win has already banked, which the ICM
|
||||
// branch below cannot see and would value below points the league has paid out.
|
||||
//
|
||||
// Imported lazily: probability-updater → runner → scoring-calculator →
|
||||
// probability-updater is a module cycle, and a static import leaves the binding
|
||||
// undefined at module-init time.
|
||||
try {
|
||||
const { runSportsSeasonSimulation } = await import("~/services/simulations/runner");
|
||||
// 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;
|
||||
} catch (error) {
|
||||
// A run already in flight, failed readiness, or a bracket the simulator refuses to
|
||||
// read (afl_10 seeded into only some of its slots). Leave the existing probabilities
|
||||
// alone rather than falling back to ICM — for these seasons ICM is precisely the
|
||||
// thing being replaced, and reintroducing it here would reintroduce sub-floor EVs.
|
||||
// Completed seasons never reach this: shouldRerunSimulator excludes them.
|
||||
logger.error(
|
||||
`[ProbabilityUpdater] Failed to re-run simulator for sports season ${sportsSeasonId}; ` +
|
||||
`leaving existing probabilities in place:`,
|
||||
error
|
||||
);
|
||||
errors.push(`Failed to re-run simulator for sports season ${sportsSeasonId}: ${error}`);
|
||||
}
|
||||
} else if (unfinishedEVs.length > 0) {
|
||||
// Get their current championship probabilities (use existing P(1st) as proxy)
|
||||
const unfinishedOdds = unfinishedEVs.map(ev => {
|
||||
const pFirst = parseFloat(ev.probFirst);
|
||||
|
|
@ -220,6 +269,38 @@ export async function updateProbabilitiesAfterResult(
|
|||
}
|
||||
}
|
||||
|
||||
// Update finished participants. The shared default table is used because we only
|
||||
// care about setting probabilities here, not the EV — each league re-derives its own
|
||||
// EV from the stored probabilities in calculateTeamProjectedScore.
|
||||
//
|
||||
// This runs *after* the recalculation above, not before, because re-running a simulator
|
||||
// rewrites every participant in the season — the finalized ones included. A finalized
|
||||
// placement is a fact, not a projection, so it is written last and wins: if a simulator
|
||||
// ever puts a knocked-out team back in contention (a bracket-aware one whose bracket has
|
||||
// since been cleared and not re-seeded, say), the pin still zeroes them.
|
||||
|
||||
// Sequential: upsertParticipantEV calls syncVorpForSeason internally, which
|
||||
// reads and rewrites every seasonParticipants row for this sportsSeasonId.
|
||||
// Running these in parallel would race on that shared state.
|
||||
for (const [participantId, finalPosition] of finishedMap.entries()) {
|
||||
try {
|
||||
const probs = createFinishedProbabilities(finalPosition);
|
||||
const probabilities = arrayToProbabilityDistribution(probs);
|
||||
|
||||
await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probabilities,
|
||||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'manual', // Result is from actual outcome
|
||||
});
|
||||
|
||||
updated++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to update participant ${participantId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finishedParticipants: finishedMap.size,
|
||||
unfishedParticipants: existingEVs.length - finishedMap.size,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
|
||||
import {
|
||||
getTeamData,
|
||||
eloWinProbability,
|
||||
AFLSimulator,
|
||||
readAflBracketSeeds,
|
||||
type BracketMatch,
|
||||
} from "../afl-simulator";
|
||||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||||
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
|
||||
|
||||
// ─── normalizeTeamName ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -125,8 +133,82 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
|
|||
|
||||
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
|
||||
|
||||
/**
|
||||
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
|
||||
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
|
||||
* recorded result.
|
||||
*/
|
||||
function aflBracketMatches(
|
||||
seedIds: string[],
|
||||
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
|
||||
): BracketMatch[] {
|
||||
const seed = (n: number) => seedIds[n - 1] ?? null;
|
||||
const rows: BracketMatch[] = [
|
||||
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
|
||||
// participant2 is TBD until a Wildcard winner advances into it.
|
||||
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||||
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||||
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
|
||||
|
||||
for (const done of completed) {
|
||||
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
|
||||
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
|
||||
row.isComplete = true;
|
||||
row.winnerId = done.winnerId;
|
||||
row.loserId = done.loserId;
|
||||
// A Wildcard winner is advanced into the Elimination Final it feeds.
|
||||
if (done.round === "Wildcard Round") {
|
||||
const ef = rows.find(
|
||||
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
|
||||
);
|
||||
if (ef) ef.participant2Id = done.winnerId;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
|
||||
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
|
||||
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||||
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
|
||||
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
|
||||
const found = results.find((r) => r.participantId === participantId);
|
||||
if (!found) throw new Error(`no simulation result for ${participantId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** EV on the reference scale the runner persists with. */
|
||||
function evOf(result: { probabilities: ProbabilityDistribution }): number {
|
||||
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
|
||||
}
|
||||
|
||||
describe("AFLSimulator.simulate()", () => {
|
||||
let mockDb: { select: MockInstance };
|
||||
let mockDb: {
|
||||
select: MockInstance;
|
||||
query: {
|
||||
scoringEvents: { findMany: MockInstance };
|
||||
playoffMatches: { findMany: MockInstance };
|
||||
};
|
||||
};
|
||||
|
||||
/** Put a seeded afl_10 bracket in front of the simulator. */
|
||||
function seedBracket(matches: BracketMatch[]) {
|
||||
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
|
||||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { database } = await import("~/database/context");
|
||||
|
|
@ -136,6 +218,11 @@ describe("AFLSimulator.simulate()", () => {
|
|||
|
||||
let selectCallCount = 0;
|
||||
mockDb = {
|
||||
// Default: no bracket generated yet, so the ladder-projection path runs.
|
||||
query: {
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
|
|
@ -355,4 +442,184 @@ describe("AFLSimulator.simulate()", () => {
|
|||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
||||
});
|
||||
|
||||
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
|
||||
//
|
||||
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
|
||||
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
|
||||
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
|
||||
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
|
||||
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
|
||||
// EV assertion below is that floor.
|
||||
|
||||
describe("bracket-aware mode", () => {
|
||||
/**
|
||||
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
|
||||
* strongest ten would let the ladder-projection path produce much the same field by
|
||||
* accident, so the floor assertions below would pass even with the bracket ignored.
|
||||
*/
|
||||
const SEEDS = PARTICIPANT_IDS.slice(8);
|
||||
|
||||
it("never values a seed below the entry floor its seeding already banked", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
|
||||
}
|
||||
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
|
||||
for (const seed of [5, 6]) {
|
||||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
|
||||
// 7th-8th tier is reachable only by losing an Elimination Final.
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
|
||||
}
|
||||
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
|
||||
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses the bracket's draw rather than a re-projected ladder", async () => {
|
||||
// Deliberately inverted: the weakest club is the minor premier and the strongest
|
||||
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
|
||||
// holds if the bracket's own slots are being read.
|
||||
const inverted = [
|
||||
"team-18", "team-17", "team-16", "team-15", "team-14",
|
||||
"team-13", "team-12", "team-11", "team-10", "team-1",
|
||||
];
|
||||
seedBracket(aflBracketMatches(inverted));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
|
||||
// finish 7th-8th, and its EV clears the seed 1-4 floor.
|
||||
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
|
||||
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
|
||||
|
||||
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
|
||||
// with nothing banked and can be knocked out for 0.
|
||||
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("zeroes every participant outside the bracket", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
|
||||
expect(evOf(r), r.participantId).toBe(0);
|
||||
}
|
||||
expect(results).toHaveLength(18);
|
||||
});
|
||||
|
||||
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
|
||||
seedBracket(aflBracketMatches(SEEDS));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
const keys = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||||
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 6);
|
||||
}
|
||||
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
|
||||
});
|
||||
|
||||
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
|
||||
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
|
||||
seedBracket(
|
||||
aflBracketMatches(SEEDS, [
|
||||
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
|
||||
])
|
||||
);
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
|
||||
// The loser is out with nothing, in every iteration.
|
||||
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
|
||||
});
|
||||
|
||||
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
|
||||
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
|
||||
// and the loser drops into a Semi-Final (floor 5th, 25 points).
|
||||
seedBracket(
|
||||
aflBracketMatches(SEEDS, [
|
||||
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
|
||||
])
|
||||
);
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
const winner = resultFor(results, SEEDS[0]);
|
||||
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
|
||||
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
|
||||
expect(winner.probabilities.probFifth).toBe(0);
|
||||
|
||||
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
|
||||
seedBracket(aflBracketMatches([]));
|
||||
const results = await new AFLSimulator().simulate("season-1");
|
||||
|
||||
// Every club is back in contention, so nobody is structurally zeroed.
|
||||
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
|
||||
|
||||
describe("readAflBracketSeeds", () => {
|
||||
const teamsById = new Map(
|
||||
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
|
||||
);
|
||||
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
|
||||
|
||||
it("returns null when there is no bracket at all", () => {
|
||||
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a generated but unseeded bracket", () => {
|
||||
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the 10 seeds in ladder order", () => {
|
||||
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
|
||||
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
|
||||
});
|
||||
|
||||
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
|
||||
expect(m.participant2Id).toBeNull();
|
||||
}
|
||||
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("throws on a partially seeded bracket rather than discarding the draw", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
|
||||
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
|
||||
});
|
||||
|
||||
it("throws when one participant holds two slots", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
|
||||
});
|
||||
|
||||
it("throws when the bracket references a participant outside the season", () => {
|
||||
const matches = aflBracketMatches(SEEDS);
|
||||
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
|
||||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,33 @@ describe("simulator manifest", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// updateProbabilitiesAfterResult sends a season down the re-run path or the ICM path purely
|
||||
// on this flag, and getting it wrong is silent in both directions: set it on a simulator
|
||||
// that re-plays decided games and eliminated teams come back to life; leave it off a
|
||||
// bracket-aware one and ICM keeps reporting placement floors as worth less than the points
|
||||
// already awarded. Pinning the set makes a new simulator an explicit decision rather than a
|
||||
// default. To add one, confirm it reads playoff_matches AND honors isComplete/winnerId.
|
||||
it("pins which simulators are bracket-aware", () => {
|
||||
const bracketAware = SIMULATOR_TYPES.filter((t) => SIMULATOR_MANIFEST[t].bracketAware);
|
||||
expect(bracketAware.toSorted()).toEqual(
|
||||
[
|
||||
"afl_bracket",
|
||||
"college_hockey_bracket",
|
||||
"cs2_major_qualifying_points",
|
||||
"darts_bracket",
|
||||
"llws_bracket",
|
||||
"nba_bracket",
|
||||
"ncaam_bracket",
|
||||
"ncaaw_bracket",
|
||||
"nhl_bracket",
|
||||
"nll_bracket",
|
||||
"snooker_bracket",
|
||||
"ucl_bracket",
|
||||
"world_cup",
|
||||
].toSorted()
|
||||
);
|
||||
});
|
||||
|
||||
it("only derives inputs from declared optional inputs", () => {
|
||||
for (const simulatorType of SIMULATOR_TYPES) {
|
||||
const profile = SIMULATOR_MANIFEST[simulatorType];
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import {
|
|||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { batchUpsertParticipantEVs } from "~/models/participant-expected-value";
|
||||
import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot";
|
||||
import { recalculateStandings } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import { getSimulator } from "~/services/simulations/registry";
|
||||
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" }]);
|
||||
});
|
||||
|
||||
/** 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 () => {
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,36 @@
|
|||
*
|
||||
* Monte Carlo simulation of the AFL regular season and finals for 2026.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is
|
||||
* re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is
|
||||
* modelled as still uncertain.
|
||||
* 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed
|
||||
* across every iteration, and games already played are replayed from their recorded
|
||||
* result instead of being re-simulated.
|
||||
*
|
||||
* Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that
|
||||
* awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a
|
||||
* simulator that re-draws the ladder every iteration puts those teams back in the Wildcard
|
||||
* Round — or out of the finals entirely — where they score 0, pulling EV below points the
|
||||
* league has already paid out. Reading the real draw removes that by construction: a team
|
||||
* seeded into an Elimination Final is in that game in 100% of iterations, so its worst
|
||||
* outcome is the 7th-8th tier.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load all participants for the sports season from DB
|
||||
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
|
||||
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
|
||||
* 3. Load current regular season standings (wins, gamesPlayed) — if available
|
||||
* 4. For each simulation:
|
||||
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
|
||||
* using Elo win probability vs. an average opponent (Elo 1500)
|
||||
* → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||
* b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder
|
||||
* → Top 10 advance to the AFL Finals Series
|
||||
* c. Simulate AFL Finals Series (AFL_10 bracket):
|
||||
* 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
|
||||
* 5. For each simulation:
|
||||
* a. Pre-bracket mode only: for each team, simulate remaining regular season games
|
||||
* (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
|
||||
* (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4
|
||||
* b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
|
||||
* tiebreaker → final ladder → top 10 advance to the AFL Finals Series.
|
||||
* In bracket-aware mode the bracket's own 10 seeds are used as-is.
|
||||
* c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match:
|
||||
*
|
||||
* Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts)
|
||||
* Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye)
|
||||
|
|
@ -24,8 +42,8 @@
|
|||
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
|
||||
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
|
||||
*
|
||||
* 5. Track placement counts per scoring tier
|
||||
* 6. Convert counts to probability distributions
|
||||
* 6. Track placement counts per scoring tier
|
||||
* 7. Convert counts to probability distributions
|
||||
*
|
||||
* Win probability (Elo, PARITY_FACTOR = 450):
|
||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450))
|
||||
|
|
@ -53,7 +71,7 @@
|
|||
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
|
||||
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
|
||||
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
|
||||
* Missed finals → all 0
|
||||
* Missed finals → all 0 (in bracket-aware mode, every team outside the bracket)
|
||||
*
|
||||
* NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two
|
||||
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
|
||||
|
|
@ -62,7 +80,7 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||
|
|
@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access";
|
|||
|
||||
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||
|
||||
/** The bracket template the AFL finals are scored against. */
|
||||
const AFL_TEMPLATE_ID = "afl_10";
|
||||
|
||||
/**
|
||||
* Elo parity factor for AFL single-game win probability.
|
||||
* 450 reflects moderate variance — lower than NHL (1000) to account for
|
||||
|
|
@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number {
|
|||
return entry.currentWins + extra;
|
||||
}
|
||||
|
||||
/** The playoff_matches columns the simulator actually reads. */
|
||||
export type BracketMatch = Pick<
|
||||
typeof schema.playoffMatches.$inferSelect,
|
||||
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
|
||||
>;
|
||||
|
||||
interface LoadedBracket {
|
||||
/** The 10 finalists in seed order — index 0 is the minor premier. */
|
||||
seeds: TeamEntry[];
|
||||
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
|
||||
matches: Map<string, BracketMatch>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
|
||||
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
|
||||
*/
|
||||
type PlayGame = (
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
t1: TeamEntry,
|
||||
t2: TeamEntry
|
||||
) => { winner: TeamEntry; loser: TeamEntry };
|
||||
|
||||
function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
|
||||
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
|
||||
? { winner: t1, loser: t2 }
|
||||
: { winner: t2, loser: t1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
|
||||
*
|
||||
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
|
||||
* design until a Wildcard winner advances into them, so they are never a missing seed.
|
||||
* That leaves exactly 10 named slots for the 10 finalists.
|
||||
*/
|
||||
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
|
||||
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
|
||||
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
|
||||
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
|
||||
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
|
||||
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
|
||||
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
|
||||
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
|
||||
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
|
||||
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the seeded afl_10 bracket for this season, if there is one.
|
||||
*
|
||||
* Returns null only when the bracket carries no draw at all — no matches, or a freshly
|
||||
* generated bracket with every slot still empty — in which case the caller falls back to
|
||||
* projecting the ladder.
|
||||
*
|
||||
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
|
||||
* throw away the real draw and every recorded result with it, putting eliminated teams back
|
||||
* in contention; and it is reachable in practice, because playoff_matches.participant1Id /
|
||||
* participant2Id are ON DELETE SET NULL, so removing and re-adding one participant
|
||||
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
|
||||
* reason.
|
||||
*/
|
||||
export function readAflBracketSeeds(
|
||||
matches: BracketMatch[],
|
||||
teamsById: Map<string, TeamEntry>
|
||||
): LoadedBracket | null {
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
|
||||
|
||||
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
|
||||
const match = byKey.get(matchKey(round, matchNumber));
|
||||
if (!match) return null;
|
||||
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
|
||||
});
|
||||
|
||||
const seededCount = drawn.filter((id) => id !== null).length;
|
||||
|
||||
// Generated but not yet filled in — no draw to honor.
|
||||
if (seededCount === 0) return null;
|
||||
|
||||
if (seededCount < drawn.length) {
|
||||
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
|
||||
.map((s) => s.seed)
|
||||
.toSorted((a, b) => a - b)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
|
||||
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
|
||||
`simulating around the gap would discard the draw and every recorded result.`
|
||||
);
|
||||
}
|
||||
|
||||
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
|
||||
const seeds: TeamEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < SEED_SLOTS.length; i++) {
|
||||
const participantId = drawn[i] as string;
|
||||
if (seen.has(participantId)) {
|
||||
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
|
||||
}
|
||||
seen.add(participantId);
|
||||
|
||||
const team = teamsById.get(participantId);
|
||||
if (!team) {
|
||||
throw new Error(
|
||||
`AFL bracket references participant ${participantId}, which is not in this sports season.`
|
||||
);
|
||||
}
|
||||
seeds[SEED_SLOTS[i].seed - 1] = team;
|
||||
}
|
||||
|
||||
return { seeds, matches: byKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded loser of a completed match. loserId is written by the scoring flow, but fall
|
||||
* back to "whichever slot isn't the winner" for older rows.
|
||||
*/
|
||||
function completedLoser(match: BracketMatch): string | null {
|
||||
if (match.loserId) return match.loserId;
|
||||
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
|
||||
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the game-playing function for a bracket.
|
||||
*
|
||||
* When the bracket has a completed result for a game AND that result is between the two teams
|
||||
* the simulation routed into it, the recorded winner is used verbatim — that is what makes an
|
||||
* already-played result stick across all iterations, and what stops a banked floor from being
|
||||
* re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or
|
||||
* out-of-order row from desynchronising the rest of the bracket.
|
||||
*/
|
||||
export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
|
||||
if (!bracket) {
|
||||
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
|
||||
}
|
||||
|
||||
return (round, matchNumber, t1, t2) => {
|
||||
const match = bracket.matches.get(matchKey(round, matchNumber));
|
||||
if (match?.isComplete && match.winnerId) {
|
||||
const loserId = completedLoser(match);
|
||||
const arrived = [t1.id, t2.id];
|
||||
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
|
||||
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||||
}
|
||||
}
|
||||
return simGame(t1, t2, parityFactor);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||
*
|
||||
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
|
||||
* recorded result is looked up against the game it was actually played in:
|
||||
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
|
||||
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
|
||||
*
|
||||
* Returns the placement for each team:
|
||||
* "gf_winner" → 1st
|
||||
* "gf_loser" → 2nd
|
||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||
* "sf_loser" → 5th/6th (two teams per sim)
|
||||
* "ef_loser" → 7th/8th (two teams per sim)
|
||||
* "wc_loser" → 9th/10th (zero scoring points)
|
||||
*/
|
||||
export function simAFLFinals(
|
||||
finalists: TeamEntry[],
|
||||
play: PlayGame
|
||||
): {
|
||||
gfWinner: TeamEntry;
|
||||
gfLoser: TeamEntry;
|
||||
pfLosers: [TeamEntry, TeamEntry];
|
||||
sfLosers: [TeamEntry, TeamEntry];
|
||||
efLosers: [TeamEntry, TeamEntry];
|
||||
} {
|
||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||
|
||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||
const wc1 = play("Wildcard Round", 1, s7, s10);
|
||||
const wc2 = play("Wildcard Round", 2, s8, s9);
|
||||
|
||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
|
||||
const qf1 = play("Qualifying Finals", 1, s1, s4);
|
||||
const qf2 = play("Qualifying Finals", 2, s2, s3);
|
||||
|
||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
|
||||
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
|
||||
|
||||
// Semi-Finals: QF losers (second chance) vs EF winners
|
||||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
|
||||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
|
||||
|
||||
// Preliminary Finals: QF winners vs SF winners
|
||||
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
|
||||
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
|
||||
|
||||
// Grand Final
|
||||
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
|
||||
|
||||
return {
|
||||
gfWinner: gf.winner,
|
||||
gfLoser: gf.loser,
|
||||
pfLosers: [pf1.loser, pf2.loser],
|
||||
sfLosers: [sf1.loser, sf2.loser],
|
||||
efLosers: [ef1.loser, ef2.loser],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AFLSimulator implements Simulator {
|
||||
|
|
@ -270,11 +511,34 @@ export class AFLSimulator implements Simulator {
|
|||
};
|
||||
});
|
||||
|
||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||
const teamsById = new Map(teams.map((t) => [t.id, t]));
|
||||
|
||||
/** Simulate a single AFL game. Returns the winner. */
|
||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b;
|
||||
// 4. Load the real bracket (draw + results so far), if one has been generated.
|
||||
// Events are filtered on bracketTemplateId rather than eventType and taken most
|
||||
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
|
||||
// several events, and landing on a stale or template-less row would silently
|
||||
// discard the real draw and every recorded result. createdAt can tie when a bracket
|
||||
// is generated alongside a sibling event, so id breaks the tie.
|
||||
const playoffEvents = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
|
||||
),
|
||||
columns: { id: true },
|
||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||
});
|
||||
const bracketEvent = playoffEvents[0];
|
||||
|
||||
const bracketMatches = bracketEvent
|
||||
? await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||||
})
|
||||
: [];
|
||||
|
||||
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
|
||||
const play = makePlayGame(bracket, parityFactor);
|
||||
|
||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||
|
|
@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator {
|
|||
return projected.slice(0, 10).map((x) => x.team);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulate the AFL Finals Series from a seeded list of 10 teams.
|
||||
*
|
||||
* Returns the placement for each team:
|
||||
* "gf_winner" → 1st
|
||||
* "gf_loser" → 2nd
|
||||
* "pf_loser" → 3rd/4th (two teams per sim)
|
||||
* "sf_loser" → 5th/6th (two teams per sim)
|
||||
* "ef_loser" → 7th/8th (two teams per sim)
|
||||
* "wc_loser" → 9th/10th (zero scoring points)
|
||||
*/
|
||||
const simAFLFinals = (
|
||||
finalists: TeamEntry[]
|
||||
): {
|
||||
gfWinner: TeamEntry;
|
||||
gfLoser: TeamEntry;
|
||||
pfLosers: [TeamEntry, TeamEntry];
|
||||
sfLosers: [TeamEntry, TeamEntry];
|
||||
efLosers: [TeamEntry, TeamEntry];
|
||||
} => {
|
||||
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
|
||||
|
||||
// Wildcard Round: #7 vs #10, #8 vs #9
|
||||
const wc1Winner = simGame(s7, s10);
|
||||
const wc2Winner = simGame(s8, s9);
|
||||
|
||||
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF)
|
||||
const qf1Winner = simGame(s1, s4);
|
||||
const qf1Loser = qf1Winner === s1 ? s4 : s1;
|
||||
const qf2Winner = simGame(s2, s3);
|
||||
const qf2Loser = qf2Winner === s2 ? s3 : s2;
|
||||
|
||||
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
|
||||
const ef1Winner = simGame(s5, wc2Winner);
|
||||
const ef1Loser = ef1Winner === s5 ? wc2Winner : s5;
|
||||
const ef2Winner = simGame(s6, wc1Winner);
|
||||
const ef2Loser = ef2Winner === s6 ? wc1Winner : s6;
|
||||
|
||||
// Semi-Finals: QF losers (2nd chance) vs EF winners
|
||||
const sf1Winner = simGame(qf1Loser, ef2Winner);
|
||||
const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser;
|
||||
const sf2Winner = simGame(qf2Loser, ef1Winner);
|
||||
const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser;
|
||||
|
||||
// Preliminary Finals: QF winners vs SF winners
|
||||
const pf1Winner = simGame(qf1Winner, sf2Winner);
|
||||
const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner;
|
||||
const pf2Winner = simGame(qf2Winner, sf1Winner);
|
||||
const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner;
|
||||
|
||||
// Grand Final
|
||||
const gfWinner = simGame(pf1Winner, pf2Winner);
|
||||
const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner;
|
||||
|
||||
return {
|
||||
gfWinner,
|
||||
gfLoser,
|
||||
pfLosers: [pf1Loser, pf2Loser ],
|
||||
sfLosers: [sf1Loser, sf2Loser ],
|
||||
efLosers: [ef1Loser, ef2Loser ],
|
||||
};
|
||||
};
|
||||
|
||||
// 3. Integer placement count maps — initialized to 0 for all participants.
|
||||
// 5. Integer placement count maps — initialized to 0 for all participants.
|
||||
//
|
||||
// AFL scoring uses the AFL_10 bracket template which splits 5–8 into two
|
||||
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
|
||||
|
|
@ -368,10 +569,12 @@ export class AFLSimulator implements Simulator {
|
|||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
|
||||
// 4. Monte Carlo simulation loop.
|
||||
// 6. Monte Carlo simulation loop.
|
||||
for (let s = 0; s < numSimulations; s++) {
|
||||
const finalists = buildFinalsList();
|
||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists);
|
||||
// With a real bracket the draw is fixed and its played games are replayed from their
|
||||
// recorded result; without one the ladder is re-projected every iteration.
|
||||
const finalists = bracket ? bracket.seeds : buildFinalsList();
|
||||
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play);
|
||||
|
||||
championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
|
||||
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.id) ?? 0) + 1);
|
||||
|
|
@ -388,7 +591,7 @@ export class AFLSimulator implements Simulator {
|
|||
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
|
||||
}
|
||||
|
||||
// 5. Convert integer counts to probability distributions.
|
||||
// 7. Convert integer counts to probability distributions.
|
||||
//
|
||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
|
||||
|
|
@ -421,8 +624,8 @@ export class AFLSimulator implements Simulator {
|
|||
};
|
||||
});
|
||||
|
||||
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
||||
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||
// division residuals. Columns are already near-exactly 1.0 after step 7.
|
||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||
"probFirst", "probSecond", "probThird", "probFourth",
|
||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,25 @@ export interface SimulatorManifestProfile {
|
|||
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
|
||||
setupSections: SimulatorSetupSection[];
|
||||
minParticipantInputs?: number;
|
||||
/**
|
||||
* The simulator reads the season's generated bracket: it seeds from the real draw and
|
||||
* replays completed matches from their recorded result, rather than re-drawing the field
|
||||
* and re-playing decided games every iteration.
|
||||
*
|
||||
* updateProbabilitiesAfterResult reads this to decide whether a result should be absorbed
|
||||
* by re-running the simulator or by the generic ICM recalculation. Re-running is both more
|
||||
* accurate and the only option that respects a banked placement floor, but it is only safe
|
||||
* here: re-running a bracket-blind simulator would re-draw the field and hand equity back
|
||||
* to teams already knocked out.
|
||||
*
|
||||
* Both halves are required. A simulator that reads the draw but re-simulates games already
|
||||
* played is NOT bracket-aware for this purpose — it resurrects eliminated teams just the
|
||||
* same. Check for an `isComplete`/`winnerId` replay before setting this on a new simulator.
|
||||
*
|
||||
* This is deliberately separate from `setupSections: ["bracket"]`, which only drives admin
|
||||
* links and a readiness warning and does not track this accurately in either direction.
|
||||
*/
|
||||
bracketAware?: boolean;
|
||||
}
|
||||
|
||||
const BASE_CONFIG = {
|
||||
|
|
@ -71,6 +90,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaam_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } },
|
||||
|
|
@ -78,6 +98,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaaw_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } },
|
||||
|
|
@ -85,6 +106,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "seed", "region"],
|
||||
derivableInputs: { rating: ["sourceOdds"] },
|
||||
setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nba_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
|
||||
|
|
@ -92,6 +114,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nhl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
|
||||
|
|
@ -99,6 +122,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
nfl_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
|
||||
|
|
@ -112,7 +136,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["projectedWins"],
|
||||
derivableInputs: { sourceElo: ["projectedWins"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||
// The bracket is optional — before one exists the ladder is projected from Elo — but once
|
||||
// it is drawn the simulator seeds from it and honors completed results.
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
epl_standings: {
|
||||
defaultConfig: {
|
||||
|
|
@ -135,6 +162,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
tennis_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
|
||||
|
|
@ -162,18 +190,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
|
||||
bracketAware: true,
|
||||
},
|
||||
darts_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
|
||||
requiredInputs: ["sourceElo", "worldRanking"],
|
||||
optionalInputs: ["seed"],
|
||||
setupSections: ["participants", "eloRatings", "rankings"],
|
||||
bracketAware: true,
|
||||
},
|
||||
cs2_major_qualifying_points: {
|
||||
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
|
||||
requiredInputs: ["sourceElo"],
|
||||
optionalInputs: ["worldRanking", "metadata"],
|
||||
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
|
||||
bracketAware: true,
|
||||
},
|
||||
ncaa_football_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } },
|
||||
|
|
@ -189,6 +220,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
// The bracket is optional — without one the draw is randomized — but once it
|
||||
// exists the simulator reads the real draw and honors completed results from it.
|
||||
setupSections: ["participants", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
college_hockey_bracket: {
|
||||
// College hockey blends odds into Elo internally (and also uses NPI rank,
|
||||
|
|
@ -201,6 +233,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["sourceOdds", "worldRanking"],
|
||||
derivableInputs: { sourceElo: ["sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
brackt: {
|
||||
defaultConfig: { iterations: 20_000 },
|
||||
|
|
@ -224,6 +257,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
|
||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
|
||||
bracketAware: true,
|
||||
},
|
||||
mls_bracket: {
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
sportsSeasonId: string;
|
||||
simulatorType: SimulatorType;
|
||||
|
|
@ -71,7 +94,8 @@ export interface RunSportsSeasonSimulationResult {
|
|||
}
|
||||
|
||||
export async function runSportsSeasonSimulation(
|
||||
sportsSeasonId: string
|
||||
sportsSeasonId: string,
|
||||
options: RunSportsSeasonSimulationOptions = {}
|
||||
): Promise<RunSportsSeasonSimulationResult> {
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
if (!sportsSeason) {
|
||||
|
|
@ -135,12 +159,15 @@ export async function runSportsSeasonSimulation(
|
|||
})),
|
||||
]);
|
||||
|
||||
if (!options.skipStandingsRecalc) {
|
||||
const seasonSports = await database().query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
||||
});
|
||||
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
|
||||
}
|
||||
|
||||
const snapshotDate = new Date().toISOString().slice(0, 10);
|
||||
if (!options.skipSnapshots) {
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
|
|
@ -158,6 +185,7 @@ export async function runSportsSeasonSimulation(
|
|||
source: r.source,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue