From cf817cc9ff0f1256c68a4ca0ea88865717707097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:42:31 +0000 Subject: [PATCH] Make the AFL simulator read the bracket that was actually drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS --- .../__tests__/probability-updater.test.ts | 165 ++++++++ app/services/probability-updater.ts | 122 ++++-- .../__tests__/afl-simulator.test.ts | 271 ++++++++++++- app/services/simulations/afl-simulator.ts | 373 ++++++++++++++---- app/services/simulations/manifest.ts | 16 +- 5 files changed, 831 insertions(+), 116 deletions(-) diff --git a/app/services/__tests__/probability-updater.test.ts b/app/services/__tests__/probability-updater.test.ts index 21faa64..8e5e4b1 100644 --- a/app/services/__tests__/probability-updater.test.ts +++ b/app/services/__tests__/probability-updater.test.ts @@ -8,6 +8,8 @@ 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("~/services/simulations/runner"); vi.mock("~/database/context", () => ({ database: () => ({ query: { @@ -18,6 +20,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 +325,163 @@ describe("probability-updater", () => { }); }); }); + +// ─── Bracket-aware simulator seasons ────────────────────────────────────────── +// +// The ICM branch re-derives a whole distribution from P(1st) alone and knows nothing about +// the bracket, 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. +// For a season whose EVs came from a simulator that reads the bracket, re-running that +// simulator is the correct refresh; for every other season ICM stays exactly as it was. + +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[]; + }) { + const simulatorModel = await import("~/models/simulator"); + const runner = await import("~/services/simulations/runner"); + + 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(icmWrites()).toHaveLength(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("keeps the ICM path for a bracket-blind simulator", async () => { + // Re-running one of these would re-draw the field and hand equity back to teams already + // knocked out, so nothing changes for them. + const { runner } = await setup({ evSource: "elo_simulation", simulatorType: "nba_bracket" }); + + await updateProbabilitiesAfterResult("season-1", true); + + expect(runner.runSportsSeasonSimulation).not.toHaveBeenCalled(); + expect(icmWrites().length).toBeGreaterThan(0); + }); + + it("keeps the ICM path when the EVs did not come from the simulator", async () => { + const { runner } = await setup({ evSource: "futures_odds", simulatorType: "afl_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/probability-updater.ts b/app/services/probability-updater.ts index 51cf8d5..e8af9a5 100644 --- a/app/services/probability-updater.ts +++ b/app/services/probability-updater.ts @@ -21,6 +21,9 @@ 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 { getManifestSimulatorProfile } from "~/services/simulations/manifest"; +import { logger } from "~/lib/logger"; /** * Result of probability update operation @@ -97,6 +100,36 @@ function createFinishedProbabilities(finalPosition: number): number[] { return probs; } +/** EV sources written by a simulation run rather than by odds import or manual entry. */ +const SIMULATOR_EV_SOURCES = new Set(["elo_simulation", "performance_model"]); + +/** + * Decide whether a season's still-alive participants should be refreshed by re-running its + * simulator instead of by the ICM recalculation below. + * + * Two conditions, and both matter: + * + * - The simulator must be bracket-aware (manifest `bracketAware`). Re-running a + * bracket-blind simulator after a result would re-draw the field and hand championship + * equity back to teams that have already been knocked out — strictly worse than ICM. + * Only AFL and LLWS read the real draw and replay completed matches. + * - The EVs must actually have come from that simulator. If an admin entered them by hand + * or imported them from futures odds, overwriting them with a simulation is not a refresh. + * The finished-participant loop below rewrites rows to `manual`, so only the unfinished + * rows — the ones about to be recalculated — are consulted. + */ +async function shouldRerunSimulator( + sportsSeasonId: string, + unfinishedEVs: ParticipantEV[] +): Promise { + if (!unfinishedEVs.some((ev) => SIMULATOR_EV_SOURCES.has(ev.source ?? ""))) 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 +137,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 +172,39 @@ 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, unfinishedEVs))) { + // 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"); + await runSportsSeasonSimulation(sportsSeasonId); + updated += unfinishedEVs.length; + } catch (error) { + // runSportsSeasonSimulation throws on a completed season, on a run already in + // flight, and on failed readiness. Leave the existing probabilities alone rather + // than falling back to ICM: for these seasons ICM is the thing being replaced, and + // a completed season has nothing unfinished left to recalculate anyway. + 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 +254,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/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..9e29aab 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -34,6 +34,16 @@ export interface SimulatorManifestProfile { derivableInputs?: Partial>; setupSections: SimulatorSetupSection[]; minParticipantInputs?: number; + /** + * The simulator reads the season's generated bracket: it uses the real draw as its seeding + * and replays completed matches from their recorded result, instead of re-drawing the field + * every iteration. + * + * Only such a simulator can be safely re-run once results are in — a bracket-blind one puts + * knocked-out teams back in contention. updateProbabilitiesAfterResult reads this to decide + * whether to re-run the simulator or fall back to the generic ICM recalculation. + */ + bracketAware?: boolean; } const BASE_CONFIG = { @@ -112,7 +122,10 @@ const PROFILES: Record