* 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 <noreply@anthropic.com>
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
import { Form, useLoaderData, useActionData, useNavigation, Link } from 'react-router';
|
||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||
|
||
import { findSportsSeasonById } from '~/models/sports-season';
|
||
import { getScoringEventById } from '~/models/scoring-event';
|
||
import { findParticipantsBySportsSeasonId } from '~/models/participant';
|
||
import {
|
||
getCs2StageResultsForEvent,
|
||
upsertCs2StageAssignments,
|
||
markCs2StageEliminations,
|
||
clearCs2StageAssignments,
|
||
} from '~/models/cs2-major-stage';
|
||
import { Button } from '~/components/ui/button';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '~/components/ui/card';
|
||
import { Badge } from '~/components/ui/badge';
|
||
import { ArrowLeft, Save, Trash2, CheckCircle2 } from 'lucide-react';
|
||
import { useState } from 'react';
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `CS2 Stage Setup — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||
}
|
||
|
||
export async function loader({ params }: Route.LoaderArgs) {
|
||
const sportsSeasonId = params.id;
|
||
const eventId = params.eventId;
|
||
|
||
const [sportsSeason, event] = await Promise.all([
|
||
findSportsSeasonById(sportsSeasonId),
|
||
getScoringEventById(eventId),
|
||
]);
|
||
|
||
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||
if (!event) throw new Response('Event not found', { status: 404 });
|
||
|
||
const [participants, stageResults] = await Promise.all([
|
||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||
getCs2StageResultsForEvent(eventId),
|
||
]);
|
||
|
||
return { sportsSeason, event, participants, stageResults };
|
||
}
|
||
|
||
interface ActionData {
|
||
success?: boolean;
|
||
message?: string;
|
||
}
|
||
|
||
export async function action({ request, params }: Route.ActionArgs) {
|
||
const eventId = params.eventId;
|
||
const formData = await request.formData();
|
||
const intent = formData.get('intent') as string;
|
||
|
||
if (intent === 'reset') {
|
||
await clearCs2StageAssignments(eventId);
|
||
return { success: true, message: 'Stage assignments cleared.' };
|
||
}
|
||
|
||
if (intent === 'assign') {
|
||
const assignments: Array<{ scoringEventId: string; participantId: string; stageEntry: number }> = [];
|
||
for (const [key, value] of formData.entries()) {
|
||
if (key.startsWith('stage_')) {
|
||
const participantId = key.slice('stage_'.length);
|
||
const stageEntry = parseInt(value as string, 10);
|
||
if (!isNaN(stageEntry) && stageEntry >= 1 && stageEntry <= 3) {
|
||
assignments.push({ scoringEventId: eventId, participantId, stageEntry });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (assignments.length === 0) {
|
||
return { success: false, message: 'No stage assignments submitted.' };
|
||
}
|
||
|
||
await upsertCs2StageAssignments(assignments);
|
||
return { success: true, message: `Saved ${assignments.length} stage assignments.` };
|
||
}
|
||
|
||
if (intent === 'mark-eliminations') {
|
||
// Parse elimination data: elim_{participantId} = winsAtElimination (0, 1, or 2)
|
||
const eliminations: Array<{ participantId: string; winsAtElimination: number }> = [];
|
||
for (const [key, value] of formData.entries()) {
|
||
if (key.startsWith('elim_')) {
|
||
const participantId = key.slice('elim_'.length);
|
||
const wins = parseInt(value as string, 10);
|
||
if (!isNaN(wins) && wins >= 0 && wins <= 2) {
|
||
eliminations.push({ participantId, winsAtElimination: wins });
|
||
}
|
||
}
|
||
}
|
||
|
||
await markCs2StageEliminations(eventId, eliminations);
|
||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||
}
|
||
|
||
return { success: false, message: 'Unknown action.' };
|
||
}
|
||
|
||
const STAGE_LABELS: Record<number, { label: string; description: string; maxTeams: number; badgeVariant: 'default' | 'secondary' | 'outline' }> = {
|
||
1: { label: 'Stage 1', description: 'Opening Stage (16 teams, Bo1 Swiss)', maxTeams: 16, badgeVariant: 'outline' },
|
||
2: { label: 'Stage 2', description: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' },
|
||
3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' },
|
||
};
|
||
|
||
const RECORD_OPTIONS = [
|
||
{ value: '2', label: '2-3 (advanced furthest)' },
|
||
{ value: '1', label: '1-3' },
|
||
{ value: '0', label: '0-3 (earliest exit)' },
|
||
];
|
||
|
||
export default function AdminCs2Setup() {
|
||
const { sportsSeason, event, participants, stageResults } = useLoaderData<typeof loader>();
|
||
const actionData = useActionData<ActionData>();
|
||
const navigation = useNavigation();
|
||
const isSubmitting = navigation.state === 'submitting';
|
||
|
||
// Stage assignment state (local, submitted via form)
|
||
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
|
||
const initial: Record<string, string> = {};
|
||
stageResults.forEach(r => { initial[r.participantId] = r.stageEntry.toString(); });
|
||
return initial;
|
||
});
|
||
|
||
// Elimination checkbox state — tracks which teams are checked as eliminated
|
||
const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => {
|
||
const initial: Record<string, boolean> = {};
|
||
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; });
|
||
return initial;
|
||
});
|
||
|
||
// Group participants by stage for display
|
||
const byStage: Record<number, typeof stageResults> = { 1: [], 2: [], 3: [] };
|
||
for (const r of stageResults) {
|
||
byStage[r.stageEntry]?.push(r);
|
||
}
|
||
|
||
const stageCounts = Object.fromEntries(
|
||
Object.entries(byStage).map(([k, v]) => [k, v.length])
|
||
);
|
||
|
||
return (
|
||
<div className="container mx-auto py-8">
|
||
<div className="mb-6">
|
||
<Link
|
||
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
|
||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
Back to event
|
||
</Link>
|
||
<h1 className="text-3xl font-bold mb-1">CS2 Stage Setup</h1>
|
||
<p className="text-muted-foreground">{event.name} — {sportsSeason.name}</p>
|
||
</div>
|
||
|
||
{actionData?.message && (
|
||
<div className={`mb-4 p-3 rounded-md text-sm ${actionData.success ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30' : 'bg-destructive/10 text-destructive border border-destructive/30'}`}>
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stage Assignment */}
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
Stage Assignments
|
||
{stageResults.length > 0 && (
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="reset" />
|
||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||
<Trash2 className="h-4 w-4 mr-1" />
|
||
Reset all
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Assign each team to their starting stage. Stage 1 = 16 Opening Stage teams,
|
||
Stage 2 = 8 Challengers (skip Stage 1), Stage 3 = 8 Legends (skip Stages 1 and 2).
|
||
Total: 32 teams across all three stages.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<input type="hidden" name="intent" value="assign" />
|
||
|
||
{/* Stage summary badges */}
|
||
<div className="flex gap-3 flex-wrap">
|
||
{[1, 2, 3].map(stage => (
|
||
<div key={stage} className="flex items-center gap-2 text-sm">
|
||
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
|
||
<span className="text-muted-foreground">
|
||
{stageCounts[stage] ?? 0}/{STAGE_LABELS[stage].maxTeams} teams
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Teams listed with stage selectors */}
|
||
<div className="space-y-1">
|
||
<div className="grid grid-cols-[1fr_160px] gap-x-3 text-xs font-medium text-muted-foreground px-1 mb-2">
|
||
<span>Team</span>
|
||
<span>Starting Stage</span>
|
||
</div>
|
||
{participants.map(participant => (
|
||
<div key={participant.id} className="grid grid-cols-[1fr_160px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
|
||
<span className="text-sm">{participant.name}</span>
|
||
<select
|
||
name={`stage_${participant.id}`}
|
||
value={stageSelections[participant.id] ?? ''}
|
||
onChange={e => setStageSelections(prev => ({ ...prev, [participant.id]: e.target.value }))}
|
||
className="h-8 text-sm rounded-md border border-input bg-background px-2"
|
||
>
|
||
<option value="">— not assigned —</option>
|
||
<option value="1">Stage 1 (Opening)</option>
|
||
<option value="2">Stage 2 (Elimination)</option>
|
||
<option value="3">Stage 3 (Decider)</option>
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<Save className="h-4 w-4 mr-2" />
|
||
{isSubmitting ? 'Saving...' : 'Save Stage Assignments'}
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Advancement Tracking (only shown when stage assignments exist) */}
|
||
{stageResults.length > 0 && (
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle>Stage Advancement Tracking</CardTitle>
|
||
<CardDescription>
|
||
Record which teams were eliminated and their W-L record at elimination.
|
||
Teams not listed as eliminated are assumed to have advanced (or are still playing).
|
||
Stage 3 W-L records determine QP sub-placements (9–16).
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<input type="hidden" name="intent" value="mark-eliminations" />
|
||
|
||
{[1, 2, 3].map(stage => {
|
||
const teamsInStage = byStage[stage];
|
||
if (teamsInStage.length === 0) return null;
|
||
|
||
return (
|
||
<div key={stage}>
|
||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
|
||
{STAGE_LABELS[stage].description}
|
||
</h3>
|
||
<div className="space-y-1">
|
||
{teamsInStage.map(r => {
|
||
const isEliminated = eliminatedChecked[r.participantId] ?? false;
|
||
return (
|
||
<div key={r.participantId} className="grid grid-cols-[1fr_auto_200px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
|
||
<span className="text-sm flex items-center gap-2">
|
||
{r.participantName}
|
||
{r.stageEliminated !== null && (
|
||
<Badge variant="outline" className="text-xs">
|
||
eliminated {r.stageEliminatedWins}-3
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={isEliminated}
|
||
onChange={e =>
|
||
setEliminatedChecked(prev => ({
|
||
...prev,
|
||
[r.participantId]: e.target.checked,
|
||
}))
|
||
}
|
||
className="rounded"
|
||
/>
|
||
Eliminated
|
||
</label>
|
||
{isEliminated && (
|
||
<select
|
||
name={`elim_${r.participantId}`}
|
||
defaultValue={r.stageEliminatedWins?.toString() ?? '0'}
|
||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||
>
|
||
{RECORD_OPTIONS.map(opt => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{!isEliminated && <div />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||
{isSubmitting ? 'Saving...' : 'Save Eliminations'}
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Champions Stage link */}
|
||
{stageResults.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Champions Stage (Playoffs)</CardTitle>
|
||
<CardDescription>
|
||
The 8-team single-elimination bracket. Use the bracket admin to track
|
||
Quarterfinals, Semifinals, and Grand Final results.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
|
||
<Button variant="outline">
|
||
Open Bracket Admin →
|
||
</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|