Add AFL season + finals simulator (closes #126) (#204)

Monte Carlo simulation of the 2026 AFL season and 10-team finals series.
Elo ratings are backsolved from Squiggle's projected season win totals using
the inverse formula: elo = 1500 - 450×log₁₀((1−wins/23)/(wins/23)).

- New `afl_bracket` simulator type (schema + migration)
- `afl-simulator.ts`: projects remaining regular season via Elo, seeds the
  AFL_10 finals bracket (Wildcard → QF/EF → SF → PF → Grand Final), and
  correctly tracks P5/P6 and P7/P8 as separate scoring tiers
- `afl.ts` standings sync adapter pulling from Squiggle API (no auth required)
- Finals line on standings display set to 10 for AFL seasons
- Substring name matching with longest-key-wins to prevent "Port Adelaide"
  colliding with "Adelaide" when matching "Port Adelaide Power"
- 27 unit tests covering bracket logic, column-sum guarantees, and name matching

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-22 01:57:39 -07:00 committed by GitHub
parent 46a2e9c199
commit c1be92b2af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 4691 additions and 2 deletions

View file

@ -6,3 +6,4 @@ CLERK_WEBHOOK_SECRET=""
CONTAINER_REGISTRY="" CONTAINER_REGISTRY=""
DEV_ADMIN_CLERK_ID="" DEV_ADMIN_CLERK_ID=""
SENTRY_AUTH_TOKEN="" SENTRY_AUTH_TOKEN=""
SQUIGGLE_CONTACT_EMAIL=""

View file

@ -75,8 +75,10 @@ export default function SportSeasonDetail({
const standingsDisplayMode = const standingsDisplayMode =
simulatorType === "nhl_bracket" ? "nhl-divisions" : "flat"; simulatorType === "nhl_bracket" ? "nhl-divisions" : "flat";
// NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in) // NBA: 10 teams make the postseason (6 auto-qualify + 4 play-in)
// AFL: top 10 advance to the finals series (Wildcard + QF/EF + SF + PF + GF)
// NHL: handled by the nhl-divisions mode (3 per div + 2 wild cards) // NHL: handled by the nhl-divisions mode (3 per div + 2 wild cards)
const playoffSpots = simulatorType === "nba_bracket" ? 10 : 8; const playoffSpots =
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
// Build ownership map for RegularSeasonStandings // Build ownership map for RegularSeasonStandings
const ownershipMap = Object.fromEntries( const ownershipMap = Object.fromEntries(

View file

@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getTeamData, eloWinProbability, AFLSimulator } from "../afl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Western Bulldogs ")).toBe("western bulldogs");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("Greater Western Sydney")).toBe("greater western sydney");
});
it("is already-normalized identity", () => {
expect(normalizeTeamName("gold coast")).toBe("gold coast");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Western Bulldogs");
expect(d).toBeDefined();
expect(d?.elo).toBe(1646);
});
it("is case-insensitive", () => {
expect(getTeamData("western bulldogs")).toEqual(getTeamData("Western Bulldogs"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Koalas")).toBeUndefined();
});
it("all 18 AFL clubs are present", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
it("Western Bulldogs has the highest Elo", () => {
const bulldogs = getTeamData("Western Bulldogs")?.elo ?? 0;
const westCoast = getTeamData("West Coast")?.elo ?? 0;
expect(bulldogs).toBeGreaterThan(westCoast);
});
it("Elo ratings are in the expected range (12501750)", () => {
const allTeams = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
for (const name of allTeams) {
const elo = getTeamData(name)?.elo ?? 0;
expect(elo, `${name} elo out of range`).toBeGreaterThanOrEqual(1250);
expect(elo, `${name} elo out of range`).toBeLessThanOrEqual(1750);
}
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability (PARITY_FACTOR = 450)", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1706, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1295, 1500)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1706, 1295);
expect(p + eloWinProbability(1295, 1706)).toBeCloseTo(1.0, 10);
});
it("a 450-pt gap gives ~90.9% win probability", () => {
// P = 1 / (1 + 10^(-450/450)) = 1 / (1 + 10^-1) = 1/1.1 ≈ 0.909
const p = eloWinProbability(1950, 1500);
expect(p).toBeCloseTo(1 / 1.1, 5);
});
it("Bulldogs (1646) vs Essendon (1342): strongly favors Bulldogs", () => {
// 304-pt gap at parity 450: P = 1/(1+10^(-304/450)) ≈ 0.826
const p = eloWinProbability(1646, 1342);
expect(p).toBeGreaterThan(0.80);
});
});
// ─── AFLSimulator.simulate() integration tests ───────────────────────────────
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
const AFL_TEAMS = [
"Western Bulldogs", "Gold Coast", "Hawthorn", "Geelong",
"Adelaide", "Sydney", "Fremantle", "Collingwood",
"Brisbane Lions", "Greater Western Sydney", "Carlton", "Port Adelaide",
"St Kilda", "North Melbourne", "Melbourne", "Essendon",
"Richmond", "West Coast",
];
const PARTICIPANT_ROWS = AFL_TEAMS.map((name, i) => ({
id: `team-${i + 1}`,
name,
}));
const PARTICIPANT_IDS = PARTICIPANT_ROWS.map((r) => r.id);
describe("AFLSimulator.simulate()", () => {
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
// Default: no standings (pre-season)
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
});
it("throws if no participants found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const sim = new AFLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("returns 18 results — one per AFL club", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(18);
});
it("all probability values are non-negative", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
for (const val of Object.values(r.probabilities)) {
expect(val).toBeGreaterThanOrEqual(0);
}
}
});
it("each column (probFirst through probEighth) sums to 1.0 across all participants", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 2);
}
});
it("P5/P6 and P7/P8 are distinct tiers (separate column sums, not a combined 58 pool)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// probFifth should sum to 1.0 (SF losers only) — NOT 2.0 (which would happen if EF losers were mixed in)
const fifthSum = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
const seventhSum = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
expect(fifthSum).toBeCloseTo(1.0, 2);
expect(seventhSum).toBeCloseTo(1.0, 2);
});
it("probThird === probFourth for every participant (3rd/4th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth === probSixth for every participant (5th/6th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probFifth).toBeCloseTo(r.probabilities.probSixth, 10);
}
});
it("probSeventh === probEighth for every participant (7th/8th share same points in AFL)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probSeventh).toBeCloseTo(r.probabilities.probEighth, 10);
}
});
it("uses source: 'afl_bracket_monte_carlo' on all results", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("afl_bracket_monte_carlo");
}
});
it("all result participant IDs match input participant IDs", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const resultIds = new Set(results.map((r) => r.participantId));
for (const id of PARTICIPANT_IDS) {
expect(resultIds.has(id), `missing participant: ${id}`).toBe(true);
}
});
it("Western Bulldogs (highest Elo) has the highest championship probability", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const bulldogsResult = results.find((r) => r.participantId === "team-1"); // Western Bulldogs (highest Elo)
const westCoastResult = results.find((r) => r.participantId === "team-18"); // West Coast (near-lowest Elo)
if (!bulldogsResult || !westCoastResult) throw new Error("Expected results not found");
// The #1 Elo team should win the championship more often than the last-ranked team
expect(bulldogsResult.probabilities.probFirst).toBeGreaterThan(westCoastResult.probabilities.probFirst);
});
it("bottom-ranked teams rarely make finals (low combined probability)", async () => {
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
// West Coast and Richmond (16th/17th Elo) should have very low combined finals probability
const westCoast = results.find((r) => r.participantId === "team-18");
const richmond = results.find((r) => r.participantId === "team-17");
if (!westCoast || !richmond) throw new Error("Expected results not found");
const wcTotal = Object.values(westCoast.probabilities).reduce((a, b) => a + b, 0);
const ricTotal = Object.values(richmond.probabilities).reduce((a, b) => a + b, 0);
// Combined probability for a bottom team should be well below 1.0
expect(wcTotal).toBeLessThan(0.5);
expect(ricTotal).toBeLessThan(0.5);
});
it("mid-season standings: team with most wins has elevated finals probability", async () => {
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
// Give Western Bulldogs (team-1) 15 wins from 18 games — top of ladder
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([
{ participantId: "team-1", wins: 15, gamesPlayed: 18, losses: 3 },
// All other teams have 5 wins
...PARTICIPANT_IDS.slice(1).map((id) => ({ participantId: id, wins: 5, gamesPlayed: 18, losses: 13 })),
]);
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const leader = results.find((r) => r.participantId === "team-1");
const bottom = results.find((r) => r.participantId === "team-18");
if (!leader || !bottom) throw new Error("Expected results not found");
expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
});

View file

@ -0,0 +1,423 @@
/**
* 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;
}
}

View file

@ -15,6 +15,7 @@ import { NCAAMSimulator } from "./ncaam-simulator";
import { NCAAWSimulator } from "./ncaaw-simulator"; import { NCAAWSimulator } from "./ncaaw-simulator";
import { NBASimulator } from "./nba-simulator"; import { NBASimulator } from "./nba-simulator";
import { NHLSimulator } from "./nhl-simulator"; import { NHLSimulator } from "./nhl-simulator";
import { AFLSimulator } from "./afl-simulator";
export const SIMULATOR_TYPES = [ export const SIMULATOR_TYPES = [
"f1_standings", "f1_standings",
@ -26,6 +27,7 @@ export const SIMULATOR_TYPES = [
"ncaaw_bracket", "ncaaw_bracket",
"nba_bracket", "nba_bracket",
"nhl_bracket", "nhl_bracket",
"afl_bracket",
] as const; ] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number]; export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -86,6 +88,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "NHL Playoff Monte Carlo", description: "Simulates NHL playoff seedings (divisional format, via seed probabilities) and full bracket (best-of-7 series) using Elo ratings" }, info: { name: "NHL Playoff Monte Carlo", description: "Simulates NHL playoff seedings (divisional format, via seed probabilities) and full bracket (best-of-7 series) using Elo ratings" },
create: () => new NHLSimulator(), create: () => new NHLSimulator(),
}, },
afl_bracket: {
info: { name: "AFL Season + Finals Monte Carlo", description: "Projects AFL regular season standings via Elo, then simulates the 10-team finals series (Wildcard → QF/EF → SF → PF → Grand Final). Reads current standings from DB; falls back to full-season projection pre-season." },
create: () => new AFLSimulator(),
},
}; };
export function getSimulator(simulatorType: SimulatorType): Simulator { export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -0,0 +1,67 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
const AFL_STANDINGS_URL = "https://api.squiggle.com.au/?q=standings;year=2026;format=json";
/**
* Squiggle API response shape for a single team's ladder entry.
* Docs: https://api.squiggle.com.au/
*/
interface SquiggleStandingsEntry {
id: number; // Squiggle team ID (118)
name: string; // Full team name, e.g. "Western Bulldogs"
wins: number;
losses: number;
draws: number;
played: number; // Games played
for: number; // Points scored for
against: number; // Points scored against
percentage: number;// (for / (for + against)) * 100
pts: number; // Ladder points (4=win, 2=draw, 0=loss)
rank: number; // Current ladder position (1-based)
}
interface SquiggleStandingsResponse {
standings: SquiggleStandingsEntry[];
}
export class AflStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const response = await fetch(AFL_STANDINGS_URL, {
headers: {
// Squiggle asks all clients to identify themselves for rate-limit contact.
// Set SQUIGGLE_CONTACT_EMAIL in your environment to identify this client.
"User-Agent": `Brackt.com AFL standings sync - ${process.env.SQUIGGLE_CONTACT_EMAIL ?? "admin@brackt.com"}`,
},
});
if (!response.ok) {
throw new Error(
`AFL Squiggle standings API returned ${response.status}: ${response.statusText}`
);
}
const json = (await response.json()) as SquiggleStandingsResponse;
const entries = json.standings;
if (!entries || entries.length === 0) {
throw new Error(
"AFL Squiggle standings API returned no entries — response shape may have changed"
);
}
// Squiggle already includes `rank` (ladder position), sorted by ladder position.
// Sort ascending by rank so leagueRank matches the AFL ladder order.
const sorted = [...entries].sort((a, b) => a.rank - b.rank);
return sorted.map((entry): FetchedStandingsRecord => ({
teamName: entry.name,
externalTeamId: String(entry.id),
wins: entry.wins,
losses: entry.losses,
ties: entry.draws, // AFL draws are stored in the `ties` column
winPct: entry.percentage / 100, // Squiggle returns e.g. 62.5; store as 0.625
gamesPlayed: entry.played,
leagueRank: entry.rank,
}));
}
}

View file

@ -7,6 +7,7 @@ import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappi
import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { findMatchingTeamName } from "~/lib/normalize-team-name";
import { NhlStandingsAdapter } from "./nhl"; import { NhlStandingsAdapter } from "./nhl";
import { NbaStandingsAdapter } from "./nba"; import { NbaStandingsAdapter } from "./nba";
import { AflStandingsAdapter } from "./afl";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
/** /**
@ -20,6 +21,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
return new NbaStandingsAdapter(); return new NbaStandingsAdapter();
case "nhl_bracket": case "nhl_bracket":
return new NhlStandingsAdapter(); return new NhlStandingsAdapter();
case "afl_bracket":
return new AflStandingsAdapter();
case "f1_standings": case "f1_standings":
throw new Error( throw new Error(
"F1 standings sync is not yet implemented. Use the manual standings page." "F1 standings sync is not yet implemented. Use the manual standings page."

View file

@ -92,6 +92,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"ncaaw_bracket", "ncaaw_bracket",
"nba_bracket", "nba_bracket",
"nhl_bracket", "nhl_bracket",
"afl_bracket",
]); ]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [ export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [

View file

@ -0,0 +1 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'afl_bracket';

File diff suppressed because it is too large Load diff

View file

@ -393,6 +393,13 @@
"when": 1774072156237, "when": 1774072156237,
"tag": "0055_special_vampiro", "tag": "0055_special_vampiro",
"breakpoints": true "breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1774167142673,
"tag": "0056_jittery_the_fallen",
"breakpoints": true
} }
] ]
} }