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

424 lines
18 KiB
TypeScript
Raw Normal View History

/**
* 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 current regular season standings (wins, gamesPlayed) if available
* 3. Match participant names to hardcoded team data (Elo ratings)
* 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 (see TEAMS_DATA below):
* Backsolved from Squiggle's projected season win totals using the inverse formula:
* elo = 1500 - 450 × log((1 wins/23) / (wins/23))
* Projected win counts were read from a screenshot of squiggle.com.au's season
* simulation table (as of Round 2, 2026). Update each round as projections shift.
* 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 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 { 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";
// ─── 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;
// ─── Team data (2026 AFL season, as of end of Round 2) ───────────────────────
//
// Elo ratings are backsolved from Squiggle's projected season win totals.
// Process: we screenshotted squiggle.com.au's season simulation table (Round 2,
// 2026), read each team's projected wins, then applied the inverse formula:
// elo = 1500 - 450 × log₁₀((1 wins/23) / (wins/23))
// This calibrates each team so the simulator reproduces Squiggle's ladder
// projection when every game is played against an average opponent (Elo 1500).
// Update each round by re-reading the projected wins from Squiggle and recalculating.
// Source: https://squiggle.com.au
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): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: AflTeamData | undefined;
/** 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;
}
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/** 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<SimulationResult[]> {
const db = database();
// 1. Load participants and standings in parallel.
const [participantRows, standings] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.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 standings lookup and construct team entries.
// 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 data = getTeamData(r.name);
if (!data) {
logger.warn(
{ participantName: r.name, sportsSeasonId },
`AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` +
`Add an entry to TEAMS_DATA or rename the participant to match an existing key.`
);
}
const gamesPlayed = standing?.gamesPlayed ?? 0;
return {
id: r.id,
name: r.name,
data,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(data?.elo ?? 1400, 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(elo(a), elo(b)) ? a : b;
/**
* 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);
};
/**
* 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 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]));
// 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<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;
}
}