brackt/app/services/simulations/afl-simulator.ts
Claude cf817cc9ff
Make the AFL simulator read the bracket that was actually drawn
An AFL club seeded into an Elimination Final is awarded 15 fantasy points the
moment the bracket is generated — afl_10's entryFloor: 7 — and cannot finish
worse than the 7th-8th tier. Its EV still read 13.

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

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

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

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

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

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

The finalized-participant pinning loop now runs after that recalculation rather
than before. A simulation run rewrites every participant in the season, the
finalized ones included; a finalized placement is a fact, not a projection, so it
is written last and wins.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPxDnSEKVoKx9HFcQ6gmcS
2026-08-29 02:42:31 +00:00

646 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AFL Season + Finals Simulator
*
* 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. 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)
* losers → Semi-Finals (2nd chance)
* Elimination Finals: #5 vs WC2w, #6 vs WC1w → losers exit (7th/8th)
* Semi-Finals: QF1L vs EF2w, QF2L vs EF1w → losers exit (5th/6th)
* Preliminary Finals: QF1w vs SF2w, QF2w vs SF1w → losers exit (3rd/4th)
* Grand Final: PF1w vs PF2w → winner 1st, loser 2nd
*
* 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))
* A higher parity factor means more randomness per game. AFL uses 450, which is
* slightly above the NBA (400) — meaning AFL games are marginally less predictable
* than NBA games but far more predictable than NHL (1000).
*
* Regular season projection:
* Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent.
* If no standings exist in DB, defaults to 0 wins / TOTAL_GAMES remaining (seeding by Elo only).
*
* Elo ratings:
* Priority: sourceElo from participantExpectedValues (admin UI) → hardcoded TEAMS_DATA
* → fallback 1400.
* Admin can enter Elo directly or via "Projected Wins" mode on the Elo Ratings admin page,
* which auto-converts projected season wins to Elo using the inverse formula:
* elo = 1500 - 450 × log₁₀((1 wins/23) / (wins/23))
* The hardcoded TEAMS_DATA values are backsolved from Squiggle's projected season
* win totals (as of Round 2, 2026). Source: https://squiggle.com.au
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = Grand Final winner (1 per sim)
* probSecond = Grand Final loser (1 per sim)
* probThird/Fourth = Preliminary Finals losers (2 per sim — split evenly)
* probFifth/Sixth = Semi-Finals losers (2 per sim — split evenly)
* probSeventh/Eighth = Elimination Finals losers (2 per sim — split evenly)
* Wildcard losers → all 0 (score 0 points, same as 9th/10th)
* Missed finals → all 0 (in bracket-aware mode, every team outside the bracket)
*
* NOTE: AFL uses the AFL_10 bracket template which splits the 58 tier into two
* separate pairs (5/6 and 7/8). This is already handled by scoring-rules.ts
* (SPLIT_5678_TEMPLATE_IDS); this simulator outputs the correct probabilities
* into the appropriate tiers.
*/
import { database } from "~/database/context";
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";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { positiveConfigNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
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
* AFL's relatively predictable results vs. basketball's coin-flip tendencies.
* Overridable via the season config's `parityFactor`.
*/
const DEFAULT_PARITY_FACTOR = 450;
/** Approximate total regular season games per AFL team (2026 season). */
const DEFAULT_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */
const AVERAGE_OPPONENT_ELO = 1500;
// ─── Hardcoded team data (FALLBACK — used only when no sourceElo in DB) ──────
//
// Elo ratings are backsolved from Squiggle's projected season win totals.
// These serve as fallback defaults when no sourceElo has been entered via the
// admin Elo Ratings page. Prefer updating via Admin → Elo Ratings (projected
// wins mode) rather than editing these values.
// Source: https://squiggle.com.au (Round 2, 2026)
interface AflTeamData {
elo: number;
}
const TEAMS_DATA: Record<string, AflTeamData> = {
"Western Bulldogs": { elo: 1646 }, // 15.6 projected wins
"Hawthorn": { elo: 1604 }, // 14.5
"Gold Coast": { elo: 1601 }, // 14.5 (3rd by %)
"Sydney": { elo: 1579 }, // 13.8
"Adelaide": { elo: 1576 }, // 13.7
"Geelong": { elo: 1572 }, // 13.6
"Brisbane Lions": { elo: 1541 }, // 12.7
"Fremantle": { elo: 1524 }, // 12.2
"Collingwood": { elo: 1517 }, // 12.0
"Greater Western Sydney":{ elo: 1500 }, // 11.5
"GWS Giants": { elo: 1500 }, // alias
"Melbourne": { elo: 1473 }, // 10.7
"St Kilda": { elo: 1466 }, // 10.5
"North Melbourne": { elo: 1459 }, // 10.3
"Carlton": { elo: 1449 }, // 10.0
"Port Adelaide": { elo: 1435 }, // 9.6
"Richmond": { elo: 1366 }, // 7.7
"West Coast": { elo: 1362 }, // 7.6
"Essendon": { elo: 1342 }, // 7.1
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
/**
* Look up team data by participant name.
*
* Uses a two-step match so "Gold Coast Suns" → "Gold Coast", "Hawthorn Hawks" → "Hawthorn", etc.
* When multiple keys substring-match (e.g. "Adelaide" AND "Port Adelaide" both appear in
* "Port Adelaide Power"), the longest key wins — giving the more specific match priority.
* "GWS Giants" is an explicit alias since it won't substring-match "Greater Western Sydney".
*/
export function getTeamData(name: string): AflTeamData | undefined {
const normalized = normalizeTeamName(name);
const keys = Object.keys(TEAMS_DATA);
// 1. Exact match (fast path)
for (const key of keys) {
if (normalizeTeamName(key) === normalized) return TEAMS_DATA[key];
}
// 2. Substring match — collect all candidates then pick the longest key so that
// "Port Adelaide" (13) beats "Adelaide" (8) for "Port Adelaide Power".
const candidates = keys.filter((key) => {
const normKey = normalizeTeamName(key);
return (
normKey.length >= 4 &&
normalized.length >= 4 &&
(normalized.includes(normKey) || normKey.includes(normalized))
);
});
if (candidates.length === 0) return undefined;
candidates.sort((a, b) => b.length - a.length);
return TEAMS_DATA[candidates[0]];
}
/**
* Elo win probability for team A in a single game against team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
/** Resolved Elo: DB sourceElo > hardcoded TEAMS_DATA > fallback 1400. */
elo: number;
/** Actual wins from the standings table (0 if no standings loaded). */
currentWins: number;
/** Remaining regular season games = TOTAL_GAMES - gamesPlayed (0 if season is complete). */
remainingGames: number;
/** Elo win probability vs. average opponent — constant per team. */
winProb: number;
}
/** Simulate remaining regular season games for a team.
* Returns projected total wins for the season. */
function simulateProjectedWins(entry: TeamEntry): number {
let extra = 0;
for (let g = 0; g < entry.remainingGames; g++) {
if (Math.random() < entry.winProb) extra++;
}
return entry.currentWins + extra;
}
/** The playoff_matches columns the simulator actually reads. */
export type BracketMatch = Pick<
typeof schema.playoffMatches.$inferSelect,
"round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete"
>;
interface LoadedBracket {
/** The 10 finalists in seed order — index 0 is the minor premier. */
seeds: TeamEntry[];
/** Every bracket match, keyed by `${round}#${matchNumber}`. */
matches: Map<string, BracketMatch>;
}
/**
* Plays one finals game. `round`/`matchNumber` identify it within the bracket so an
* already-played result can be looked up; `t1`/`t2` are the teams routed into it.
*/
type PlayGame = (
round: string,
matchNumber: number,
t1: TeamEntry,
t2: TeamEntry
) => { winner: TeamEntry; loser: TeamEntry };
function matchKey(round: string, matchNumber: number): string {
return `${round}#${matchNumber}`;
}
function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } {
return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor)
? { winner: t1, loser: t2 }
: { winner: t2, loser: t1 };
}
/**
* Where generateAFL10Bracket (models/playoff-match.ts) writes each seed.
*
* The two Elimination Final participant2 slots are deliberately absent: they are TBD by
* design until a Wildcard winner advances into them, so they are never a missing seed.
* That leaves exactly 10 named slots for the 10 finalists.
*/
const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [
{ round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 },
{ round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 },
{ round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 },
{ round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 },
{ round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 },
{ round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 },
{ round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 },
{ round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 },
{ round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 },
{ round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 },
];
/**
* Read the seeded afl_10 bracket for this season, if there is one.
*
* Returns null only when the bracket carries no draw at all — no matches, or a freshly
* generated bracket with every slot still empty — in which case the caller falls back to
* projecting the ladder.
*
* A *partially* seeded bracket is an error rather than a fallback. Falling back there would
* throw away the real draw and every recorded result with it, putting eliminated teams back
* in contention; and it is reachable in practice, because playoff_matches.participant1Id /
* participant2Id are ON DELETE SET NULL, so removing and re-adding one participant
* mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same
* reason.
*/
export function readAflBracketSeeds(
matches: BracketMatch[],
teamsById: Map<string, TeamEntry>
): LoadedBracket | null {
if (matches.length === 0) return null;
const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m]));
const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => {
const match = byKey.get(matchKey(round, matchNumber));
if (!match) return null;
return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null;
});
const seededCount = drawn.filter((id) => id !== null).length;
// Generated but not yet filled in — no draw to honor.
if (seededCount === 0) return null;
if (seededCount < drawn.length) {
const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null)
.map((s) => s.seed)
.toSorted((a, b) => a - b)
.join(", ");
throw new Error(
`AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` +
`missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` +
`simulating around the gap would discard the draw and every recorded result.`
);
}
// Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each.
const seeds: TeamEntry[] = [];
const seen = new Set<string>();
for (let i = 0; i < SEED_SLOTS.length; i++) {
const participantId = drawn[i] as string;
if (seen.has(participantId)) {
throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`);
}
seen.add(participantId);
const team = teamsById.get(participantId);
if (!team) {
throw new Error(
`AFL bracket references participant ${participantId}, which is not in this sports season.`
);
}
seeds[SEED_SLOTS[i].seed - 1] = team;
}
return { seeds, matches: byKey };
}
/**
* The recorded loser of a completed match. loserId is written by the scoring flow, but fall
* back to "whichever slot isn't the winner" for older rows.
*/
function completedLoser(match: BracketMatch): string | null {
if (match.loserId) return match.loserId;
if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id;
if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id;
return null;
}
/**
* Build the game-playing function for a bracket.
*
* When the bracket has a completed result for a game AND that result is between the two teams
* the simulation routed into it, the recorded winner is used verbatim — that is what makes an
* already-played result stick across all iterations, and what stops a banked floor from being
* re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or
* out-of-order row from desynchronising the rest of the bracket.
*/
export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame {
if (!bracket) {
return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor);
}
return (round, matchNumber, t1, t2) => {
const match = bracket.matches.get(matchKey(round, matchNumber));
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
const arrived = [t1.id, t2.id];
if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) {
return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
}
}
return simGame(t1, t2, parityFactor);
};
}
/**
* Simulate the AFL Finals Series from a seeded list of 10 teams.
*
* Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a
* recorded result is looked up against the game it was actually played in:
* SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner,
* PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner.
*
* Returns the placement for each team:
* "gf_winner" → 1st
* "gf_loser" → 2nd
* "pf_loser" → 3rd/4th (two teams per sim)
* "sf_loser" → 5th/6th (two teams per sim)
* "ef_loser" → 7th/8th (two teams per sim)
* "wc_loser" → 9th/10th (zero scoring points)
*/
export function simAFLFinals(
finalists: TeamEntry[],
play: PlayGame
): {
gfWinner: TeamEntry;
gfLoser: TeamEntry;
pfLosers: [TeamEntry, TeamEntry];
sfLosers: [TeamEntry, TeamEntry];
efLosers: [TeamEntry, TeamEntry];
} {
const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = finalists;
// Wildcard Round: #7 vs #10, #8 vs #9
const wc1 = play("Wildcard Round", 1, s7, s10);
const wc2 = play("Wildcard Round", 2, s8, s9);
// Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF)
const qf1 = play("Qualifying Finals", 1, s1, s4);
const qf2 = play("Qualifying Finals", 2, s2, s3);
// Elimination Finals: #5 vs WC2 winner, #6 vs WC1 winner
const ef1 = play("Elimination Finals", 1, s5, wc2.winner);
const ef2 = play("Elimination Finals", 2, s6, wc1.winner);
// Semi-Finals: QF losers (second chance) vs EF winners
const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner);
const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner);
// Preliminary Finals: QF winners vs SF winners
const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner);
const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner);
// Grand Final
const gf = play("Grand Final", 1, pf1.winner, pf2.winner);
return {
gfWinner: gf.winner,
gfLoser: gf.loser,
pfLosers: [pf1.loser, pf2.loser],
sfLosers: [sf1.loser, sf2.loser],
efLosers: [ef1.loser, ef2.loser],
};
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class AFLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
// 1. Load participants, DB Elo, and standings in parallel.
const [participantRows, evRows, standings] = await Promise.all([
db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add all 18 AFL clubs as participants before running simulation.`
);
}
if (participantRows.length < 10) {
throw new Error(
`AFL simulation requires at least 10 participants to fill the finals bracket ` +
`(got ${participantRows.length}). Add all 18 AFL clubs before running simulation.`
);
}
// 2. Build Elo map from DB sourceElo values.
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
// 3. Build standings lookup and construct team entries.
// Elo priority: DB sourceElo → hardcoded TEAMS_DATA → fallback 1400.
// currentWins, remainingGames, and per-game winProb are all resolved once
// here so nothing is recomputed inside the hot simulation loop.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
const dbElo = dbEloMap.get(r.id);
const fallbackData = getTeamData(r.name);
const resolvedElo = dbElo ?? fallbackData?.elo ?? 1400;
if (dbElo === undefined && !fallbackData) {
logger.warn(
{ participantName: r.name, sportsSeasonId },
`AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` +
`Enter Elo via Admin → Elo Ratings or rename the participant to match a TEAMS_DATA key.`
);
}
const gamesPlayed = standing?.gamesPlayed ?? 0;
return {
id: r.id,
name: r.name,
elo: resolvedElo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, seasonGames - gamesPlayed),
winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor),
};
});
const teamsById = new Map(teams.map((t) => [t.id, t]));
// 4. Load the real bracket (draw + results so far), if one has been generated.
// Events are filtered on bracketTemplateId rather than eventType and taken most
// recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own
// several events, and landing on a stale or template-less row would silently
// discard the real draw and every recorded result. createdAt can tie when a bracket
// is generated alongside a sibling event, so id breaks the tie.
const playoffEvents = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID)
),
columns: { id: true },
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
});
const bracketEvent = playoffEvents[0];
const bracketMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
})
: [];
const bracket = readAflBracketSeeds(bracketMatches, teamsById);
const play = makePlayGame(bracket, parityFactor);
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/**
* Project end-of-season ladder and return the top 10 finalists seeded 110.
*
* Teams are sorted by projected ladder points (4 per win) descending.
* A small random tiebreaker simulates the percentage-based AFL tiebreaker
* without requiring actual scores.
*/
const buildFinalsList = (): TeamEntry[] => {
const projected = teams.map((t) => ({
team: t,
points: simulateProjectedWins(t) * 4,
tiebreaker: Math.random(),
}));
projected.sort((a, b) => b.points - a.points || b.tiebreaker - a.tiebreaker);
return projected.slice(0, 10).map((x) => x.team);
};
// 5. Integer placement count maps — initialized to 0 for all participants.
//
// AFL scoring uses the AFL_10 bracket template which splits 58 into two
// separate pairs: Semi-Finals losers share 5th/6th (higher value), and
// Elimination Finals losers share 7th/8th (lower value). Both pairs get
// distinct point values so we track them in separate count maps.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const pfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const efLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 6. Monte Carlo simulation loop.
for (let s = 0; s < numSimulations; s++) {
// 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);
for (const loser of pfLosers) {
pfLoserCounts.set(loser.id, (pfLoserCounts.get(loser.id) ?? 0) + 1);
}
for (const loser of sfLosers) {
sfLoserCounts.set(loser.id, (sfLoserCounts.get(loser.id) ?? 0) + 1);
}
for (const loser of efLosers) {
efLoserCounts.set(loser.id, (efLoserCounts.get(loser.id) ?? 0) + 1);
}
// Wildcard losers and non-finalists are not counted (0 points per scoring rules).
}
// 7. Convert integer counts to probability distributions.
//
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → / NUM_SIMULATIONS (1 per sim)
// probThird/Fourth → / (2 * NUM_SIMULATIONS) (2 PF losers per sim)
// probFifth/Sixth → / (2 * NUM_SIMULATIONS) (2 SF losers per sim)
// probSeventh/Eighth → / (2 * NUM_SIMULATIONS) (2 EF losers per sim)
//
// Within each pair (3rd/4th, 5th/6th, 7th/8th), both positions receive the
// same probability — matching the AFL_10 bracket's averaged point values.
const N = numSimulations;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const pf = pfLoserCounts.get(participantId) ?? 0;
const sf = sfLoserCounts.get(participantId) ?? 0;
const ef = efLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: pf / (2 * N),
probFourth: pf / (2 * N),
probFifth: sf / (2 * N),
probSixth: sf / (2 * N),
probSeventh: ef / (2 * N),
probEighth: ef / (2 * N),
},
source: "afl_bracket_monte_carlo",
};
});
// 8. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 7.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}