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
701 lines
29 KiB
TypeScript
701 lines
29 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||
import {
|
||
getTeamData,
|
||
eloWinProbability,
|
||
AFLSimulator,
|
||
readAflBracketSeeds,
|
||
simAFLFinals,
|
||
type BracketMatch,
|
||
} from "../afl-simulator";
|
||
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
||
import { calculateEV, type ProbabilityDistribution } from "~/services/ev-calculator";
|
||
|
||
// ─── 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);
|
||
|
||
/**
|
||
* Build the playoff_matches rows generateAFL10Bracket writes, seeded with `seedIds` in
|
||
* ladder order (index 0 = minor premier). `completed` overrides individual matches with a
|
||
* recorded result.
|
||
*/
|
||
function aflBracketMatches(
|
||
seedIds: string[],
|
||
completed: Array<{ round: string; matchNumber: number; winnerId: string; loserId: string }> = []
|
||
): BracketMatch[] {
|
||
const seed = (n: number) => seedIds[n - 1] ?? null;
|
||
const rows: BracketMatch[] = [
|
||
{ round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||
{ round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||
{ round: "Qualifying Finals", matchNumber: 1, participant1Id: seed(1), participant2Id: seed(4) },
|
||
{ round: "Qualifying Finals", matchNumber: 2, participant1Id: seed(2), participant2Id: seed(3) },
|
||
// participant2 is TBD until a Wildcard winner advances into it.
|
||
{ round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||
{ round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||
{ round: "Semi-Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||
{ round: "Semi-Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||
{ round: "Preliminary Finals", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||
{ round: "Preliminary Finals", matchNumber: 2, participant1Id: null, participant2Id: null },
|
||
{ round: "Grand Final", matchNumber: 1, participant1Id: null, participant2Id: null },
|
||
].map((m) => ({ ...m, winnerId: null, loserId: null, isComplete: false }));
|
||
|
||
for (const done of completed) {
|
||
const row = rows.find((r) => r.round === done.round && r.matchNumber === done.matchNumber);
|
||
if (!row) throw new Error(`no such match: ${done.round} #${done.matchNumber}`);
|
||
row.isComplete = true;
|
||
row.winnerId = done.winnerId;
|
||
row.loserId = done.loserId;
|
||
// A Wildcard winner is advanced into the Elimination Final it feeds.
|
||
if (done.round === "Wildcard Round") {
|
||
const ef = rows.find(
|
||
(r) => r.round === "Elimination Finals" && r.matchNumber === (done.matchNumber === 1 ? 2 : 1)
|
||
);
|
||
if (ef) ef.participant2Id = done.winnerId;
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/** The one bracket row for a round/match, failing loudly if the fixture changes shape. */
|
||
function matchIn(matches: BracketMatch[], round: string, matchNumber: number): BracketMatch {
|
||
const found = matches.find((m) => m.round === round && m.matchNumber === matchNumber);
|
||
if (!found) throw new Error(`no such match: ${round} #${matchNumber}`);
|
||
return found;
|
||
}
|
||
|
||
/** Look up one participant's result, failing loudly rather than silently passing on undefined. */
|
||
function resultFor<T extends { participantId: string }>(results: T[], participantId: string): T {
|
||
const found = results.find((r) => r.participantId === participantId);
|
||
if (!found) throw new Error(`no simulation result for ${participantId}`);
|
||
return found;
|
||
}
|
||
|
||
/** EV on the reference scale the runner persists with. */
|
||
function evOf(result: { probabilities: ProbabilityDistribution }): number {
|
||
return calculateEV(result.probabilities, DEFAULT_SCORING_RULES);
|
||
}
|
||
|
||
describe("AFLSimulator.simulate()", () => {
|
||
let mockDb: {
|
||
select: MockInstance;
|
||
query: {
|
||
scoringEvents: { findMany: MockInstance };
|
||
playoffMatches: { findMany: MockInstance };
|
||
};
|
||
};
|
||
|
||
/** Put a seeded afl_10 bracket in front of the simulator. */
|
||
function seedBracket(matches: BracketMatch[]) {
|
||
mockDb.query.scoringEvents.findMany.mockResolvedValue([{ id: "event-1" }]);
|
||
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
const { database } = await import("~/database/context");
|
||
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
|
||
|
||
const participantRows = PARTICIPANT_ROWS;
|
||
|
||
let selectCallCount = 0;
|
||
mockDb = {
|
||
// Default: no bracket generated yet, so the ladder-projection path runs.
|
||
query: {
|
||
scoringEvents: { findMany: vi.fn().mockResolvedValue([]) },
|
||
playoffMatches: { findMany: vi.fn().mockResolvedValue([]) },
|
||
},
|
||
select: vi.fn().mockImplementation(() => {
|
||
selectCallCount++;
|
||
if (selectCallCount === 1) {
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
where: vi.fn().mockResolvedValue(participantRows),
|
||
}),
|
||
};
|
||
}
|
||
// Second call: sourceElo query (no DB Elo by default)
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
where: vi.fn().mockResolvedValue([]),
|
||
}),
|
||
};
|
||
}),
|
||
};
|
||
|
||
(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 () => {
|
||
let selectCallCount = 0;
|
||
mockDb.select.mockImplementation(() => {
|
||
selectCallCount++;
|
||
if (selectCallCount === 1) {
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
where: vi.fn().mockResolvedValue([]),
|
||
}),
|
||
};
|
||
}
|
||
return {
|
||
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);
|
||
});
|
||
|
||
it("DB sourceElo overrides hardcoded TEAMS_DATA values", async () => {
|
||
// Set DB Elo for West Coast (team-18) to 1800 (higher than Bulldogs)
|
||
let selectCallCount = 0;
|
||
mockDb.select.mockImplementation(() => {
|
||
selectCallCount++;
|
||
if (selectCallCount === 1) {
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
|
||
}),
|
||
};
|
||
}
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
where: vi.fn().mockResolvedValue([
|
||
{ participantId: "team-18", sourceElo: 1800 },
|
||
]),
|
||
}),
|
||
};
|
||
});
|
||
|
||
const sim = new AFLSimulator();
|
||
const results = await sim.simulate("season-1");
|
||
|
||
const westCoast = results.find((r) => r.participantId === "team-18");
|
||
const bulldogs = results.find((r) => r.participantId === "team-1");
|
||
if (!westCoast || !bulldogs) throw new Error("Expected results not found");
|
||
|
||
// With DB Elo 1800, West Coast should now be favored over Bulldogs (1646)
|
||
expect(westCoast.probabilities.probFirst).toBeGreaterThan(bulldogs.probabilities.probFirst);
|
||
});
|
||
|
||
it("falls back to hardcoded TEAMS_DATA when no DB sourceElo exists", async () => {
|
||
// Default mock already returns no sourceElo rows — should use TEAMS_DATA
|
||
const sim = new AFLSimulator();
|
||
const results = await sim.simulate("season-1");
|
||
|
||
const bulldogs = results.find((r) => r.participantId === "team-1");
|
||
const westCoast = results.find((r) => r.participantId === "team-18");
|
||
if (!bulldogs || !westCoast) throw new Error("Expected results not found");
|
||
|
||
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
|
||
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
|
||
});
|
||
|
||
// ─── Bracket-aware mode ─────────────────────────────────────────────────────
|
||
//
|
||
// afl_10 banks points on seeding alone (entryFloor 5 for seeds 1-4, 7 for seeds 5-6) and
|
||
// on winning a non-scoring round (nonScoringWinnerFloor 7 for the Wildcard Round, 3 for a
|
||
// Qualifying Final). Those floors are paid out as real fantasy points, so a simulator that
|
||
// re-draws the ladder every iteration — putting a seeded team back in the Wildcard Round or
|
||
// out of the finals, where it scores 0 — reports an EV below points already awarded. Each
|
||
// EV assertion below is that floor.
|
||
|
||
describe("bracket-aware mode", () => {
|
||
/**
|
||
* Seeds 1-10 in ladder order, drawn from the ten *weakest* clubs by Elo. Seeding the
|
||
* strongest ten would let the ladder-projection path produce much the same field by
|
||
* accident, so the floor assertions below would pass even with the bracket ignored.
|
||
*/
|
||
const SEEDS = PARTICIPANT_IDS.slice(8);
|
||
|
||
it("never values a seed below the entry floor its seeding already banked", async () => {
|
||
seedBracket(aflBracketMatches(SEEDS));
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
// Seeds 1-4 enter a Qualifying Final: lose it, lose the Semi-Final, still 5th-6th (25).
|
||
for (const seed of [1, 2, 3, 4]) {
|
||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(25);
|
||
}
|
||
// Seeds 5-6 enter an Elimination Final: lose it and they are 7th-8th (15).
|
||
for (const seed of [5, 6]) {
|
||
expect(evOf(resultFor(results, SEEDS[seed - 1])), `seed ${seed}`).toBeGreaterThanOrEqual(15);
|
||
}
|
||
});
|
||
|
||
it("keeps a Qualifying Final entrant out of the 7th-8th tier entirely", async () => {
|
||
seedBracket(aflBracketMatches(SEEDS));
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
// A seed 1-4 loses the QF into a Semi-Final, so 5th-6th is its worst finish. The
|
||
// 7th-8th tier is reachable only by losing an Elimination Final.
|
||
for (const seed of [1, 2, 3, 4]) {
|
||
expect(resultFor(results, SEEDS[seed - 1]).probabilities.probSeventh, `seed ${seed}`).toBe(0);
|
||
}
|
||
// Seeds 5-10 all reach an Elimination Final only by playing one, so they can.
|
||
expect(resultFor(results, SEEDS[4]).probabilities.probSeventh).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("uses the bracket's draw rather than a re-projected ladder", async () => {
|
||
// Deliberately inverted: the weakest club is the minor premier and the strongest
|
||
// scrapes in 10th. On the ladder-projection path Elo decides the seeding, so this only
|
||
// holds if the bracket's own slots are being read.
|
||
const inverted = [
|
||
"team-18", "team-17", "team-16", "team-15", "team-14",
|
||
"team-13", "team-12", "team-11", "team-10", "team-1",
|
||
];
|
||
seedBracket(aflBracketMatches(inverted));
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
// West Coast (weakest Elo) is seeded 1, so it holds the double chance and can never
|
||
// finish 7th-8th, and its EV clears the seed 1-4 floor.
|
||
expect(resultFor(results, "team-18").probabilities.probSeventh).toBe(0);
|
||
expect(evOf(resultFor(results, "team-18"))).toBeGreaterThanOrEqual(25);
|
||
|
||
// Western Bulldogs (strongest Elo) is seeded 10, so it starts in the Wildcard Round
|
||
// with nothing banked and can be knocked out for 0.
|
||
expect(resultFor(results, "team-1").probabilities.probSeventh).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("zeroes every participant outside the bracket", async () => {
|
||
seedBracket(aflBracketMatches(SEEDS));
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
for (const r of results.filter((x) => !SEEDS.includes(x.participantId))) {
|
||
expect(evOf(r), r.participantId).toBe(0);
|
||
}
|
||
expect(results).toHaveLength(18);
|
||
});
|
||
|
||
it("still normalizes every column to 1.0 and the field to 340 total EV", async () => {
|
||
seedBracket(aflBracketMatches(SEEDS));
|
||
const results = await new AFLSimulator().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, 6);
|
||
}
|
||
expect(results.reduce((s, r) => s + evOf(r), 0)).toBeCloseTo(340, 4);
|
||
});
|
||
|
||
it("replays a completed Wildcard Round instead of re-simulating it", async () => {
|
||
// Seed 10 beat seed 7, which banks seed 10 a 7th-place floor (15 points).
|
||
seedBracket(
|
||
aflBracketMatches(SEEDS, [
|
||
{ round: "Wildcard Round", matchNumber: 1, winnerId: SEEDS[9], loserId: SEEDS[6] },
|
||
])
|
||
);
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
expect(evOf(resultFor(results, SEEDS[9]))).toBeGreaterThanOrEqual(15);
|
||
// The loser is out with nothing, in every iteration.
|
||
expect(evOf(resultFor(results, SEEDS[6]))).toBe(0);
|
||
});
|
||
|
||
it("replays a completed Qualifying Final, banking the winner's 3rd-4th floor", async () => {
|
||
// Seed 1 beat seed 4: the winner byes into a Preliminary Final (floor 3rd, 45 points)
|
||
// and the loser drops into a Semi-Final (floor 5th, 25 points).
|
||
seedBracket(
|
||
aflBracketMatches(SEEDS, [
|
||
{ round: "Qualifying Finals", matchNumber: 1, winnerId: SEEDS[0], loserId: SEEDS[3] },
|
||
])
|
||
);
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
const winner = resultFor(results, SEEDS[0]);
|
||
expect(evOf(winner)).toBeGreaterThanOrEqual(45);
|
||
// Already through to a Preliminary Final, so the 5th-6th tier is behind it.
|
||
expect(winner.probabilities.probFifth).toBe(0);
|
||
|
||
expect(evOf(resultFor(results, SEEDS[3]))).toBeGreaterThanOrEqual(25);
|
||
});
|
||
|
||
it("falls back to the ladder projection when the bracket carries no seeds", async () => {
|
||
seedBracket(aflBracketMatches([]));
|
||
const results = await new AFLSimulator().simulate("season-1");
|
||
|
||
// Every club is back in contention, so nobody is structurally zeroed.
|
||
expect(results.filter((r) => evOf(r) > 0).length).toBeGreaterThan(10);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── readAflBracketSeeds ──────────────────────────────────────────────────────
|
||
|
||
describe("readAflBracketSeeds", () => {
|
||
const teamsById = new Map(
|
||
PARTICIPANT_IDS.map((id) => [id, { id, name: id, elo: 1500, currentWins: 0, remainingGames: 0, winProb: 0.5 }])
|
||
);
|
||
const SEEDS = PARTICIPANT_IDS.slice(0, 10);
|
||
|
||
it("returns null when there is no bracket at all", () => {
|
||
expect(readAflBracketSeeds([], teamsById as never)).toBeNull();
|
||
});
|
||
|
||
it("returns null for a generated but unseeded bracket", () => {
|
||
expect(readAflBracketSeeds(aflBracketMatches([]), teamsById as never)).toBeNull();
|
||
});
|
||
|
||
it("reads the 10 seeds in ladder order", () => {
|
||
const bracket = readAflBracketSeeds(aflBracketMatches(SEEDS), teamsById as never);
|
||
expect(bracket?.seeds.map((t) => t.id)).toEqual(SEEDS);
|
||
});
|
||
|
||
it("does not treat the TBD Elimination Final slots as missing seeds", () => {
|
||
const matches = aflBracketMatches(SEEDS);
|
||
for (const m of matches.filter((r) => r.round === "Elimination Finals")) {
|
||
expect(m.participant2Id).toBeNull();
|
||
}
|
||
expect(readAflBracketSeeds(matches, teamsById as never)).not.toBeNull();
|
||
});
|
||
|
||
it("throws on a partially seeded bracket rather than discarding the draw", () => {
|
||
const matches = aflBracketMatches(SEEDS);
|
||
// ON DELETE SET NULL empties a slot when a participant is removed and re-added.
|
||
matchIn(matches, "Qualifying Finals", 1).participant2Id = null;
|
||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/partially seeded.*seed\(s\) 4/s);
|
||
});
|
||
|
||
it("throws when one participant holds two slots", () => {
|
||
const matches = aflBracketMatches(SEEDS);
|
||
matchIn(matches, "Wildcard Round", 1).participant2Id = SEEDS[0];
|
||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/more than one slot/);
|
||
});
|
||
|
||
it("throws when the bracket references a participant outside the season", () => {
|
||
const matches = aflBracketMatches(SEEDS);
|
||
matchIn(matches, "Wildcard Round", 1).participant2Id = "ghost";
|
||
expect(() => readAflBracketSeeds(matches, teamsById as never)).toThrow(/not in this sports season/);
|
||
});
|
||
});
|
||
|
||
// ─── simAFLFinals ─────────────────────────────────────────────────────────────
|
||
|
||
describe("simAFLFinals bracket pathways", () => {
|
||
const finalists = Array.from({ length: 10 }, (_, i) => ({
|
||
id: `s${i + 1}`,
|
||
name: `s${i + 1}`,
|
||
elo: 1500,
|
||
currentWins: 0,
|
||
remainingGames: 0,
|
||
winProb: 0.5,
|
||
}));
|
||
|
||
/**
|
||
* Play the finals with the Wildcard Round forced to the given winners (every other
|
||
* game goes to whoever was routed in first), and report who met whom.
|
||
*/
|
||
function pairingsWith(wc1Winner: string, wc2Winner: string): Map<string, [string, string]> {
|
||
const pairings = new Map<string, [string, string]>();
|
||
const play = (
|
||
round: string,
|
||
matchNumber: number,
|
||
t1: { id: string },
|
||
t2: { id: string }
|
||
) => {
|
||
pairings.set(`${round}#${matchNumber}`, [t1.id, t2.id]);
|
||
if (round === "Wildcard Round") {
|
||
const forced = matchNumber === 1 ? wc1Winner : wc2Winner;
|
||
return t1.id === forced ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||
}
|
||
return { winner: t1, loser: t2 };
|
||
};
|
||
|
||
simAFLFinals(finalists as never, play as never);
|
||
return pairings;
|
||
}
|
||
|
||
it("draws the Wildcard Round 7v10 and 8v9", () => {
|
||
const pairings = pairingsWith("s7", "s8");
|
||
expect(pairings.get("Wildcard Round#1")).toEqual(["s7", "s10"]);
|
||
expect(pairings.get("Wildcard Round#2")).toEqual(["s8", "s9"]);
|
||
});
|
||
|
||
it.each([
|
||
{ wc1: "s7", wc2: "s8", ef1: "s8", ef2: "s7" },
|
||
{ wc1: "s7", wc2: "s9", ef1: "s9", ef2: "s7" },
|
||
// 10th beating 7th is where a fixed crossover misfires: it would send 10th to 6th
|
||
// and leave 5th with the stronger survivor.
|
||
{ wc1: "s10", wc2: "s8", ef1: "s10", ef2: "s8" },
|
||
{ wc1: "s10", wc2: "s9", ef1: "s10", ef2: "s9" },
|
||
])(
|
||
"pairs 5th with $ef1 and 6th with $ef2 when $wc1 and $wc2 win through",
|
||
({ wc1, wc2, ef1, ef2 }) => {
|
||
const pairings = pairingsWith(wc1, wc2);
|
||
expect(pairings.get("Elimination Finals#1")).toEqual(["s5", ef1]);
|
||
expect(pairings.get("Elimination Finals#2")).toEqual(["s6", ef2]);
|
||
}
|
||
);
|
||
|
||
// The pathway out of the Elimination Finals is fixed (EF n → SF n) — unlike the
|
||
// Wildcard Round's re-seed. The crossover lands a round later, at the Prelims, so a
|
||
// Qualifying Final loser cannot meet the side that just beat it. `play` here hands
|
||
// every non-Wildcard game to participant1, so QF1 sends s1 through and s4 down.
|
||
it("feeds each Elimination Final into the Semi-Final of the same number", () => {
|
||
const pairings = pairingsWith("s7", "s8");
|
||
expect(pairings.get("Semi-Finals#1")).toEqual(["s4", "s5"]);
|
||
expect(pairings.get("Semi-Finals#2")).toEqual(["s3", "s6"]);
|
||
});
|
||
|
||
it("crosses the Semi-Final winners over into the Preliminary Finals", () => {
|
||
const pairings = pairingsWith("s7", "s8");
|
||
expect(pairings.get("Preliminary Finals#1")).toEqual(["s1", "s3"]);
|
||
expect(pairings.get("Preliminary Finals#2")).toEqual(["s2", "s4"]);
|
||
});
|
||
});
|