/** * Bracket Simulator * * Monte Carlo simulation of single-elimination playoff brackets. * Supports NHL (8 teams) and NFL (14 teams) formats. * * The simulator: * 1. Takes teams with Elo ratings * 2. Simulates the bracket many times (typically 100,000) * 3. Tracks placement frequencies (1st, 2nd, 3-4, 5-8, etc.) * 4. Returns probability distribution for each team */ import { eloWinProbability } from './probability-engine'; /** * Team with Elo rating for simulation */ export interface TeamForSimulation { participantId: string; elo: number; } /** * Bracket format configurations */ export type BracketFormat = 'nhl-8' | 'nfl-14'; /** * Result of a single simulation * Maps participantId to their placement (1 = champion, 2 = runner-up, etc.) */ export type SimulationResult = Map; /** * Aggregated results across all simulations * For each participant, tracks how many times they finished in each placement */ export interface PlacementCounts { [participantId: string]: { 1: number; // Champion 2: number; // Runner-up 3: number; // Semifinal loser (tied 3-4) 4: number; // Semifinal loser (tied 3-4) 5: number; // Quarterfinal loser (tied 5-8) 6: number; // Quarterfinal loser (tied 5-8) 7: number; // Quarterfinal loser (tied 5-8) 8: number; // Quarterfinal loser (tied 5-8) }; } /** * Probability distribution for a single participant * Array of 8 probabilities [P(1st), P(2nd), P(3rd), P(4th), P(5th), P(6th), P(7th), P(8th)] */ export type ProbabilityDistribution = [number, number, number, number, number, number, number, number]; /** * Final simulation results * Maps participantId to their probability distribution */ export type SimulationResults = Map; /** * Simulate a single head-to-head matchup * * @param team1 First team * @param team2 Second team * @returns Winner of the matchup */ function simulateMatchup(team1: TeamForSimulation, team2: TeamForSimulation): TeamForSimulation { const team1WinProb = eloWinProbability(team1.elo, team2.elo); const random = Math.random(); return random < team1WinProb ? team1 : team2; } /** * Simulate a single round of the bracket * * @param teams Teams in this round (must be even number) * @returns Winners advancing to next round */ function simulateRound(teams: TeamForSimulation[]): TeamForSimulation[] { if (teams.length % 2 !== 0) { throw new Error('Number of teams must be even for bracket round'); } const winners: TeamForSimulation[] = []; // Simulate each matchup for (let i = 0; i < teams.length; i += 2) { const winner = simulateMatchup(teams[i], teams[i + 1]); winners.push(winner); } return winners; } /** * Simulate an entire 8-team bracket * * Placement determination: * - 1st: Finals winner * - 2nd: Finals loser * - 3-4: Semifinal losers (tied) * - 5-8: Quarterfinal losers (tied) * * @param teams 8 teams in bracket order * @returns Map of participantId to placement */ function simulate8TeamBracket(teams: TeamForSimulation[]): SimulationResult { if (teams.length !== 8) { throw new Error('8-team bracket requires exactly 8 teams'); } const placements = new Map(); // Quarterfinals: 8 → 4 const semifinalists = simulateRound(teams); const quarterfinalsLosers = teams.filter(t => !semifinalists.some(s => s.participantId === t.participantId)); // Quarterfinal losers tie for 5-8 quarterfinalsLosers.forEach(team => placements.set(team.participantId, 5)); // Semifinals: 4 → 2 const finalists = simulateRound(semifinalists); const semifinalsLosers = semifinalists.filter(t => !finalists.some(f => f.participantId === t.participantId)); // Semifinal losers tie for 3-4 semifinalsLosers.forEach(team => placements.set(team.participantId, 3)); // Finals: 2 → 1 const champion = simulateMatchup(finalists[0], finalists[1]); const runnerUp = finalists.find(t => t.participantId !== champion.participantId); if (!runnerUp) throw new Error("Runner-up not found in finalists"); placements.set(champion.participantId, 1); placements.set(runnerUp.participantId, 2); return placements; } /** * Initialize placement counts for participants * * @param participantIds List of participant IDs * @returns Placement counts initialized to 0 */ function initializePlacementCounts(participantIds: string[]): PlacementCounts { const counts: PlacementCounts = {}; participantIds.forEach(id => { counts[id] = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 }; }); return counts; } /** * Convert placement counts to probability distributions * * @param counts Placement counts from simulations * @param totalSimulations Total number of simulations run * @returns Probability distribution for each participant */ function countsToDistributions( counts: PlacementCounts, totalSimulations: number ): SimulationResults { const distributions = new Map(); Object.entries(counts).forEach(([participantId, placementCounts]) => { const distribution: ProbabilityDistribution = [ placementCounts[1] / totalSimulations, placementCounts[2] / totalSimulations, placementCounts[3] / totalSimulations, placementCounts[4] / totalSimulations, placementCounts[5] / totalSimulations, placementCounts[6] / totalSimulations, placementCounts[7] / totalSimulations, placementCounts[8] / totalSimulations, ]; distributions.set(participantId, distribution); }); return distributions; } /** * Run Monte Carlo simulation of playoff bracket * * @param teams Teams with Elo ratings * @param format Bracket format ('nhl-8' or 'nfl-14') * @param simulations Number of simulations to run (default: 100,000) * @param onProgress Optional progress callback (called every 10,000 simulations) * @returns Probability distribution for each team * * @example * const teams = [ * { participantId: '1', elo: 1650 }, * { participantId: '2', elo: 1600 }, * // ... 6 more teams * ]; * const results = await simulateBracket(teams, 'nhl-8', 100000); * // Map { '1' => [0.25, 0.18, 0.15, ...], '2' => [0.18, 0.20, ...], ... } */ export async function simulateBracket( teams: TeamForSimulation[], format: BracketFormat = 'nhl-8', simulations = 100000, onProgress?: (current: number, total: number) => void ): Promise { // Validate input if (format === 'nhl-8' && teams.length !== 8) { throw new Error('NHL format requires exactly 8 teams'); } if (simulations <= 0) { throw new Error('Number of simulations must be positive'); } // Initialize placement counters const participantIds = teams.map(t => t.participantId); const placementCounts = initializePlacementCounts(participantIds); // Run simulations for (let i = 0; i < simulations; i++) { // Simulate bracket (currently only 8-team supported) const result = simulate8TeamBracket(teams); // Record placements result.forEach((placement, participantId) => { // Handle ties: placement 3 or 4 both count as tied-3rd // placement 5-8 all count as tied-5th if (placement >= 3 && placement <= 4) { placementCounts[participantId][3] += 0.5; // Split tied placements placementCounts[participantId][4] += 0.5; } else if (placement >= 5 && placement <= 8) { // Split across all 5-8 placements placementCounts[participantId][5] += 0.25; placementCounts[participantId][6] += 0.25; placementCounts[participantId][7] += 0.25; placementCounts[participantId][8] += 0.25; } else { // 1st or 2nd place - no ties placementCounts[participantId][placement as 1 | 2] += 1; } }); // Report progress every 10,000 simulations if (onProgress && (i + 1) % 10000 === 0) { onProgress(i + 1, simulations); } } // Convert counts to probabilities const results = countsToDistributions(placementCounts, simulations); // Final progress callback if (onProgress) { onProgress(simulations, simulations); } return results; } /** * Simulate bracket synchronously (blocking) * * Use this for smaller simulation counts or when you don't need progress updates. * For large simulations (100k+), prefer the async version with progress callbacks. * * @param teams Teams with Elo ratings * @param format Bracket format * @param simulations Number of simulations to run * @returns Probability distribution for each team */ export function simulateBracketSync( teams: TeamForSimulation[], format: BracketFormat = 'nhl-8', simulations = 100000 ): SimulationResults { // Validate input if (format === 'nhl-8' && teams.length !== 8) { throw new Error('NHL format requires exactly 8 teams'); } if (simulations <= 0) { throw new Error('Number of simulations must be positive'); } // Initialize placement counters const participantIds = teams.map(t => t.participantId); const placementCounts = initializePlacementCounts(participantIds); // Run simulations for (let i = 0; i < simulations; i++) { const result = simulate8TeamBracket(teams); // Record placements result.forEach((placement, participantId) => { if (placement >= 3 && placement <= 4) { placementCounts[participantId][3] += 0.5; placementCounts[participantId][4] += 0.5; } else if (placement >= 5 && placement <= 8) { placementCounts[participantId][5] += 0.25; placementCounts[participantId][6] += 0.25; placementCounts[participantId][7] += 0.25; placementCounts[participantId][8] += 0.25; } else { placementCounts[participantId][placement as 1 | 2] += 1; } }); } // Convert counts to probabilities return countsToDistributions(placementCounts, simulations); } /** * Get expected placement counts from probability distribution * * Useful for debugging/validation * * @param distribution Probability distribution * @param simulations Number of simulations that produced this distribution * @returns Expected count for each placement */ export function getExpectedCounts( distribution: ProbabilityDistribution, simulations: number ): Record { return { 1: Math.round(distribution[0] * simulations), 2: Math.round(distribution[1] * simulations), 3: Math.round(distribution[2] * simulations), 4: Math.round(distribution[3] * simulations), 5: Math.round(distribution[4] * simulations), 6: Math.round(distribution[5] * simulations), 7: Math.round(distribution[6] * simulations), 8: Math.round(distribution[7] * simulations), }; }