Add snooker bracket simulator and Elo ratings admin UI, fixes #119

- SnookerSimulator: Monte Carlo simulation of the 32-player World
  Championship bracket using per-frame Bernoulli win probabilities
  derived from Elo ratings (ELO_DIVISOR=700). Two paths: bracket-
  populated (respects completed matches) and pre-bracket (simulates
  qualifying, then seeds full draw).
- Admin route /sports-seasons/:id/elo-ratings: bulk-import and per-
  player Elo entry with fuzzy name matching; saves ratings and auto-
  runs the simulation in one action.
- schema: add snooker_bracket simulator type, source_elo column on
  participant_expected_values, unique index on (participant_id,
  sports_season_id).
- Fix batchSaveSourceElos to use INSERT ... ON CONFLICT DO UPDATE
  instead of N+1 SELECT+UPDATE loop.
- Fix existingElos returned as plain Record (Map doesn't survive JSON
  serialization through useLoaderData).
- Fix "How It Works" formula to show correct divisor (700, not 400).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-23 08:24:28 -07:00
parent e4285876ad
commit 1bdd5c3372
15 changed files with 9451 additions and 5 deletions

View file

@ -2,11 +2,11 @@
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -I{} sh -c 'case \"{}\" in *.ts|*.tsx) ./node_modules/.bin/oxlint \"{}\" 2>&1 ;; esac'"
"command": "python3 -c \"import sys,json; d=json.load(sys.stdin); f=d.get('tool_input',{}).get('file_path',''); print(f)\" | xargs -I{} sh -c 'case \"{}\" in *.ts|*.tsx) npm run lint:file -- \"{}\" 2>&1 ;; esac'"
}
]
}

View file

@ -7,7 +7,7 @@
import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema";
import { eq, and, count } from "drizzle-orm";
import { eq, and, count, sql } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
@ -28,6 +28,7 @@ export interface ParticipantEV {
expectedValue: string;
source: ProbabilitySource | null;
sourceOdds: number | null;
sourceElo?: number | null;
calculatedAt: Date;
updatedAt: Date;
}
@ -360,6 +361,48 @@ export async function batchSaveSourceOdds(
});
}
/**
* Persist raw Elo ratings for a batch of participants (used by Elo-based simulators like snooker_bracket).
* Creates stub records if none exist; does not touch probabilities or EV.
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
*/
export async function batchSaveSourceElos(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number }>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
await db
.insert(participantExpectedValues)
.values(
inputs.map(({ participantId, sportsSeasonId, sourceElo }) => ({
participantId,
sportsSeasonId,
probFirst: "0",
probSecond: "0",
probThird: "0",
probFourth: "0",
probFifth: "0",
probSixth: "0",
probSeventh: "0",
probEighth: "0",
expectedValue: "0",
source: "elo_simulation" as const,
sourceElo,
calculatedAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [participantExpectedValues.participantId, participantExpectedValues.sportsSeasonId],
set: {
sourceElo: sql`excluded.source_elo`,
updatedAt: sql`excluded.updated_at`,
},
});
}
/**
* Convert database record to ProbabilityDistribution
*/

View file

@ -81,6 +81,10 @@ export default [
"sports-seasons/:id/futures-odds",
"routes/admin.sports-seasons.$id.futures-odds.tsx"
),
route(
"sports-seasons/:id/elo-ratings",
"routes/admin.sports-seasons.$id.elo-ratings.tsx"
),
route(
"sports-seasons/:id/standings",
"routes/admin.sports-seasons.$id.standings.tsx"

View file

@ -0,0 +1,422 @@
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.elo-ratings';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId } from '~/models/participant';
import {
getAllParticipantEVsForSeason,
batchSaveSourceElos,
batchUpsertParticipantEVs,
} from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
import { eq } from 'drizzle-orm';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
const existingElos: Record<string, number> = {};
for (const ev of existingEVs) {
if (ev.sourceElo != null) {
existingElos[ev.participantId] = ev.sourceElo;
}
}
return { sportsSeason, participants, existingElos };
}
interface ActionData {
success?: boolean;
message?: string;
}
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse Elo ratings from form
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number }> = [];
for (const participant of participants) {
const val = formData.get(`elo_${participant.id}`) as string;
if (val && val.trim() !== '') {
const elo = Number(val);
if (!isNaN(elo) && elo > 0) {
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
}
}
}
if (eloInputs.length === 0) {
return { success: false, message: 'Please enter an Elo rating for at least one participant' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
// Persist Elo ratings
await batchSaveSourceElos(eloInputs);
// Auto-run simulation
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
try {
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) {
throw new Error('Simulation returned no results.');
}
// Zero out any participants not in simulation results
const allParticipants = participants;
const simulatedIds = new Set(results.map(r => r.participantId));
const ZERO_PROBS = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
const evInputs = [
...results.map(r => ({
participantId: r.participantId,
sportsSeasonId,
probabilities: r.probabilities,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
...allParticipants
.filter(p => !simulatedIds.has(p.id))
.map(p => ({
participantId: p.id,
sportsSeasonId,
probabilities: ZERO_PROBS,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
];
await batchUpsertParticipantEVs(evInputs);
// Refresh projected points in team standings
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
// EV snapshot
const today = new Date().toISOString().slice(0, 10);
await batchUpsertParticipantEvSnapshots(
results.map(r => ({
participantId: r.participantId,
sportsSeasonId,
snapshotDate: today,
probFirst: r.probabilities.probFirst,
probSecond: r.probabilities.probSecond,
probThird: r.probabilities.probThird,
probFourth: r.probabilities.probFourth,
probFifth: r.probabilities.probFifth,
probSixth: r.probabilities.probSixth,
probSeventh: r.probabilities.probSeventh,
probEighth: r.probabilities.probEighth,
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
source: r.source,
}))
);
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
} catch (error) {
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
logger.error('Error running snooker simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
export default function AdminSportsSeasonEloRatings() {
const { sportsSeason, participants, existingElos } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
participants.forEach(p => {
const existing = existingElos[p.id];
if (existing !== undefined && existing !== null) {
initial[p.id] = existing.toString();
}
});
return initial;
});
// Bulk import state: "Player Name, 2450" format one per line
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number }>;
} | null>(null);
function findParticipantMatch(inputName: string) {
const normalizedInput = normalizeName(inputName);
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter(w => w.length > 2);
const shared = inputWords.filter(w => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function parseBulkText() {
// Format: "Player Name, 2450" or "Player Name: 2450" or "Player Name 2450"
const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number }> = [];
const seen = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Match "Name, 2450" or "Name: 2450" or "Name 2450" (Elo is a 4-digit integer)
const match = /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const elo = parseInt(match[2], 10);
if (isNaN(elo) || elo < 500 || elo > 5000) continue;
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo });
}
}
setParseResults({ matched, unmatched });
}
function applyMatches() {
if (!parseResults) return;
const newValues = { ...eloValues };
for (const m of parseResults.matched) {
newValues[m.participantId] = m.elo.toString();
}
setEloValues(newValues);
setParseResults(null);
setBulkText('');
}
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Elo Ratings</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste Elo ratings one per line. Format: <code>Player Name, 2450</code>. Player names
are fuzzy-matched to participants. Elo values should be in the 20002900 range for snooker.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Judd Trump, 2594\nRonnie O'Sullivan, 2441\nMark Selby, 2432`}
value={bulkText}
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Ratings
</Button>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map(m => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">{m.name} &rarr; {m.elo}</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length}) enter manually below
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
{parseResults.unmatched.map(u => (
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span>
<span className="font-medium">{u.elo}</span>
</div>
))}
</div>
</div>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched ratings to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Player Elo Ratings</CardTitle>
<CardDescription>
Enter each player's current Elo rating. Saving will automatically run the simulation
and update expected values. Snooker Elo typically ranges from ~2000 (qualifier level)
to ~2600 (world-class).
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-3">
{participants.map(participant => (
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
<Label htmlFor={`elo_${participant.id}`}>{participant.name}</Label>
<Input
type="number"
id={`elo_${participant.id}`}
name={`elo_${participant.id}`}
placeholder="2450"
value={eloValues[participant.id] ?? ''}
onChange={e =>
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
}
/>
</div>
))}
</div>
{actionData && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Saving & Running Simulation...' : 'Save Ratings & Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<ol className="list-decimal list-inside space-y-2">
<li>Save Elo ratings for all participants</li>
<li>Compute per-frame win probability: p = 1 / (1 + e^(ΔElo/700))</li>
<li>Compute match win probability using the Bernoulli frame model for each round's best-of length</li>
<li>Run 50,000 Monte Carlo simulations of the full bracket</li>
<li>Distribute probabilities across 1st8th place buckets</li>
<li>Calculate expected fantasy value per player</li>
</ol>
<div className="mt-4 text-muted-foreground text-xs space-y-1">
<div>Round frames: R32 best-of-19, R16/QF best-of-25, SF best-of-33, Final best-of-35</div>
<div>World rankings (for pre-bracket draws) are hardcoded in the simulator.</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -529,6 +529,14 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" />
Futures Odds
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
>
<Calculator className="mr-2 h-4 w-4" />
Elo Ratings
</Button>
{simulatorInfo && (
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
<Button

View file

@ -0,0 +1,466 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { frameWinProb, matchWinProb, SnookerSimulator } from "../snooker-simulator";
// ─── Pure math: frameWinProb ──────────────────────────────────────────────────
describe("frameWinProb", () => {
it("returns 0.5 for equal Elo", () => {
expect(frameWinProb(2400, 2400)).toBeCloseTo(0.5, 5);
expect(frameWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
});
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
expect(frameWinProb(2600, 2300)).toBeGreaterThan(0.5);
expect(frameWinProb(2300, 2600)).toBeLessThan(0.5);
});
it("larger Elo gap → higher win probability (monotonic)", () => {
const p300 = frameWinProb(2600, 2300);
const p400 = frameWinProb(2700, 2300);
const p200 = frameWinProb(2500, 2300);
expect(p400).toBeGreaterThan(p300);
expect(p300).toBeGreaterThan(p200);
});
it("is symmetric: frameWinProb(a, b) + frameWinProb(b, a) = 1", () => {
expect(frameWinProb(2600, 2300) + frameWinProb(2300, 2600)).toBeCloseTo(1.0, 5);
expect(frameWinProb(2837, 2756) + frameWinProb(2756, 2837)).toBeCloseTo(1.0, 5);
});
it("returns value in [0, 1]", () => {
expect(frameWinProb(3000, 1000)).toBeLessThan(1);
expect(frameWinProb(3000, 1000)).toBeGreaterThan(0);
expect(frameWinProb(1000, 3000)).toBeLessThan(1);
expect(frameWinProb(1000, 3000)).toBeGreaterThan(0);
});
it("is symmetric: p1 vs p2 win probs sum to 1", () => {
expect(frameWinProb(2500, 2200) + frameWinProb(2200, 2500)).toBeCloseTo(1.0, 5);
});
});
// ─── Pure math: matchWinProb ──────────────────────────────────────────────────
describe("matchWinProb", () => {
it("returns 0.5 for equal players (p=0.5) at any match length", () => {
// Best-of-19 (F=10), best-of-25 (F=13), best-of-33 (F=17), best-of-35 (F=18)
expect(matchWinProb(0.5, 10)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 13)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 17)).toBeCloseTo(0.5, 3);
expect(matchWinProb(0.5, 18)).toBeCloseTo(0.5, 3);
});
it("returns ~0.758 for p=0.68, best-of-3 (F=2) — matches reference example", () => {
// Site example: 68% per-frame win rate → 75.8% match win for best-of-3
expect(matchWinProb(0.68, 2)).toBeCloseTo(0.758, 2);
});
it("approaches 1.0 as p → 1.0", () => {
expect(matchWinProb(0.9999, 10)).toBeCloseTo(1.0, 3);
expect(matchWinProb(0.9999, 18)).toBeCloseTo(1.0, 3);
});
it("approaches 0.0 as p → 0.0", () => {
expect(matchWinProb(0.0001, 10)).toBeCloseTo(0.0, 3);
expect(matchWinProb(0.0001, 18)).toBeCloseTo(0.0, 3);
});
it("longer matches amplify the stronger player's advantage", () => {
// p=0.6: win prob should be higher in best-of-35 than best-of-3
const bo3 = matchWinProb(0.6, 2);
const bo35 = matchWinProb(0.6, 18);
expect(bo35).toBeGreaterThan(bo3);
});
it("match win prob + opponent win prob sums to 1.0", () => {
// p = 0.65, F = 13
const p = 0.65;
const winProb = matchWinProb(p, 13);
const lossProb = matchWinProb(1 - p, 13);
expect(winProb + lossProb).toBeCloseTo(1.0, 5);
});
});
// ─── Integration tests with mocked DB ────────────────────────────────────────
const PARTICIPANT_IDS = Array.from({ length: 32 }, (_, i) => `player-${i + 1}`);
const ELO_BASE = 2400;
function makeMatch(
round: string,
matchNumber: number,
p1Index: number,
p2Index: number,
opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}
) {
return {
id: `${round}-match-${matchNumber}`,
scoringEventId: "event-1",
round,
matchNumber,
participant1Id: PARTICIPANT_IDS[p1Index],
participant2Id: PARTICIPANT_IDS[p2Index],
winnerId: opts.winnerId ?? null,
loserId: opts.loserId ?? null,
isComplete: opts.isComplete ?? false,
isScoring: true,
templateRound: round,
seedInfo: null,
participant1Score: null,
participant2Score: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/** Build a full 5-round bracket (32 players, 31 matches total). */
function makeFullBracket(): ReturnType<typeof makeMatch>[] {
const matches: ReturnType<typeof makeMatch>[] = [];
// R32: 16 matches, pairs: (0,1), (2,3), ..., (30,31)
for (let i = 0; i < 16; i++) {
matches.push(makeMatch("First Round", i + 1, i * 2, i * 2 + 1));
}
// R16: 8 matches
for (let i = 0; i < 8; i++) {
matches.push(makeMatch("Second Round", i + 1, 0, 1)); // participants filled in by bracket
}
// QF: 4 matches
for (let i = 0; i < 4; i++) {
matches.push(makeMatch("Quarter-Finals", i + 1, 0, 1));
}
// SF: 2 matches
for (let i = 0; i < 2; i++) {
matches.push(makeMatch("Semi-Finals", i + 1, 0, 1));
}
// Final: 1 match
matches.push(makeMatch("Final", 1, 0, 1));
return matches;
}
/** Build EV rows with uniform Elo for all 32 participants. */
function makeEVRows(eloOverride?: Map<string, number>) {
return PARTICIPANT_IDS.map((participantId) => ({
participantId,
sourceElo: eloOverride?.get(participantId) ?? ELO_BASE,
}));
}
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
describe("SnookerSimulator.simulate() — Path A (bracket populated)", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
beforeEach(async () => {
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(makeEVRows()),
}),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
});
it("throws if bracket has wrong number of rounds", async () => {
// Only 4 rounds instead of 5 (missing Final)
const matches = [
...Array.from({ length: 16 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", i + 1, 0, 1)),
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 5 rounds/);
});
it("throws if First Round match count is wrong", async () => {
// Only 8 R32 matches instead of 16
const matches = [
...Array.from({ length: 8 }, (_, i) => makeMatch("First Round", i + 1, i * 2, i * 2 + 1)),
...Array.from({ length: 8 }, (_, i) => makeMatch("Second Round", i + 1, 0, 1)),
...Array.from({ length: 4 }, (_, i) => makeMatch("Quarter-Finals", i + 1, 0, 1)),
...Array.from({ length: 2 }, (_, i) => makeMatch("Semi-Finals", i + 1, 0, 1)),
makeMatch("Final", 1, 0, 1),
];
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 16 First Round/);
});
it("throws if a First Round match is missing participants", async () => {
const matches = makeFullBracket();
// Remove participant2Id from First Round match 5
const r32Match5 = matches.find(m => m.round === "First Round" && m.matchNumber === 5);
if (!r32Match5) throw new Error("match not found");
Object.assign(r32Match5, { participant2Id: null });
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/);
});
it("returns 32 results with valid probability distributions", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(32);
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probSecond).toBeGreaterThanOrEqual(0);
expect(p.probFirst).toBeLessThanOrEqual(1);
const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth +
p.probFifth + p.probSixth + p.probSeventh + p.probEighth;
expect(sum).toBeGreaterThanOrEqual(0);
expect(sum).toBeLessThanOrEqual(1.001);
}
});
it("each probability column sums to 1.0 across all 32 participants", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum).toBeCloseTo(1.0, 2);
}
});
it("completed tournament: champion has probFirst≈1, all other players have probFirst=0", async () => {
const champion = PARTICIPANT_IDS[0]; // player-1
const finalist = PARTICIPANT_IDS[2]; // player-3
// Build a complete bracket where player-1 wins everything
const r32Matches = Array.from({ length: 16 }, (_, i) => {
const p1 = PARTICIPANT_IDS[i * 2];
const p2 = PARTICIPANT_IDS[i * 2 + 1];
const winner = i === 0 ? p1 : p2; // match 1: p1 wins; rest: p2 wins
return makeMatch("First Round", i + 1, i * 2, i * 2 + 1, {
winnerId: winner,
loserId: winner === p1 ? p2 : p1,
isComplete: true,
});
});
// R16: player-1 vs player-4 (p2 winners from R32 were p2s)
// For simplicity, mark QF/SF/Final all complete with player-1 winning
const r16Matches = Array.from({ length: 8 }, (_, i) => ({
...makeMatch("Second Round", i + 1, 0, 1),
winnerId: i === 0 ? champion : PARTICIPANT_IDS[3],
loserId: i === 0 ? PARTICIPANT_IDS[3] : champion,
isComplete: true,
}));
const qfMatches = Array.from({ length: 4 }, (_, i) => ({
...makeMatch("Quarter-Finals", i + 1, 0, 1),
winnerId: i === 0 ? champion : PARTICIPANT_IDS[5],
loserId: i === 0 ? PARTICIPANT_IDS[5] : champion,
isComplete: true,
}));
const sfMatches = Array.from({ length: 2 }, (_, i) => ({
...makeMatch("Semi-Finals", i + 1, 0, 1),
winnerId: i === 0 ? champion : finalist,
loserId: i === 0 ? finalist : champion,
isComplete: true,
}));
const finalMatch = {
...makeMatch("Final", 1, 0, 1),
winnerId: champion,
loserId: finalist,
isComplete: true,
};
mockDb.query.playoffMatches.findMany.mockResolvedValue([
...r32Matches, ...r16Matches, ...qfMatches, ...sfMatches, finalMatch,
]);
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
const championResult = results.find(r => r.participantId === champion);
expect(championResult?.probabilities.probFirst).toBeCloseTo(1.0, 2);
const finalistResult = results.find(r => r.participantId === finalist);
expect(finalistResult?.probabilities.probSecond).toBeCloseTo(1.0, 2);
// R32 losers (the p2s from matches 2-16) should have all-zero probs
const r32Loser = PARTICIPANT_IDS[3]; // p2 of match 2, loses in R32
const loserResult = results.find(r => r.participantId === r32Loser);
if (!loserResult) throw new Error("loserResult not found");
const lp = loserResult.probabilities;
expect(lp.probFirst).toBe(0);
expect(lp.probSecond).toBe(0);
const loserSum = lp.probFirst + lp.probSecond + lp.probThird + lp.probFourth +
lp.probFifth + lp.probSixth + lp.probSeventh + lp.probEighth;
expect(loserSum).toBe(0);
});
it("falls back to equal Elo (50/50) when no sourceElo stored", async () => {
// Return empty EV rows — no Elo stored
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
// With equal Elo, probFirst should be roughly equal across all participants
expect(results).toHaveLength(32);
// Each player should have a roughly fair shot — not zero for all
const hasNonZero = results.some(r => r.probabilities.probFirst > 0);
expect(hasNonZero).toBe(true);
});
it("labels results with snooker_world_championship_monte_carlo source", async () => {
mockDb.query.playoffMatches.findMany.mockResolvedValue(makeFullBracket());
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results.every(r => r.source === "snooker_world_championship_monte_carlo")).toBe(true);
});
});
// ─── Path B: pre-bracket simulation ──────────────────────────────────────────
describe("SnookerSimulator.simulate() — Path B (pre-bracket)", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
// 32 participants: use names that match the hardcoded rankings
const RANKED_NAMES = [
"Judd Trump", "Kyren Wilson", "Mark Selby", "Ronnie O'Sullivan",
"Mark Allen", "Luca Brecel", "Neil Robertson", "John Higgins",
"Ryan Day", "Zhao Xintong", "Jak Jones", "Si Jiahui",
"Barry Hawkins", "Ding Junhui", "Pang Junxu", "Zhang Anda",
"Xiao Guodong", "Jack Lisowski", "Shaun Murphy", "David Gilbert",
"Anthony McGill", "Tom Ford", "Stuart Bingham", "Andrew Higginson",
"Chris Wakelin", "Gary Wilson", "Matthew Selt", "Gerard Greene",
"Ben Woollaston", "Jamie Jones", "Robbie Williams", "Mark Williams",
];
const SEASON_PARTICIPANTS = RANKED_NAMES.map((name, i) => ({
id: `player-${i + 1}`,
name,
}));
beforeEach(async () => {
const { database } = await import("~/database/context");
// select() is called twice in Path B:
// 1. for participantExpectedValues (EV/Elo rows)
// 2. for participants (all players in the season)
const eloRows = SEASON_PARTICIPANTS.map(p => ({ participantId: p.id, sourceElo: ELO_BASE }));
const participantRows = SEASON_PARTICIPANTS;
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }),
}),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
mockDb.query.scoringEvents.findFirst.mockResolvedValue({
id: "event-1",
sportsSeasonId: "season-1",
eventType: "playoff_game",
});
// No matches → triggers Path B
mockDb.query.playoffMatches.findMany.mockResolvedValue([]);
});
it("returns 32 results when bracket is not yet populated", async () => {
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(32);
});
it("each probability column sums to 1.0 across all 32 participants", async () => {
const sim = new SnookerSimulator();
const results = await sim.simulate("season-1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(colSum).toBeCloseTo(1.0, 2);
}
});
it("throws if fewer than 17 participants exist", async () => {
const { database } = await import("~/database/context");
const tooFew = SEASON_PARTICIPANTS.slice(0, 10);
const eloRows = tooFew.map(p => ({ participantId: p.id, sourceElo: ELO_BASE }));
(database as unknown as MockInstance).mockReturnValue({
...mockDb,
select: vi.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(eloRows) }),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(tooFew) }),
}),
});
const sim = new SnookerSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/at least 17 participants/);
});
});

View file

@ -16,6 +16,7 @@ import { NCAAWSimulator } from "./ncaaw-simulator";
import { NBASimulator } from "./nba-simulator";
import { NHLSimulator } from "./nhl-simulator";
import { AFLSimulator } from "./afl-simulator";
import { SnookerSimulator } from "./snooker-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -28,6 +29,7 @@ export const SIMULATOR_TYPES = [
"nba_bracket",
"nhl_bracket",
"afl_bracket",
"snooker_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -92,6 +94,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "AFL Season + Finals Monte Carlo", description: "Projects AFL regular season standings via Elo, then simulates the 10-team finals series (Wildcard → QF/EF → SF → PF → Grand Final). Reads current standings from DB; falls back to full-season projection pre-season." },
create: () => new AFLSimulator(),
},
snooker_bracket: {
info: { name: "Snooker World Championship Monte Carlo", description: "Simulates the 32-player World Championship bracket using frame-by-frame Bernoulli win probability and direct Elo ratings. Pre-bracket path simulates qualifying (ranks 17-48) and randomly draws qualifiers vs top 16 seeds." },
create: () => new SnookerSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -0,0 +1,643 @@
/**
* Snooker World Championship Simulator
*
* Monte Carlo simulation of the World Snooker Championship (Crucible, Sheffield).
* The tournament is a 32-player single-elimination bracket with 5 rounds, using
* best-of frame formats that increase in length each round.
*
* Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB.
* 2. Load Elo ratings from participantExpectedValues.sourceElo.
* 3. Compute per-match win probability using the Bernoulli frame model:
* p_frame = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR))
* P(win match) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
* where F = frames to win, which varies by round.
* 4. Two simulation paths:
* a. Bracket populated: simulate from actual draw, respecting completed matches.
* b. No bracket: simulate qualifying (ranks 17-48 play one round best-of-19),
* randomly pair 16 winners vs top 16 seeds, then simulate 5-round bracket.
* 5. Track integer placement counts per tier across 50,000 simulations.
* 6. Convert to probability distributions using exact denominators (column sums = 1.0).
*
* Round format (Crucible main draw):
* First Round (R32): best-of-19, first to 10
* Second Round (R16): best-of-25, first to 13
* Quarter-Finals (QF): best-of-25, first to 13
* Semi-Finals (SF): best-of-33, first to 17
* Final: best-of-35, first to 18
*
* Placement bucketing (8-slot probability model):
* probFirst Champion
* probSecond Finalist
* probThird/Fourth SF losers (2/sim)
* probFifthEighth QF losers (4/sim)
* R16 losers all 0
* R32 losers all 0
*
* World rankings (for pre-bracket path): hardcoded below. Update annually.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-frame win probability.
* Higher = softer probabilities (more randomness, less Elo dominance).
* Lower = sharper probabilities (Elo differences matter more).
*
* Standard chess Elo uses 400. Snooker frames are more random than chess
* games, so a higher value is appropriate. Tune this until tournament win
* probabilities for the top seed feel realistic (~15% for a clear favourite
* in a 32-player field).
*
* Reference calibration points at ELO_DIVISOR=700:
* 100-pt gap 53.5% per frame
* 300-pt gap 60.6% per frame
* At ELO_DIVISOR=400 (standard chess):
* 100-pt gap 56.2% per frame
* 300-pt gap 67.9% per frame
*/
const ELO_DIVISOR = 700;
/**
* Frames needed to win per round, indexed by round order (most matches first).
* Index 0 = R32 (16 matches, best-of-19, need 10)
* Index 1 = R16 (8 matches, best-of-25, need 13)
* Index 2 = QF (4 matches, best-of-25, need 13)
* Index 3 = SF (2 matches, best-of-33, need 17)
* Index 4 = Final (1 match, best-of-35, need 18)
*/
const FRAMES_TO_WIN = [10, 13, 13, 17, 18] as const;
// ─── Tournament seedings (2025 World Snooker Championship) ────────────────────
// Seed 1 = defending champion; seeds 2-16 = world ranking order (skipping champion).
// Seeds 17+ = qualifiers — only their relative order here matters (all > 16).
// Update this map each year when a new snooker season is added.
// Players not in this map default to seed 999 (treated as qualifiers).
const SEEDINGS_2026: Record<string, number> = {
"Judd Trump": 1,
"Kyren Wilson": 2,
"Neil Robertson": 3,
"Mark Williams": 4,
"Zhao Xintong": 5,
"John Higgins": 6,
"Mark Selby": 7,
"Shaun Murphy": 8,
"Xiao Guodong": 9,
"Ronnie O'Sullivan": 10,
"Barry Hawkins": 11,
"Wu Yize": 12,
"Chris Wakelin": 13,
"Mark Allen": 14,
"Si Jiahui": 15,
"Ding Junhui": 16,
"Stuart Bingham": 17,
"Jack Lisowski": 18,
"Jak Jones": 19,
"Zhang Anda": 20,
"Elliot Slessor": 21,
"Thepchaiya Un-Nooh": 22,
"Ali Carter": 23,
"Gary Wilson": 24,
"Zhou Yuelong": 25,
"David Gilbert": 26,
"Stephen Maguire": 27,
"Joe O'Connor": 28,
"Pang Junxu": 29,
"Lei Peifan": 30,
"Yuan Sijun": 31,
"Tom Ford": 32,
"Hossein Vafaei": 33,
"Jimmy Robertson": 34,
"Ryan Day": 35,
"Jackson Page": 36,
"Matthew Selt": 37,
"Xu Si": 38,
"Ben Woollaston": 39,
"Aaron Hill": 40,
"Daniel Wells": 41,
"Anthony McGill": 42,
"Zak Surety": 43,
"Stan Moody": 44,
"Noppon Saengkham": 45,
"Luca Brecel": 46,
"He Guoqiang": 47,
"Matthew Stevens": 48,
};
/**
* Standard seeded R32 bracket for 32-player single-elimination.
* Each entry is [seedA, seedB] for one R32 match, in bracket order.
* Consecutive pairs of R32 winners form R16 matches (bracket structure preserved).
* Ensures seed 1 and seed 2 can only meet in the Final.
*
* Structure (seeds 1-16 are fixed; seeds 17-32 are randomly drawn qualifiers):
* Top half: 1v32, 16v17, 9v24, 8v25, 5v28, 12v21, 13v20, 4v29
* Bottom half: 3v30, 14v19, 11v22, 6v27, 7v26, 10v23, 15v18, 2v31
*/
const R32_BRACKET: Array<[number, number]> = [
[1, 32], [16, 17],
[9, 24], [8, 25],
[5, 28], [12, 21],
[13, 20], [4, 29],
[3, 30], [14, 19],
[11, 22], [6, 27],
[7, 26], [10, 23],
[15, 18], [2, 31],
];
// ─── Name normalization ──────────────────────────────────────────────────────
// Normalize unicode apostrophes/quotes so DB names (which may use curly quotes
// like U+2019 ') match the ASCII apostrophes (U+0027 ') in SEEDINGS keys.
function normalizeApostrophes(name: string): string {
return name.replace(/[\u2018\u2019\u2032\u0060]/g, "'");
}
const SEEDINGS_NORMALIZED = new Map(
Object.entries(SEEDINGS_2026).map(([name, seed]) => [normalizeApostrophes(name), seed])
);
function getSeedingForName(name: string): number {
return SEEDINGS_NORMALIZED.get(normalizeApostrophes(name)) ?? 999;
}
// ─── Math helpers ──────────────────────────────────────────────────────────────
/**
* Per-frame win probability for player 1 vs player 2 based on their Elo ratings.
*
* Uses the logistic sigmoid: p = 1 / (1 + e^(-(R1 - R2) / ELO_DIVISOR))
*
* ELO_DIVISOR is set higher than standard chess (400) to reflect the greater
* randomness of snooker frames vs chess games. At ELO_DIVISOR=700:
* 100-pt gap ~53.5% per frame
* 300-pt gap ~60.6% per frame
*
* Exported for unit testing.
*/
export function frameWinProb(elo1: number, elo2: number): number {
return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR));
}
/**
* Match win probability for player 1 using the Bernoulli frame model.
*
* For a best-of-(2F-1) match (first to F frames wins):
* P(win) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
*
* where p = per-frame win probability and F = framesToWin.
* This sums over all winning score combinations (F-0, F-1, ..., F-(F-1)).
* Exported for unit testing.
*/
export function matchWinProb(p: number, framesToWin: number): number {
const F = framesToWin;
let prob = 0;
for (let w2 = 0; w2 < F; w2++) {
prob += binomialCoeff(F - 1 + w2, w2) * Math.pow(p, F) * Math.pow(1 - p, w2);
}
return prob;
}
/** Binomial coefficient C(n, k) using iterative multiplication to avoid overflow. */
function binomialCoeff(n: number, k: number): number {
if (k === 0) return 1;
if (k > n - k) k = n - k; // Symmetry: C(n,k) = C(n, n-k)
let result = 1;
for (let i = 0; i < k; i++) {
result = (result * (n - i)) / (i + 1);
}
return result;
}
/** Fisher-Yates shuffle (in-place). */
function shuffle<T>(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;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Find the bracket scoring event for this sports season.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
// 2. Load playoff matches (may be empty if bracket hasn't been drawn yet).
const allMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
})
: [];
// 3. Load Elo ratings from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo map; fall back to 1500 for any participant with no stored rating.
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
} else if (!eloMap.has(r.participantId)) {
eloMap.set(r.participantId, 1500);
}
}
// Determine which simulation path to take.
const bracketPopulated =
allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, db);
}
}
// ─── Path A: Full bracket simulation ────────────────────────────────────────
private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first).
const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []);
byRound.get(m.round)?.push(m);
}
const sortedRounds = [...byRound.values()]
.toSorted((a, b) => b.length - a.length)
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
if (sortedRounds.length !== 5) {
throw new Error(
`Expected 5 rounds for World Snooker Championship, found ${sortedRounds.length}. ` +
`Rounds: ${[...byRound.keys()].join(", ")}`
);
}
const [r32Matches, r16Matches, qfMatches, sfMatches, finalMatches] = sortedRounds;
if (r32Matches.length !== 16) {
throw new Error(
`Expected 16 First Round matches, found ${r32Matches.length}.`
);
}
// Collect all 32 participant IDs from R32.
const participantIds: string[] = [];
for (const m of r32Matches) {
if (!m.participant1Id || !m.participant2Id) {
throw new Error(
`First Round match ${m.matchNumber} is missing participants. ` +
`Assign all 32 players to the bracket before running simulation.`
);
}
participantIds.push(m.participant1Id, m.participant2Id);
}
// Build lookup maps by matchNumber for O(1) access in the hot loop.
const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m]));
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
const finalMatch = finalMatches[0];
const fallbackElo = 1500;
// Cache matchWinProb results — the Elo values and framesToWin are fixed across
// all simulations, so the same (elo1, elo2, framesToWin) triple always yields
// the same probability. Avoids recomputing the Bernoulli sum ~2M times per run.
const matchProbCache = new Map<string, number>();
const simMatch = (p1: string, p2: string, framesToWin: number): { winner: string; loser: string } => {
const elo1 = eloMap.get(p1) ?? fallbackElo;
const elo2 = eloMap.get(p2) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
const winner = Math.random() < winProb ? p1 : p2;
return { winner, loser: winner === p1 ? p2 : p1 };
};
// Integer placement counts.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── First Round (R32) ──────────────────────────────────────────────────
const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) {
const m = r32ByNum.get(i);
if (!m) continue;
if (m.isComplete && m.winnerId) {
r32Winners.push(m.winnerId);
} else {
const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", FRAMES_TO_WIN[0]);
r32Winners.push(winner);
}
}
// ── Second Round (R16) ────────────────────────────────────────────────
// R16 losers score 0 — only the winner is tracked.
const r16Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
const dbMatch = r16ByNum.get(i);
let winner: string;
if (dbMatch?.isComplete && dbMatch.winnerId) {
winner = dbMatch.winnerId;
} else {
const p1 = r32Winners[(i - 1) * 2];
const p2 = r32Winners[(i - 1) * 2 + 1];
({ winner } = simMatch(p1, p2, FRAMES_TO_WIN[1]));
}
r16Winners.push(winner);
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 1; i <= 4; i++) {
const dbMatch = qfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = r16Winners[(i - 1) * 2];
const p2 = r16Winners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[2]));
}
qfWinners.push(winner);
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 1; i <= 2; i++) {
const dbMatch = sfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = qfWinners[(i - 1) * 2];
const p2 = qfWinners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[3]));
}
sfWinners.push(winner);
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
let champion: string;
let finalist: string;
if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
champion = finalMatch.winnerId;
finalist = finalMatch.loserId;
} else {
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]));
}
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
// ─── Path B: Pre-bracket simulation ─────────────────────────────────────────
// Players 17-48 haven't qualified yet. Each simulation:
// 1. Randomly pairs the qualifiers (17-48) and simulates 16 best-of-19 matches.
// 2. The 16 winners are shuffled and randomly drawn into seed slots 17-32.
// 3. The full 32-player bracket is then simulated using the proper seeded structure.
private async simulatePreBracket(
sportsSeasonId: string,
eloMap: Map<string, number>,
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 17) {
throw new Error(
`Pre-bracket simulation requires at least 17 participants (got ${allParticipants.length}). ` +
`Add players to this sports season first.`
);
}
// Sort by seeding. Unranked players default to 999 (treated as qualifiers).
const ranked = allParticipants.toSorted((a, b) => {
return getSeedingForName(a.name) - getSeedingForName(b.name);
});
const topSeeds = ranked.slice(0, 16); // Fixed seed positions 1-16
const qualifiers = ranked.slice(16); // Randomly drawn into positions 17-32
const fallbackElo = 1500;
const matchProbCache = new Map<string, number>();
const simMatch = (p1Id: string, p2Id: string, framesToWin: number): string => {
const elo1 = eloMap.get(p1Id) ?? fallbackElo;
const elo2 = eloMap.get(p2Id) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
return Math.random() < winProb ? p1Id : p2Id;
};
// Pre-extract qualifier IDs once — reused (with a fresh shuffle) each iteration.
const qualifierIds = qualifiers.map((p) => p.id);
const allParticipantIds = allParticipants.map((p) => p.id);
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
// Whether qualifying needs to be simulated (>16 players competing for 16 bracket slots).
// If exactly 16 qualifiers are already loaded (the common case when only 32 total players
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
const needsQualifying = qualifiers.length > 16;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
let drawnQualifiers: string[];
if (!needsQualifying) {
// All 16 qualifiers go straight through — just shuffle the draw order.
drawnQualifiers = shuffle([...qualifierIds]);
} else {
// Multiple qualifying rounds (best-of-19 each) to whittle down to 16.
let pool = shuffle([...qualifierIds]);
while (pool.length > 16) {
const winners: string[] = [];
for (let i = 0; i + 1 < pool.length; i += 2) {
winners.push(simMatch(pool[i], pool[i + 1], FRAMES_TO_WIN[0]));
}
// Odd player out gets a bye.
if (pool.length % 2 !== 0) {
winners.push(pool[pool.length - 1]);
}
pool = shuffle(winners);
}
drawnQualifiers = pool;
}
// Seeds 5-16: randomly swap adjacent pairs to reflect uncertainty in
// mid-tier seedings (top 4 are locked in).
const midSeeds = topSeeds.slice(4).map((p) => p.id);
for (let i = 0; i < midSeeds.length - 1; i++) {
if (Math.random() < 0.5) {
[midSeeds[i], midSeeds[i + 1]] = [midSeeds[i + 1], midSeeds[i]];
}
}
// Map seed number (1-indexed) → participant ID.
const seedToId = new Map<number, string>();
topSeeds.slice(0, 4).forEach((p, i) => seedToId.set(i + 1, p.id));
midSeeds.forEach((id, i) => seedToId.set(i + 5, id));
drawnQualifiers.forEach((id, i) => seedToId.set(i + 17, id));
// ── First Round (R32) — proper seeded bracket ─────────────────────────
// R32_BRACKET defines which seeds meet in each match, in bracket order so
// consecutive R32 winner pairs form the correct R16 matchups.
const r32Winners: string[] = [];
for (const [s1, s2] of R32_BRACKET) {
const p1 = seedToId.get(s1) ?? "";
const p2 = seedToId.get(s2) ?? "";
r32Winners.push(simMatch(p1, p2, FRAMES_TO_WIN[0]));
}
// ── Second Round (R16) ────────────────────────────────────────────────
const r16Winners: string[] = [];
for (let i = 0; i < r32Winners.length; i += 2) {
r16Winners.push(simMatch(r32Winners[i], r32Winners[i + 1], FRAMES_TO_WIN[1]));
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 0; i < r16Winners.length; i += 2) {
const p1 = r16Winners[i], p2 = r16Winners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[2]);
qfWinners.push(winner);
qfLoserCounts.set(winner === p1 ? p2 : p1, (qfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 0; i < qfWinners.length; i += 2) {
const p1 = qfWinners[i], p2 = qfWinners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[3]);
sfWinners.push(winner);
sfLoserCounts.set(winner === p1 ? p2 : p1, (sfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
const champion = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]);
const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0];
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
}
// ─── Shared result builder ─────────────────────────────────────────────────────
function buildResults(
participantIds: string[],
N: number,
counts: {
championCounts: Map<string, number>;
finalistCounts: Map<string, number>;
sfLoserCounts: Map<string, number>;
qfLoserCounts: Map<string, number>;
}
): SimulationResult[] {
const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const sf = sfLoserCounts.get(participantId) ?? 0;
const qf = qfLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: sf / (2 * N),
probFourth: sf / (2 * N),
probFifth: qf / (4 * N),
probSixth: qf / (4 * N),
probSeventh: qf / (4 * N),
probEighth: qf / (4 * N),
},
source: "snooker_world_championship_monte_carlo",
};
});
// Per-position column normalization — ensures sums are exactly 1.0.
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
"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;
}

View file

@ -93,6 +93,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"nba_bracket",
"nhl_bracket",
"afl_bracket",
"snooker_bracket",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
@ -581,9 +582,14 @@ export const participantExpectedValues = pgTable("participant_expected_values",
// Metadata
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format)
sourceElo: integer("source_elo"), // Raw Elo rating if source is elo-based simulator (e.g. snooker_bracket)
calculatedAt: timestamp("calculated_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
}, (t) => ({
uniqueParticipantSeason: uniqueIndex("participant_ev_participant_season_unique").on(
t.participantId, t.sportsSeasonId
),
}));
// Tournament group stage tables (for FIFA World Cup style tournaments)
export const tournamentGroups = pgTable("tournament_groups", {

View file

@ -0,0 +1,2 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'snooker_bracket';--> statement-breakpoint
ALTER TABLE "participant_expected_values" ADD COLUMN "source_elo" integer;

View file

@ -0,0 +1,15 @@
-- Remove duplicate participant_expected_values rows before adding unique constraint.
-- For each (participant_id, sports_season_id) group with duplicates, keep the row
-- that has a non-null source_elo (preferred) or the most recently updated row.
DELETE FROM participant_expected_values
WHERE id NOT IN (
SELECT DISTINCT ON (participant_id, sports_season_id) id
FROM participant_expected_values
ORDER BY participant_id, sports_season_id,
(source_elo IS NOT NULL) DESC,
updated_at DESC
);
-- Now add unique constraint to prevent future duplicates.
CREATE UNIQUE INDEX "participant_ev_participant_season_unique"
ON "participant_expected_values" ("participant_id", "sports_season_id");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -400,6 +400,20 @@
"when": 1774167142673,
"tag": "0056_jittery_the_fallen",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1774239259550,
"tag": "0057_cultured_doctor_doom",
"breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1774310000000,
"tag": "0058_add_unique_participant_ev",
"breakpoints": true
}
]
}

View file

@ -20,6 +20,7 @@
"test:all": "npm run test:run && npm run test:e2e:headless",
"typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit",
"lint": "oxlint app/ server/ database/",
"lint:path": "oxlint",
"lint:fix": "oxlint app/ server/ database/ --fix"
},
"dependencies": {