diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index 8967180..86b42d8 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -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).mockRejectedValueOnce( new Error("network error") diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 431b467..f0a3302 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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 ): Promise { 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,13 +650,15 @@ export async function processMatchResult( : undefined; // Update probabilities first so the standings recalc reads fresh EVs and // projected points reflect the new result. - try { - await updateProbabilitiesAfterResult(sportsSeasonId, true); - } catch (error) { - logger.error( - `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, - error - ); + if (!skipProbabilities) { + try { + await updateProbabilitiesAfterResult(sportsSeasonId, true); + } catch (error) { + logger.error( + `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`, + error + ); + } } await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions); } diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index 21faa64..c890ccf 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -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[]; + 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); + }); +}); diff --git a/app/services/match-sync/index.ts b/app/services/match-sync/index.ts index 891739d..a958f60 100644 --- a/app/services/match-sync/index.ts +++ b/app/services/match-sync/index.ts @@ -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 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 }; diff --git a/app/services/probability-updater.ts b/app/services/probability-updater.ts index 51cf8d5..b2caabb 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -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 { + // 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, diff --git a/app/services/simulations/__tests__/afl-simulator.test.ts b/app/services/simulations/__tests__/afl-simulator.test.ts index 56d7025..0b63307 100644 --- a/app/services/simulations/__tests__/afl-simulator.test.ts +++ b/app/services/simulations/__tests__/afl-simulator.test.ts @@ -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(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/); + }); }); diff --git a/app/services/simulations/__tests__/manifest.test.ts b/app/services/simulations/__tests__/manifest.test.ts index be36fa7..762a549 100644 --- a/app/services/simulations/__tests__/manifest.test.ts +++ b/app/services/simulations/__tests__/manifest.test.ts @@ -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]; diff --git a/app/services/simulations/__tests__/runner.test.ts b/app/services/simulations/__tests__/runner.test.ts index 0c4f9b4..9d50a7d 100644 --- a/app/services/simulations/__tests__/runner.test.ts +++ b/app/services/simulations/__tests__/runner.test.ts @@ -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); diff --git a/app/services/simulations/afl-simulator.ts b/app/services/simulations/afl-simulator.ts index 195d394..174e91a 100644 --- a/app/services/simulations/afl-simulator.ts +++ b/app/services/simulations/afl-simulator.ts @@ -3,18 +3,36 @@ * * Monte Carlo simulation of the AFL regular season and finals for 2026. * + * Two modes: + * 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is + * re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is + * modelled as still uncertain. + * 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed + * across every iteration, and games already played are replayed from their recorded + * result instead of being re-simulated. + * + * Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that + * awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a + * simulator that re-draws the ladder every iteration puts those teams back in the Wildcard + * Round — or out of the finals entirely — where they score 0, pulling EV below points the + * league has already paid out. Reading the real draw removes that by construction: a team + * seeded into an Elimination Final is in that game in 100% of iterations, so its worst + * outcome is the 7th-8th tier. + * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained) * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set. * 3. Load current regular season standings (wins, gamesPlayed) — if available - * 4. For each simulation: - * a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed) - * using Elo win probability vs. an average opponent (Elo 1500) - * → projectedPoints = currentWins*4 + simulatedRemainingWins*4 - * b. Sort all 18 teams by projected points desc + random tiebreaker → final ladder - * → Top 10 advance to the AFL Finals Series - * c. Simulate AFL Finals Series (AFL_10 bracket): + * 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far + * 5. For each simulation: + * a. Pre-bracket mode only: for each team, simulate remaining regular season games + * (TOTAL_GAMES - gamesPlayed) using Elo win probability vs. an average opponent + * (Elo 1500) → projectedPoints = currentWins*4 + simulatedRemainingWins*4 + * b. Pre-bracket mode only: sort all 18 teams by projected points desc + random + * tiebreaker → final ladder → top 10 advance to the AFL Finals Series. + * In bracket-aware mode the bracket's own 10 seeds are used as-is. + * c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match: * * Wildcard Round: #7 vs #10, #8 vs #9 → losers exit (0 pts) * Qualifying Finals: #1 vs #4, #2 vs #3 → winners → Prelim Finals (bye) @@ -24,8 +42,8 @@ * Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th) * Grand Final: PF1w vs PF2w → winner 1st, loser 2nd * - * 5. Track placement counts per scoring tier - * 6. Convert counts to probability distributions + * 6. Track placement counts per scoring tier + * 7. Convert counts to probability distributions * * Win probability (Elo, PARITY_FACTOR = 450): * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 450)) @@ -53,7 +71,7 @@ * probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly) * probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly) * Wildcard losers → all 0 (score 0 points, same as 9th/10th) - * Missed finals → all 0 + * Missed finals → all 0 (in bracket-aware mode, every team outside the bracket) * * NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 tier into two * separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts @@ -62,7 +80,7 @@ */ import { database } from "~/database/context"; -import { eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { normalizeTeamName } from "~/lib/normalize-team-name"; @@ -75,6 +93,9 @@ import { positiveConfigNumber } from "./config-access"; const DEFAULT_NUM_SIMULATIONS = 10_000; +/** The bracket template the AFL finals are scored against. */ +const AFL_TEMPLATE_ID = "afl_10"; + /** * Elo parity factor for AFL single-game win probability. * 450 reflects moderate variance — lower than NHL (1000) to account for @@ -192,6 +213,226 @@ function simulateProjectedWins(entry: TeamEntry): number { return entry.currentWins + extra; } +/** The playoff_matches columns the simulator actually reads. */ +export type BracketMatch = Pick< + typeof schema.playoffMatches.$inferSelect, + "round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete" +>; + +interface LoadedBracket { + /** The 10 finalists in seed order — index 0 is the minor premier. */ + seeds: TeamEntry[]; + /** Every bracket match, keyed by `${round}#${matchNumber}`. */ + matches: Map; +} + +/** + * 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 +): 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(); + + for (let i = 0; i < SEED_SLOTS.length; i++) { + const participantId = drawn[i] as string; + if (seen.has(participantId)) { + throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`); + } + seen.add(participantId); + + const team = teamsById.get(participantId); + if (!team) { + throw new Error( + `AFL bracket references participant ${participantId}, which is not in this sports season.` + ); + } + seeds[SEED_SLOTS[i].seed - 1] = team; + } + + return { seeds, matches: byKey }; +} + +/** + * The recorded loser of a completed match. loserId is written by the scoring flow, but fall + * back to "whichever slot isn't the winner" for older rows. + */ +function completedLoser(match: BracketMatch): string | null { + if (match.loserId) return match.loserId; + if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; + if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; + return null; +} + +/** + * Build the game-playing function for a bracket. + * + * When the bracket has a completed result for a game AND that result is between the two teams + * the simulation routed into it, the recorded winner is used verbatim — that is what makes an + * already-played result stick across all iterations, and what stops a banked floor from being + * re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or + * out-of-order row from desynchronising the rest of the bracket. + */ +export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame { + if (!bracket) { + return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor); + } + + return (round, matchNumber, t1, t2) => { + const match = bracket.matches.get(matchKey(round, matchNumber)); + if (match?.isComplete && match.winnerId) { + const loserId = completedLoser(match); + const arrived = [t1.id, t2.id]; + if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { + return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; + } + } + return simGame(t1, t2, parityFactor); + }; +} + +/** + * Simulate the AFL Finals Series from a seeded list of 10 teams. + * + * Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a + * recorded result is looked up against the game it was actually played in: + * SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner, + * PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner. + * + * Returns the placement for each team: + * "gf_winner" → 1st + * "gf_loser" → 2nd + * "pf_loser" → 3rd/4th (two teams per sim) + * "sf_loser" → 5th/6th (two teams per sim) + * "ef_loser" → 7th/8th (two teams per sim) + * "wc_loser" → 9th/10th (zero scoring points) + */ +export function simAFLFinals( + finalists: TeamEntry[], + play: PlayGame +): { + gfWinner: TeamEntry; + gfLoser: TeamEntry; + pfLosers: [TeamEntry, TeamEntry]; + sfLosers: [TeamEntry, TeamEntry]; + efLosers: [TeamEntry, TeamEntry]; +} { + const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists; + + // Wildcard Round: #7 vs #10, #8 vs #9 + const wc1 = play("Wildcard Round", 1, s7, s10); + const wc2 = play("Wildcard Round", 2, s8, s9); + + // Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF) + const qf1 = play("Qualifying Finals", 1, s1, s4); + const qf2 = play("Qualifying Finals", 2, s2, s3); + + // Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner + const ef1 = play("Elimination Finals", 1, s5, wc2.winner); + const ef2 = play("Elimination Finals", 2, s6, wc1.winner); + + // Semi-Finals: QF losers (second chance) vs EF winners + const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner); + const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner); + + // Preliminary Finals: QF winners vs SF winners + const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner); + const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner); + + // Grand Final + const gf = play("Grand Final", 1, pf1.winner, pf2.winner); + + return { + gfWinner: gf.winner, + gfLoser: gf.loser, + pfLosers: [pf1.loser, pf2.loser], + sfLosers: [sf1.loser, sf2.loser], + efLosers: [ef1.loser, ef2.loser], + }; +} + // ─── Simulator ──────────────────────────────────────────────────────────────── export class AFLSimulator implements Simulator { @@ -270,11 +511,34 @@ export class AFLSimulator implements Simulator { }; }); - // ─── Helpers (defined once, outside the hot loop) ───────────────────────── + const teamsById = new Map(teams.map((t) => [t.id, t])); - /** Simulate a single AFL game. Returns the winner. */ - const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => - Math.random() < eloWinProbability(a.elo, b.elo, parityFactor) ? a : b; + // 4. Load the real bracket (draw + results so far), if one has been generated. + // Events are filtered on bracketTemplateId rather than eventType and taken most + // recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own + // several events, and landing on a stale or template-less row would silently + // discard the real draw and every recorded result. createdAt can tie when a bracket + // is generated alongside a sibling event, so id breaks the tie. + const playoffEvents = await db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID) + ), + columns: { id: true }, + orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)], + }); + const bracketEvent = playoffEvents[0]; + + const bracketMatches = bracketEvent + ? await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + }) + : []; + + const bracket = readAflBracketSeeds(bracketMatches, teamsById); + const play = makePlayGame(bracket, parityFactor); + + // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** * Project end-of-season ladder and return the top 10 finalists seeded 1–10. @@ -293,70 +557,7 @@ export class AFLSimulator implements Simulator { return projected.slice(0, 10).map((x) => x.team); }; - /** - * Simulate the AFL Finals Series from a seeded list of 10 teams. - * - * Returns the placement for each team: - * "gf_winner" → 1st - * "gf_loser" → 2nd - * "pf_loser" → 3rd/4th (two teams per sim) - * "sf_loser" → 5th/6th (two teams per sim) - * "ef_loser" → 7th/8th (two teams per sim) - * "wc_loser" → 9th/10th (zero scoring points) - */ - const simAFLFinals = ( - finalists: TeamEntry[] - ): { - gfWinner: TeamEntry; - gfLoser: TeamEntry; - pfLosers: [TeamEntry, TeamEntry]; - sfLosers: [TeamEntry, TeamEntry]; - efLosers: [TeamEntry, TeamEntry]; - } => { - const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists; - - // Wildcard Round: #7 vs #10, #8 vs #9 - const wc1Winner = simGame(s7, s10); - const wc2Winner = simGame(s8, s9); - - // Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get bye to PF) - const qf1Winner = simGame(s1, s4); - const qf1Loser = qf1Winner === s1 ? s4 : s1; - const qf2Winner = simGame(s2, s3); - const qf2Loser = qf2Winner === s2 ? s3 : s2; - - // Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner - const ef1Winner = simGame(s5, wc2Winner); - const ef1Loser = ef1Winner === s5 ? wc2Winner : s5; - const ef2Winner = simGame(s6, wc1Winner); - const ef2Loser = ef2Winner === s6 ? wc1Winner : s6; - - // Semi-Finals: QF losers (2nd chance) vs EF winners - const sf1Winner = simGame(qf1Loser, ef2Winner); - const sf1Loser = sf1Winner === qf1Loser ? ef2Winner : qf1Loser; - const sf2Winner = simGame(qf2Loser, ef1Winner); - const sf2Loser = sf2Winner === qf2Loser ? ef1Winner : qf2Loser; - - // Preliminary Finals: QF winners vs SF winners - const pf1Winner = simGame(qf1Winner, sf2Winner); - const pf1Loser = pf1Winner === qf1Winner ? sf2Winner : qf1Winner; - const pf2Winner = simGame(qf2Winner, sf1Winner); - const pf2Loser = pf2Winner === qf2Winner ? sf1Winner : qf2Winner; - - // Grand Final - const gfWinner = simGame(pf1Winner, pf2Winner); - const gfLoser = gfWinner === pf1Winner ? pf2Winner : pf1Winner; - - return { - gfWinner, - gfLoser, - pfLosers: [pf1Loser, pf2Loser ], - sfLosers: [sf1Loser, sf2Loser ], - efLosers: [ef1Loser, ef2Loser ], - }; - }; - - // 3. Integer placement count maps — initialized to 0 for all participants. + // 5. Integer placement count maps — initialized to 0 for all participants. // // AFL scoring uses the AFL_10 bracket template which splits 5–8 into two // separate pairs: Semi-Finals losers share 5th/6th (higher value), and @@ -368,10 +569,12 @@ export class AFLSimulator implements Simulator { const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map(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 = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 0a6f801..b035804 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -34,6 +34,25 @@ export interface SimulatorManifestProfile { derivableInputs?: Partial>; 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 { const sportsSeason = await findSportsSeasonById(sportsSeasonId); if (!sportsSeason) { @@ -135,29 +159,33 @@ export async function runSportsSeasonSimulation( })), ]); - const seasonSports = await database().query.seasonSports.findMany({ - where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), - }); - await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId))); + 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); - await batchUpsertParticipantEvSnapshots( - results.map((r) => ({ - participantId: r.participantId, - sportsSeasonId, - snapshotDate, - probFirst: r.probabilities.probFirst, - probSecond: r.probabilities.probSecond, - probThird: r.probabilities.probThird, - probFourth: r.probabilities.probFourth, - probFifth: r.probabilities.probFifth, - probSixth: r.probabilities.probSixth, - probSeventh: r.probabilities.probSeventh, - probEighth: r.probabilities.probEighth, - calculatedEV: calculateEV(r.probabilities, persistence.scoringRules), - source: r.source, - })) - ); + if (!options.skipSnapshots) { + await batchUpsertParticipantEvSnapshots( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + snapshotDate, + probFirst: r.probabilities.probFirst, + probSecond: r.probabilities.probSecond, + probThird: r.probabilities.probThird, + probFourth: r.probabilities.probFourth, + probFifth: r.probabilities.probFifth, + probSixth: r.probabilities.probSixth, + probSeventh: r.probabilities.probSeventh, + probEighth: r.probabilities.probEighth, + calculatedEV: calculateEV(r.probabilities, persistence.scoringRules), + source: r.source, + })) + ); + } await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" });