brackt/app/services/simulations/__tests__/afl-simulator.test.ts
Chris Parsons c1be92b2af
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>
2026-03-22 01:57:39 -07:00

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});