* Add projected-wins input mode to admin Elo Ratings page Admins can now enter projected season win totals instead of raw Elo numbers on the Elo Ratings page. Wins are auto-converted to Elo using the inverse formula and stored as sourceElo, keeping the rest of the simulation pipeline unchanged. - Add `projectedWinsToElo` / `eloToProjectedWins` to probability-engine - Add `simulator-config.ts` centralising per-sport season length, parity factor, and average opponent Elo (AFL, NFL, NBA, NHL, MLB, WNBA) - Admin Elo Ratings page: toggle between Elo and Projected Wins input modes; bulk import parses wins format; existing sourceElo back-fills the wins field on load - AFL simulator now reads sourceElo from participantExpectedValues first, falling back to hardcoded TEAMS_DATA then 1400 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix lint errors in simulator-config tests and probability-engine - Replace non-null assertions (`!`) with optional chaining (`?.`) in simulator-config tests - Remove redundant type annotations on default parameters in probability-engine Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
437 lines
19 KiB
TypeScript
437 lines
19 KiB
TypeScript
/**
|
||
* 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";
|
||
|
||
// ─── 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<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;
|
||
/** 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<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// 1. Load participants, DB Elo, and standings in parallel.
|
||
const [participantRows, evRows, standings] = await Promise.all([
|
||
db
|
||
.select({ id: schema.participants.id, name: schema.participants.name })
|
||
.from(schema.participants)
|
||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
|
||
db
|
||
.select({
|
||
participantId: schema.participantExpectedValues.participantId,
|
||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.participantExpectedValues)
|
||
.where(eq(schema.participantExpectedValues.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, 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<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;
|
||
}
|
||
}
|