* 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>
260 lines
8.8 KiB
TypeScript
260 lines
8.8 KiB
TypeScript
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";
|
|
import { db } from "./db";
|
|
|
|
let timerInterval: NodeJS.Timeout | null = null;
|
|
let timerTickRunning = false;
|
|
|
|
// Draft slots never change during an active draft — cache them to avoid
|
|
// a redundant query on every tick.
|
|
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
|
|
|
|
/**
|
|
* Start the draft timer system
|
|
* Runs every second to update all active draft timers
|
|
*/
|
|
export function startDraftTimerSystem(): void {
|
|
if (timerInterval) {
|
|
logger.log("[Timer] Timer system already running");
|
|
return;
|
|
}
|
|
|
|
timerInterval = setInterval(async () => {
|
|
// Skip this tick if the previous one is still running (e.g. long autodraft chain)
|
|
// to prevent concurrent ticks from racing on the same pick slot.
|
|
if (timerTickRunning) return;
|
|
timerTickRunning = true;
|
|
try {
|
|
await updateDraftTimers();
|
|
} catch (error) {
|
|
logger.error("[Timer] Error updating draft timers:", error);
|
|
} finally {
|
|
timerTickRunning = false;
|
|
}
|
|
}, 1000);
|
|
|
|
logger.log("[Timer] Draft timer system started");
|
|
}
|
|
|
|
/**
|
|
* Stop the draft timer system
|
|
*/
|
|
export function stopDraftTimerSystem(): void {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
logger.log("[Timer] Draft timer system stopped");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update all active draft timers
|
|
* Called every second by the timer interval
|
|
*/
|
|
async function updateDraftTimers(): Promise<void> {
|
|
const io = getSocketIO();
|
|
|
|
// Get all active drafts
|
|
const activeDrafts = await db.query.seasons.findMany({
|
|
where: eq(schema.seasons.status, "draft"),
|
|
});
|
|
|
|
if (activeDrafts.length === 0) {
|
|
draftSlotsCache.clear();
|
|
return;
|
|
}
|
|
|
|
// Evict cache entries for seasons no longer actively drafting.
|
|
const activeIds = new Set(activeDrafts.map((s) => s.id));
|
|
for (const cachedId of draftSlotsCache.keys()) {
|
|
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
|
|
}
|
|
|
|
for (const season of activeDrafts) {
|
|
// Skip if draft is paused
|
|
if (season.draftPaused) {
|
|
continue;
|
|
}
|
|
|
|
const currentPickNumber = season.currentPickNumber ?? 1;
|
|
|
|
// Draft slots never change during an active draft — use the cache.
|
|
let draftSlots = draftSlotsCache.get(season.id);
|
|
if (!draftSlots) {
|
|
draftSlots = await db.query.draftSlots.findMany({
|
|
where: eq(schema.draftSlots.seasonId, season.id),
|
|
orderBy: asc(schema.draftSlots.draftOrder),
|
|
});
|
|
draftSlotsCache.set(season.id, draftSlots);
|
|
}
|
|
|
|
const totalTeams = draftSlots.length;
|
|
if (totalTeams === 0) continue;
|
|
|
|
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
|
const currentDraftSlot = draftSlots.find(
|
|
(slot) => slot.draftOrder === pickInRound
|
|
);
|
|
|
|
if (!currentDraftSlot) {
|
|
continue;
|
|
}
|
|
|
|
const currentTeamId = currentDraftSlot.teamId;
|
|
|
|
// Get current team's timer
|
|
const timer = await db.query.draftTimers.findFirst({
|
|
where: and(
|
|
eq(schema.draftTimers.seasonId, season.id),
|
|
eq(schema.draftTimers.teamId, currentTeamId)
|
|
),
|
|
});
|
|
|
|
if (!timer) {
|
|
logger.warn(
|
|
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
|
|
);
|
|
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
|
|
const initialTime = season.draftTimerMode === "standard"
|
|
? (season.draftIncrementTime || 30)
|
|
: (season.draftInitialTime || 120);
|
|
await db
|
|
.insert(schema.draftTimers)
|
|
.values({
|
|
seasonId: season.id,
|
|
teamId: currentTeamId,
|
|
timeRemaining: initialTime,
|
|
});
|
|
|
|
// Emit timer update so clients are aware of the new timer
|
|
io.to(`draft-${season.id}`).emit("timer-update", {
|
|
seasonId: season.id,
|
|
teamId: currentTeamId,
|
|
timeRemaining: initialTime,
|
|
currentPickNumber,
|
|
});
|
|
|
|
// Continue processing with the newly created timer on the next tick
|
|
continue;
|
|
}
|
|
|
|
// Fetch autodraft settings once — used both for while_on bypass and timer-expiry path.
|
|
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
|
where: and(
|
|
eq(schema.autodraftSettings.seasonId, season.id),
|
|
eq(schema.autodraftSettings.teamId, currentTeamId)
|
|
),
|
|
});
|
|
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
|
|
// while_on means "pick immediately when it's my turn" — bypass the countdown.
|
|
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
|
|
|
|
// Trigger pick when: timer expired OR team is in while_on autodraft mode.
|
|
// The chain picks consecutive while_on teams after each pick, but it is capped at
|
|
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks
|
|
// the next while_on team within one tick (≤1 s) rather than waiting for the full
|
|
// countdown to expire. If the chain already made this pick, executeAutoPick
|
|
// returns "Pick already made" which triggerAutoPick treats as a non-fatal no-op.
|
|
if (timer.timeRemaining <= 0 || isWhileOn) {
|
|
if (timer.timeRemaining <= 0) {
|
|
logger.log(
|
|
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
|
|
);
|
|
} else {
|
|
logger.log(
|
|
`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
|
|
);
|
|
// Emit 0 so clients see the timer hit zero before the pick-made event arrives.
|
|
io.to(`draft-${season.id}`).emit("timer-update", {
|
|
seasonId: season.id,
|
|
teamId: currentTeamId,
|
|
timeRemaining: 0,
|
|
currentPickNumber,
|
|
});
|
|
}
|
|
|
|
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
|
|
|
if (!success) {
|
|
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
|
|
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
|
|
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Atomically decrement timer (race-condition safe: uses DB-level update
|
|
// so concurrent increments from pick handlers are never overwritten)
|
|
const [updatedTimer] = await db
|
|
.update(schema.draftTimers)
|
|
.set({
|
|
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.draftTimers.id, timer.id))
|
|
.returning();
|
|
|
|
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
|
|
|
|
// Emit timer update to all clients in the draft room
|
|
io.to(`draft-${season.id}`).emit("timer-update", {
|
|
seasonId: season.id,
|
|
teamId: currentTeamId,
|
|
timeRemaining: newTimeRemaining,
|
|
currentPickNumber,
|
|
});
|
|
|
|
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)
|
|
if (newTimeRemaining === 0) {
|
|
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
|
|
|
if (!success) {
|
|
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
|
|
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
|
|
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger an automatic pick when timer expires.
|
|
* Returns true if the pick succeeded (or was already made by another path),
|
|
* false if a real failure occurred that requires commissioner intervention.
|
|
*/
|
|
async function triggerAutoPick(
|
|
seasonId: string,
|
|
teamId: string,
|
|
pickNumber: number,
|
|
autodraftSettings: InferSelectModel<typeof schema.autodraftSettings> | null
|
|
): Promise<boolean> {
|
|
try {
|
|
const result = await executeAutoPick({
|
|
seasonId,
|
|
teamId,
|
|
pickNumber,
|
|
triggeredBy: "timer",
|
|
autodraftSettings,
|
|
db,
|
|
});
|
|
|
|
if (!result.success) {
|
|
// A race condition where the pick was already made is not a real failure
|
|
if (result.error === "Pick already made") {
|
|
return true;
|
|
}
|
|
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error("[Timer] Error in triggerAutoPick:", error);
|
|
return false;
|
|
}
|
|
}
|