## Summary - **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started` - **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display - **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire - **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero - **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event - **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload - **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt` - **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure - **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early - **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction - **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join ## Test plan - [ ] Manual pick: all clients see bank increment immediately after pick - [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s - [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log - [ ] Force-manual-pick: all clients see bank increment - [ ] Pause while clock running: countdown freezes on all clients - [ ] Resume: clock continues from frozen value - [ ] adjust-time-bank on on-clock team: countdown shifts immediately - [ ] adjust-time-bank to zero: returns 400 error - [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team - [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately - [ ] Draft complete: "Room closes in X" counts down - [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen - [ ] `npm run test:run` — all 158 files / 2351 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #72
449 lines
16 KiB
TypeScript
449 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";
|
|
|
|
// 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));
|
|
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.
|
|
// 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 = 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; }
|
|
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<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));
|
|
}
|