brackt/server/timer.ts
Chris Parsons 77c0f47552
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m30s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix draft pause clock: use Math.ceil consistently via shared msToSeconds helper
- Pause snapshot used Math.floor while the client display used Math.ceil,
  causing the visible clock to round down by 1s on pause click
- draft-state-sync also used Math.floor for reconnecting clients
- Extract msToSeconds() to app/lib/draft-timer.ts as the single source of
  truth for all ms→seconds conversions across server and client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:39:53 -07:00

456 lines
16 KiB
TypeScript

import * as schema from "~/database/schema";
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check";
import { logger } from "./logger";
import { db } from "./db";
import { startDraft } from "~/services/draft-autostart";
import { msToSeconds } from "~/lib/draft-timer";
// 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 }[]>();
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);
}
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));
const timers = await onDraftPaused(seasonId);
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true, timers });
} 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.
// 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.
async function _schedulePickForSeason(seasonId: string): Promise<void> {
const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs
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;
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,
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
}
// 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 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)
)
);
}
const msUntilExpiry = expiresAt.getTime() - now;
const timeRemaining = msToSeconds(msUntilExpiry);
// 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; }
continue;
}
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
}
logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`);
}
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.
// Union both cache key sets so overnight-pause entries populated by socket.ts
// (without a corresponding draftSlotsCache entry) are also evicted.
const activeIds = new Set(activeDrafts.map((s) => s.id));
const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id)));
for (const id of staleIds) {
draftSlotsCache.delete(id);
evictOvernightPauseCache(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();
clearAllOvernightPauseCaches();
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);
}
/**
* 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<Array<{ teamId: string; timeRemaining: number }>> {
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),
),
});
const snapshots = await Promise.all(
activeTimers
.filter((t) => t.picksExpiresAt !== null)
.map(async (timer) => {
const remaining = msToSeconds((timer.picksExpiresAt?.getTime() ?? now) - now);
await db
.update(schema.draftTimers)
.set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
.where(eq(schema.draftTimers.id, timer.id));
return { teamId: timer.teamId, timeRemaining: remaining };
})
);
return snapshots;
}
/**
* 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));
}