Add CS2 Major Qualifying Points simulator and stage management (#260)
* 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>
This commit is contained in:
parent
61715b43d2
commit
a71e256bfd
18 changed files with 1706 additions and 588 deletions
193
app/models/cs2-major-stage.ts
Normal file
193
app/models/cs2-major-stage.ts
Normal file
|
|
@ -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<Cs2StageResultWithName[]> {
|
||||
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<Map<string, Cs2StageResult>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, number>
|
||||
): Promise<void> {
|
||||
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)
|
||||
))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<string, { elo: number | null; ranking: number | null }> = {};
|
||||
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<typeof loader>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
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<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
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<typeof parseResults>['matched'] = [];
|
||||
const unmatched: typeof parseResults extends null ? never : NonNullable<typeof parseResults>['unmatched'] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Darts Elo & World Rankings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{sportsSeason.sport.name} — {sportsSeason.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk Import */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Bulk Import</CardTitle>
|
||||
<CardDescription>
|
||||
Paste one player per line. Format:{' '}
|
||||
<code>Player Name, 2099, 1</code> (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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3`}
|
||||
value={bulkText}
|
||||
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
|
||||
Parse
|
||||
</Button>
|
||||
|
||||
{parseResults && (
|
||||
<div className="space-y-3">
|
||||
{parseResults.matched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Matched ({parseResults.matched.length})
|
||||
</div>
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
|
||||
{parseResults.matched.map(m => (
|
||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} → Elo {m.elo}{m.ranking !== null ? `, Rank #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.unmatched.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Not matched ({parseResults.unmatched.length}) — enter manually below
|
||||
</div>
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 divide-y divide-amber-100 text-sm">
|
||||
{parseResults.unmatched.map(u => (
|
||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">
|
||||
Elo {u.elo}{u.ranking !== null ? `, Rank #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResults.matched.length > 0 && (
|
||||
<Button type="button" onClick={applyMatches}>
|
||||
Apply {parseResults.matched.length} matched entries to form
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Player Elo & Rankings</CardTitle>
|
||||
<CardDescription>
|
||||
Enter each player's Darts Elo and world ranking. World ranking determines bracket seeding
|
||||
(top 32 seeded, rest randomly drawn). Saving will automatically run the simulation and
|
||||
update expected values.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<div className="grid grid-cols-[1fr_100px_80px] gap-x-3 gap-y-2 items-center text-xs font-medium text-muted-foreground mb-1">
|
||||
<span>Player</span>
|
||||
<span>Elo</span>
|
||||
<span>Rank #</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedParticipants.map(participant => (
|
||||
<div key={participant.id} className="grid grid-cols-[1fr_100px_80px] gap-x-3 items-center">
|
||||
<Label htmlFor={`elo_${participant.id}`} className="truncate text-sm">
|
||||
{participant.name}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id={`elo_${participant.id}`}
|
||||
name={`elo_${participant.id}`}
|
||||
placeholder="1800"
|
||||
value={eloValues[participant.id] ?? ''}
|
||||
onChange={e =>
|
||||
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name={`rank_${participant.id}`}
|
||||
placeholder="—"
|
||||
value={rankValues[participant.id] ?? ''}
|
||||
onChange={e =>
|
||||
setRankValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{actionData && !actionData.success && actionData.message && (
|
||||
<div className="text-sm text-destructive">{actionData.message}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Saving & Running Simulation...' : 'Save & Run Simulation'}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>Save Elo ratings and world rankings for all 128 players</li>
|
||||
<li>Top 32 seeds placed in fixed bracket positions; remaining 96 randomly drawn per simulation</li>
|
||||
<li>Per-set win probability: p = 1 / (1 + e^(−ΔElo/500))</li>
|
||||
<li>Match win probability via Bernoulli sets model for each round's best-of length</li>
|
||||
<li>Run 50,000 Monte Carlo simulations of the full bracket</li>
|
||||
<li>Distribute probabilities across 1st–8th place buckets</li>
|
||||
<li>Calculate expected fantasy value per player</li>
|
||||
</ol>
|
||||
<div className="mt-4 text-muted-foreground text-xs space-y-1">
|
||||
<div>Round formats: R1 bo3, R2/R3 bo5, R4/QF bo7, SF bo11, Final bo13</div>
|
||||
<div>If world ranking is omitted, players are seeded by Elo order.</div>
|
||||
<div>Once the bracket draw is announced, add players to the bracket event for live simulation.</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,15 @@ import {
|
|||
} from '~/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { normalizeName } from '~/lib/fuzzy-match';
|
||||
|
||||
// Simulator types that use worldRanking in addition to sourceElo
|
||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']);
|
||||
|
||||
function rankingLabel(simulatorType: string | null | undefined): string {
|
||||
if (simulatorType === 'cs2_major_qualifying_points') return 'HLTV Rank';
|
||||
return 'World Rank';
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -48,14 +56,17 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
||||
const existingElos: Record<string, number> = {};
|
||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||
for (const ev of existingEVs) {
|
||||
if (ev.sourceElo !== null && ev.sourceElo !== undefined) {
|
||||
existingElos[ev.participantId] = ev.sourceElo;
|
||||
}
|
||||
existingData[ev.participantId] = {
|
||||
elo: ev.sourceElo ?? null,
|
||||
ranking: ev.worldRanking ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { sportsSeason, participants, existingElos };
|
||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||
|
||||
return { sportsSeason, participants, existingData, usesRanking };
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
|
|
@ -73,15 +84,27 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||
|
||||
// Parse Elo ratings from form
|
||||
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number }> = [];
|
||||
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> = [];
|
||||
for (const participant of participants) {
|
||||
const val = formData.get(`elo_${participant.id}`) as string;
|
||||
if (val && val.trim() !== '') {
|
||||
const elo = Number(val);
|
||||
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) {
|
||||
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
|
||||
if (usesRanking) {
|
||||
const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
|
||||
eloInputs.push({
|
||||
participantId: participant.id,
|
||||
sportsSeasonId,
|
||||
sourceElo: elo,
|
||||
worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null,
|
||||
});
|
||||
} else {
|
||||
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,10 +121,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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 {
|
||||
|
|
@ -112,8 +133,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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,
|
||||
|
|
@ -127,7 +146,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
scoringRules: DEFAULT_SCORING_RULES,
|
||||
source: 'elo_simulation' as const,
|
||||
})),
|
||||
...allParticipants
|
||||
...participants
|
||||
.filter(p => !simulatedIds.has(p.id))
|
||||
.map(p => ({
|
||||
participantId: p.id,
|
||||
|
|
@ -140,13 +159,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
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 => ({
|
||||
|
|
@ -169,7 +186,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
|
||||
} catch (error) {
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
|
||||
logger.error('Error running snooker simulation:', error);
|
||||
logger.error('Error running simulation:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Simulation failed',
|
||||
|
|
@ -179,31 +196,33 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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<typeof loader>();
|
||||
const { sportsSeason, participants, existingData, usesRanking } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
participants.forEach(p => {
|
||||
const existing = existingElos[p.id];
|
||||
if (existing !== undefined && existing !== null) {
|
||||
initial[p.id] = existing.toString();
|
||||
}
|
||||
const d = existingData[p.id];
|
||||
if (d?.elo !== null && d?.elo !== undefined) initial[p.id] = d.elo.toString();
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [rankValues, setRankValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
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, 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 }>;
|
||||
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) {
|
||||
|
|
@ -227,30 +246,33 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
}
|
||||
|
||||
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 matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number; ranking: number | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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);
|
||||
// Match "Name, Elo" or "Name, Elo, Ranking" (comma/colon/tab separated)
|
||||
const match = usesRanking
|
||||
? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed)
|
||||
: /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed);
|
||||
if (!match) continue;
|
||||
|
||||
const inputName = match[1].trim();
|
||||
const elo = parseInt(match[2], 10);
|
||||
const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null;
|
||||
|
||||
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 });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo });
|
||||
unmatched.push({ inputName, elo, ranking });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,16 +281,33 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
function applyMatches() {
|
||||
if (!parseResults) return;
|
||||
const newValues = { ...eloValues };
|
||||
const newElos = { ...eloValues };
|
||||
const newRanks = { ...rankValues };
|
||||
for (const m of parseResults.matched) {
|
||||
newValues[m.participantId] = m.elo.toString();
|
||||
newElos[m.participantId] = m.elo.toString();
|
||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||
}
|
||||
setEloValues(newValues);
|
||||
setEloValues(newElos);
|
||||
setRankValues(newRanks);
|
||||
setParseResults(null);
|
||||
setBulkText('');
|
||||
}
|
||||
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
||||
const rankLabel = rankingLabel(simulatorType);
|
||||
|
||||
// When ranking is available, sort by rank ascending, then unranked alphabetically
|
||||
const sortedParticipants = usesRanking
|
||||
? [...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);
|
||||
})
|
||||
: participants;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
|
|
@ -284,13 +323,29 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
<CardHeader>
|
||||
<CardTitle>Bulk Import</CardTitle>
|
||||
<CardDescription>
|
||||
Paste Elo ratings one per line. Format: <code>Player Name, 2450</code>. Player names
|
||||
are fuzzy-matched to participants. Elo values should be in the 2000–2900 range for snooker.
|
||||
{usesRanking ? (
|
||||
<>
|
||||
Paste one entry per line. Format:{' '}
|
||||
<code>Name, Elo, {rankLabel}</code> (ranking is optional — omit it and the simulator
|
||||
will use Elo order for seeding). Names are fuzzy-matched to participants.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Paste Elo ratings one per line. Format: <code>Player Name, 2450</code>. Player names
|
||||
are fuzzy-matched to participants.
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder={`Judd Trump, 2594\nRonnie O'Sullivan, 2441\nMark Selby, 2432`}
|
||||
placeholder={
|
||||
usesRanking
|
||||
? simulatorType === 'cs2_major_qualifying_points'
|
||||
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
|
||||
: `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3`
|
||||
: `Judd Trump, 2594\nRonnie O'Sullivan, 2441\nMark Selby, 2432`
|
||||
}
|
||||
value={bulkText}
|
||||
onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
|
||||
rows={8}
|
||||
|
|
@ -312,7 +367,10 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
{parseResults.matched.map(m => (
|
||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">{m.name} → {m.elo}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} → Elo {m.elo}
|
||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -329,7 +387,10 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
{parseResults.unmatched.map(u => (
|
||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">{u.elo}</span>
|
||||
<span className="font-medium">
|
||||
Elo {u.elo}
|
||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -349,29 +410,58 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Player Elo Ratings</CardTitle>
|
||||
<CardTitle>
|
||||
{usesRanking ? `Player Elo & ${rankLabel}s` : 'Player Elo Ratings'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter each player's current Elo rating. Saving will automatically run the simulation
|
||||
and update expected values. Snooker Elo typically ranges from ~2000 (qualifier level)
|
||||
to ~2600 (world-class).
|
||||
{usesRanking
|
||||
? `Enter each player's Elo and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||||
: 'Enter each player\'s current Elo rating. Saving will automatically run the simulation and update expected values.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{participants.map(participant => (
|
||||
<div key={participant.id} className="grid grid-cols-2 gap-4 items-center">
|
||||
<Label htmlFor={`elo_${participant.id}`}>{participant.name}</Label>
|
||||
{usesRanking && (
|
||||
<div className="grid grid-cols-[1fr_100px_90px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
|
||||
<span>Player</span>
|
||||
<span>Elo</span>
|
||||
<span>{rankLabel} #</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{sortedParticipants.map(participant => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={usesRanking
|
||||
? 'grid grid-cols-[1fr_100px_90px] gap-x-3 items-center'
|
||||
: 'grid grid-cols-2 gap-4 items-center'}
|
||||
>
|
||||
<Label htmlFor={`elo_${participant.id}`} className="truncate text-sm">
|
||||
{participant.name}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id={`elo_${participant.id}`}
|
||||
name={`elo_${participant.id}`}
|
||||
placeholder="2450"
|
||||
placeholder={usesRanking ? '1800' : '2450'}
|
||||
value={eloValues[participant.id] ?? ''}
|
||||
onChange={e =>
|
||||
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||
}
|
||||
className={usesRanking ? 'h-8 text-sm' : undefined}
|
||||
/>
|
||||
{usesRanking && (
|
||||
<Input
|
||||
type="number"
|
||||
name={`rank_${participant.id}`}
|
||||
placeholder="—"
|
||||
value={rankValues[participant.id] ?? ''}
|
||||
onChange={e =>
|
||||
setRankValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -394,17 +484,18 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>Save Elo ratings for all participants</li>
|
||||
<li>Compute per-frame win probability: p = 1 / (1 + e^(−ΔElo/700))</li>
|
||||
<li>Compute match win probability using the Bernoulli frame model for each round's best-of length</li>
|
||||
<li>Run 50,000 Monte Carlo simulations of the full bracket</li>
|
||||
<li>Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants</li>
|
||||
<li>Compute per-game win probability from Elo difference</li>
|
||||
<li>Compute match win probability using the Bernoulli model for each round's format</li>
|
||||
<li>Run Monte Carlo simulations of the full event</li>
|
||||
<li>Distribute probabilities across 1st–8th place buckets</li>
|
||||
<li>Calculate expected fantasy value per player</li>
|
||||
</ol>
|
||||
<div className="mt-4 text-muted-foreground text-xs space-y-1">
|
||||
<div>Round frames: R32 best-of-19, R16/QF best-of-25, SF best-of-33, Final best-of-35</div>
|
||||
<div>World rankings (for pre-bracket draws) are hardcoded in the simulator.</div>
|
||||
</div>
|
||||
{usesRanking && (
|
||||
<div className="mt-4 text-muted-foreground text-xs">
|
||||
{rankLabel} is optional — if omitted, the simulator uses Elo order for seeding.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||
},
|
||||
event,
|
||||
participants,
|
||||
|
|
|
|||
|
|
@ -230,6 +230,14 @@ export default function EventResults({
|
|||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{sportsSeason.sport?.simulatorType === "cs2_major_qualifying_points" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/cs2-setup`}>
|
||||
<Brackets className="mr-2 h-4 w-4" />
|
||||
CS2 Stage Setup
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{event.isComplete ? (
|
||||
<Badge variant="default" className="bg-emerald-500">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
|
|
|
|||
237
app/services/simulations/__tests__/cs-major-simulator.test.ts
Normal file
237
app/services/simulations/__tests__/cs-major-simulator.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
gameWinProb,
|
||||
seriesWinProb,
|
||||
sampleField,
|
||||
simulateSwiss,
|
||||
simulateChampionsStage,
|
||||
} from "../cs-major-simulator";
|
||||
|
||||
// Helper to build a pool of teams
|
||||
function makeTeams(n: number, baseElo = 1800, baseRank = 1) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `team-${i}`,
|
||||
elo: baseElo - i * 10,
|
||||
rank: baseRank + i,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── gameWinProb ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("gameWinProb", () => {
|
||||
it("returns 0.5 for equal Elo", () => {
|
||||
expect(gameWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
|
||||
expect(gameWinProb(2000, 2000)).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it("returns > 0.5 for positive advantage, < 0.5 for negative", () => {
|
||||
expect(gameWinProb(2000, 1700)).toBeGreaterThan(0.5);
|
||||
expect(gameWinProb(1700, 2000)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("is symmetric: p(a,b) + p(b,a) = 1", () => {
|
||||
expect(gameWinProb(2000, 1700) + gameWinProb(1700, 2000)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it("stays strictly in (0, 1)", () => {
|
||||
const p = gameWinProb(3000, 1000);
|
||||
expect(p).toBeLessThan(1);
|
||||
expect(p).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── seriesWinProb ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("seriesWinProb", () => {
|
||||
it("returns 0.5 for equal players (p=0.5) in any format", () => {
|
||||
expect(seriesWinProb(0.5, 2)).toBeCloseTo(0.5, 5); // Bo3
|
||||
expect(seriesWinProb(0.5, 3)).toBeCloseTo(0.5, 5); // Bo5
|
||||
expect(seriesWinProb(0.5, 4)).toBeCloseTo(0.5, 5); // Bo7
|
||||
});
|
||||
|
||||
it("amplifies better team advantage in longer formats", () => {
|
||||
const bo3 = seriesWinProb(0.6, 2);
|
||||
const bo5 = seriesWinProb(0.6, 3);
|
||||
expect(bo5).toBeGreaterThan(bo3);
|
||||
});
|
||||
|
||||
it("approaches 1.0 as p → 1.0", () => {
|
||||
expect(seriesWinProb(0.9999, 2)).toBeCloseTo(1.0, 3);
|
||||
expect(seriesWinProb(0.9999, 3)).toBeCloseTo(1.0, 3);
|
||||
});
|
||||
|
||||
it("sums to 1 with complement", () => {
|
||||
const p = 0.63;
|
||||
expect(seriesWinProb(p, 2) + seriesWinProb(1 - p, 2)).toBeCloseTo(1.0, 5);
|
||||
expect(seriesWinProb(p, 3) + seriesWinProb(1 - p, 3)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it("Bo3 win prob with p=0.6 is in expected range", () => {
|
||||
// P(2-0) + P(2-1) = 0.36 + 2*0.36*0.4 = 0.36 + 0.288 = 0.648
|
||||
expect(seriesWinProb(0.6, 2)).toBeCloseTo(0.648, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sampleField ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("sampleField", () => {
|
||||
it("returns all teams when pool <= fieldSize", () => {
|
||||
const pool = makeTeams(20);
|
||||
const field = sampleField(pool, 32);
|
||||
expect(field).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("returns fieldSize teams when pool > fieldSize", () => {
|
||||
const pool = makeTeams(50);
|
||||
const field = sampleField(pool, 32);
|
||||
expect(field).toHaveLength(32);
|
||||
});
|
||||
|
||||
it("always includes the top 12 ranked teams", () => {
|
||||
const pool = makeTeams(50);
|
||||
const top12Ids = new Set(pool.slice(0, 12).map((t) => t.id));
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const field = sampleField(pool, 32);
|
||||
const fieldIds = new Set(field.map((t) => t.id));
|
||||
for (const id of top12Ids) {
|
||||
expect(fieldIds.has(id)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never includes more teams than fieldSize", () => {
|
||||
const pool = makeTeams(100);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(sampleField(pool, 32)).toHaveLength(32);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns unique teams", () => {
|
||||
const pool = makeTeams(50);
|
||||
const field = sampleField(pool, 32);
|
||||
const ids = field.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateSwiss ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("simulateSwiss", () => {
|
||||
it("returns exactly 8 advanced and 8 eliminated from 16 teams", () => {
|
||||
const teams = makeTeams(16);
|
||||
const result = simulateSwiss(teams, false);
|
||||
expect(result.advanced).toHaveLength(8);
|
||||
expect(result.eliminated).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("all advanced teams have 3 wins (implicit by advancement threshold)", () => {
|
||||
const teams = makeTeams(16);
|
||||
const result = simulateSwiss(teams, false);
|
||||
// Advanced teams reached 3 wins — we can't directly check wins here,
|
||||
// but we verify each participant appears in exactly one group
|
||||
const allIds = new Set([
|
||||
...result.advanced.map((t) => t.id),
|
||||
...result.eliminated.map((t) => t.id),
|
||||
]);
|
||||
expect(allIds.size).toBe(16);
|
||||
for (const team of teams) {
|
||||
expect(allIds.has(team.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminated teams have wins 0, 1, or 2", () => {
|
||||
const teams = makeTeams(16);
|
||||
const result = simulateSwiss(teams, false);
|
||||
for (const t of result.eliminated) {
|
||||
expect(t.wins).toBeGreaterThanOrEqual(0);
|
||||
expect(t.wins).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("total wins + losses = total matches played (conservation check)", () => {
|
||||
const teams = makeTeams(16);
|
||||
const result = simulateSwiss(teams, false);
|
||||
// Eliminated teams have exactly 3 losses
|
||||
// Total losses = 8 * 3 = 24
|
||||
// Total wins = sum of wins for all eliminated + 8 * 3 for advanced = ?
|
||||
// Each win by advanced team = 1 loss for an eliminated team
|
||||
const totalLossesEliminated = result.eliminated.length * 3; // 24
|
||||
expect(totalLossesEliminated).toBe(24);
|
||||
});
|
||||
|
||||
it("works with Bo3 format (Swiss all Bo3)", () => {
|
||||
const teams = makeTeams(16);
|
||||
const result = simulateSwiss(teams, true);
|
||||
expect(result.advanced).toHaveLength(8);
|
||||
expect(result.eliminated).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("teams with much higher Elo advance more often (stochastic check)", () => {
|
||||
// Top 8 teams have Elo 2000+, bottom 8 have Elo 1000
|
||||
const teams = [
|
||||
...Array.from({ length: 8 }, (_, i) => ({ id: `top-${i}`, elo: 2000, rank: i + 1 })),
|
||||
...Array.from({ length: 8 }, (_, i) => ({ id: `bot-${i}`, elo: 1000, rank: i + 9 })),
|
||||
];
|
||||
|
||||
let topAdvances = 0;
|
||||
const runs = 100;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const result = simulateSwiss(teams, false);
|
||||
for (const t of result.advanced) {
|
||||
if (t.id.startsWith("top-")) topAdvances++;
|
||||
}
|
||||
}
|
||||
|
||||
// Top teams should advance much more than half the time across runs
|
||||
// Expected: ~7-8 top teams advance per run → topAdvances should be >> 400 (50%)
|
||||
expect(topAdvances / runs).toBeGreaterThan(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateChampionsStage ───────────────────────────────────────────────────
|
||||
|
||||
describe("simulateChampionsStage", () => {
|
||||
it("throws if not exactly 8 teams", () => {
|
||||
expect(() => simulateChampionsStage(makeTeams(7))).toThrow();
|
||||
expect(() => simulateChampionsStage(makeTeams(9))).toThrow();
|
||||
});
|
||||
|
||||
it("assigns placements to all 8 teams", () => {
|
||||
const teams = makeTeams(8);
|
||||
const result = simulateChampionsStage(teams);
|
||||
expect(result.placements.size).toBe(8);
|
||||
for (const team of teams) {
|
||||
expect(result.placements.has(team.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly one 1st, one 2nd, two 3rd/4th, four 5th-8th", () => {
|
||||
const teams = makeTeams(8);
|
||||
const result = simulateChampionsStage(teams);
|
||||
const placements = [...result.placements.values()];
|
||||
expect(placements.filter(p => p === 1)).toHaveLength(1);
|
||||
expect(placements.filter(p => p === 2)).toHaveLength(1);
|
||||
expect(placements.filter(p => p >= 3 && p <= 4)).toHaveLength(2);
|
||||
expect(placements.filter(p => p >= 5 && p <= 8)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("top Elo team wins more often than last (stochastic)", () => {
|
||||
// Use a clear Elo spread so team-0 has a decisive advantage.
|
||||
// makeTeams(8) only gives a 70-pt gap (1800→1730) which puts the
|
||||
// true win rate right at the 0.2 threshold — extremely flaky.
|
||||
// 100-pt steps (1800→1100) give team-0 a ~40% win rate, well clear of 0.25.
|
||||
const teams = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `team-${i}`,
|
||||
elo: 1800 - i * 100,
|
||||
rank: i + 1,
|
||||
}));
|
||||
let wins = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const result = simulateChampionsStage(teams);
|
||||
if (result.placements.get("team-0") === 1) wins++;
|
||||
}
|
||||
// Should win well above 12.5% (1/8 random baseline)
|
||||
expect(wins / 1000).toBeGreaterThan(0.25);
|
||||
});
|
||||
});
|
||||
658
app/services/simulations/cs-major-simulator.ts
Normal file
658
app/services/simulations/cs-major-simulator.ts
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
/**
|
||||
* CS2 Major Qualifying Points Simulator
|
||||
*
|
||||
* Monte Carlo simulation of the 2 CS2 Majors per year. Qualifying points (QP)
|
||||
* accumulate across both majors; final QP totals determine fantasy placements (1st–8th).
|
||||
*
|
||||
* CS2 Major format (32 teams, 3 Swiss stages + playoffs):
|
||||
* Stage 1 (Opening Stage): 16 teams, Swiss, Bo1 matches
|
||||
* Stage 2 (Elimination Stage): 16 teams (8 Challengers + 8 from Stage 1), Swiss, Bo1/Bo3
|
||||
* Stage 3 (Decider Stage): 16 teams (8 Legends + 8 from Stage 2), Swiss, all Bo3
|
||||
* Champions Stage: 8 teams, single-elimination (QF Bo3, SF Bo3, GF Bo5)
|
||||
*
|
||||
* Field selection (per iteration):
|
||||
* - Top 12 participants by world ranking are always in the simulated field.
|
||||
* - Remaining spots (up to 32 total) are sampled from the rest of the pool,
|
||||
* weighted by 1/rank (lower rank = higher inclusion probability).
|
||||
* - If cs2MajorStageResults records exist for an event, those explicit
|
||||
* stage assignments are used instead of sampling/rank inference.
|
||||
*
|
||||
* Stage assignment within the 32-team field:
|
||||
* - With explicit stage data: use stageEntry from cs2MajorStageResults
|
||||
* - Without: top 8 by world ranking → Stage 3, next 8 → Stage 2, bottom 16 → Stage 1
|
||||
*
|
||||
* QP for Stage 3 exits (placements 9–16) is sub-ranked by W-L record:
|
||||
* - 2-3 teams → higher placements within 9–16
|
||||
* - 1-3 teams → middle placements
|
||||
* - 0-3 teams → lowest placements within 9–16
|
||||
* QP is tie-split (averaged) within each W-L group.
|
||||
*
|
||||
* Stages 1 and 2 exits (placements 17–32) earn 0 QP.
|
||||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import * as schema from "~/database/schema";
|
||||
import { getQPConfig } from "~/models/qualifying-points";
|
||||
import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage";
|
||||
import type { Simulator, SimulationResult } from "./types";
|
||||
|
||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||
|
||||
const NUM_SIMULATIONS = 10_000;
|
||||
|
||||
/** Total field size per CS2 Major. */
|
||||
const FIELD_SIZE = 32;
|
||||
|
||||
/** Number of teams guaranteed in the simulated field (always included). */
|
||||
const GUARANTEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Elo divisor for per-game win probability.
|
||||
* Standard chess Elo uses 400. CS2 maps well to single-game level.
|
||||
* 200-pt gap ≈ 76% win probability; 400-pt gap ≈ 91%.
|
||||
*/
|
||||
const ELO_DIVISOR = 400;
|
||||
|
||||
/** Fallback Elo for teams with no stored rating. */
|
||||
const FALLBACK_ELO = 1500;
|
||||
|
||||
// ─── Math helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-game win probability for team 1 vs team 2.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function gameWinProb(elo1: number, elo2: number): number {
|
||||
return 1 / (1 + Math.pow(10, (elo2 - elo1) / ELO_DIVISOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match win probability using the Bernoulli series model.
|
||||
* For a best-of-(2S-1) match (first to S wins):
|
||||
* P(win) = Σ_{k=0}^{S-1} C(S-1+k, k) × p^S × (1-p)^k
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function seriesWinProb(p: number, winsNeeded: number): number {
|
||||
let prob = 0;
|
||||
for (let k = 0; k < winsNeeded; k++) {
|
||||
prob += binomialCoeff(winsNeeded - 1 + k, k) * Math.pow(p, winsNeeded) * Math.pow(1 - p, k);
|
||||
}
|
||||
return prob;
|
||||
}
|
||||
|
||||
/** Binomial coefficient C(n, k). */
|
||||
function binomialCoeff(n: number, k: number): number {
|
||||
if (k === 0) return 1;
|
||||
if (k > n - k) k = n - k;
|
||||
let result = 1;
|
||||
for (let i = 0; i < k; i++) {
|
||||
result = (result * (n - i)) / (i + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fisher-Yates in-place shuffle. Returns the array for chaining. */
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// ─── Field selection ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TeamWithElo {
|
||||
id: string;
|
||||
elo: number;
|
||||
rank: number; // HLTV world ranking (lower = better)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a 32-team field from the pool.
|
||||
* - Top GUARANTEED_COUNT (12) by rank are always included.
|
||||
* - Remaining spots are weighted-randomly sampled from the rest, weight = 1/rank.
|
||||
* - If pool.length <= FIELD_SIZE, returns all teams.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function sampleField(
|
||||
pool: TeamWithElo[],
|
||||
fieldSize: number = FIELD_SIZE
|
||||
): TeamWithElo[] {
|
||||
const sorted = [...pool].toSorted((a, b) => a.rank - b.rank);
|
||||
|
||||
if (sorted.length <= fieldSize) return sorted;
|
||||
|
||||
const guaranteed = sorted.slice(0, GUARANTEED_COUNT);
|
||||
const rest = sorted.slice(GUARANTEED_COUNT);
|
||||
const needed = fieldSize - guaranteed.length;
|
||||
|
||||
if (needed <= 0) return guaranteed;
|
||||
if (rest.length <= needed) return [...guaranteed, ...rest];
|
||||
|
||||
// Weighted sample without replacement, weight = 1/rank
|
||||
const weights = rest.map((t) => 1 / t.rank);
|
||||
const sampled = weightedSampleWithoutReplacement(rest, weights, needed);
|
||||
|
||||
return [...guaranteed, ...sampled];
|
||||
}
|
||||
|
||||
/** Weighted sampling without replacement using the Gumbel-max trick. */
|
||||
function weightedSampleWithoutReplacement<T>(
|
||||
items: T[],
|
||||
weights: number[],
|
||||
count: number
|
||||
): T[] {
|
||||
const keys = weights.map((w) => -Math.log(Math.random()) / w);
|
||||
const indexed = items.map((item, i) => ({ item, key: keys[i] }));
|
||||
const sortedIndexed = indexed.toSorted((a, b) => a.key - b.key);
|
||||
return sortedIndexed.slice(0, count).map((x) => x.item);
|
||||
}
|
||||
|
||||
// ─── Swiss stage simulation ───────────────────────────────────────────────────
|
||||
|
||||
interface SwissResult {
|
||||
/** Teams that advanced (3 wins). */
|
||||
advanced: Array<{ id: string; elo: number; rank: number }>;
|
||||
/** Teams eliminated, with their win count at time of elimination. */
|
||||
eliminated: Array<{ id: string; elo: number; rank: number; wins: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate one Swiss-format stage.
|
||||
* Teams are grouped by their (wins, losses) record each round. Teams are paired
|
||||
* randomly within each record group. First to 3 wins advances; first to 3 losses
|
||||
* is eliminated.
|
||||
*
|
||||
* @param teams - Teams entering this stage.
|
||||
* @param bo3 - If true, use Bo3 series win probability; otherwise Bo1 (single game).
|
||||
* @returns advanced and eliminated arrays.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult {
|
||||
const wins = new Map<string, number>(teams.map((t) => [t.id, 0]));
|
||||
const losses = new Map<string, number>(teams.map((t) => [t.id, 0]));
|
||||
const eloMap = new Map<string, number>(teams.map((t) => [t.id, t.elo]));
|
||||
|
||||
const advanced: SwissResult["advanced"] = [];
|
||||
const eliminated: SwissResult["eliminated"] = [];
|
||||
const advancedIds = new Set<string>();
|
||||
const eliminatedIds = new Set<string>();
|
||||
|
||||
const teamById = new Map<string, TeamWithElo>(teams.map((t) => [t.id, t]));
|
||||
|
||||
// Run rounds until every team has reached 3W or 3L
|
||||
while (advancedIds.size + eliminatedIds.size < teams.length) {
|
||||
// Collect active teams grouped by (W, L) record
|
||||
const active = teams.filter((t) => !advancedIds.has(t.id) && !eliminatedIds.has(t.id));
|
||||
const groups = groupByRecord(active, wins, losses);
|
||||
|
||||
// If a group has an odd number, move one team to the nearest adjacent group
|
||||
// (simplified: skip teams that can't be paired — they sit out this round)
|
||||
const pairs = pairGroups(groups);
|
||||
|
||||
// Safety: if no pairs can be formed (shouldn't happen with even team counts
|
||||
// but guards against an infinite loop if an odd active count somehow occurs)
|
||||
if (pairs.length === 0) break;
|
||||
|
||||
for (const [t1, t2] of pairs) {
|
||||
const e1 = eloMap.get(t1.id) ?? FALLBACK_ELO;
|
||||
const e2 = eloMap.get(t2.id) ?? FALLBACK_ELO;
|
||||
const p = bo3
|
||||
? seriesWinProb(gameWinProb(e1, e2), 2) // Bo3: first to 2
|
||||
: gameWinProb(e1, e2); // Bo1: single game
|
||||
|
||||
const t1Wins = Math.random() < p;
|
||||
const winnerId = t1Wins ? t1.id : t2.id;
|
||||
const loserId = t1Wins ? t2.id : t1.id;
|
||||
|
||||
wins.set(winnerId, (wins.get(winnerId) ?? 0) + 1);
|
||||
losses.set(loserId, (losses.get(loserId) ?? 0) + 1);
|
||||
|
||||
if ((wins.get(winnerId) ?? 0) >= 3) {
|
||||
advancedIds.add(winnerId);
|
||||
const t = teamById.get(winnerId);
|
||||
if (t) advanced.push({ id: t.id, elo: t.elo, rank: t.rank });
|
||||
}
|
||||
if ((losses.get(loserId) ?? 0) >= 3) {
|
||||
eliminatedIds.add(loserId);
|
||||
const t = teamById.get(loserId);
|
||||
if (t) eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: wins.get(loserId) ?? 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { advanced, eliminated };
|
||||
}
|
||||
|
||||
/** Group active teams by (wins, losses) record key. */
|
||||
function groupByRecord(
|
||||
active: TeamWithElo[],
|
||||
wins: Map<string, number>,
|
||||
losses: Map<string, number>
|
||||
): Map<string, TeamWithElo[]> {
|
||||
const groups = new Map<string, TeamWithElo[]>();
|
||||
for (const t of active) {
|
||||
const key = `${wins.get(t.id) ?? 0}-${losses.get(t.id) ?? 0}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)?.push(t);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair teams within each record group. Teams in groups with an odd count
|
||||
* are handled by merging the leftover into an adjacent group (simpler: skip
|
||||
* them for this round — they sit out and play next round).
|
||||
*/
|
||||
function pairGroups(groups: Map<string, TeamWithElo[]>): Array<[TeamWithElo, TeamWithElo]> {
|
||||
const pairs: Array<[TeamWithElo, TeamWithElo]> = [];
|
||||
const leftover: TeamWithElo[] = [];
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const shuffled = shuffle([...group]);
|
||||
for (let i = 0; i + 1 < shuffled.length; i += 2) {
|
||||
pairs.push([shuffled[i], shuffled[i + 1]]);
|
||||
}
|
||||
if (shuffled.length % 2 !== 0) {
|
||||
leftover.push(shuffled[shuffled.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Pair leftover teams with each other (different records, cross-group match)
|
||||
for (let i = 0; i + 1 < leftover.length; i += 2) {
|
||||
pairs.push([leftover[i], leftover[i + 1]]);
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ─── Champions Stage (8-team single-elimination bracket) ─────────────────────
|
||||
|
||||
interface ChampionsResult {
|
||||
placements: Map<string, number>; // participantId → placement (1–8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the Champions Stage 8-team single-elimination bracket.
|
||||
* Seeds are assigned by rank within the 8 advancing teams.
|
||||
* Rounds: QF (Bo3), SF (Bo3), GF (Bo5).
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function simulateChampionsStage(teams: TeamWithElo[]): ChampionsResult {
|
||||
if (teams.length !== 8) {
|
||||
throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`);
|
||||
}
|
||||
|
||||
// Seed by rank ascending
|
||||
const seeded = [...teams].toSorted((a, b) => a.rank - b.rank);
|
||||
// Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7
|
||||
const bracket: [TeamWithElo, TeamWithElo][] = [
|
||||
[seeded[0], seeded[7]],
|
||||
[seeded[3], seeded[4]],
|
||||
[seeded[2], seeded[5]],
|
||||
[seeded[1], seeded[6]],
|
||||
];
|
||||
|
||||
const placements = new Map<string, number>();
|
||||
|
||||
const simMatch = (t1: TeamWithElo, t2: TeamWithElo, winsNeeded: number): TeamWithElo => {
|
||||
const p = seriesWinProb(gameWinProb(t1.elo, t2.elo), winsNeeded);
|
||||
return Math.random() < p ? t1 : t2;
|
||||
};
|
||||
|
||||
// Quarterfinals (Bo3 = first to 2)
|
||||
const sfTeams: TeamWithElo[] = [];
|
||||
for (const [t1, t2] of bracket) {
|
||||
const winner = simMatch(t1, t2, 2);
|
||||
const loser = winner.id === t1.id ? t2 : t1;
|
||||
sfTeams.push(winner);
|
||||
placements.set(loser.id, 7); // QF losers: 5th–8th (averaged to 6.5, use 7 for counting)
|
||||
}
|
||||
// Assign QF losers to placements 5–8
|
||||
const qfLosers = [...placements.keys()];
|
||||
qfLosers.forEach((id, i) => placements.set(id, 5 + i));
|
||||
|
||||
// Semifinals (Bo3 = first to 2)
|
||||
const finalTeams: TeamWithElo[] = [];
|
||||
const sfLosers: TeamWithElo[] = [];
|
||||
for (let i = 0; i < sfTeams.length; i += 2) {
|
||||
const winner = simMatch(sfTeams[i], sfTeams[i + 1], 2);
|
||||
const loser = winner.id === sfTeams[i].id ? sfTeams[i + 1] : sfTeams[i];
|
||||
finalTeams.push(winner);
|
||||
sfLosers.push(loser);
|
||||
}
|
||||
sfLosers.forEach((t, i) => placements.set(t.id, 3 + i));
|
||||
|
||||
// Grand Final (Bo5 = first to 3)
|
||||
const champion = simMatch(finalTeams[0], finalTeams[1], 3);
|
||||
const finalist = champion.id === finalTeams[0].id ? finalTeams[1] : finalTeams[0];
|
||||
placements.set(champion.id, 1);
|
||||
placements.set(finalist.id, 2);
|
||||
|
||||
return { placements };
|
||||
}
|
||||
|
||||
// ─── QP helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate QP for Stage 3 exits based on their W-L record.
|
||||
* Teams are ranked within 9–16 by wins (2-3 > 1-3 > 0-3).
|
||||
* Within the same wins count, QP is tie-split (averaged) across placement slots.
|
||||
*
|
||||
* qpConfig: array indexed by placement (1-indexed), qpConfig[placement] = QP value.
|
||||
* Placements 9–16 correspond to indices 9–16.
|
||||
*/
|
||||
function calcStage3ExitQP(
|
||||
elimTeams: Array<{ id: string; wins: number }>,
|
||||
qpConfig: Map<number, number>
|
||||
): Map<string, number> {
|
||||
// Group teams by wins count (0, 1, 2)
|
||||
const byWins = new Map<number, string[]>();
|
||||
for (const t of elimTeams) {
|
||||
if (!byWins.has(t.wins)) byWins.set(t.wins, []);
|
||||
byWins.get(t.wins)?.push(t.id);
|
||||
}
|
||||
|
||||
// Assign placement slots 9–16 from highest wins first
|
||||
const result = new Map<string, number>();
|
||||
const winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]); // descending wins
|
||||
|
||||
let slotStart = 9;
|
||||
for (const [, ids] of winsGroups) {
|
||||
const slots = Array.from({ length: ids.length }, (_, i) => slotStart + i);
|
||||
const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length;
|
||||
for (const id of ids) {
|
||||
result.set(id, avgQP);
|
||||
}
|
||||
slotStart += ids.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class CSMajorSimulator implements Simulator {
|
||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||
const db = database();
|
||||
|
||||
// 1. Load all participants for this sports season.
|
||||
const allParticipants = await db
|
||||
.select({ id: schema.participants.id })
|
||||
.from(schema.participants)
|
||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
const participantIds = allParticipants.map((p) => p.id);
|
||||
|
||||
if (participantIds.length === 0) {
|
||||
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
|
||||
}
|
||||
|
||||
// 2. Load Elo ratings and world rankings from participantExpectedValues.
|
||||
const evRows = await db
|
||||
.select({
|
||||
participantId: schema.participantExpectedValues.participantId,
|
||||
sourceElo: schema.participantExpectedValues.sourceElo,
|
||||
worldRanking: schema.participantExpectedValues.worldRanking,
|
||||
})
|
||||
.from(schema.participantExpectedValues)
|
||||
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||||
|
||||
const eloMap = new Map<string, { elo: number; rank: number }>();
|
||||
for (const row of evRows) {
|
||||
if (row.sourceElo !== null) {
|
||||
eloMap.set(row.participantId, {
|
||||
elo: row.sourceElo,
|
||||
rank: row.worldRanking ?? 9999,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build pool: all participants with Elo ratings, sorted by rank
|
||||
const pool: TeamWithElo[] = participantIds
|
||||
.filter((id) => eloMap.has(id))
|
||||
.map((id) => {
|
||||
const e = eloMap.get(id);
|
||||
return { id, elo: e?.elo ?? FALLBACK_ELO, rank: e?.rank ?? 9999 };
|
||||
})
|
||||
.toSorted((a, b) => a.rank - b.rank);
|
||||
|
||||
if (pool.length === 0) {
|
||||
throw new Error(
|
||||
`No participants with Elo ratings found for sports season ${sportsSeasonId}. ` +
|
||||
`Enter Elo ratings via the CS Elo admin page before simulating.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Load QP config (placements 1–16 earn QP; 17+ earn 0).
|
||||
const qpConfigArray = await getQPConfig(sportsSeasonId);
|
||||
const qpConfig = new Map<number, number>(
|
||||
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
|
||||
);
|
||||
|
||||
// 4. Load all CS major scoring events for this sports season.
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.eventType, "major_tournament")
|
||||
),
|
||||
orderBy: (e, { asc }) => [asc(e.eventDate)],
|
||||
});
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error(
|
||||
`No major_tournament scoring events found for sports season ${sportsSeasonId}. ` +
|
||||
`Create the CS Major scoring events first.`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. For completed events, read actual QP from eventResults.
|
||||
const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id);
|
||||
const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||
|
||||
if (completedEventIds.length > 0) {
|
||||
const actualResults = await db
|
||||
.select({
|
||||
participantId: schema.eventResults.participantId,
|
||||
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
|
||||
})
|
||||
.from(schema.eventResults)
|
||||
.where(inArray(schema.eventResults.scoringEventId, completedEventIds));
|
||||
|
||||
for (const r of actualResults) {
|
||||
if (r.qualifyingPointsAwarded !== null) {
|
||||
const prev = actualQPMap.get(r.participantId) ?? 0;
|
||||
actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Load stage results for each event (for explicit field composition).
|
||||
const eventStageResults = new Map<string, Awaited<ReturnType<typeof getCs2StageResultsMapForEvent>>>();
|
||||
for (const event of events) {
|
||||
const stageResults = await getCs2StageResultsMapForEvent(event.id);
|
||||
if (stageResults.size > 0) {
|
||||
eventStageResults.set(event.id, stageResults);
|
||||
}
|
||||
}
|
||||
|
||||
const incompleteEvents = events.filter((e) => !e.isComplete);
|
||||
|
||||
// 7. Monte Carlo loop.
|
||||
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0));
|
||||
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
|
||||
|
||||
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
|
||||
const simQP = new Map<string, number>(actualQPMap);
|
||||
|
||||
for (const event of incompleteEvents) {
|
||||
const stageResultsForEvent = eventStageResults.get(event.id);
|
||||
const eventQP = simulateOneMajor(pool, stageResultsForEvent, qpConfig);
|
||||
for (const [pid, qp] of eventQP) {
|
||||
simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
|
||||
}
|
||||
}
|
||||
|
||||
// Rank all participants by total QP descending.
|
||||
const ranked = [...simQP.entries()].toSorted((a, b) => b[1] - a[1]);
|
||||
for (let rank = 0; rank < Math.min(8, ranked.length); rank++) {
|
||||
const [pid] = ranked[rank];
|
||||
const idx = idToIndex.get(pid);
|
||||
if (idx !== undefined) counts[idx][rank]++;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Convert counts to probabilities.
|
||||
return participantIds.map((participantId, i) => ({
|
||||
participantId,
|
||||
probabilities: {
|
||||
probFirst: counts[i][0] / NUM_SIMULATIONS,
|
||||
probSecond: counts[i][1] / NUM_SIMULATIONS,
|
||||
probThird: counts[i][2] / NUM_SIMULATIONS,
|
||||
probFourth: counts[i][3] / NUM_SIMULATIONS,
|
||||
probFifth: counts[i][4] / NUM_SIMULATIONS,
|
||||
probSixth: counts[i][5] / NUM_SIMULATIONS,
|
||||
probSeventh: counts[i][6] / NUM_SIMULATIONS,
|
||||
probEighth: counts[i][7] / NUM_SIMULATIONS,
|
||||
},
|
||||
source: "cs2_major_qualifying_points_monte_carlo",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Single major simulation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simulate one CS2 Major and return QP earned per participant.
|
||||
*
|
||||
* If stage results are provided, uses them to determine field composition
|
||||
* and to lock in results for any completed stages, only simulating the
|
||||
* remaining stages. A stage is considered complete when the expected 8
|
||||
* eliminations for that stage have been recorded.
|
||||
*
|
||||
* Returns a Map from participantId → QP earned in this major.
|
||||
*/
|
||||
function simulateOneMajor(
|
||||
pool: TeamWithElo[],
|
||||
stageResults: Map<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined,
|
||||
qpConfig: Map<number, number>
|
||||
): Map<string, number> {
|
||||
const qpMap = new Map<string, number>();
|
||||
const poolById = new Map<string, TeamWithElo>(pool.map((t) => [t.id, t]));
|
||||
|
||||
// ── Determine field and stage assignments ─────────────────────────────────
|
||||
let stage1Teams: TeamWithElo[];
|
||||
let stage2Direct: TeamWithElo[];
|
||||
let stage3Direct: TeamWithElo[];
|
||||
|
||||
if (stageResults && stageResults.size > 0) {
|
||||
const s1: TeamWithElo[] = [];
|
||||
const s2: TeamWithElo[] = [];
|
||||
const s3: TeamWithElo[] = [];
|
||||
for (const [participantId, result] of stageResults) {
|
||||
const team = poolById.get(participantId);
|
||||
if (!team) continue;
|
||||
if (result.stageEntry === 1) s1.push(team);
|
||||
else if (result.stageEntry === 2) s2.push(team);
|
||||
else if (result.stageEntry === 3) s3.push(team);
|
||||
}
|
||||
stage1Teams = s1;
|
||||
stage2Direct = s2;
|
||||
stage3Direct = s3;
|
||||
} else {
|
||||
const field = sampleField(pool, FIELD_SIZE);
|
||||
const sorted = field.toSorted((a, b) => a.rank - b.rank);
|
||||
stage3Direct = sorted.slice(0, 8);
|
||||
stage2Direct = sorted.slice(8, 16);
|
||||
stage1Teams = sorted.slice(16, 32);
|
||||
}
|
||||
|
||||
// ── Lock in known stage results ───────────────────────────────────────────
|
||||
// A stage is "complete" when exactly 8 teams have been recorded as
|
||||
// eliminated at that stage (the expected output of each Swiss stage).
|
||||
const eliminatedAtStage = (stageNum: number) =>
|
||||
stageResults
|
||||
? [...stageResults.entries()]
|
||||
.filter(([, r]) => r.stageEliminated === stageNum)
|
||||
.map(([id, r]) => ({
|
||||
id,
|
||||
elo: poolById.get(id)?.elo ?? FALLBACK_ELO,
|
||||
rank: poolById.get(id)?.rank ?? 9999,
|
||||
wins: r.stageEliminatedWins ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const stage1Elim = eliminatedAtStage(1);
|
||||
const stage2Elim = eliminatedAtStage(2);
|
||||
const stage3Elim = eliminatedAtStage(3);
|
||||
|
||||
const stage1Complete = stage1Elim.length === 8;
|
||||
const stage2Complete = stage2Elim.length === 8;
|
||||
const stage3Complete = stage3Elim.length === 8;
|
||||
|
||||
// ── Stage 1 ───────────────────────────────────────────────────────────────
|
||||
let stage1Advanced: TeamWithElo[];
|
||||
let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
|
||||
|
||||
if (stage1Complete) {
|
||||
// Use known results: teams that entered Stage 1 but weren't eliminated there advanced
|
||||
const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id));
|
||||
stage1Advanced = stage1Teams.filter((t) => !stage1EliminatedIds.has(t.id));
|
||||
stage1EliminatedFinal = stage1Elim;
|
||||
} else {
|
||||
const result = simulateSwiss(stage1Teams, false);
|
||||
stage1Advanced = result.advanced;
|
||||
stage1EliminatedFinal = result.eliminated;
|
||||
}
|
||||
|
||||
// ── Stage 2 ───────────────────────────────────────────────────────────────
|
||||
const stage2Teams = [...stage2Direct, ...stage1Advanced];
|
||||
let stage2Advanced: TeamWithElo[];
|
||||
let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
|
||||
|
||||
if (stage2Complete) {
|
||||
const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id));
|
||||
stage2Advanced = stage2Teams.filter((t) => !stage2EliminatedIds.has(t.id));
|
||||
stage2EliminatedFinal = stage2Elim;
|
||||
} else {
|
||||
const result = simulateSwiss(stage2Teams, false);
|
||||
stage2Advanced = result.advanced;
|
||||
stage2EliminatedFinal = result.eliminated;
|
||||
}
|
||||
|
||||
// ── Stage 3 ───────────────────────────────────────────────────────────────
|
||||
const stage3Teams = [...stage3Direct, ...stage2Advanced];
|
||||
let champTeams: TeamWithElo[];
|
||||
let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
|
||||
|
||||
if (stage3Complete) {
|
||||
const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id));
|
||||
champTeams = stage3Teams.filter((t) => !stage3EliminatedIds.has(t.id));
|
||||
stage3EliminatedFinal = stage3Elim;
|
||||
} else {
|
||||
const result = simulateSwiss(stage3Teams, true);
|
||||
champTeams = result.advanced;
|
||||
stage3EliminatedFinal = result.eliminated;
|
||||
}
|
||||
|
||||
// ── Champions Stage ───────────────────────────────────────────────────────
|
||||
const champResult = simulateChampionsStage(champTeams);
|
||||
|
||||
// ── Assign QP ─────────────────────────────────────────────────────────────
|
||||
for (const [pid, placement] of champResult.placements) {
|
||||
qpMap.set(pid, qpConfig.get(placement) ?? 0);
|
||||
}
|
||||
|
||||
const stage3ExitQP = calcStage3ExitQP(stage3EliminatedFinal, qpConfig);
|
||||
for (const [pid, qp] of stage3ExitQP) {
|
||||
qpMap.set(pid, qp);
|
||||
}
|
||||
|
||||
for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0);
|
||||
for (const t of stage2EliminatedFinal) qpMap.set(t.id, 0);
|
||||
|
||||
return qpMap;
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { AFLSimulator } from "./afl-simulator";
|
|||
import { SnookerSimulator } from "./snooker-simulator";
|
||||
import { TennisSimulator } from "./tennis-simulator";
|
||||
import { DartsSimulator } from "./darts-simulator";
|
||||
import { CSMajorSimulator } from "./cs-major-simulator";
|
||||
import { MLBSimulator } from "./mlb-simulator";
|
||||
import { WNBASimulator } from "./wnba-simulator";
|
||||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
|
|
@ -40,6 +41,7 @@ export const SIMULATOR_TYPES = [
|
|||
"wnba_bracket",
|
||||
"world_cup",
|
||||
"darts_bracket",
|
||||
"cs2_major_qualifying_points",
|
||||
] as const;
|
||||
|
||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
||||
|
|
@ -128,6 +130,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
info: { name: "PDC World Darts Championship Monte Carlo", description: "Simulates the 128-player World Darts Championship bracket using set-level Bernoulli win probability and Elo ratings. Top 32 seeds placed in fixed positions; remaining 96 players randomly drawn per simulation." },
|
||||
create: () => new DartsSimulator(),
|
||||
},
|
||||
cs2_major_qualifying_points: {
|
||||
info: { name: "CS2 Major Qualifying Points Monte Carlo", description: "Simulates 2 CS2 Majors per season (3 Swiss stages: Opening Bo1, Elimination Bo1, Decider Bo3, then Champions Stage single-elimination). Awards QP by final placement; ranks teams by total QP across both majors. Stage 3 exits (placements 9–16) are sub-ranked by W-L record." },
|
||||
create: () => new CSMajorSimulator(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getSimulator(simulatorType: SimulatorType): Simulator {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"wnba_bracket",
|
||||
"world_cup",
|
||||
"darts_bracket",
|
||||
"cs2_major_qualifying_points",
|
||||
]);
|
||||
|
||||
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
||||
|
|
@ -859,6 +860,7 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
|
|||
eventResults: many(eventResults),
|
||||
playoffMatches: many(playoffMatches),
|
||||
tournamentGroups: many(tournamentGroups),
|
||||
cs2MajorStageResults: many(cs2MajorStageResults),
|
||||
}));
|
||||
|
||||
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
||||
|
|
@ -1196,3 +1198,43 @@ export const participantGolfSkillsRelations = relations(participantGolfSkills, (
|
|||
references: [sportsSeasons.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── CS2 Major Stage Results ───────────────────────────────────────────────────
|
||||
// Tracks which teams are assigned to each Swiss stage of a CS2 Major, and their
|
||||
// advancement/elimination results. One row per (scoringEventId, participantId).
|
||||
//
|
||||
// stageEntry: 1=Opening Stage, 2=Elimination Stage, 3=Decider Stage
|
||||
// stageEliminated: which stage they were eliminated at (null = made Champions Stage)
|
||||
// stageEliminatedWins: wins at time of elimination (0, 1, or 2); used to rank teams
|
||||
// within placements 9–16 for Stage 3 QP (2-3 > 1-3 > 0-3)
|
||||
// finalPlacement: overall placement 1–32 (set after event completes)
|
||||
|
||||
export const cs2MajorStageResults = pgTable("cs2_major_stage_results", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
scoringEventId: uuid("scoring_event_id")
|
||||
.notNull()
|
||||
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
||||
participantId: uuid("participant_id")
|
||||
.notNull()
|
||||
.references(() => participants.id, { onDelete: "cascade" }),
|
||||
stageEntry: integer("stage_entry").notNull(),
|
||||
stageEliminated: integer("stage_eliminated"),
|
||||
stageEliminatedWins: integer("stage_eliminated_wins"),
|
||||
finalPlacement: integer("final_placement"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
uniqueEventParticipant: uniqueIndex("cs2_major_stage_results_unique")
|
||||
.on(t.scoringEventId, t.participantId),
|
||||
}));
|
||||
|
||||
export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({ one }) => ({
|
||||
scoringEvent: one(scoringEvents, {
|
||||
fields: [cs2MajorStageResults.scoringEventId],
|
||||
references: [scoringEvents.id],
|
||||
}),
|
||||
participant: one(participants, {
|
||||
fields: [cs2MajorStageResults.participantId],
|
||||
references: [participants.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
26
drizzle/0068_cs2_major_simulator.sql
Normal file
26
drizzle/0068_cs2_major_simulator.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'cs2_major_qualifying_points';--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "cs2_major_stage_results" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"scoring_event_id" uuid NOT NULL,
|
||||
"participant_id" uuid NOT NULL,
|
||||
"stage_entry" integer NOT NULL,
|
||||
"stage_eliminated" integer,
|
||||
"stage_eliminated_wins" integer,
|
||||
"final_placement" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "cs2_major_stage_results" ADD CONSTRAINT "cs2_major_stage_results_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "cs2_major_stage_results" ADD CONSTRAINT "cs2_major_stage_results_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "cs2_major_stage_results_unique" ON "cs2_major_stage_results" USING btree ("scoring_event_id","participant_id");
|
||||
|
|
@ -477,6 +477,13 @@
|
|||
"when": 1774900000000,
|
||||
"tag": "0067_darts_bracket",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1775000000000,
|
||||
"tag": "0068_cs2_major_simulator",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,27 +1,14 @@
|
|||
import { createRequestHandler } from "@react-router/express";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import express from "express";
|
||||
import postgres from "postgres";
|
||||
import type { ServerBuild } from "react-router";
|
||||
import { RouterContextProvider } from "react-router";
|
||||
|
||||
import { DatabaseContext } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { db } from "./db";
|
||||
import { expressValueContext } from "~/contexts/express";
|
||||
|
||||
export const app = express();
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
|
||||
|
||||
// In dev, ssrLoadModule re-evaluates this file on every request. Cache the
|
||||
// client on globalThis so HMR re-loads reuse the same connection pool instead
|
||||
// of leaking a new one each time.
|
||||
declare global {
|
||||
// TypeScript requires `var` in `declare global` blocks — `let`/`const` are not allowed here.
|
||||
var __pgClient: ReturnType<typeof postgres> | undefined; // eslint-disable-line no-var
|
||||
}
|
||||
const client = (globalThis.__pgClient ??= postgres(process.env.DATABASE_URL));
|
||||
const db = drizzle(client, { schema });
|
||||
app.use((_, __, next) => DatabaseContext.run(db, next));
|
||||
|
||||
app.use(
|
||||
|
|
|
|||
29
server/db.ts
Normal file
29
server/db.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
// Cache the client on globalThis so HMR re-evaluations and multiple server
|
||||
// modules share a single connection pool instead of each opening their own.
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __pgClient: ReturnType<typeof postgres> | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __pgDb: ReturnType<typeof drizzle<typeof schema>> | undefined;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!globalThis.__pgDb) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
|
||||
globalThis.__pgClient ??= postgres(process.env.DATABASE_URL);
|
||||
globalThis.__pgDb = drizzle(globalThis.__pgClient, { schema });
|
||||
}
|
||||
return globalThis.__pgDb;
|
||||
}
|
||||
|
||||
// Export a Proxy so callers can use `db.query.foo` etc. without calling getDb() themselves.
|
||||
// The proxy defers the DATABASE_URL check until the first actual property access.
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle<typeof schema>>, {
|
||||
get(_target, prop) {
|
||||
return getDb()[prop as keyof ReturnType<typeof drizzle<typeof schema>>];
|
||||
},
|
||||
});
|
||||
|
|
@ -1,17 +1,8 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// Create a dedicated database connection for the snapshot system
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required for snapshot system");
|
||||
}
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
import { db } from "./db";
|
||||
|
||||
let snapshotInterval: NodeJS.Timeout | null = null;
|
||||
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // Check once per day (in milliseconds)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import type { Socket } from "socket.io";
|
||||
import { Server as SocketIOServer } from "socket.io";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// Lazy-initialized DB for socket-level validation queries (team ownership checks)
|
||||
let _socketDb: PostgresJsDatabase<typeof schema> | null = null;
|
||||
function getSocketDb(): PostgresJsDatabase<typeof schema> {
|
||||
if (!_socketDb) {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is required");
|
||||
_socketDb = drizzle(postgres(url), { schema });
|
||||
}
|
||||
return _socketDb;
|
||||
}
|
||||
import { db } from "./db";
|
||||
|
||||
// Socket event types
|
||||
interface ServerToClientEvents {
|
||||
|
|
@ -142,7 +129,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
// If teamId provided, validate it belongs to this season before tracking
|
||||
if (teamId) {
|
||||
try {
|
||||
const db = getSocketDb();
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)),
|
||||
});
|
||||
|
|
@ -185,7 +171,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
// tokens or flaky mobile networks. This socket-based sync provides an
|
||||
// additional, more reliable path since the socket is already connected.
|
||||
try {
|
||||
const db = getSocketDb();
|
||||
const [seasonData, picks, timerRows, queueItems] = await Promise.all([
|
||||
db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,10 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, asc, sql } from "drizzle-orm";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { getSocketIO } from "./socket";
|
||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// Create a dedicated database connection for the timer
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required for timer system");
|
||||
}
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
import { db } from "./db";
|
||||
|
||||
let timerInterval: NodeJS.Timeout | null = null;
|
||||
let timerTickRunning = false;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue