From a71e256bfdffc36abec1186dc77dbe5aca4a96f4 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:40:05 -0700 Subject: [PATCH] Add CS2 Major Qualifying Points simulator and stage management (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CS2 Major qualifying points simulator Implements a full CS2 Major tournament simulator with: - 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3) + Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5) - Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season - Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank - Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3) - Stage assignments stored per-event so actual field composition drives simulation - Admin CS Elo form for entering team Elo + HLTV world rankings - Admin CS2 stage setup page for assigning teams to stages and tracking advancement - Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table - 24 unit tests covering all exported pure functions https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate Elo + ranking input into generic elo-ratings page The darts-elo and cs-elo pages were unreachable from the admin nav, which always links to the generic elo-ratings page. Extended elo-ratings to conditionally show world ranking fields for simulator types that need it (darts_bracket, cs2_major_qualifying_points), then deleted the redundant sport-specific pages. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate server postgres connections into one shared pool Four separate postgres() clients were open simultaneously (app, timer, snapshots, socket), each defaulting to 10 connections, exhausting the database's max_connections limit. Replaced with a single shared lazy- initialized client in server/db.ts using a Proxy to defer the DATABASE_URL check until first use (preserving test compatibility). Also bumps the CS2 Champions Stage stochastic test from 200 → 1000 iterations to eliminate flakiness. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix and() bug and add Swiss loop safety guard - cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements were using JS && instead of Drizzle and(), causing WHERE to filter only by participantId (not scoringEventId), which would update rows across all events instead of just the target event - cs-major-simulator.ts: add break guard in simulateSwiss while loop to prevent infinite loop if pairGroups returns no pairs https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix all remaining code review issues - cs2-major-stage.ts: use schema column reference for stageEliminated in markCs2StageEliminations instead of raw SQL string - cs-major-simulator.ts: simulateOneMajor now locks in known stage results when a stage is complete (8 recorded eliminations), only simulating the remaining stages during live events - admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points simulator types; expose simulatorType in server loader type cast - cs2-setup.tsx: replace document.getElementById DOM manipulation with React state (eliminatedChecked map) for checkbox show/hide logic; remove unused stageMap and unassignedParticipants variables https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix oxlint errors: non-null assertions, sort→toSorted, unused vars - cs-major-simulator.ts: replace 5 non-null assertions (!) with safe optional chaining / if-guards; replace 6 .sort() with .toSorted() - cs2-major-stage.ts: remove unused `inArray` import - cs2-setup.tsx: remove unused `assignedIds` variable https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix flaky Champions Stage stochastic test The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730). With the Champions Stage bracket math this gives team-0 a ~19.6% win rate — right at the 0.2 threshold, causing the test to fail ~63% of the time in CI despite 1000 iterations. Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate and raising the assertion threshold to 0.25 for a clear safety margin. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR --------- Co-authored-by: Claude --- app/models/cs2-major-stage.ts | 193 +++++ app/routes.ts | 8 +- .../admin.sports-seasons.$id.darts-elo.tsx | 470 ------------- .../admin.sports-seasons.$id.elo-ratings.tsx | 217 ++++-- ...-seasons.$id.events.$eventId.cs2-setup.tsx | 337 +++++++++ ...orts-seasons.$id.events.$eventId.server.ts | 2 +- ...min.sports-seasons.$id.events.$eventId.tsx | 8 + .../__tests__/cs-major-simulator.test.ts | 237 +++++++ .../simulations/cs-major-simulator.ts | 658 ++++++++++++++++++ app/services/simulations/registry.ts | 6 + database/schema.ts | 42 ++ drizzle/0068_cs2_major_simulator.sql | 26 + drizzle/meta/_journal.json | 7 + server/app.ts | 15 +- server/db.ts | 29 + server/snapshots.ts | 11 +- server/socket.ts | 17 +- server/timer.ts | 11 +- 18 files changed, 1706 insertions(+), 588 deletions(-) create mode 100644 app/models/cs2-major-stage.ts delete mode 100644 app/routes/admin.sports-seasons.$id.darts-elo.tsx create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx create mode 100644 app/services/simulations/__tests__/cs-major-simulator.test.ts create mode 100644 app/services/simulations/cs-major-simulator.ts create mode 100644 drizzle/0068_cs2_major_simulator.sql create mode 100644 server/db.ts diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts new file mode 100644 index 0000000..6a30bc5 --- /dev/null +++ b/app/models/cs2-major-stage.ts @@ -0,0 +1,193 @@ +/** + * Model for CS2 Major Stage Results + * + * Tracks the 3-stage Swiss format of CS2 Majors: + * Stage 1 (Opening Stage): 16 teams, lowest-seeded, Swiss Bo1/Bo3 + * Stage 2 (Elimination Stage): 16 teams (8 direct Challengers + 8 from Stage 1), Swiss Bo1/Bo3 + * Stage 3 (Decider Stage): 16 teams (8 direct Legends + 8 from Stage 2), Swiss all Bo3 + * Champions Stage: 8 teams, single-elimination (uses simple_8 bracket template) + * + * One row per (scoringEventId, participantId) — tracks which stage they enter, + * where they were eliminated, their W-L record at elimination (for QP sub-ranking + * within placements 9–16), and final placement after the event completes. + */ + +import { database } from "~/database/context"; +import { cs2MajorStageResults, participants } from "~/database/schema"; +import { eq, and, sql } from "drizzle-orm"; + +export interface Cs2StageResult { + id: string; + scoringEventId: string; + participantId: string; + stageEntry: number; // 1, 2, or 3 + stageEliminated: number | null; // null = made Champions Stage + stageEliminatedWins: number | null; // 0, 1, or 2 wins at time of elimination + finalPlacement: number | null; // 1–32, set after event completes + createdAt: Date; + updatedAt: Date; +} + +export interface Cs2StageResultWithName extends Cs2StageResult { + participantName: string; +} + +export interface Cs2StageAssignmentInput { + scoringEventId: string; + participantId: string; + stageEntry: number; +} + +/** + * Get all stage results for a CS2 Major event, joined with participant names. + */ +export async function getCs2StageResultsForEvent( + scoringEventId: string +): Promise { + const db = database(); + const rows = await db + .select({ + id: cs2MajorStageResults.id, + scoringEventId: cs2MajorStageResults.scoringEventId, + participantId: cs2MajorStageResults.participantId, + stageEntry: cs2MajorStageResults.stageEntry, + stageEliminated: cs2MajorStageResults.stageEliminated, + stageEliminatedWins: cs2MajorStageResults.stageEliminatedWins, + finalPlacement: cs2MajorStageResults.finalPlacement, + createdAt: cs2MajorStageResults.createdAt, + updatedAt: cs2MajorStageResults.updatedAt, + participantName: participants.name, + }) + .from(cs2MajorStageResults) + .innerJoin(participants, eq(cs2MajorStageResults.participantId, participants.id)) + .where(eq(cs2MajorStageResults.scoringEventId, scoringEventId)) + .orderBy(cs2MajorStageResults.stageEntry, participants.name); + + return rows; +} + +/** + * Get raw stage results for a CS2 Major event (without participant names). + * Used by the simulator. + */ +export async function getCs2StageResultsMapForEvent( + scoringEventId: string +): Promise> { + const db = database(); + const rows = await db + .select() + .from(cs2MajorStageResults) + .where(eq(cs2MajorStageResults.scoringEventId, scoringEventId)); + + return new Map(rows.map((r) => [r.participantId, r])); +} + +/** + * Upsert stage assignments for a CS2 Major event. + * Sets stageEntry for each participant; resets stageEliminated/stageEliminatedWins + * so that re-assigning a team's stage also clears any previously recorded results. + */ +export async function upsertCs2StageAssignments( + inputs: Cs2StageAssignmentInput[] +): Promise { + if (inputs.length === 0) return; + const db = database(); + const now = new Date(); + + await db + .insert(cs2MajorStageResults) + .values( + inputs.map(({ scoringEventId, participantId, stageEntry }) => ({ + scoringEventId, + participantId, + stageEntry, + stageEliminated: null, + stageEliminatedWins: null, + finalPlacement: null, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [cs2MajorStageResults.scoringEventId, cs2MajorStageResults.participantId], + set: { + stageEntry: sql`excluded.stage_entry`, + stageEliminated: null, + stageEliminatedWins: null, + finalPlacement: null, + updatedAt: sql`excluded.updated_at`, + }, + }); +} + +/** + * Remove all stage assignments for a CS2 Major event. + * Called when the admin resets the event setup. + */ +export async function clearCs2StageAssignments( + scoringEventId: string +): Promise { + const db = database(); + await db + .delete(cs2MajorStageResults) + .where(eq(cs2MajorStageResults.scoringEventId, scoringEventId)); +} + +/** + * Mark teams as eliminated from a specific stage. + * Records stageEliminated and stageEliminatedWins for the specified participants. + * Teams NOT in eliminatedEntries that are in this stage are implicitly considered + * to have advanced (stageEliminated remains null). + */ +export async function markCs2StageEliminations( + scoringEventId: string, + eliminations: Array<{ participantId: string; winsAtElimination: number }> +): Promise { + if (eliminations.length === 0) return; + const db = database(); + const now = new Date(); + + // Use individual upserts for each eliminated team to set their specific win count + await Promise.all( + eliminations.map(({ participantId, winsAtElimination }) => + db + .update(cs2MajorStageResults) + .set({ + stageEliminated: sql`${cs2MajorStageResults.stageEntry}`, // eliminated at their current stage + stageEliminatedWins: winsAtElimination, + updatedAt: now, + }) + .where(and( + eq(cs2MajorStageResults.scoringEventId, scoringEventId), + eq(cs2MajorStageResults.participantId, participantId) + )) + ) + ); +} + +/** + * Set final placements for all participants in a CS2 Major event. + * Called after the Champions Stage is complete. + * placements: Map from participantId to final placement (1–32) + */ +export async function setCs2FinalPlacements( + scoringEventId: string, + placements: Record +): Promise { + const db = database(); + const now = new Date(); + const participantIds = Object.keys(placements); + if (participantIds.length === 0) return; + + await Promise.all( + participantIds.map((participantId) => + db + .update(cs2MajorStageResults) + .set({ finalPlacement: placements[participantId], updatedAt: now }) + .where(and( + eq(cs2MajorStageResults.scoringEventId, scoringEventId), + eq(cs2MajorStageResults.participantId, participantId) + )) + ) + ); +} diff --git a/app/routes.ts b/app/routes.ts index cf108fa..5d586d0 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -79,6 +79,10 @@ export default [ "sports-seasons/:id/events/:eventId/bracket", "routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx" ), + route( + "sports-seasons/:id/events/:eventId/cs2-setup", + "routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx" + ), route( "sports-seasons/:id/expected-values", "routes/admin.sports-seasons.$id.expected-values.tsx" @@ -95,10 +99,6 @@ 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 deleted file mode 100644 index 52cd5f4..0000000 --- a/app/routes/admin.sports-seasons.$id.darts-elo.tsx +++ /dev/null @@ -1,470 +0,0 @@ -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. - - - -