diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index c985c0b..e0a6f53 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -302,6 +302,73 @@ export const SIMPLE_32: BracketTemplate = { ], }; +/** + * PDC World Darts Championship (128 players) + * R1 (128) → R2 (64) → R3 (32) → R4 (16) → Quarterfinals → Semifinals → Final + * Only Quarterfinals and beyond score points. + * + * Match formats: + * R1: best-of-3 sets (first to 2) + * R2: best-of-5 sets (first to 3) + * R3: best-of-5 sets (first to 3) + * R4: best-of-7 sets (first to 4) + * QF: best-of-7 sets (first to 4) + * SF: best-of-11 sets (first to 6) + * Final: best-of-13 sets (first to 7) + * + * Seeding: top 32 seeds placed in fixed positions; remaining 96 randomly drawn. + */ +export const DARTS_128: BracketTemplate = { + id: "darts_128", + name: "PDC World Darts Championship (128 Players)", + totalTeams: 128, + scoringStartsAtRound: "Quarterfinals", + rounds: [ + { + name: "Round 1", + matchCount: 64, + feedsInto: "Round 2", + isScoring: false, + }, + { + name: "Round 2", + matchCount: 32, + feedsInto: "Round 3", + isScoring: false, + }, + { + name: "Round 3", + matchCount: 16, + feedsInto: "Round 4", + isScoring: false, + }, + { + name: "Round 4", + matchCount: 8, + feedsInto: "Quarterfinals", + isScoring: false, + }, + { + name: "Quarterfinals", + matchCount: 4, + feedsInto: "Semi-Finals", + isScoring: true, // QF losers share 5th–8th + }, + { + name: "Semi-Finals", + matchCount: 2, + feedsInto: "Final", + isScoring: true, // SF losers share 3rd–4th + }, + { + name: "Final", + matchCount: 1, + feedsInto: null, + isScoring: true, // Winner 1st, Loser 2nd + }, + ], +}; + /** * NCAA March Madness (68 teams) * First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship @@ -578,6 +645,7 @@ export const BRACKET_TEMPLATES: Record = { nfl_14: NFL_14, afl_10: AFL_10, fifa_48: FIFA_48, + darts_128: DARTS_128, }; /** diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index da99c33..405b06f 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -29,6 +29,7 @@ export interface ParticipantEV { source: ProbabilitySource | null; sourceOdds: number | null; sourceElo?: number | null; + worldRanking?: number | null; calculatedAt: Date; updatedAt: Date; } @@ -362,12 +363,13 @@ export async function batchSaveSourceOdds( } /** - * Persist raw Elo ratings for a batch of participants (used by Elo-based simulators like snooker_bracket). + * Persist raw Elo ratings (and optional world rankings) for a batch of participants. + * Used by Elo-based simulators like snooker_bracket and darts_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 }> + inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> ): Promise { if (inputs.length === 0) return; const db = database(); @@ -376,7 +378,7 @@ export async function batchSaveSourceElos( await db .insert(participantExpectedValues) .values( - inputs.map(({ participantId, sportsSeasonId, sourceElo }) => ({ + inputs.map(({ participantId, sportsSeasonId, sourceElo, worldRanking }) => ({ participantId, sportsSeasonId, probFirst: "0", @@ -390,6 +392,7 @@ export async function batchSaveSourceElos( expectedValue: "0", source: "elo_simulation" as const, sourceElo, + worldRanking: worldRanking ?? null, calculatedAt: now, updatedAt: now, })) @@ -398,6 +401,7 @@ export async function batchSaveSourceElos( target: [participantExpectedValues.participantId, participantExpectedValues.sportsSeasonId], set: { sourceElo: sql`excluded.source_elo`, + worldRanking: sql`excluded.world_ranking`, updatedAt: sql`excluded.updated_at`, }, }); diff --git a/app/routes.ts b/app/routes.ts index 50e4959..cf108fa 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -95,6 +95,10 @@ export default [ "sports-seasons/:id/surface-elo", "routes/admin.sports-seasons.$id.surface-elo.tsx" ), + route( + "sports-seasons/:id/darts-elo", + "routes/admin.sports-seasons.$id.darts-elo.tsx" + ), route( "sports-seasons/:id/golf-skills", "routes/admin.sports-seasons.$id.golf-skills.tsx" diff --git a/app/routes/admin.sports-seasons.$id.darts-elo.tsx b/app/routes/admin.sports-seasons.$id.darts-elo.tsx new file mode 100644 index 0000000..52cd5f4 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.darts-elo.tsx @@ -0,0 +1,470 @@ +import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; +import type { Route } from './+types/admin.sports-seasons.$id.darts-elo'; + +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 } from '~/services/ev-calculator'; +import { DEFAULT_SCORING_RULES } from '~/lib/scoring-types'; +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'; +import { normalizeName } from '~/lib/fuzzy-match'; + +export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { + return [{ title: `Darts Elo & Rankings — ${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 existingData: Record = {}; + for (const ev of existingEVs) { + existingData[ev.participantId] = { + elo: ev.sourceElo ?? null, + ranking: ev.worldRanking ?? null, + }; + } + + return { sportsSeason, participants, existingData }; +} + +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 and world rankings from form + const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> = []; + for (const participant of participants) { + const eloVal = formData.get(`elo_${participant.id}`) as string; + const rankVal = formData.get(`rank_${participant.id}`) as string; + + if (eloVal && eloVal.trim() !== '') { + const elo = Number(eloVal); + if (!isNaN(elo) && elo > 0) { + const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null; + eloInputs.push({ + participantId: participant.id, + sportsSeasonId, + sourceElo: elo, + worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null, + }); + } + } + } + + 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 and world rankings + 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 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, + })), + ...participants + .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 darts simulation:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Simulation failed', + }; + } + + return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); +} + +export default function AdminSportsSeasonDartsElo() { + const { sportsSeason, participants, existingData } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + + const [eloValues, setEloValues] = useState>(() => { + const initial: Record = {}; + participants.forEach(p => { + const d = existingData[p.id]; + if (d?.elo !== null && d?.elo !== undefined) initial[p.id] = d.elo.toString(); + }); + return initial; + }); + + const [rankValues, setRankValues] = useState>(() => { + const initial: Record = {}; + participants.forEach(p => { + const d = existingData[p.id]; + if (d?.ranking !== null && d?.ranking !== undefined) initial[p.id] = d.ranking.toString(); + }); + return initial; + }); + + // Bulk import state: "Player Name, 2099, 1" format (name, elo, optional world ranking) + const [bulkText, setBulkText] = useState(''); + const [parseResults, setParseResults] = useState<{ + matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }>; + unmatched: Array<{ inputName: string; elo: number; ranking: number | null }>; + } | 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() { + // Formats supported: + // "Player Name, 2099, 1" (name, elo, world ranking) + // "Player Name, 2099" (name, elo — ranking omitted) + // "Player Name: 2099: 1" (colon-separated) + const lines = bulkText.split('\n'); + const matched: typeof parseResults extends null ? never : NonNullable['matched'] = []; + const unmatched: typeof parseResults extends null ? never : NonNullable['unmatched'] = []; + const seen = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Match "Name, Elo" or "Name, Elo, Ranking" (comma, colon, or tab separated) + const match = /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed); + if (!match) continue; + + const inputName = match[1].trim(); + const elo = parseInt(match[2], 10); + const ranking = match[3] ? parseInt(match[3], 10) : null; + + if (isNaN(elo) || elo < 500 || elo > 5000) continue; + if (ranking !== null && (isNaN(ranking) || ranking < 1 || ranking > 256)) continue; + + const participant = findParticipantMatch(inputName); + if (participant && !seen.has(participant.id)) { + seen.add(participant.id); + matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName }); + } else if (!participant) { + unmatched.push({ inputName, elo, ranking }); + } + } + + setParseResults({ matched, unmatched }); + } + + function applyMatches() { + if (!parseResults) return; + const newElos = { ...eloValues }; + const newRanks = { ...rankValues }; + for (const m of parseResults.matched) { + newElos[m.participantId] = m.elo.toString(); + if (m.ranking !== null) { + newRanks[m.participantId] = m.ranking.toString(); + } + } + setEloValues(newElos); + setRankValues(newRanks); + setParseResults(null); + setBulkText(''); + } + + const isSubmitting = navigation.state === 'submitting'; + + // Sort participants for display: by current rank (ascending), then unranked alphabetically + const sortedParticipants = [...participants].toSorted((a, b) => { + const rankA = rankValues[a.id] ? parseInt(rankValues[a.id], 10) : null; + const rankB = rankValues[b.id] ? parseInt(rankValues[b.id], 10) : null; + if (rankA !== null && rankB !== null) return rankA - rankB; + if (rankA !== null) return -1; + if (rankB !== null) return 1; + return a.name.localeCompare(b.name); + }); + + return ( +
+
+

Darts Elo & World Rankings

+

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

+
+ + {/* Bulk Import */} + + + Bulk Import + + Paste one player per line. Format:{' '} + Player Name, 2099, 1 (name, Elo, world ranking). + World ranking is optional — omit it and the simulator will use Elo order for seeding. + Player names are fuzzy-matched to participants. + + + +