diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts new file mode 100644 index 0000000..4875178 --- /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, inArray, 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`stage_entry`, // eliminated at their current stage + stageEliminatedWins: winsAtElimination, + updatedAt: now, + }) + .where( + 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( + eq(cs2MajorStageResults.scoringEventId, scoringEventId) && + eq(cs2MajorStageResults.participantId, participantId) + ) + ) + ); +} diff --git a/app/routes.ts b/app/routes.ts index cf108fa..158fcd4 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" @@ -99,6 +103,10 @@ export default [ "sports-seasons/:id/darts-elo", "routes/admin.sports-seasons.$id.darts-elo.tsx" ), + route( + "sports-seasons/:id/cs-elo", + "routes/admin.sports-seasons.$id.cs-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.cs-elo.tsx b/app/routes/admin.sports-seasons.$id.cs-elo.tsx new file mode 100644 index 0000000..7b6a226 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.cs-elo.tsx @@ -0,0 +1,457 @@ +import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; +import type { Route } from './+types/admin.sports-seasons.$id.cs-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: `CS2 Elo & HLTV 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); + + 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 team' }; + } + + 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.' }; + } + + await batchSaveSourceElos(eloInputs); + + 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.'); + } + + 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); + + const seasonSports = await database().query.seasonSports.findMany({ + where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId), + }); + await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId))); + + 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 CS2 major simulation:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Simulation failed', + }; + } + + return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); +} + +export default function AdminSportsSeasonCsElo() { + 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; + }); + + 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() { + 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; + + 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'; + + 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 ( +
+
+

CS2 Team Elo & HLTV Rankings

+

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

+
+ + + + Bulk Import + + Paste one team per line. Format:{' '} + Team Name, 1850, 5 (name, Elo, HLTV ranking). + HLTV ranking is optional — omit it and the simulator will use Elo order for stage seeding. + Team names are fuzzy-matched to participants. + + + +