2025-10-24 21:20:19 -07:00
|
|
|
import * as schema from "~/database/schema";
|
2026-06-03 03:04:46 +00:00
|
|
|
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
|
2026-02-23 23:23:24 -08:00
|
|
|
import type { InferSelectModel } from "drizzle-orm";
|
2025-10-18 23:13:04 -07:00
|
|
|
import { getSocketIO } from "./socket";
|
2026-02-23 23:23:24 -08:00
|
|
|
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
2026-06-05 22:42:19 -07:00
|
|
|
import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check";
|
2026-03-21 13:41:39 -07:00
|
|
|
import { logger } from "./logger";
|
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>
2026-04-05 13:40:05 -07:00
|
|
|
import { db } from "./db";
|
2026-05-16 23:37:29 -07:00
|
|
|
import { startDraft } from "~/services/draft-autostart";
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
// Per-season in-memory state
|
|
|
|
|
const pickTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → active setTimeout
|
|
|
|
|
const overnightResumeTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → resume setTimeout
|
|
|
|
|
const schedulingInProgress = new Set<string>(); // prevents concurrent scheduling
|
2026-03-24 19:09:38 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
// Draft slots never change during an active draft — cache them to avoid a redundant query.
|
2026-03-24 19:09:38 -07:00
|
|
|
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
let recoveryInterval: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
|
|
|
|
|
let slots = draftSlotsCache.get(seasonId);
|
|
|
|
|
if (!slots) {
|
|
|
|
|
slots = await db.query.draftSlots.findMany({
|
|
|
|
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
|
|
|
|
orderBy: asc(schema.draftSlots.draftOrder),
|
|
|
|
|
});
|
|
|
|
|
draftSlotsCache.set(seasonId, slots);
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
2026-06-03 03:04:46 +00:00
|
|
|
return slots;
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 23:37:29 -07:00
|
|
|
async function checkAndAutoStartDrafts(): Promise<void> {
|
|
|
|
|
const io = getSocketIO();
|
|
|
|
|
const seasonsToStart = await db.query.seasons.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.seasons.status, "pre_draft"),
|
|
|
|
|
eq(schema.seasons.autoStartDraft, true),
|
|
|
|
|
isNotNull(schema.seasons.draftDateTime),
|
|
|
|
|
lte(schema.seasons.draftDateTime, new Date())
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const season of seasonsToStart) {
|
|
|
|
|
logger.log(`[Timer] Auto-starting draft for season ${season.id}`);
|
|
|
|
|
const result = await startDraft({
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
actorUserId: "system",
|
|
|
|
|
actorDisplayName: "System (auto-start)",
|
|
|
|
|
db,
|
|
|
|
|
io,
|
|
|
|
|
});
|
|
|
|
|
if (result.success) continue;
|
|
|
|
|
if (result.error === "Draft already started or completed") continue;
|
|
|
|
|
|
|
|
|
|
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
|
|
|
|
|
|
|
|
|
|
if (result.error === "No draft slots found for this season") {
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.seasons)
|
|
|
|
|
.set({ autoStartDraft: false })
|
|
|
|
|
.where(eq(schema.seasons.id, season.id));
|
|
|
|
|
logger.log(`[Timer] Disabled autoStartDraft for season ${season.id} — no draft order set`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
function cancelPickTimeout(seasonId: string): void {
|
|
|
|
|
const existing = pickTimeouts.get(seasonId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
clearTimeout(existing);
|
|
|
|
|
pickTimeouts.delete(seasonId);
|
2026-03-24 19:09:38 -07:00
|
|
|
}
|
2026-06-03 03:04:46 +00:00
|
|
|
}
|
2026-03-24 19:09:38 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
function cancelOvernightResumeTimeout(seasonId: string): void {
|
|
|
|
|
const existing = overnightResumeTimeouts.get(seasonId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
clearTimeout(existing);
|
|
|
|
|
overnightResumeTimeouts.delete(seasonId);
|
2026-04-26 22:31:52 -07:00
|
|
|
}
|
2026-06-03 03:04:46 +00:00
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
|
|
|
|
|
logger.error(`[Timer] Pausing draft ${seasonId} — pick failed for team ${teamId}`);
|
|
|
|
|
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, seasonId));
|
|
|
|
|
try {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error("[Timer] Failed to emit draft-paused:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
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,
|
|
|
|
|
});
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
if (!result.success) {
|
|
|
|
|
if (result.error === "Pick already made") return true;
|
|
|
|
|
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
|
|
|
|
|
return false;
|
2026-03-24 19:09:38 -07:00
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("[Timer] Error in triggerAutoPick:", error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-03 03:04:46 +00:00
|
|
|
// Core scheduling logic — called after acquiring the schedulingInProgress lock.
|
2026-06-05 22:42:19 -07:00
|
|
|
// Iterative rather than recursive so while_on chains and bank-depleted runs don't
|
|
|
|
|
// build unbounded call-stack depth or bypass the outer lock via re-entry.
|
2026-06-03 03:04:46 +00:00
|
|
|
async function _schedulePickForSeason(seasonId: string): Promise<void> {
|
2026-06-05 22:42:19 -07:00
|
|
|
const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) {
|
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
|
|
|
where: eq(schema.seasons.id, seasonId),
|
|
|
|
|
});
|
|
|
|
|
if (!season || season.status !== "draft" || season.draftPaused) return;
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const draftSlots = await getDraftSlotsCached(seasonId);
|
|
|
|
|
const totalTeams = draftSlots.length;
|
|
|
|
|
if (totalTeams === 0) return;
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const currentPickNumber = season.currentPickNumber ?? 1;
|
|
|
|
|
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
|
|
|
|
const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
|
|
|
|
|
if (!currentDraftSlot) return;
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const currentTeamId = currentDraftSlot.teamId;
|
2026-06-03 03:04:46 +00:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.autodraftSettings.seasonId, seasonId),
|
|
|
|
|
eq(schema.autodraftSettings.teamId, currentTeamId)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
|
|
|
|
|
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
|
|
|
|
|
|
|
|
|
|
if (isWhileOn) {
|
|
|
|
|
logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`);
|
|
|
|
|
try {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", {
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentTeamId,
|
|
|
|
|
pickNumber: currentPickNumber,
|
|
|
|
|
expiresAt: Date.now(),
|
|
|
|
|
timeRemaining: 0,
|
|
|
|
|
});
|
|
|
|
|
} catch { /* non-fatal */ }
|
|
|
|
|
|
|
|
|
|
const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null);
|
|
|
|
|
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
|
|
|
|
|
continue; // re-read season state — currentPickNumber has advanced
|
|
|
|
|
}
|
Refactor autodraft chain logic and improve timer handling (#25)
* Fix draft timer missing after rollback causing draft to freeze
The rollback endpoint was deleting all timers but only recreating one
for the team currently on the clock. After that team made their pick,
the next team had no timer row, causing the timer system to log
"No timer found" and skip indefinitely, freezing the draft.
Fix: recreate timers for ALL teams after a rollback, each starting at
draftInitialTime.
Also add a defensive fallback in the timer system: if a timer row is
missing for the current team during an active draft, create it at
draftInitialTime instead of skipping, so the draft can never get
permanently stuck due to a missing timer.
https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV
* Fix rollback to not touch timers at all
Timers are each team's time bank and should not be affected by rolling
back a pick. The previous code (both original and the first fix) was
deleting all timer rows during rollback, which was the actual root cause
of the missing timer bug.
Rollback now only does what it should: delete picks from the rollback
point onwards and reset currentPickNumber. The timer system will
naturally resume for the correct team on its next tick.
Also removes the incorrect timer-update socket emit that was sending
initialTime instead of the team's actual remaining bank.
https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV
* Fix all code review issues across timer, rollback, start, and draft-utils
server/timer.ts:
- Remove noisy per-tick console.log that fired every second per active draft
- Remove slot:any type cast (Drizzle query results are already typed)
- Fix inconsistent autodraftSettings argument in the === 0 trigger path
to match the <= 0 path (pass null when autodraft is disabled)
- Fix infinite auto-pick loop: triggerAutoPick now returns boolean;
on failure the draft is paused and a draft-paused socket event is
emitted so clients and commissioners are notified. "Pick already made"
(race condition) is treated as success, not failure.
draft.start.ts:
- Validate that draft slots exist before updating season status to draft.
Previously a missing-slots error left the season stuck in draft status
with no timer rows.
draft.rollback.ts:
- Guard against empty draftSlots before performing snake draft math,
which would produce Infinity/NaN with zero teams.
draft-utils.ts:
- Convert checkAndTriggerNextAutodraft from recursive to iterative.
The mutual recursion (executeAutoPick → checkAndTriggerNextAutodraft
→ executeAutoPick) is now a while loop, preventing unbounded call
stack growth in all-autodraft leagues. Add chainEnabled param to
executeAutoPick so chain calls skip spawning a second chain.
- Restructure getTopAvailableParticipant so the single-sport query is
only built when actually needed (not thrown away in the multi-sport
branch).
- Update misleading timeUsed comment to accurately describe that the
stored value is the team's bank balance at pick time, not time spent.
https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 17:02:35 -08:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
// Overnight pause: freeze timer; schedule a wakeup for when the window ends.
|
|
|
|
|
const overnightPause = await checkOvernightPause(season, currentTeamId);
|
|
|
|
|
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
|
|
|
|
|
|
|
|
|
|
if (isOvernightFreeze) {
|
|
|
|
|
try {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", {
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentTeamId,
|
|
|
|
|
resumesAtUTC: overnightPause.resumesAtUTC,
|
|
|
|
|
});
|
|
|
|
|
} catch { /* non-fatal */ }
|
|
|
|
|
|
|
|
|
|
if (overnightPause.resumesAtUTC) {
|
|
|
|
|
const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now());
|
|
|
|
|
const resumeTimeout = setTimeout(async () => {
|
|
|
|
|
overnightResumeTimeouts.delete(seasonId);
|
|
|
|
|
await schedulePickForSeason(seasonId);
|
|
|
|
|
}, msUntilResume + 2_000); // 2 s buffer in case clocks drift
|
|
|
|
|
overnightResumeTimeouts.set(seasonId, resumeTimeout);
|
|
|
|
|
}
|
2026-06-03 03:04:46 +00:00
|
|
|
return;
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
// Get or create timer row for this team.
|
|
|
|
|
let timer = await db.query.draftTimers.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentTeamId)
|
|
|
|
|
),
|
|
|
|
|
});
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
if (!timer) {
|
|
|
|
|
const initialTime =
|
|
|
|
|
season.draftTimerMode === "standard"
|
|
|
|
|
? (season.draftIncrementTime || 30)
|
|
|
|
|
: (season.draftInitialTime || 120);
|
|
|
|
|
const startedAt = new Date();
|
|
|
|
|
const expiresAt = new Date(startedAt.getTime() + initialTime * 1000);
|
|
|
|
|
await db.insert(schema.draftTimers).values({
|
2026-06-03 03:04:46 +00:00
|
|
|
seasonId,
|
2026-04-26 22:31:52 -07:00
|
|
|
teamId: currentTeamId,
|
2026-06-05 22:42:19 -07:00
|
|
|
timeRemaining: initialTime,
|
|
|
|
|
picksExpiresAt: expiresAt,
|
|
|
|
|
picksStartedAt: startedAt,
|
2026-04-26 22:31:52 -07:00
|
|
|
});
|
2026-06-05 22:42:19 -07:00
|
|
|
// Re-fetch to get the full row (including generated id)
|
|
|
|
|
timer = await db.query.draftTimers.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentTeamId)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
if (!timer) return; // unexpected
|
2026-04-26 22:31:52 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const now = Date.now();
|
|
|
|
|
let expiresAt: Date;
|
|
|
|
|
|
|
|
|
|
if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) {
|
|
|
|
|
// Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry.
|
|
|
|
|
expiresAt = timer.picksExpiresAt;
|
|
|
|
|
} else {
|
|
|
|
|
// Fresh start of this team's turn (normal path after a pick or on startup).
|
|
|
|
|
const bank = timer.timeRemaining;
|
|
|
|
|
if (bank <= 0) {
|
|
|
|
|
// Bank depleted — trigger autopick immediately and loop to schedule the next team.
|
|
|
|
|
const success = await triggerAutoPick(
|
|
|
|
|
seasonId,
|
|
|
|
|
currentTeamId,
|
|
|
|
|
currentPickNumber,
|
|
|
|
|
shouldAutodraft ? (autodraftSettings ?? null) : null
|
|
|
|
|
);
|
|
|
|
|
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
expiresAt = new Date(now + bank * 1000);
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.draftTimers)
|
|
|
|
|
.set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() })
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentTeamId)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const msUntilExpiry = expiresAt.getTime() - now;
|
|
|
|
|
const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000));
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
// Tell clients to start their local countdown.
|
|
|
|
|
try {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", {
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentTeamId,
|
|
|
|
|
pickNumber: currentPickNumber,
|
|
|
|
|
expiresAt: expiresAt.getTime(),
|
|
|
|
|
timeRemaining,
|
|
|
|
|
});
|
|
|
|
|
} catch { /* non-fatal */ }
|
|
|
|
|
|
|
|
|
|
if (msUntilExpiry <= 0) {
|
|
|
|
|
// Already expired (e.g. picked up on recovery interval after a long pause).
|
2026-06-03 03:04:46 +00:00
|
|
|
const success = await triggerAutoPick(
|
|
|
|
|
seasonId,
|
|
|
|
|
currentTeamId,
|
|
|
|
|
currentPickNumber,
|
|
|
|
|
shouldAutodraft ? (autodraftSettings ?? null) : null
|
|
|
|
|
);
|
|
|
|
|
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
|
2026-06-05 22:42:19 -07:00
|
|
|
continue;
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
const timeout = setTimeout(async () => {
|
|
|
|
|
pickTimeouts.delete(seasonId);
|
|
|
|
|
// Hold the schedulingInProgress lock for the entire pick+reschedule sequence.
|
|
|
|
|
// Without this, the recovery interval (which checks !pickTimeouts.has) could fire
|
|
|
|
|
// between the delete above and the DB commit inside triggerAutoPick, read a stale
|
|
|
|
|
// currentPickNumber, and schedule a duplicate timeout for the same pick.
|
|
|
|
|
if (schedulingInProgress.has(seasonId)) {
|
|
|
|
|
logger.warn(`[Timer] Skipping expired-timer callback for ${seasonId} — scheduling already in progress`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
schedulingInProgress.add(seasonId);
|
|
|
|
|
try {
|
|
|
|
|
logger.log(
|
|
|
|
|
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`
|
|
|
|
|
);
|
|
|
|
|
const success = await triggerAutoPick(
|
|
|
|
|
seasonId,
|
|
|
|
|
currentTeamId,
|
|
|
|
|
currentPickNumber,
|
|
|
|
|
shouldAutodraft ? (autodraftSettings ?? null) : null
|
|
|
|
|
);
|
|
|
|
|
if (!success) {
|
|
|
|
|
await pauseDraftOnError(seasonId, currentTeamId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await _schedulePickForSeason(seasonId);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error(`[Timer] Error in timer callback for ${seasonId}:`, err);
|
|
|
|
|
} finally {
|
|
|
|
|
schedulingInProgress.delete(seasonId);
|
|
|
|
|
}
|
|
|
|
|
}, msUntilExpiry);
|
|
|
|
|
|
|
|
|
|
pickTimeouts.set(seasonId, timeout);
|
|
|
|
|
return; // timer is set — done until it fires
|
2026-06-03 03:04:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:42:19 -07:00
|
|
|
logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`);
|
2026-06-03 03:04:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function schedulePickForSeason(seasonId: string): Promise<void> {
|
|
|
|
|
if (schedulingInProgress.has(seasonId)) return;
|
|
|
|
|
schedulingInProgress.add(seasonId);
|
|
|
|
|
try {
|
|
|
|
|
await _schedulePickForSeason(seasonId);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error(`[Timer] schedulePickForSeason error for ${seasonId}:`, err);
|
|
|
|
|
} finally {
|
|
|
|
|
schedulingInProgress.delete(seasonId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function scheduleAllActiveDrafts(): Promise<void> {
|
|
|
|
|
await checkAndAutoStartDrafts();
|
|
|
|
|
|
|
|
|
|
const activeDrafts = await db.query.seasons.findMany({
|
|
|
|
|
where: eq(schema.seasons.status, "draft"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Evict caches for seasons no longer drafting.
|
2026-06-05 22:42:19 -07:00
|
|
|
// Union both cache key sets so overnight-pause entries populated by socket.ts
|
|
|
|
|
// (without a corresponding draftSlotsCache entry) are also evicted.
|
2026-06-03 03:04:46 +00:00
|
|
|
const activeIds = new Set(activeDrafts.map((s) => s.id));
|
2026-06-05 22:42:19 -07:00
|
|
|
const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id)));
|
|
|
|
|
for (const id of staleIds) {
|
|
|
|
|
draftSlotsCache.delete(id);
|
|
|
|
|
evictOvernightPauseCache(id);
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
2026-06-03 03:04:46 +00:00
|
|
|
|
|
|
|
|
for (const season of activeDrafts) {
|
|
|
|
|
// Only schedule if not already scheduled (avoids duplicate timeouts).
|
|
|
|
|
if (!pickTimeouts.has(season.id) && !overnightResumeTimeouts.has(season.id)) {
|
|
|
|
|
await schedulePickForSeason(season.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function startDraftTimerSystem(): void {
|
|
|
|
|
if (recoveryInterval) {
|
|
|
|
|
logger.log("[Timer] Timer system already running");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Schedule any in-progress drafts immediately on startup.
|
|
|
|
|
scheduleAllActiveDrafts().catch((err) =>
|
|
|
|
|
logger.error("[Timer] Error during startup scheduling:", err)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Recovery interval: re-schedule any draft whose in-memory timeout was lost
|
|
|
|
|
// (process restart, overnight pause end, etc.). Runs every 30 s, not every 1 s.
|
|
|
|
|
recoveryInterval = setInterval(() => {
|
|
|
|
|
scheduleAllActiveDrafts().catch((err) =>
|
|
|
|
|
logger.error("[Timer] Error in recovery interval:", err)
|
|
|
|
|
);
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
logger.log("[Timer] Draft timer system started (event-driven)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stopDraftTimerSystem(): void {
|
|
|
|
|
if (recoveryInterval) {
|
|
|
|
|
clearInterval(recoveryInterval);
|
|
|
|
|
recoveryInterval = null;
|
|
|
|
|
}
|
|
|
|
|
for (const t of pickTimeouts.values()) clearTimeout(t);
|
|
|
|
|
pickTimeouts.clear();
|
|
|
|
|
for (const t of overnightResumeTimeouts.values()) clearTimeout(t);
|
|
|
|
|
overnightResumeTimeouts.clear();
|
|
|
|
|
schedulingInProgress.clear();
|
|
|
|
|
draftSlotsCache.clear();
|
2026-06-05 22:42:19 -07:00
|
|
|
clearAllOvernightPauseCaches();
|
2026-06-03 03:04:46 +00:00
|
|
|
logger.log("[Timer] Draft timer system stopped");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Called by pick routes after a pick is made so the timer immediately
|
|
|
|
|
* reschedules for the next team rather than waiting for the recovery interval.
|
|
|
|
|
*/
|
|
|
|
|
export async function rescheduleTimer(seasonId: string): Promise<void> {
|
|
|
|
|
cancelPickTimeout(seasonId);
|
|
|
|
|
cancelOvernightResumeTimeout(seasonId);
|
|
|
|
|
await schedulePickForSeason(seasonId);
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
2026-06-05 22:42:19 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Called by the pause route. Cancels the in-memory timeout and snapshots the
|
|
|
|
|
* remaining time into timeRemaining so resume starts from exactly where it left off.
|
|
|
|
|
*/
|
|
|
|
|
export async function onDraftPaused(seasonId: string): Promise<void> {
|
|
|
|
|
cancelPickTimeout(seasonId);
|
|
|
|
|
cancelOvernightResumeTimeout(seasonId);
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const activeTimers = await db.query.draftTimers.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
isNotNull(schema.draftTimers.picksExpiresAt),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
for (const timer of activeTimers) {
|
|
|
|
|
if (!timer.picksExpiresAt) continue;
|
|
|
|
|
const remaining = Math.max(0, Math.floor((timer.picksExpiresAt.getTime() - now) / 1000));
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.draftTimers)
|
|
|
|
|
.set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
|
|
|
|
|
.where(eq(schema.draftTimers.id, timer.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Called by the rollback route. Cancels any pending timeout and resets all timer
|
|
|
|
|
* rows to the initial bank. Does NOT reschedule — the rollback leaves the draft
|
|
|
|
|
* paused so the commissioner can review before resuming.
|
|
|
|
|
*/
|
|
|
|
|
export async function onDraftRolledBack(seasonId: string, initialTime: number): Promise<void> {
|
|
|
|
|
cancelPickTimeout(seasonId);
|
|
|
|
|
cancelOvernightResumeTimeout(seasonId);
|
|
|
|
|
draftSlotsCache.delete(seasonId);
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(schema.draftTimers)
|
|
|
|
|
.set({ timeRemaining: initialTime, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
|
|
|
|
|
.where(eq(schema.draftTimers.seasonId, seasonId));
|
|
|
|
|
}
|