2025-11-17 22:19:46 -08:00
|
|
|
/**
|
|
|
|
|
* 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]);
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
const runnerUp = finalists.find(t => t.participantId !== champion.participantId);
|
|
|
|
|
if (!runnerUp) throw new Error("Runner-up not found in finalists");
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
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: number = 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: number = 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),
|
|
|
|
|
};
|
|
|
|
|
}
|