/** * Little League World Series (LLWS) Bracket Simulator * * Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+). * * The tournament is two independent 10-team double-elimination brackets — United * States and International — each producing a side champion, then a World * Championship game and a Consolation game between the side runners-up. There is no * pool play. This mirrors the llws_20 bracket template so simulated placements line * up with the bracket admins actually score. * * Two modes: * 1. Pre-bracket mode: no llws_20 bracket exists yet (or it has no participants * seeded). Each side is shuffled into the 10 bracket slots every iteration, so * the draw is modelled as random. * 2. Bracket-aware mode: a seeded llws_20 bracket exists. Teams sit in their real * slots and completed match results are honored rather than re-simulated, so a * team that has already lost carries that loss into every iteration. * * Algorithm: * 1. Load all 20 participants for the sports season from DB * 2. Load the llws_20 playoff bracket, if one exists, to get the real draw and * whatever results have been recorded so far * 3. Load championship futures odds from participantExpectedValues.sourceOdds * (entered via Admin → Futures Odds; American format) * 4. Convert those futures to Elo via the shared probability engine, then drive * each game with the Elo win probability (see "Why Elo" below) * 5. Per simulation: * a. Place each side's 10 teams into the bracket slots (real draw when known, * otherwise shuffled) * b. Simulate the 10-team double-elimination bracket for each side, replaying * completed games from their recorded result (see simulateSideBracket) * c. Consolation game: US side loser vs Intl side loser → 3rd / 4th * d. World Championship: US champion vs Intl champion → 1st / 2nd * 6. Track placement counts across all simulations * 7. Convert counts to probability distributions * * Why Elo rather than raw futures: * A championship future already bakes in the ~6 wins needed to lift the trophy, so * using it directly as a single-game strength (p1 / (p1 + p2)) makes every * individual game as lopsided as the whole tournament and compounds the favorite's * edge over and over. buildLLWSElos undoes that compression first (the empirically * calibrated cube-root step in decompressProbability) before mapping to an Elo * scale, and LLWS_PARITY_FACTOR then widens the Elo curve to reflect how much * single-game variance there is in six-inning Little League baseball. Unlike the * shared convertFuturesToElo helper, the mapping preserves how spread out the board * actually is — see buildLLWSElos for why that matters. * * Side assignment (externalId): "US" or "Intl". The legacy pool suffixes * ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side * alone, so seasons configured for the old pool-play format keep working — pools * no longer exist, so the suffix has no effect. When a seeded bracket exists the * bracket's own slots decide the sides and externalId is not consulted. * * Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring): * probFirst = World Championship winner (1 per sim) * probSecond = World Championship loser (1 per sim) * probThird = Consolation winner (1 per sim) * probFourth = Consolation loser (1 per sim) * probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side) * probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side) * Everyone else → all 0 (12 teams out in Elimination Rounds 1–3) * * Admin setup: * 1. Create a Sport with simulatorType = "llws_bracket" * 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International) * 3. Set externalId on each participant via Admin → Manage Participants to "US" or * "Intl" (optional — names starting with "US " infer US, all others infer Intl). * Once the bracket is generated and seeded this is no longer used. * 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds) * 5. Run simulation via Admin → Simulate */ import { database } from "~/database/context"; import { and, eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { convertAmericanOddsToProbability, decompressProbability, eloWinProbabilityWithParity, } from "~/services/probability-engine"; import { llwsMatchNumber } from "~/lib/bracket-templates"; import type { Simulator, SimulationResult } from "./types"; import { positiveConfigNumber } from "./config-access"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; const US_TEAM_COUNT = 10; const INTL_TEAM_COUNT = 10; const DEFAULT_ELO = 1500; const LLWS_TEMPLATE_ID = "llws_20"; /** * Elo scaling for a single LLWS game. * * Higher than the 400-point standard because a six-inning Little League game between * 12-year-olds is far closer to a coin flip than a pro game: one pitcher, one big * inning, and the mercy rule all compress the gap. * * Calibrated by sweeping this value until a randomized-draw simulation reproduces the * championship futures it was fed, across boards of different shape (see * LLWS_ELO_SPREAD for why the shape matters). Total RMSE over a wide board, a * top-heavy board, and a nearly flat one: * parity 450 → 0.028 * parity 550 → 0.016 ← chosen * parity 750 → 0.040 * parity 1000 → 0.061 * Overridable per season via the `parityFactor` simulator config. */ const LLWS_PARITY_FACTOR = 550; /** * Elo points per natural-log unit of relative team strength. * * Only the ratio LLWS_ELO_SPREAD / parityFactor affects the simulation, so this fixes * the readable scale of the ratings and LLWS_PARITY_FACTOR does the calibrating. 300 * puts a typical 20-team board in the familiar ~1350–1700 range. */ const LLWS_ELO_SPREAD = 300; /** * Power transform undoing the compounding baked into a championship future. * Matches DEFAULT_CALIBRATION.exponent in the probability engine. */ const LLWS_DECOMPRESSION_EXPONENT = 0.33; // ─── Types ──────────────────────────────────────────────────────────────────── type Side = "US" | "Intl"; /** Bracket-template side index: U.S. matches take the low match numbers. */ const SIDE_INDEX: Record = { US: 0, Intl: 1 }; /** The playoff_matches columns the simulator actually reads. */ export type BracketMatch = Pick< typeof schema.playoffMatches.$inferSelect, "round" | "matchNumber" | "participant1Id" | "participant2Id" | "winnerId" | "loserId" | "isComplete" >; interface Team { participantId: string; side: Side; /** Single-game strength on an Elo scale, decompressed from championship futures. */ elo: number; } interface PlacementCounts { champion: number; finalist: number; thirdPlace: number; fourthPlace: number; /** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */ elimFinalLoser: number; /** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */ elimRound4Loser: number; } /** * Plays one bracket game. `round`/`localMatch` identify the game within its side so a * completed result can be looked up; `t1`/`t2` are the teams the simulation has * routed into it. */ type PlayGame = ( round: string, localMatch: number, t1: Team, t2: Team ) => { winner: Team; loser: Team }; interface LoadedBracket { /** Each side's 10 teams in bracket slot order (8 opening-round, then 2 byes). */ slots: Record; /** All bracket matches, keyed by `${round}#${globalMatchNumber}`. */ matches: Map; } // ─── Helpers ───────────────────────────────────────────────────────────────── function zeroCounts(): PlacementCounts { return { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, elimFinalLoser: 0, elimRound4Loser: 0, }; } function matchKey(round: string, matchNumber: number): string { return `${round}#${matchNumber}`; } function simGame(t1: Team, t2: Team, parityFactor: number): { winner: Team; loser: Team } { const p1Win = eloWinProbabilityWithParity(t1.elo, t2.elo, parityFactor); return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } /** * Fisher-Yates shuffle (in-place). */ function shuffle(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * 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 one side. * * 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 loss stick across all iterations. 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( sideIndex: 0 | 1, bracket: LoadedBracket | null, parityFactor: number ): PlayGame { if (!bracket) { return (_round, _localMatch, t1, t2) => simGame(t1, t2, parityFactor); } return (round, localMatch, t1, t2) => { const match = bracket.matches.get( matchKey(round, llwsMatchNumber(round, sideIndex, localMatch)) ); if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); const arrived = [t1.participantId, t2.participantId]; if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { return match.winnerId === t1.participantId ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } } return simGame(t1, t2, parityFactor); }; } /** * Play one of the two cross-side games (Consolation, World Championship). Both are a * single shared match numbered 1, so they don't go through the side-local mapping. */ export function playCrossoverGame( round: string, bracket: LoadedBracket | null, parityFactor: number, t1: Team, t2: Team ): { winner: Team; loser: Team } { const match = bracket?.matches.get(matchKey(round, 1)); if (match?.isComplete && match.winnerId) { const loserId = completedLoser(match); const arrived = [t1.participantId, t2.participantId]; if (loserId && arrived.includes(match.winnerId) && arrived.includes(loserId)) { return match.winnerId === t1.participantId ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 }; } } return simGame(t1, t2, parityFactor); } /** * Simulate one side's 10-team double-elimination bracket. * * `slots` holds the side's teams in bracket order, matching the llws_20 participant * layout: slots[0..7] are the four opening-round games (two teams each) and * slots[8], slots[9] are the two bye teams entering Winners Round 2. * * Structure (side-local, mirroring LLWS_ADVANCEMENT in lib/llws-bracket): * Winners bracket * OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7 * WR2-1 s8 v OP1w WR2-2 s9 v OP2w * WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w * WF WSF1w v WSF2w → winner to the side championship * Elimination bracket (a loss here is final) * ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l * ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w * ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over) * ER4 ER3-2w v ER3-1w → loser is the 7th–8th tier * EF WFl v ER4w → loser is the 5th–6th tier * Side championship: WFw v EFw → loser drops to the consolation game * * Note the double-chance path: the Winners Final loser is NOT out, it drops to the * Elimination Final. There is no "if necessary" game, so the side championship is * decided in one game. * * The team order passed to `play` matches each match's participant1 / participant2 * slots in the generated bracket, so recorded results line up game for game. * * Returns { sideChampion, sideLoser }; the two scoring elimination losers are * bumped into the counts directly. */ function simulateSideBracket( slots: Team[], bump: (id: string, key: keyof PlacementCounts) => void, play: PlayGame ): { sideChampion: Team; sideLoser: Team } { // ── Winners bracket ──────────────────────────────────────────────────────── const op1 = play("Opening Round", 1, slots[0], slots[1]); const op2 = play("Opening Round", 2, slots[2], slots[3]); const op3 = play("Opening Round", 3, slots[4], slots[5]); const op4 = play("Opening Round", 4, slots[6], slots[7]); const wr21 = play("Winners Round 2", 1, slots[8], op1.winner); const wr22 = play("Winners Round 2", 2, slots[9], op2.winner); const wsf1 = play("Winners Semifinals", 1, op3.winner, wr21.winner); const wsf2 = play("Winners Semifinals", 2, wr22.winner, op4.winner); const wf = play("Winners Final", 1, wsf1.winner, wsf2.winner); // ── Elimination bracket ──────────────────────────────────────────────────── const er11 = play("Elimination Round 1", 1, op2.loser, op3.loser); const er12 = play("Elimination Round 1", 2, op1.loser, op4.loser); const er21 = play("Elimination Round 2", 1, wr21.loser, er11.winner); const er22 = play("Elimination Round 2", 2, wr22.loser, er12.winner); // Cross-over: each semifinal loser meets the winner from the opposite half. const er31 = play("Elimination Round 3", 1, wsf1.loser, er22.winner); const er32 = play("Elimination Round 3", 2, wsf2.loser, er21.winner); const er4 = play("Elimination Round 4", 1, er32.winner, er31.winner); bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier // The Winners Final loser gets its second chance here. const ef = play("Elimination Final", 1, wf.loser, er4.winner); bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier // ── Side championship ────────────────────────────────────────────────────── const sideChampionship = play("Bracket Championship", 1, wf.winner, ef.winner); return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser }; } // ─── Validation helpers ─────────────────────────────────────────────────────── /** * Parse a participant's externalId into a side. * * The legacy pool-play suffixes ("US:A", "Intl:B", …) are still accepted so seasons * configured before the format change keep loading; the pool part is ignored because * the tournament no longer has pools. */ function parseExternalId(raw: string | null): { side: Side } | null { if (!raw) return null; const side = raw.toUpperCase().split(":")[0]; if (side === "US") return { side: "US" }; if (side === "INTL") return { side: "Intl" }; return null; } /** * Infer an externalId from a participant name when none is stored. * Teams whose name is exactly "US" or starts with "US " (case-insensitive) * are assigned to the US side; all others are assigned to Intl. */ function inferExternalIdFromName(name: string): string { const upper = name.trim().toUpperCase(); return upper === "US" || upper.startsWith("US ") ? "US" : "Intl"; } // ─── Elo construction ───────────────────────────────────────────────────────── /** * Map participants to single-game Elo ratings from their championship futures. * * Deliberately NOT convertFuturesToElo. That helper finishes by rescaling the field * onto a fixed 1250–1750 span (mapToElo), which throws away how spread out the board * actually is: a board whose favorite is priced at 22% and one whose favorite is * priced at 6% both come out 500 Elo wide, so the tight board's field gets pulled * apart into contenders and no-hopers that the market never implied. On such a board * that inflated the favorite from 6% to 13%. * * Instead the decompressed strengths are mapped by their log-ratio to the field's * geometric mean, which preserves dispersion: a tight board yields a narrow Elo span * and a top-heavy one a wide span, both centred on DEFAULT_ELO. * * Returns the ratings alongside the rating to use for a team with no odds entered — * the median of the priced field, so leaving odds blank neither promotes nor buries a * team. (DEFAULT_ELO is the centre of the scale, but futures fields are skewed, so on * a typical board it would rank a team around 6th of 20.) */ export function buildLLWSElos( evRows: Array<{ participantId: string; sourceOdds: number | null }> ): { elos: Map; unpricedElo: number } { const priced = evRows.filter((row) => row.sourceOdds !== null); // A single priced team carries no information about the rest of the field, so // there is nothing to normalise against — treat the season as unpriced. if (priced.length < 2) return { elos: new Map(), unpricedElo: DEFAULT_ELO }; const rawProbs = priced.map((row) => convertAmericanOddsToProbability(row.sourceOdds ?? 0)); const rawSum = rawProbs.reduce((a, b) => a + b, 0); if (rawSum <= 0) return { elos: new Map(), unpricedElo: DEFAULT_ELO }; // Vig-removed championship probability → single-game strength. const logStrengths = rawProbs.map((prob) => Math.log( Math.max(decompressProbability(prob / rawSum, LLWS_DECOMPRESSION_EXPONENT), Number.MIN_VALUE) ) ); const meanLog = logStrengths.reduce((a, b) => a + b, 0) / logStrengths.length; const elos = new Map( priced.map((row, i) => [ row.participantId, DEFAULT_ELO + LLWS_ELO_SPREAD * (logStrengths[i] - meanLog), ]) ); return { elos, unpricedElo: median([...elos.values()]) }; } function median(values: number[]): number { if (values.length === 0) return DEFAULT_ELO; const sorted = values.toSorted((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } // ─── Bracket loading ────────────────────────────────────────────────────────── /** * Read the seeded llws_20 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 a randomized draw. * * A *partially* seeded bracket is an error rather than a fallback. Silently falling * back there would throw away the real draw and every recorded result along 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 a single participant mid-tournament empties a slot. * Likewise, a bracket seeded with unknown or duplicated participants fails loudly. */ export function readBracketSlots( 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])); // Collect both sides' draws before deciding, so "nothing seeded" is judged over the // whole bracket rather than one side at a time. const draw: Record = { US: [], Intl: [] }; for (const side of ["US", "Intl"] as const) { const sideIndex = SIDE_INDEX[side]; for (let local = 1; local <= 4; local++) { const match = byKey.get( matchKey("Opening Round", llwsMatchNumber("Opening Round", sideIndex, local)) ); draw[side].push(match?.participant1Id ?? null, match?.participant2Id ?? null); } for (let local = 1; local <= 2; local++) { const match = byKey.get( matchKey("Winners Round 2", llwsMatchNumber("Winners Round 2", sideIndex, local)) ); draw[side].push(match?.participant1Id ?? null); } } const allSlots = [...draw.US, ...draw.Intl]; const seededCount = allSlots.filter((id) => id !== null).length; // Generated but not yet filled in — no draw to honor. if (seededCount === 0) return null; if (seededCount < allSlots.length) { throw new Error( `LLWS bracket is only partially seeded (${seededCount} of ${allSlots.length} slots ` + `filled). Re-seed the bracket in Admin → Bracket before simulating; simulating ` + `around the gap would discard the draw and every recorded result.` ); } const slots: Record = { US: [], Intl: [] }; const seen = new Set(); for (const side of ["US", "Intl"] as const) { for (const id of draw[side]) { const participantId = id as string; if (seen.has(participantId)) { throw new Error( `LLWS bracket seeds participant ${participantId} into more than one slot.` ); } seen.add(participantId); const team = teamsById.get(participantId); if (!team) { throw new Error( `LLWS bracket references participant ${participantId}, which is not in this sports season.` ); } // The bracket is authoritative about which side a team is on. slots[side].push({ ...team, side }); } } return { slots, matches: byKey }; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class LLWSSimulator implements Simulator { constructor(private numSimulations = NUM_SIMULATIONS) {} async simulate(sportsSeasonId: string, config: Record = {}): Promise { const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations)); const parityFactor = positiveConfigNumber(config, "parityFactor", LLWS_PARITY_FACTOR); const db = database(); // 1. Load all participants. const participants = await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name, externalId: schema.seasonParticipants.externalId }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); if (participants.length !== US_TEAM_COUNT + INTL_TEAM_COUNT) { throw new Error( `LLWS simulator requires exactly ${US_TEAM_COUNT + INTL_TEAM_COUNT} participants, ` + `found ${participants.length}.` ); } // 2. Load championship futures odds. const evRows = await db .select({ participantId: schema.seasonParticipantExpectedValues.participantId, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); // 3. Decompress the futures into single-game Elo ratings. const { elos, unpricedElo } = buildLLWSElos(evRows); // 4. Parse externalId for each participant to determine which side they're on. // A seeded bracket overrides this below, but the field still has to be a legal // 10/10 split before we know whether a bracket exists. const teams: Team[] = []; const unparseableSides: Array<{ id: string; externalId: string | null }> = []; for (const p of participants) { const raw = p.externalId ?? inferExternalIdFromName(p.name); const parsed = parseExternalId(raw); if (!parsed) unparseableSides.push({ id: p.id, externalId: p.externalId }); teams.push({ // Provisional: a seeded bracket overwrites this below. participantId: p.id, side: parsed?.side ?? "Intl", elo: elos.get(p.id) ?? unpricedElo, }); } const teamsById = new Map(teams.map((t) => [t.participantId, t])); // 5. Load the real bracket (draw + results so far), if one has been generated. // If several llws_20 playoff events exist, take the most recent so a re-created // event wins over a stale one — landing on the stale row would silently discard // the real draw and every recorded result. const playoffEvents = await db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game"), eq(schema.scoringEvents.bracketTemplateId, LLWS_TEMPLATE_ID) ), }); const bracketEvent = playoffEvents.toSorted( (a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) )[0]; const bracketMatches = bracketEvent ? await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), }) : []; const bracket = readBracketSlots(bracketMatches, teamsById); // Validate sides. A seeded bracket already fixes the draw and an even 10/10 split, // so externalId only has to be usable on the randomized pre-bracket path. if (!bracket) { const [firstBad] = unparseableSides; if (firstBad) { throw new Error( `Participant ${firstBad.id} has invalid externalId "${firstBad.externalId}". ` + `Expected: "US" or "Intl".` ); } const usTeams = teams.filter((t) => t.side === "US"); const intlTeams = teams.filter((t) => t.side === "Intl"); if (usTeams.length !== US_TEAM_COUNT) { throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`); } if (intlTeams.length !== INTL_TEAM_COUNT) { throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`); } } const usPool = bracket ? bracket.slots.US : teams.filter((t) => t.side === "US"); const intlPool = bracket ? bracket.slots.Intl : teams.filter((t) => t.side === "Intl"); const playUS = makePlayGame(SIDE_INDEX.US, bracket, parityFactor); const playIntl = makePlayGame(SIDE_INDEX.Intl, bracket, parityFactor); // 6. Initialise placement count accumulators for all participants. const allIds = participants.map((p) => p.id); const counts = new Map(allIds.map((id) => [id, zeroCounts()])); const bump = (id: string, key: keyof PlacementCounts) => { const entry = counts.get(id); if (entry) entry[key]++; }; // 7. Run Monte Carlo simulations. for (let s = 0; s < numSimulations; s++) { // With a real bracket the draw is fixed; without one it is modelled as random. const usSlots = bracket ? usPool : shuffle([...usPool]); const intlSlots = bracket ? intlPool : shuffle([...intlPool]); const { sideChampion: usChamp, sideLoser: usLose } = simulateSideBracket(usSlots, bump, playUS); const { sideChampion: intlChamp, sideLoser: intlLose } = simulateSideBracket(intlSlots, bump, playIntl); // Consolation game: 3rd / 4th place. const consolation = playCrossoverGame( "Consolation Third Place", bracket, parityFactor, usLose, intlLose ); bump(consolation.winner.participantId, "thirdPlace"); bump(consolation.loser.participantId, "fourthPlace"); // World Championship: 1st / 2nd place. const ws = playCrossoverGame( "World Championship", bracket, parityFactor, usChamp, intlChamp ); bump(ws.winner.participantId, "champion"); bump(ws.loser.participantId, "finalist"); } // 8. Convert counts to probability distributions. // Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and // the teams within a tier are tied, so the tier probability is split across // its two positions. const tierDivisor = 2 * numSimulations; const empty = zeroCounts(); return allIds.map((id) => { const c = counts.get(id) ?? empty; const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th return { participantId: id, probabilities: { probFirst: c.champion / numSimulations, probSecond: c.finalist / numSimulations, probThird: c.thirdPlace / numSimulations, probFourth: c.fourthPlace / numSimulations, probFifth: upperTier, probSixth: upperTier, probSeventh: lowerTier, probEighth: lowerTier, }, source: "llws_monte_carlo", }; }); } }