* 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>
239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
import { drizzle } from "drizzle-orm/postgres-js";
|
|
import postgres from "postgres";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, asc } from "drizzle-orm";
|
|
import { getSocketIO } from "./socket";
|
|
import { executeAutoPick } from "~/models/draft-utils";
|
|
|
|
// 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) {
|
|
console.log("[Timer] Timer system already running");
|
|
return;
|
|
}
|
|
|
|
timerInterval = setInterval(async () => {
|
|
try {
|
|
await updateDraftTimers();
|
|
} catch (error) {
|
|
console.error("[Timer] Error updating draft timers:", error);
|
|
}
|
|
}, 1000);
|
|
|
|
console.log("[Timer] Draft timer system started");
|
|
}
|
|
|
|
/**
|
|
* Stop the draft timer system
|
|
*/
|
|
export function stopDraftTimerSystem(): void {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
console.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;
|
|
|
|
// Calculate current round and pick
|
|
const currentRound = Math.ceil(currentPickNumber / totalTeams);
|
|
const isEvenRound = currentRound % 2 === 0;
|
|
let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
|
|
|
|
// Snake draft: reverse order on even rounds
|
|
if (isEvenRound) {
|
|
pickInRound = totalTeams - pickInRound + 1;
|
|
}
|
|
|
|
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) {
|
|
console.warn(
|
|
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
|
|
);
|
|
const initialTime = 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) {
|
|
console.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);
|
|
|
|
if (!success) {
|
|
console.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;
|
|
}
|
|
|
|
// Decrement timer
|
|
const newTimeRemaining = timer.timeRemaining - 1;
|
|
|
|
// Update timer in database
|
|
await db
|
|
.update(schema.draftTimers)
|
|
.set({
|
|
timeRemaining: newTimeRemaining,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.draftTimers.id, timer.id));
|
|
|
|
// 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);
|
|
|
|
if (!success) {
|
|
console.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: any | 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;
|
|
}
|
|
console.error(`[Timer] Auto-pick failed: ${result.error}`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("[Timer] Error in triggerAutoPick:", error);
|
|
return false;
|
|
}
|
|
}
|