- Add app/lib/logger.ts: dev passes through to console; prod routes errors to Sentry.captureException and warnings to Sentry.captureMessage, with extra context preserved. Uses captureMessage (not captureException) for string-only args to avoid fabricated stack traces. - Add server/logger.ts: dev passes through; prod silences log/info but keeps warn/error on stderr (Sentry not initialized in that process). - Replace all console.* calls across 44 app files and 4 server files. - Upgrade no-console from warn → error in oxlint; exempt logger files and scripts/** via overrides. - Add typescript/no-inferrable-types rule; fix violations in services and simulators. Exempt test files (intentional string widening for switch/if tests would break under literal type inference). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
|
|
/**
|
|
* 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<string, ProbabilityDistribution>;
|
|
|
|
/**
|
|
* 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<string, number>();
|
|
|
|
// 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<string, ProbabilityDistribution>();
|
|
|
|
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<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++) {
|
|
// 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<number, number> {
|
|
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),
|
|
};
|
|
}
|