brackt/app/services/standings-sync/afl.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

67 lines
2.4 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 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,
}));
}
}