The Elimination Final winners were crossed into the Semi-Finals — EF1's winner met the QF2 loser and EF2's the QF1 loser. The AFL feeds them straight through: SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2 winner. The crossover in this system lands a round later, at Semi-Final → Preliminary Final, so a Qualifying Final loser cannot meet the side that just beat it — that part was already right and is unchanged. In 2026 that drew Fremantle v Adelaide and Brisbane v Geelong, when Fremantle played Geelong and Brisbane played Adelaide. Placement now reconciles both Semi-Final slots on every Elimination Final result rather than writing the one it was called for, so correcting a recorded result moves the qualifier instead of leaving the beaten team alive in a semi. A slot held by anyone who never played an Elimination Final still raises "already filled", and a Semi-Final that has been played refuses the move rather than rewriting who contested it. The simulator paired the Semi-Finals the same crossed way, which biased every projection running off an undecided Elimination Final; it now feeds straight through too. Brackets already advanced under the crossover keep their wrong pairings, since no admin action re-runs advancement — a completed match cannot be re-submitted. Admin → the event's bracket gains a "Fix Semi-Final Pairings" button that runs the same reconciliation over a bracket as it stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
653 lines
29 KiB
TypeScript
653 lines
29 KiB
TypeScript
/**
|
||
* 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 lower WC winner, → losers exit (7th/8th)
|
||
* #6 vs higher WC winner
|
||
* Semi-Finals: QF1L vs EF1w, QF2L vs EF2w → 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 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 { 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 EF1 winner, SF2 = QF2 loser v EF2 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: the Wildcard winners are re-seeded by ladder position, so #5
|
||
// hosts whichever finished lower and #6 the other — not a fixed crossover.
|
||
const wc1Seed = wc1.winner === s7 ? 7 : 10;
|
||
const wc2Seed = wc2.winner === s8 ? 8 : 9;
|
||
const [betterWc, worseWc] =
|
||
wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner];
|
||
const ef1 = play("Elimination Finals", 1, s5, worseWc);
|
||
const ef2 = play("Elimination Finals", 2, s6, betterWc);
|
||
|
||
// Semi-Finals: QF losers (second chance) vs EF winners. Elimination Final n feeds
|
||
// Semi-Final n — a fixed pathway; the crossover is a round later, at the Prelims.
|
||
const sf1 = play("Semi-Finals", 1, qf1.loser, ef1.winner);
|
||
const sf2 = play("Semi-Finals", 2, qf2.loser, ef2.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 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);
|
||
};
|
||
|
||
// 5. 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]));
|
||
|
||
// 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;
|
||
}
|
||
}
|