From 1bdd5c3372fb9a19ba18102e4c33e57f31abbef5 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 23 Mar 2026 08:24:28 -0700 Subject: [PATCH] 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 --- .claude/settings.json | 6 +- app/models/participant-expected-value.ts | 45 +- app/routes.ts | 4 + .../admin.sports-seasons.$id.elo-ratings.tsx | 422 ++ app/routes/admin.sports-seasons.$id.tsx | 8 + .../__tests__/snooker-simulator.test.ts | 466 ++ app/services/simulations/registry.ts | 6 + app/services/simulations/snooker-simulator.ts | 643 +++ database/schema.ts | 8 +- drizzle/0057_cultured_doctor_doom.sql | 2 + drizzle/0058_add_unique_participant_ev.sql | 15 + drizzle/meta/0057_snapshot.json | 3897 ++++++++++++++++ drizzle/meta/0058_snapshot.json | 3919 +++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 1 + 15 files changed, 9451 insertions(+), 5 deletions(-) create mode 100644 app/routes/admin.sports-seasons.$id.elo-ratings.tsx create mode 100644 app/services/simulations/__tests__/snooker-simulator.test.ts create mode 100644 app/services/simulations/snooker-simulator.ts create mode 100644 drizzle/0057_cultured_doctor_doom.sql create mode 100644 drizzle/0058_add_unique_participant_ev.sql create mode 100644 drizzle/meta/0057_snapshot.json create mode 100644 drizzle/meta/0058_snapshot.json diff --git a/.claude/settings.json b/.claude/settings.json index 42e4124..b5852fe 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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'" } ] } @@ -22,4 +22,4 @@ } ] } -} +} \ No newline at end of file diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 5c1010e..da99c33 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -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 { + 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 */ diff --git a/app/routes.ts b/app/routes.ts index 9ad2b36..4226c7c 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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" diff --git a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx new file mode 100644 index 0000000..a4c534a --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx @@ -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 = {}; + 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(); + const actionData = useActionData(); + const navigation = useNavigation(); + + const [eloValues, setEloValues] = useState>(() => { + const initial: Record = {}; + 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(); + + 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 ( +
+
+

Elo Ratings

+

+ {sportsSeason.sport.name} — {sportsSeason.name} +

+
+ + {/* Bulk Import */} + + + Bulk Import + + Paste Elo ratings one per line. Format: Player Name, 2450. Player names + are fuzzy-matched to participants. Elo values should be in the 2000–2900 range for snooker. + + + +