Compare commits

..

4 commits

Author SHA1 Message Date
758166dd46 Merge pull request 'claude/afl-ev-calc-issue-8kcmlh' (#145) from claude/afl-ev-calc-issue-8kcmlh into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m16s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Reviewed-on: #145
2026-08-29 04:09:36 +00:00
Claude
d83f6976bf
Stop the simulator re-run from trampling its caller's side effects
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m14s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
A review of this branch found four problems, all downstream of one decision:
calling runSportsSeasonSimulation from inside the result path. That function
does three jobs — recompute probabilities, recalculate standings, write the
daily EV snapshot — and the result path wants only the first.

1. The Discord standings post was silently suppressed on every scored match,
   for all 13 bracket-aware sports.

   recalculateAffectedLeagues detects change by snapshotting teamStandings,
   recalculating, then diffing; changedTeamIds gates the notification. But
   processMatchResult runs updateProbabilitiesAfterResult first, which now
   reached the runner's own recalculateStandings. The new totals were therefore
   already written when the "before" snapshot was taken, the diff came back
   empty, and the post never fired. previousRank went the same way:
   recalculateStandings rolls it forward on every call, so the extra one erased
   rank movement.

   runSportsSeasonSimulation now takes skipStandingsRecalc / skipSnapshots and
   the probability updater passes both. The snapshot is skipped because it is a
   per-day series keyed by snapshotDate — writing it per match result just
   overwrites the day's row with intra-day values.

2. finalizeQualifyingPoints marks the season completed immediately before
   calling the updater, and the runner rejects a completed season outright. With
   the ICM fallback gone that failed every time, stranding anyone still in the
   unfinished set on permanently stale probabilities — reachable for
   cs2_major_qualifying_points, the one bracket-aware qualifying-points sport.

   A completed season is not this branch's case rather than a failure: every
   placement is final and the floor it protects can no longer be contradicted.
   shouldRerunSimulator now excludes it and it falls through to ICM as before.
   The genuine failure modes still leave probabilities alone rather than falling
   back to the path being replaced.

3. match-sync calls processMatchResult in a per-match loop with no
   skipSideEffects, so each synced match ran a full Monte Carlo plus EV rewrite,
   snapshot and standings recalc. processMatchResult gains skipProbabilities —
   mirroring the option processPlayoffEvent already takes, and narrower than
   skipSideEffects — which match-sync passes in the loop before refreshing once
   at the end. Per-match standings and Discord posts are unchanged.

4. A partially-seeded afl_10 bracket now throws from inside the result path.
   The throw is correct and stays; the concern was that it was silent, which the
   error surfacing in 2 covers.

Two claims from the review did not hold up and were left alone: the batch
bracket route already passes skipSideEffects per match and refreshes once after
the loop, and autoCompleteRoundIfDone already passes skipProbabilities, so there
is no double run per round completion.

Tests: the runner honors both skip flags and still writes EVs; the updater asks
for probabilities only; a completed season takes the ICM path without erroring;
processMatchResult skips the refresh but still announces. The two behavioral
ones were confirmed to fail against the previous behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 03:46:12 +00:00
Claude
eefe407e7f
Route every bracket-aware sport away from ICM, not just AFL
The previous commit gated the simulator re-run on a `bracketAware` flag set only
on afl_bracket and llws_bracket, on the claim that every other simulator was
bracket-blind and would resurrect eliminated teams if re-run. That claim was
wrong. Eleven more read playoff_matches and honor isComplete/winnerId already:
ucl, ncaam, ncaaw (both via ncaa-basketball), nba, nhl, snooker, world_cup,
darts, cs2_major, college_hockey and nll. All thirteen are now flagged, so any
sport with a bracket the simulator can read absorbs a result by re-running that
simulator rather than through ICM.

Two simulators are deliberately left off. playoff_bracket and
ncaa_football_bracket declare a "bracket" setup section but never read
playoff_matches, so re-running them really would re-draw the field. That
mismatch runs the other way too — world_cup, darts_bracket and
cs2_major_qualifying_points read the bracket without declaring the section — so
setupSections is not a usable signal here and the flag stays separate from it,
with both facts written down on the flag.

The EV-source condition is also gone. It only asked whether the existing EVs
came from a simulation, which protected nothing: the alternative to re-running
was never leaving them alone, it was the ICM branch overwriting them anyway.
Given two overwrites, the bracket-aware one wins regardless of what wrote them.

Tests: a bracket-blind simulator still goes through ICM, futures-odds EVs no
longer divert a bracket-aware season away from the re-run, and the bracket-aware
set is pinned in the manifest test so a new simulator is an explicit decision
rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:49:17 +00:00
Claude
cf817cc9ff
Make the AFL simulator read the bracket that was actually drawn
An AFL club seeded into an Elimination Final is awarded 15 fantasy points the
moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish
worse than the 7th-8th tier. Its EV still read 13.

AFLSimulator was stateless with respect to the live bracket. It read only
participants, sourceElo and the regular-season standings, then re-projected the
whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations.
So a team with a locked Elimination Final berth was re-drawn into the Wildcard
Round, or out of the finals entirely, in a slice of them — and there it scores 0.
Even with the ladder complete the Math.random() tiebreaker reshuffled every club
tied on ladder points, which in the AFL is most of the middle of the table.
Games already played were re-played the same way, so a completed Wildcard win was
worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what
produced 13.

afl_10 is the only template that defines entryFloor at all, which is why this
surfaced here and not on LLWS, whose floors only exist once a team has won
something.

The simulator now mirrors llws-simulator's bracket-aware mode:

  - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket
    writes them into. The two Elimination Final participant2 slots are TBD by
    design and are never read as seeds, leaving exactly 10 named slots. No draw
    at all falls back to the ladder projection; a partially seeded, duplicated or
    unknown draw throws rather than silently discarding the draw and every
    recorded result with it.
  - makePlayGame replays a completed match from its recorded result whenever both
    recorded teams are the two the simulation routed into that game, so an
    already-played result sticks across all iterations.
  - simAFLFinals labels each game with the round and match number
    generateAFL10Bracket and advanceAFLWinner use, so a result is looked up
    against the game it was played in. Its routing was already correct.

EV >= the banked floor now holds by construction, with no clamping: a team seeded
into an Elimination Final is in that game in every iteration. Column sums stay
exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams
outside the bracket simply score nothing.

Fixing the simulator alone would not have held. processMatchResult calls
updateProbabilitiesAfterResult on every result, and its ICM branch re-derives
each still-alive participant's whole distribution from P(1st) alone, knowing
nothing about the bracket — so the next finals result would have put the EV
straight back under the floor. That branch was built for futures-odds seasons.
It now runs only when the season's EVs did not come from a bracket-aware
simulator; when they did, that simulator is re-run instead, since it already
knows the completed matches. Both conditions matter: re-running a bracket-blind
simulator would re-draw the field and hand equity back to knocked-out teams, so
a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run
leaves probabilities untouched rather than falling back to the ICM path that is
being replaced.

The finalized-participant pinning loop now runs after that recalculation rather
than before. A simulation run 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.

Tests: seeds clear the entry floors their seeding banked, a Qualifying Final
entrant is structurally absent from the 7th-8th tier, the bracket's draw beats
Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and
Qualifying Finals are replayed with the winner banking its floor, teams outside
the bracket are zeroed, and the column sums and 340 total survive. The bracket
fixtures deliberately seed the ten weakest clubs, because seeding the strongest
ten lets the ladder projection reproduce much the same field by accident. Six of
the seven were confirmed to fail against the previous behavior. Plus the
probability-updater branch in each direction, its failure path, and the pin
ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +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);
});
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")

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). */
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);
}
}

View file

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

View file

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

View file

@ -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,

View file

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

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", () => {
for (const simulatorType of SIMULATOR_TYPES) {
const profile = SIMULATOR_MANIFEST[simulatorType];

View file

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

View file

@ -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 58 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 110.
@ -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 58 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",

View file

@ -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: {

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 {
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" });