brackt/server/timer.ts
Chris Parsons 789408e428
Fix chess clock increment not applied to all pick types (#67 regression) (#220)
Fixes #220

When standard draft mode was added, the chess clock increment was
accidentally restricted to owner-only picks. Commissioner, admin, and
auto-picks stopped earning the increment, causing teams that timed out
to freeze at 0s and instant-autopick every subsequent round.

Fix:
- make-pick: all pick types earn the increment in chess clock mode
- force-manual-pick: same; deduplicate standard/chess-clock branches
  into a single update with mode-selected SQL; remove now-unused
  timerSnapshot query
- draft-utils executeAutoPick: restore increment for chess clock
  auto-picks; add missing seed insert when no timer row exists
- server/timer.ts: fix fallback initialization to use draftIncrementTime
  in standard mode (was always using draftInitialTime)
- leagues/$leagueId.tsx: show Draft Timer Mode in League Info panel

Tests:
- Update draft.make-pick.timer-mode to cover owner/commissioner/admin
  in both modes (commissioner section previously asserted frozen bank)
- Add draft.force-manual-pick.timer-mode for commissioner/admin force
  picks in both modes
- Add executeAutoPick.timer for timer-triggered auto-picks in both modes
- Update draft.force-manual-pick to reflect new chess clock behavior
- Replace fragile toHaveBeenCalledTimes(2) assertions with
  toHaveBeenCalledWith checks on the timer set call

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:00:32 -07:00

236 lines
7.4 KiB
TypeScript

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "~/database/schema";
import { eq, and, asc, sql } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { logger } from "./logger";
// Create a dedicated database connection for the timer
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required for timer system");
}
const client = postgres(connectionString);
const db = drizzle(client, { schema });
let timerInterval: NodeJS.Timeout | null = null;
/**
* Start the draft timer system
* Runs every second to update all active draft timers
*/
export function startDraftTimerSystem(): void {
if (timerInterval) {
logger.log("[Timer] Timer system already running");
return;
}
timerInterval = setInterval(async () => {
try {
await updateDraftTimers();
} catch (error) {
logger.error("[Timer] Error updating draft timers:", error);
}
}, 1000);
logger.log("[Timer] Draft timer system started");
}
/**
* Stop the draft timer system
*/
export function stopDraftTimerSystem(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
logger.log("[Timer] Draft timer system stopped");
}
}
/**
* Update all active draft timers
* Called every second by the timer interval
*/
async function updateDraftTimers(): Promise<void> {
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
if (activeDrafts.length === 0) {
return; // No active drafts
}
for (const season of activeDrafts) {
// Skip if draft is paused
if (season.draftPaused) {
continue;
}
const currentPickNumber = season.currentPickNumber || 1;
// Get draft slots to determine whose turn it is
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, season.id),
orderBy: asc(schema.draftSlots.draftOrder),
});
const totalTeams = draftSlots.length;
if (totalTeams === 0) continue;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound
);
if (!currentDraftSlot) {
continue;
}
const currentTeamId = currentDraftSlot.teamId;
// Get current team's timer
const timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, season.id),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) {
logger.warn(
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
);
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
const initialTime = season.draftTimerMode === "standard"
? (season.draftIncrementTime || 30)
: (season.draftInitialTime || 120);
await db
.insert(schema.draftTimers)
.values({
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
});
// Emit timer update so clients are aware of the new timer
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
currentPickNumber,
});
// Continue processing with the newly created timer on the next tick
continue;
}
// If timer is at 0 or below, check autodraft settings and trigger auto-pick
if (timer.timeRemaining <= 0) {
logger.log(
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
);
// Check if team has autodraft enabled
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, season.id),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
continue;
}
// Atomically decrement timer (race-condition safe: uses DB-level update
// so concurrent increments from pick handlers are never overwritten)
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, timer.id))
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
// Emit timer update to all clients in the draft room
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: newTimeRemaining,
currentPickNumber,
});
// If timer just hit 0, trigger auto-pick
if (newTimeRemaining === 0) {
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, season.id),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
}
}
}
/**
* Trigger an automatic pick when timer expires.
* Returns true if the pick succeeded (or was already made by another path),
* false if a real failure occurred that requires commissioner intervention.
*/
async function triggerAutoPick(
seasonId: string,
teamId: string,
pickNumber: number,
autodraftSettings: InferSelectModel<typeof schema.autodraftSettings> | null
): Promise<boolean> {
try {
const result = await executeAutoPick({
seasonId,
teamId,
pickNumber,
triggeredBy: "timer",
autodraftSettings,
db,
});
if (!result.success) {
// A race condition where the pick was already made is not a real failure
if (result.error === "Pick already made") {
return true;
}
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
return false;
}
return true;
} catch (error) {
logger.error("[Timer] Error in triggerAutoPick:", error);
return false;
}
}