brackt/server/timer.ts

441 lines
15 KiB
TypeScript
Raw Normal View History

import * as schema from "~/database/schema";
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
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";
import { startDraft } from "~/services/draft-autostart";
// 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
// Draft slots never change during an active draft — cache them to avoid a redundant query.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
const teamTimezoneCache = new Map<string, Map<string, string | null>>();
let recoveryInterval: NodeJS.Timeout | null = null;
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
let seasonMap = teamTimezoneCache.get(seasonId);
if (!seasonMap) {
try {
const rows = await db
.select({ teamId: schema.teams.id, timezone: schema.users.timezone })
.from(schema.teams)
.leftJoin(schema.users, eq(schema.teams.ownerId, schema.users.id))
.where(eq(schema.teams.seasonId, seasonId));
seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null]));
} catch (err) {
logger.error("[Timer] getTeamTimezone failed:", err);
seasonMap = new Map();
}
teamTimezoneCache.set(seasonId, seasonMap);
}
return seasonMap.get(teamId) ?? null;
}
async function checkOvernightPause(
season: InferSelectModel<typeof schema.seasons>,
currentTeamId: string
): Promise<{ active: boolean; resumesAtUTC?: number }> {
const mode = season.overnightPauseMode;
const start = season.overnightPauseStart;
const end = season.overnightPauseEnd;
if (mode === "none" || !start || !end) return { active: false };
let tz: string | null = null;
if (mode === "league") {
tz = season.overnightPauseTimezone || null;
} else {
tz = await getTeamTimezone(season.id, currentTeamId);
if (!tz) tz = season.overnightPauseTimezone || null;
}
if (!tz) return { active: false };
if (isInOvernightWindow(tz, start, end)) {
const resumesAt = getOvernightResumeUTC(tz, end);
return { active: true, resumesAtUTC: resumesAt.getTime() };
}
return { active: false };
}
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);
}
return slots;
}
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`);
}
}
}
function cancelPickTimeout(seasonId: string): void {
const existing = pickTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
pickTimeouts.delete(seasonId);
}
}
function cancelOvernightResumeTimeout(seasonId: string): void {
const existing = overnightResumeTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
overnightResumeTimeouts.delete(seasonId);
}
}
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);
}
}
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) {
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;
}
}
// Core scheduling logic — called after acquiring the schedulingInProgress lock.
async function _schedulePickForSeason(seasonId: string): Promise<void> {
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season || season.status !== "draft" || season.draftPaused) return;
const draftSlots = await getDraftSlotsCached(seasonId);
const totalTeams = draftSlots.length;
if (totalTeams === 0) return;
const currentPickNumber = season.currentPickNumber ?? 1;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (!currentDraftSlot) return;
const currentTeamId = currentDraftSlot.teamId;
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,
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
teamId: currentTeamId,
pickNumber: currentPickNumber,
expiresAt: Date.now(),
timeRemaining: 0,
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
});
} catch (_) { /* non-fatal */ }
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
const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null);
if (!success) {
await pauseDraftOnError(seasonId, currentTeamId);
return;
}
// The chain may have advanced currentPickNumber past all while_on teams — reschedule.
await _schedulePickForSeason(seasonId);
return;
}
// 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);
}
return;
}
// 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)
),
});
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({
seasonId,
teamId: currentTeamId,
timeRemaining: initialTime,
picksExpiresAt: expiresAt,
picksStartedAt: startedAt,
});
// 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
}
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.
const success = await triggerAutoPick(
seasonId,
currentTeamId,
currentPickNumber,
shouldAutodraft ? (autodraftSettings ?? null) : null
);
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId);
return;
}
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)
)
);
}
const msUntilExpiry = expiresAt.getTime() - now;
const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000));
// 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).
const success = await triggerAutoPick(
seasonId,
currentTeamId,
currentPickNumber,
shouldAutodraft ? (autodraftSettings ?? null) : null
);
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId);
return;
}
const timeout = setTimeout(async () => {
pickTimeouts.delete(seasonId);
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);
}, msUntilExpiry);
pickTimeouts.set(seasonId, timeout);
}
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.
const activeIds = new Set(activeDrafts.map((s) => s.id));
for (const id of draftSlotsCache.keys()) {
if (!activeIds.has(id)) draftSlotsCache.delete(id);
}
for (const id of teamTimezoneCache.keys()) {
if (!activeIds.has(id)) teamTimezoneCache.delete(id);
}
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();
teamTimezoneCache.clear();
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);
draftSlotsCache.delete(seasonId); // pick may have changed the draft order state
await schedulePickForSeason(seasonId);
}