brackt/app/services/simulations/__tests__/afl-simulator.test.ts

686 lines
28 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name";
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
import {
getTeamData,
eloWinProbability,
AFLSimulator,
readAflBracketSeeds,
simAFLFinals,
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
type BracketMatch,
} from "../afl-simulator";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Western Bulldogs ")).toBe("western bulldogs");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("Greater Western Sydney")).toBe("greater western sydney");
});
it("is already-normalized identity", () => {
expect(normalizeTeamName("gold coast")).toBe("gold coast");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Western Bulldogs");
expect(d).toBeDefined();
expect(d?.elo).toBe(1646);
});
it("is case-insensitive", () => {
expect(getTeamData("western bulldogs")).toEqual(getTeamData("Western Bulldogs"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Koalas")).toBeUndefined();
});
it("all 18 AFL clubs are present", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
it("Western Bulldogs has the highest Elo", () => {
const bulldogs = getTeamData("Western Bulldogs")?.elo ?? 0;
const westCoast = getTeamData("West Coast")?.elo ?? 0;
expect(bulldogs).toBeGreaterThan(westCoast);
});
it("Elo ratings are in the expected range (12501750)", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
const elo = getTeamData(name)?.elo ?? 0;
expect(elo, `${name} elo out of range`).toBeGreaterThanOrEqual(1250);
expect(elo, `${name} elo out of range`).toBeLessThanOrEqual(1750);
}
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability (PARITY_FACTOR = 450)", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1706, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1295, 1500)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1706, 1295);
expect(p + eloWinProbability(1295, 1706)).toBeCloseTo(1.0, 10);
});
it("a 450-pt gap gives ~90.9% win probability", () => {
// P = 1 / (1 + 10^(-450/450)) = 1 / (1 + 10^-1) = 1/1.1 ≈ 0.909
const p = eloWinProbability(1950, 1500);
expect(p).toBeCloseTo(1 / 1.1, 5);
});
it("Bulldogs (1646) vs Essendon (1342): strongly favors Bulldogs", () => {
// 304-pt gap at parity 450: P = 1/(1+10^(-304/450)) ≈ 0.826
const p = eloWinProbability(1646, 1342);
expect(p).toBeGreaterThan(0.80);
});
});
// ─── AFLSimulator.simulate() integration tests ───────────────────────────────
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
const AFL_TEAMS = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
id: `team-${i + 1}`,
name,
}));
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
/**
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
* recorded result.
*/
function aflBracketMatches(
seedIds: string[],
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
): BracketMatch[] {
const seed = (n: number) => seedIds[n - 1] ?? null;
const rows: BracketMatch[] = [
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
// participant2 is TBD until a Wildcard winner advances into it.
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
for (const done of completed) {
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
row.isComplete = true;
row.winnerId = done.winnerId;
row.loserId = done.loserId;
// A Wildcard winner is advanced into the Elimination Final it feeds.
if (done.round === "Wildcard Round") {
const ef = rows.find(
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
);
if (ef) ef.participant2Id = done.winnerId;
}
}
return rows;
}
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
return found;
}
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
const found = results.find((r) => r.participantId === participantId);
if (!found) throw new Error(`no simulation result for ${participantId}`);
return found;
}
/** EV on the reference scale the runner persists with. */
function evOf(result: { probabilities: ProbabilityDistribution }): number {
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
}
describe("AFLSimulator.simulate()", () => {
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
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");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
const participantRows = PARTICIPANT_ROWS;
let selectCallCount = 0;
mockDb = {
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
// 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) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(participantRows),
}),
};
}
// Second call: sourceElo query (no DB Elo by default)
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
// Default: no standings (pre-season)
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
});
it("throws if no participants found", async () => {
let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
});
const sim = new AFLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("returns 18 results — one per AFL club", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(18);
});
it("all probability values are non-negative", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const val of Object.values(r.probabilities)) {
expect(val).toBeGreaterThanOrEqual(0);
}
}
});
it("each column (probFirst through probEighth) sums to 1.0 across all participants", async () => {
const sim = new AFLSimulator();
const results = await sim.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, 2);
}
});
it("P5/P6 and P7/P8 are distinct tiers (separate column sums, not a combined 58 pool)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// probFifth should sum to 1.0 (SF losers only) — NOT 2.0 (which would happen if EF losers were mixed in)
const fifthSum = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
const seventhSum = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
expect(fifthSum).toBeCloseTo(1.0, 2);
expect(seventhSum).toBeCloseTo(1.0, 2);
});
it("probThird === probFourth for every participant (3rd/4th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth === probSixth for every participant (5th/6th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probFifth).toBeCloseTo(r.probabilities.probSixth, 10);
}
});
it("probSeventh === probEighth for every participant (7th/8th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probSeventh).toBeCloseTo(r.probabilities.probEighth, 10);
}
});
it("uses source: 'afl_bracket_monte_carlo' on all results", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("afl_bracket_monte_carlo");
}
});
it("all result participant IDs match input participant IDs", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const resultIds = new Set(results.map((r) => r.participantId));
for (const id of PARTICIPANT_IDS) {
expect(resultIds.has(id), `missing participant: ${id}`).toBe(true);
}
});
it("Western Bulldogs (highest Elo) has the highest championship probability", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const bulldogsResult = results.find((r) => r.participantId === "team-1"); // Western Bulldogs (highest Elo)
const westCoastResult = results.find((r) => r.participantId === "team-18"); // West Coast (near-lowest Elo)
if (!bulldogsResult || !westCoastResult) throw new Error("Expected results not found");
// The #1 Elo team should win the championship more often than the last-ranked team
expect(bulldogsResult.probabilities.probFirst).toBeGreaterThan(westCoastResult.probabilities.probFirst);
});
it("bottom-ranked teams rarely make finals (low combined probability)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// West Coast and Richmond (16th/17th Elo) should have very low combined finals probability
const westCoast = results.find((r) => r.participantId === "team-18");
const richmond = results.find((r) => r.participantId === "team-17");
if (!westCoast || !richmond) throw new Error("Expected results not found");
const wcTotal = Object.values(westCoast.probabilities).reduce((a, b) => a + b, 0);
const ricTotal = Object.values(richmond.probabilities).reduce((a, b) => a + b, 0);
// Combined probability for a bottom team should be well below 1.0
expect(wcTotal).toBeLessThan(0.5);
expect(ricTotal).toBeLessThan(0.5);
});
it("mid-season standings: team with most wins has elevated finals probability", async () => {
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
// Give Western Bulldogs (team-1) 15 wins from 18 games — top of ladder
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([
{ participantId: "team-1", wins: 15, gamesPlayed: 18, losses: 3 },
// All other teams have 5 wins
...PARTICIPANT_IDS.slice(1).map((id) => ({ participantId: id, wins: 5, gamesPlayed: 18, losses: 13 })),
]);
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const leader = results.find((r) => r.participantId === "team-1");
const bottom = results.find((r) => r.participantId === "team-18");
if (!leader || !bottom) throw new Error("Expected results not found");
expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
it("DB sourceElo overrides hardcoded TEAMS_DATA values", async () => {
// Set DB Elo for West Coast (team-18) to 1800 (higher than Bulldogs)
let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
}),
};
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{ participantId: "team-18", sourceElo: 1800 },
]),
}),
};
});
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const westCoast = results.find((r) => r.participantId === "team-18");
const bulldogs = results.find((r) => r.participantId === "team-1");
if (!westCoast || !bulldogs) throw new Error("Expected results not found");
// With DB Elo 1800, West Coast should now be favored over Bulldogs (1646)
expect(westCoast.probabilities.probFirst).toBeGreaterThan(bulldogs.probabilities.probFirst);
});
it("falls back to hardcoded TEAMS_DATA when no DB sourceElo exists", async () => {
// Default mock already returns no sourceElo rows — should use TEAMS_DATA
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const bulldogs = results.find((r) => r.participantId === "team-1");
const westCoast = results.find((r) => r.participantId === "team-18");
if (!bulldogs || !westCoast) throw new Error("Expected results not found");
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
});
Make the AFL simulator read the bracket that was actually drawn An AFL club seeded into an Elimination Final is awarded 15 fantasy points the moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish worse than the 7th-8th tier. Its EV still read 13. AFLSimulator was stateless with respect to the live bracket. It read only participants, sourceElo and the regular-season standings, then re-projected the whole ladder and re-seeded 1-10 from Elo on every one of its 10,000 iterations. So a team with a locked Elimination Final berth was re-drawn into the Wildcard Round, or out of the finals entirely, in a slice of them — and there it scores 0. Even with the ladder complete the Math.random() tiebreaker reshuffled every club tied on ladder points, which in the AFL is most of the middle of the table. Games already played were re-played the same way, so a completed Wildcard win was worth 50% of nothing. Mixing zeroes into a distribution whose floor is 15 is what produced 13. afl_10 is the only template that defines entryFloor at all, which is why this surfaced here and not on LLWS, whose floors only exist once a team has won something. The simulator now mirrors llws-simulator's bracket-aware mode: - readAflBracketSeeds reads the 10 seeds from the slots generateAFL10Bracket writes them into. The two Elimination Final participant2 slots are TBD by design and are never read as seeds, leaving exactly 10 named slots. No draw at all falls back to the ladder projection; a partially seeded, duplicated or unknown draw throws rather than silently discarding the draw and every recorded result with it. - makePlayGame replays a completed match from its recorded result whenever both recorded teams are the two the simulation routed into that game, so an already-played result sticks across all iterations. - simAFLFinals labels each game with the round and match number generateAFL10Bracket and advanceAFLWinner use, so a result is looked up against the game it was played in. Its routing was already correct. EV >= the banked floor now holds by construction, with no clamping: a team seeded into an Elimination Final is in that game in every iteration. Column sums stay exactly 1.0 and the 340-point total-EV invariant is unchanged, since teams outside the bracket simply score nothing. Fixing the simulator alone would not have held. processMatchResult calls updateProbabilitiesAfterResult on every result, and its ICM branch re-derives each still-alive participant's whole distribution from P(1st) alone, knowing nothing about the bracket — so the next finals result would have put the EV straight back under the floor. That branch was built for futures-odds seasons. It now runs only when the season's EVs did not come from a bracket-aware simulator; when they did, that simulator is re-run instead, since it already knows the completed matches. Both conditions matter: re-running a bracket-blind simulator would re-draw the field and hand equity back to knocked-out teams, so a new manifest `bracketAware` flag limits this to AFL and LLWS. A failed re-run leaves probabilities untouched rather than falling back to the ICM path that is being replaced. The finalized-participant pinning loop now runs after that recalculation rather than before. A simulation run rewrites every participant in the season, the finalized ones included; a finalized placement is a fact, not a projection, so it is written last and wins. Tests: seeds clear the entry floors their seeding banked, a Qualifying Final entrant is structurally absent from the 7th-8th tier, the bracket's draw beats Elo (the weakest club seeded 1, the strongest seeded 10), completed Wildcard and Qualifying Finals are replayed with the winner banking its floor, teams outside the bracket are zeroed, and the column sums and 340 total survive. The bracket fixtures deliberately seed the ten weakest clubs, because seeding the strongest ten lets the ladder projection reproduce much the same field by accident. Six of the seven were confirmed to fail against the previous behavior. Plus the probability-updater branch in each direction, its failure path, and the pin ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00
// ─── 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/);
});
});
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
describe("simAFLFinals Elimination Finals re-seeding", () => {
const finalists = Array.from({ length: 10 }, (_, i) => ({
id: `s${i + 1}`,
name: `s${i + 1}`,
elo: 1500,
currentWins: 0,
remainingGames: 0,
winProb: 0.5,
}));
/**
* Play the finals with the Wildcard Round forced to the given winners (every other
* game goes to whoever was routed in first), and report who met whom.
*/
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
const pairings = new Map<string, [string, string]>();
const play = (
round: string,
matchNumber: number,
t1: { id: string },
t2: { id: string }
) => {
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
if (round === "Wildcard Round") {
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
return { winner: t1, loser: t2 };
};
simAFLFinals(finalists as never, play as never);
return pairings;
}
it("draws the Wildcard Round 7v10 and 8v9", () => {
const pairings = pairingsWith("s7", "s8");
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
});
it.each([
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
// and leave 5th with the stronger survivor.
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
])(
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
({ wc1, wc2, ef1, ef2 }) => {
const pairings = pairingsWith(wc1, wc2);
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
}
);
});