/** * AFL Season + Finals Simulator * * Monte Carlo simulation of the AFL regular season and finals for 2026. * * Two modes: * 1. Pre-bracket mode: no afl_10 bracket exists yet, or it carries no seeds. The ladder is * re-projected from Elo every iteration and its top 10 are seeded 1-10, so the draw is * modelled as still uncertain. * 2. Bracket-aware mode: a seeded afl_10 bracket exists. Its slots are the seeding, fixed * across every iteration, and games already played are replayed from their recorded * result instead of being re-simulated. * * Bracket-aware mode is what makes a banked floor hold. afl_10 is the only template that * awards points on seeding alone (entryFloor: seeds 1-4 bank 5th, seeds 5-6 bank 7th), and a * simulator that re-draws the ladder every iteration puts those teams back in the Wildcard * Round — or out of the finals entirely — where they score 0, pulling EV below points the * league has already paid out. Reading the real draw removes that by construction: a team * seeded into an Elimination Final is in that game in 100% of iterations, so its worst * outcome is the 7th-8th tier. * * Algorithm: * 1. Load all participants for the sports season from DB * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained) * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set. * 3. Load current regular season standings (wins, gamesPlayed) — if available * 4. Load the afl_10 bracket, if one has been generated, for its draw and results so far * 5. For each simulation: * a. Pre-bracket mode only: 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. Pre-bracket mode only: sort all 18 teams by projected points desc + random * tiebreaker → final ladder → top 10 advance to the AFL Finals Series. * In bracket-aware mode the bracket's own 10 seeds are used as-is. * c. Simulate the AFL Finals Series (AFL_10 bracket), replaying any completed match: * * 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 lower WC winner, → losers exit (7th/8th) * #6 vs higher WC winner * 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 * * 6. Track placement counts per scoring tier * 7. 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: * Priority: sourceElo from participantExpectedValues (admin UI) → hardcoded TEAMS_DATA * → fallback 1400. * Admin can enter Elo directly or via "Projected Wins" mode on the Elo Ratings admin page, * which auto-converts projected season wins to Elo using the inverse formula: * elo = 1500 - 450 × log₁₀((1 − wins/23) / (wins/23)) * The hardcoded TEAMS_DATA values are backsolved from Squiggle's projected season * win totals (as of Round 2, 2026). 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 (in bracket-aware mode, every team outside the bracket) * * NOTE: AFL uses the AFL_10 bracket template which splits the 5–8 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 { and, desc, 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"; import { eloWinProbabilityWithParity } from "~/services/probability-engine"; import { positiveConfigNumber } from "./config-access"; // ─── Simulation parameters (defaults; overridable via season config) ─────────── const DEFAULT_NUM_SIMULATIONS = 10_000; /** The bracket template the AFL finals are scored against. */ const AFL_TEMPLATE_ID = "afl_10"; /** * 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. * Overridable via the season config's `parityFactor`. */ const DEFAULT_PARITY_FACTOR = 450; /** Approximate total regular season games per AFL team (2026 season). */ const DEFAULT_REGULAR_SEASON_GAMES = 23; /** Average opponent Elo used for regular season projections. */ const AVERAGE_OPPONENT_ELO = 1500; // ─── Hardcoded team data (FALLBACK — used only when no sourceElo in DB) ────── // // Elo ratings are backsolved from Squiggle's projected season win totals. // These serve as fallback defaults when no sourceElo has been entered via the // admin Elo Ratings page. Prefer updating via Admin → Elo Ratings (projected // wins mode) rather than editing these values. // Source: https://squiggle.com.au (Round 2, 2026) interface AflTeamData { elo: number; } const TEAMS_DATA: Record = { "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, parityFactor = DEFAULT_PARITY_FACTOR): number { return eloWinProbabilityWithParity(eloA, eloB, parityFactor); } // ─── Internal types ─────────────────────────────────────────────────────────── interface TeamEntry { id: string; name: string; /** Resolved Elo: DB sourceElo > hardcoded TEAMS_DATA > fallback 1400. */ elo: number; /** 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; } /** 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; } /** The playoff_matches columns the simulator actually reads. */ export type BracketMatch = Pick< typeof schema.playoffMatches.$inferSelect, "round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete" >; interface LoadedBracket { /** The 10 finalists in seed order — index 0 is the minor premier. */ seeds: TeamEntry[]; /** Every bracket match, keyed by `${round}#${matchNumber}`. */ matches: Map; } /** * Plays one finals game. `round`/`matchNumber` identify it within the bracket so an * already-played result can be looked up; `t1`/`t2` are the teams routed into it. */ type PlayGame = ( round: string, matchNumber: number, t1: TeamEntry, t2: TeamEntry ) => { winner: TeamEntry; loser: TeamEntry }; function matchKey(round: string, matchNumber: number): string { return `${round}#${matchNumber}`; } function simGame(t1: TeamEntry, t2: TeamEntry, parityFactor: number): { winner: TeamEntry; loser: TeamEntry } { return Math.random() < eloWinProbability(t1.elo, t2.elo, parityFactor) ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } /** * Where generateAFL10Bracket (models/playoff-match.ts) writes each seed. * * The two Elimination Final participant2 slots are deliberately absent: they are TBD by * design until a Wildcard winner advances into them, so they are never a missing seed. * That leaves exactly 10 named slots for the 10 finalists. */ const SEED_SLOTS: ReadonlyArray<{ round: string; matchNumber: number; slot: 1 | 2; seed: number }> = [ { round: "Qualifying Finals", matchNumber: 1, slot: 1, seed: 1 }, { round: "Qualifying Finals", matchNumber: 2, slot: 1, seed: 2 }, { round: "Qualifying Finals", matchNumber: 2, slot: 2, seed: 3 }, { round: "Qualifying Finals", matchNumber: 1, slot: 2, seed: 4 }, { round: "Elimination Finals", matchNumber: 1, slot: 1, seed: 5 }, { round: "Elimination Finals", matchNumber: 2, slot: 1, seed: 6 }, { round: "Wildcard Round", matchNumber: 1, slot: 1, seed: 7 }, { round: "Wildcard Round", matchNumber: 2, slot: 1, seed: 8 }, { round: "Wildcard Round", matchNumber: 2, slot: 2, seed: 9 }, { round: "Wildcard Round", matchNumber: 1, slot: 2, seed: 10 }, ]; /** * Read the seeded afl_10 bracket for this season, if there is one. * * Returns null only when the bracket carries no draw at all — no matches, or a freshly * generated bracket with every slot still empty — in which case the caller falls back to * projecting the ladder. * * A *partially* seeded bracket is an error rather than a fallback. Falling back there would * throw away the real draw and every recorded result with it, putting eliminated teams back * in contention; and it is reachable in practice, because playoff_matches.participant1Id / * participant2Id are ON DELETE SET NULL, so removing and re-adding one participant * mid-finals empties a slot. A duplicated or unknown participant fails loudly for the same * reason. */ export function readAflBracketSeeds( matches: BracketMatch[], teamsById: Map ): LoadedBracket | null { if (matches.length === 0) return null; const byKey = new Map(matches.map((m) => [matchKey(m.round, m.matchNumber), m])); const drawn = SEED_SLOTS.map(({ round, matchNumber, slot }) => { const match = byKey.get(matchKey(round, matchNumber)); if (!match) return null; return (slot === 1 ? match.participant1Id : match.participant2Id) ?? null; }); const seededCount = drawn.filter((id) => id !== null).length; // Generated but not yet filled in — no draw to honor. if (seededCount === 0) return null; if (seededCount < drawn.length) { const missing = SEED_SLOTS.filter((_, i) => drawn[i] === null) .map((s) => s.seed) .toSorted((a, b) => a - b) .join(", "); throw new Error( `AFL bracket is only partially seeded (${seededCount} of ${drawn.length} slots filled; ` + `missing seed(s) ${missing}). Re-seed the bracket in Admin → Bracket before simulating; ` + `simulating around the gap would discard the draw and every recorded result.` ); } // Filled by seed number below; SEED_SLOTS covers seeds 1-10 exactly once each. const seeds: TeamEntry[] = []; const seen = new Set(); for (let i = 0; i < SEED_SLOTS.length; i++) { const participantId = drawn[i] as string; if (seen.has(participantId)) { throw new Error(`AFL bracket seeds participant ${participantId} into more than one slot.`); } seen.add(participantId); const team = teamsById.get(participantId); if (!team) { throw new Error( `AFL bracket references participant ${participantId}, which is not in this sports season.` ); } seeds[SEED_SLOTS[i].seed - 1] = team; } return { seeds, matches: byKey }; } /** * The recorded loser of a completed match. loserId is written by the scoring flow, but fall * back to "whichever slot isn't the winner" for older rows. */ function completedLoser(match: BracketMatch): string | null { if (match.loserId) return match.loserId; if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; return null; } /** * Build the game-playing function for a bracket. * * When the bracket has a completed result for a game AND that result is between the two teams * the simulation routed into it, the recorded winner is used verbatim — that is what makes an * already-played result stick across all iterations, and what stops a banked floor from being * re-litigated at 50/50. Anything else is simulated. The pair check keeps a corrupt or * out-of-order row from desynchronising the rest of the bracket. */ export function makePlayGame(bracket: LoadedBracket | null, parityFactor: number): PlayGame { if (!bracket) { return (_round, _matchNumber, t1, t2) => simGame(t1, t2, parityFactor); } return (round, matchNumber, t1, t2) => { const match = bracket.matches.get(matchKey(round, matchNumber)); if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); const arrived = [t1.id, t2.id]; if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { return match.winnerId === t1.id ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } } return simGame(t1, t2, parityFactor); }; } /** * Simulate the AFL Finals Series from a seeded list of 10 teams. * * Round names and match numbers match generateAFL10Bracket / advanceAFLWinner exactly, so a * recorded result is looked up against the game it was actually played in: * SF1 = QF1 loser v EF2 winner, SF2 = QF2 loser v EF1 winner, * PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner. * * 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) */ export function simAFLFinals( finalists: TeamEntry[], play: PlayGame ): { 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 wc1 = play("Wildcard Round", 1, s7, s10); const wc2 = play("Wildcard Round", 2, s8, s9); // Qualifying Finals: #1 vs #4, #2 vs #3 (double-chance: winners get a bye to a PF) const qf1 = play("Qualifying Finals", 1, s1, s4); const qf2 = play("Qualifying Finals", 2, s2, s3); // Elimination Finals: the Wildcard winners are re-seeded by ladder position, so #5 // hosts whichever finished lower and #6 the other — not a fixed crossover. const wc1Seed = wc1.winner === s7 ? 7 : 10; const wc2Seed = wc2.winner === s8 ? 8 : 9; const [betterWc, worseWc] = wc1Seed < wc2Seed ? [wc1.winner, wc2.winner] : [wc2.winner, wc1.winner]; const ef1 = play("Elimination Finals", 1, s5, worseWc); const ef2 = play("Elimination Finals", 2, s6, betterWc); // Semi-Finals: QF losers (second chance) vs EF winners const sf1 = play("Semi-Finals", 1, qf1.loser, ef2.winner); const sf2 = play("Semi-Finals", 2, qf2.loser, ef1.winner); // Preliminary Finals: QF winners vs SF winners const pf1 = play("Preliminary Finals", 1, qf1.winner, sf2.winner); const pf2 = play("Preliminary Finals", 2, qf2.winner, sf1.winner); // Grand Final const gf = play("Grand Final", 1, pf1.winner, pf2.winner); return { gfWinner: gf.winner, gfLoser: gf.loser, pfLosers: [pf1.loser, pf2.loser], sfLosers: [sf1.loser, sf2.loser], efLosers: [ef1.loser, ef2.loser], }; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class AFLSimulator implements Simulator { async simulate(sportsSeasonId: string, config: Record = {}): Promise { const db = database(); const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR); const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES)); // 1. Load participants, DB Elo, and standings in parallel. const [participantRows, evRows, standings] = await Promise.all([ db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)), db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.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 Elo map from DB sourceElo values. const dbEloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) { dbEloMap.set(row.participantId, row.sourceElo); } } // 3. Build standings lookup and construct team entries. // Elo priority: DB sourceElo → hardcoded TEAMS_DATA → fallback 1400. // 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 dbElo = dbEloMap.get(r.id); const fallbackData = getTeamData(r.name); const resolvedElo = dbElo ?? fallbackData?.elo ?? 1400; if (dbElo === undefined && !fallbackData) { logger.warn( { participantName: r.name, sportsSeasonId }, `AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` + `Enter Elo via Admin → Elo Ratings or rename the participant to match a TEAMS_DATA key.` ); } const gamesPlayed = standing?.gamesPlayed ?? 0; return { id: r.id, name: r.name, elo: resolvedElo, currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, seasonGames - gamesPlayed), winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO, parityFactor), }; }); const teamsById = new Map(teams.map((t) => [t.id, t])); // 4. Load the real bracket (draw + results so far), if one has been generated. // Events are filtered on bracketTemplateId rather than eventType and taken most // recent first, matching getBracketTemplateIdsForSportsSeasons: a season can own // several events, and landing on a stale or template-less row would silently // discard the real draw and every recorded result. createdAt can tie when a bracket // is generated alongside a sibling event, so id breaks the tie. const playoffEvents = await db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.bracketTemplateId, AFL_TEMPLATE_ID) ), columns: { id: true }, orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)], }); const bracketEvent = playoffEvents[0]; const bracketMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), }) : []; const bracket = readAflBracketSeeds(bracketMatches, teamsById); const play = makePlayGame(bracket, parityFactor); // ─── Helpers (defined once, outside the hot loop) ───────────────────────── /** * Project end-of-season ladder and return the top 10 finalists seeded 1–10. * * 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); }; // 5. Integer placement count maps — initialized to 0 for all participants. // // AFL scoring uses the AFL_10 bracket template which splits 5–8 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(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const pfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); const efLoserCounts = new Map(participantIds.map((id) => [id, 0])); // 6. Monte Carlo simulation loop. for (let s = 0; s < numSimulations; s++) { // With a real bracket the draw is fixed and its played games are replayed from their // recorded result; without one the ladder is re-projected every iteration. const finalists = bracket ? bracket.seeds : buildFinalsList(); const { gfWinner, gfLoser, pfLosers, sfLosers, efLosers } = simAFLFinals(finalists, play); 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). } // 7. 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 = numSimulations; 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", }; }); // 8. Per-position normalization — belt-and-suspenders guard against floating-point // division residuals. Columns are already near-exactly 1.0 after step 7. const positionKeys: Array = [ "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; } }