/** * AFL Season + Finals Simulator * * Monte Carlo simulation of the AFL regular season and finals for 2026. * * 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): * * 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 * * 5. Track placement counts per scoring tier * 6. 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 * * 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 * (SPLIT_5678_TEMPLATE_IDS); this simulator outputs the correct probabilities * into the appropriate tiers. */ import { database } from "~/database/context"; import { 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"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 10_000; /** * 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. */ const PARITY_FACTOR = 450; /** Approximate total regular season games per AFL team (2026 season). */ const AFL_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 = { "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): number { return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR); } // ─── 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; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class AFLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 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(); 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, AFL_REGULAR_SEASON_GAMES - gamesPlayed), winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO), }; }); // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** Simulate a single AFL game. Returns the winner. */ const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => Math.random() < eloWinProbability(a.elo, b.elo) ? a : b; /** * Project end-of-season ladder and return the top 10 finalists seeded 1–10. * * 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); }; /** * 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. // // 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 // 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(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const pfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map(participantIds.map((id) => [id, 0])); // 4. Monte Carlo simulation loop. for (let s = 0; s < NUM_SIMULATIONS; s++) { const finalists = buildFinalsList(); const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists); 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). } // 5. 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 = NUM_SIMULATIONS; 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", }; }); // 6. Per-position normalization — belt-and-suspenders guard against floating-point // division residuals. Columns are already near-exactly 1.0 after step 5. const positionKeys: Array = [ "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; } }