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>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
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 (1250–1750)", () => {
|
||
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 5–8 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);
|
||
});
|
||
});
|