claude/afl-ev-calc-issue-8kcmlh #145

Merged
chrisp merged 3 commits from claude/afl-ev-calc-issue-8kcmlh into main 2026-08-29 04:09:36 +00:00
11 changed files with 1081 additions and 146 deletions

View file

@ -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")

View file

@ -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,6 +650,7 @@ 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.
if (!skipProbabilities) {
try { try {
await updateProbabilitiesAfterResult(sportsSeasonId, true); await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) { } catch (error) {
@ -645,6 +659,7 @@ export async function processMatchResult(
error error
); );
} }
}
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
} }
} }

View file

@ -8,6 +8,9 @@ import * as participantEVModel from "~/models/participant-expected-value";
// Mock the dependencies // Mock the dependencies
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/sports-season");
vi.mock("~/services/simulations/runner");
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
database: () => ({ database: () => ({
query: { 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", () => { describe("probability-updater", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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);
});
});

View file

@ -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 };

View file

@ -21,6 +21,10 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 { findSportsSeasonById } from "~/models/sports-season";
import { getManifestSimulatorProfile } from "~/services/simulations/manifest";
import { logger } from "~/lib/logger";
/** /**
* Result of probability update operation * Result of probability update operation
@ -97,6 +101,40 @@ function createFinishedProbabilities(finalPosition: number): number[] {
return probs; 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 * 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) * 1. Get all participant results (finished participants)
* 2. Get all existing participant EVs * 2. Get all existing participant EVs
* 3. For finished participants: set 100% at their placement * 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 sportsSeasonId Sports season to update
* @param recalculateUnfinished Whether to recalculate unfinished participants (default true) * @param recalculateUnfinished Whether to recalculate unfinished participants (default true)
@ -138,39 +177,49 @@ export async function updateProbabilitiesAfterResult(
.map(r => [r.participantId, r.finalPosition ?? 0]) .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 // Recalculate unfinished participants if requested
if (recalculateUnfinished) { if (recalculateUnfinished) {
const unfinishedEVs = existingEVs.filter( const unfinishedEVs = existingEVs.filter(
ev => !finishedMap.has(ev.participantId) 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) // Get their current championship probabilities (use existing P(1st) as proxy)
const unfinishedOdds = unfinishedEVs.map(ev => { const unfinishedOdds = unfinishedEVs.map(ev => {
const pFirst = parseFloat(ev.probFirst); 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 { return {
finishedParticipants: finishedMap.size, finishedParticipants: finishedMap.size,
unfishedParticipants: existingEVs.length - finishedMap.size, unfishedParticipants: existingEVs.length - finishedMap.size,

View file

@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name"; 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 ──────────────────────────────────────────────────────── // ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -125,8 +133,82 @@ const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id); 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()", () => { 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 () => { beforeEach(async () => {
const { database } = await import("~/database/context"); const { database } = await import("~/database/context");
@ -136,6 +218,11 @@ describe("AFLSimulator.simulate()", () => {
let selectCallCount = 0; let selectCallCount = 0;
mockDb = { 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(() => { select: vi.fn().mockImplementation(() => {
selectCallCount++; selectCallCount++;
if (selectCallCount === 1) { if (selectCallCount === 1) {
@ -355,4 +442,184 @@ describe("AFLSimulator.simulate()", () => {
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data // Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst); 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/);
});
}); });

View file

@ -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", () => { it("only derives inputs from declared optional inputs", () => {
for (const simulatorType of SIMULATOR_TYPES) { for (const simulatorType of SIMULATOR_TYPES) {
const profile = SIMULATOR_MANIFEST[simulatorType]; const profile = SIMULATOR_MANIFEST[simulatorType];

View file

@ -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);

View file

@ -3,18 +3,36 @@
* *
* Monte Carlo simulation of the AFL regular season and finals for 2026. * 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: * Algorithm:
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained) * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
* Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set. * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
* 3. Load current regular season standings (wins, gamesPlayed) if available * 3. Load current regular season standings (wins, gamesPlayed) if available
* 4. For each simulation: * 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed) * 5. For each simulation:
* using Elo win probability vs. an average opponent (Elo 1500) * a. Pre-bracket mode only: for each team, simulate remaining regular season games
* projectedPoints = currentWins*4 + simulatedRemainingWins*4 * (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent
* b. Sort all 18 teams by projected points desc + random tiebreaker final ladder * (Elo 1500) projectedPoints = currentWins*4 + simulatedRemainingWins*4
* Top 10 advance to the AFL Finals Series * b. Pre-bracket mode only: sort all 18 teams by projected points desc + random
* c. Simulate AFL Finals Series (AFL_10 bracket): * 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) * Wildcard Round: #7 vs #10, #8 vs #9 losers exit (0 pts)
* Qualifying Finals: #1 vs #4, #2 vs #3 winners Prelim Finals (bye) * 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) * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w losers exit (3rd/4th)
* Grand Final: PF1w vs PF2w winner 1st, loser 2nd * Grand Final: PF1w vs PF2w winner 1st, loser 2nd
* *
* 5. Track placement counts per scoring tier * 6. Track placement counts per scoring tier
* 6. Convert counts to probability distributions * 7. Convert counts to probability distributions
* *
* Win probability (Elo, PARITY_FACTOR = 450): * Win probability (Elo, PARITY_FACTOR = 450):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 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) * probFifth/Sixth = Semi-Finals losers (2 per sim split evenly)
* probSeventh/Eighth = Elimination 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) * 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 58 tier into two * NOTE: AFL uses the AFL_10 bracket template which splits the 58 tier into two
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts * 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 { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name"; import { normalizeTeamName } from "~/lib/normalize-team-name";
@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access";
const DEFAULT_NUM_SIMULATIONS = 10_000; 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. * Elo parity factor for AFL single-game win probability.
* 450 reflects moderate variance lower than NHL (1000) to account for * 450 reflects moderate variance lower than NHL (1000) to account for
@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number {
return entry.currentWins + extra; 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 ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
export class AFLSimulator implements 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. */ // 4. Load the real bracket (draw + results so far), if one has been generated.
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => // Events are filtered on bracketTemplateId rather than eventType and taken most
Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b; // 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 110. * Project end-of-season ladder and return the top 10 finalists seeded 110.
@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator {
return projected.slice(0, 10).map((x) => x.team); return projected.slice(0, 10).map((x) => x.team);
}; };
/** // 5. Integer placement count maps — initialized to 0 for all participants.
* 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.
// //
// AFL scoring uses the AFL_10 bracket template which splits 58 into two // AFL scoring uses the AFL_10 bracket template which splits 58 into two
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and // 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 sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const efLoserCounts = 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++) { for (let s = 0; s < numSimulations; s++) {
const finalists = buildFinalsList(); // With a real bracket the draw is fixed and its played games are replayed from their
const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists); // 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); championCounts.set(gfWinner.id, (championCounts.get(gfWinner.id) ?? 0) + 1);
finalistCounts.set(gfLoser.id, (finalistCounts.get(gfLoser.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). // 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: // Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → / NUM_SIMULATIONS (1 per sim) // 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 // 8. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 5. // division residuals. Columns are already near-exactly 1.0 after step 7.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [ const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth", "probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth", "probFifth", "probSixth", "probSeventh", "probEighth",

View file

@ -34,6 +34,25 @@ export interface SimulatorManifestProfile {
derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>; derivableInputs?: Partial<Record<SimulatorInputKey, SimulatorInputKey[]>>;
setupSections: SimulatorSetupSection[]; setupSections: SimulatorSetupSection[];
minParticipantInputs?: number; 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 = { const BASE_CONFIG = {
@ -71,6 +90,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds"], optionalInputs: ["sourceOdds"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
}, },
ncaam_bracket: { ncaam_bracket: {
defaultConfig: { ...BASE_CONFIG, ratingScaleFactor: 7.5, inputPolicy: { ratingMin: -10, ratingMax: 35, fallbackRatingDelta: 5 } }, 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"], optionalInputs: ["sourceOdds", "sourceElo", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
ncaaw_bracket: { ncaaw_bracket: {
defaultConfig: { ...BASE_CONFIG, inputPolicy: { ratingMin: 0.70, ratingMax: 0.97, missingRatingStrategy: "worstKnownMinus", fallbackRatingDelta: 0.01 } }, 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"], optionalInputs: ["sourceOdds", "seed", "region"],
derivableInputs: { rating: ["sourceOdds"] }, derivableInputs: { rating: ["sourceOdds"] },
setupSections: ["participants", "ratings", "futuresOdds", "bracket"], setupSections: ["participants", "ratings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
nba_bracket: { nba_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 82 },
@ -92,6 +114,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
nhl_bracket: { nhl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 1000, seasonGames: 82, overtimeRate: 0.23 },
@ -99,6 +122,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "projectedWins"], optionalInputs: ["sourceOdds", "projectedWins"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
nfl_bracket: { nfl_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 }, defaultConfig: { ...BASE_CONFIG, parityFactor: 400, seasonGames: 17, homeFieldElo: 48 },
@ -112,7 +136,10 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins"], optionalInputs: ["projectedWins"],
derivableInputs: { sourceElo: ["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: { epl_standings: {
defaultConfig: { defaultConfig: {
@ -135,6 +162,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "seed"], optionalInputs: ["worldRanking", "seed"],
setupSections: ["participants", "eloRatings", "rankings", "bracket"], setupSections: ["participants", "eloRatings", "rankings", "bracket"],
bracketAware: true,
}, },
tennis_qualifying_points: { tennis_qualifying_points: {
defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 }, defaultConfig: { iterations: 10_000, eloDivisor: 400, fallbackElo: 1500 },
@ -162,18 +190,21 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["sourceOdds", "worldRanking"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "futuresOdds", "events"], setupSections: ["participants", "eloRatings", "futuresOdds", "events"],
bracketAware: true,
}, },
darts_bracket: { darts_bracket: {
defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 }, defaultConfig: { ...BASE_CONFIG, iterations: 10_000, eloDivisor: 400 },
requiredInputs: ["sourceElo", "worldRanking"], requiredInputs: ["sourceElo", "worldRanking"],
optionalInputs: ["seed"], optionalInputs: ["seed"],
setupSections: ["participants", "eloRatings", "rankings"], setupSections: ["participants", "eloRatings", "rankings"],
bracketAware: true,
}, },
cs2_major_qualifying_points: { cs2_major_qualifying_points: {
defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 }, defaultConfig: { iterations: 10_000, fieldSize: 32, guaranteedCount: 12 },
requiredInputs: ["sourceElo"], requiredInputs: ["sourceElo"],
optionalInputs: ["worldRanking", "metadata"], optionalInputs: ["worldRanking", "metadata"],
setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"], setupSections: ["participants", "eloRatings", "rankings", "cs2Setup", "events"],
bracketAware: true,
}, },
ncaa_football_bracket: { ncaa_football_bracket: {
defaultConfig: { ...BASE_CONFIG, parityFactor: 400, bracketSize: 12, inputPolicy: { oddsWeight: 0.4 } }, 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 // 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. // exists the simulator reads the real draw and honors completed results from it.
setupSections: ["participants", "futuresOdds", "bracket"], setupSections: ["participants", "futuresOdds", "bracket"],
bracketAware: true,
}, },
college_hockey_bracket: { college_hockey_bracket: {
// College hockey blends odds into Elo internally (and also uses NPI rank, // 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"], optionalInputs: ["sourceOdds", "worldRanking"],
derivableInputs: { sourceElo: ["sourceOdds"] }, derivableInputs: { sourceElo: ["sourceOdds"] },
setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"], setupSections: ["participants", "eloRatings", "rankings", "futuresOdds", "bracket"],
bracketAware: true,
}, },
brackt: { brackt: {
defaultConfig: { iterations: 20_000 }, defaultConfig: { iterations: 20_000 },
@ -224,6 +257,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: ["projectedWins", "sourceOdds", "seed"], optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"], setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
bracketAware: true,
}, },
mls_bracket: { mls_bracket: {
defaultConfig: { defaultConfig: {

View file

@ -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,12 +159,15 @@ export async function runSportsSeasonSimulation(
})), })),
]); ]);
if (!options.skipStandingsRecalc) {
const seasonSports = await database().query.seasonSports.findMany({ const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), 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);
if (!options.skipSnapshots) {
await batchUpsertParticipantEvSnapshots( await batchUpsertParticipantEvSnapshots(
results.map((r) => ({ results.map((r) => ({
participantId: r.participantId, participantId: r.participantId,
@ -158,6 +185,7 @@ export async function runSportsSeasonSimulation(
source: r.source, source: r.source,
})) }))
); );
}
await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" }); await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });