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>
This commit is contained in:
Chris Parsons 2026-02-22 17:02:35 -08:00 committed by GitHub
parent ab3437cd73
commit a3ec556ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 144 deletions

View file

@ -19,15 +19,18 @@ export async function checkAndTriggerNextAutodraft(params: {
draftSlots: any[]; draftSlots: any[];
db?: ReturnType<typeof database>; db?: ReturnType<typeof database>;
}): Promise<void> { }): Promise<void> {
const { seasonId, nextPickNumber, totalTeams, draftSlots, db: providedDb } = params; const { seasonId, totalTeams, draftSlots, db: providedDb } = params;
const db = providedDb || database(); const db = providedDb || database();
// Calculate which team is next using snake draft logic let currentPickNumber = params.nextPickNumber;
const nextRound = Math.ceil(nextPickNumber / totalTeams);
const isNextRoundEven = nextRound % 2 === 0; // Iteratively execute autodraft picks for consecutive teams with autodraft enabled
let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; while (true) {
// Calculate which team is next using snake draft logic
const nextRound = Math.ceil(currentPickNumber / totalTeams);
const isNextRoundEven = nextRound % 2 === 0;
let nextPickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
// Apply snake draft reversal for even rounds
if (isNextRoundEven) { if (isNextRoundEven) {
nextPickInRound = totalTeams - nextPickInRound + 1; nextPickInRound = totalTeams - nextPickInRound + 1;
} }
@ -37,7 +40,6 @@ export async function checkAndTriggerNextAutodraft(params: {
const nextTeamId = nextDraftSlot.teamId; const nextTeamId = nextDraftSlot.teamId;
// Check if next team has autodraft enabled
const autodraftSettings = await db.query.autodraftSettings.findFirst({ const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and( where: and(
eq(schema.autodraftSettings.seasonId, seasonId), eq(schema.autodraftSettings.seasonId, seasonId),
@ -45,24 +47,25 @@ export async function checkAndTriggerNextAutodraft(params: {
), ),
}); });
if (autodraftSettings?.isEnabled) { if (!autodraftSettings?.isEnabled) return;
console.log( console.log(
`[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${nextPickNumber}` `[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${currentPickNumber}`
); );
// Immediately execute autopick for this team const result = await executeAutoPick({
await executeAutoPick({
seasonId, seasonId,
teamId: nextTeamId, teamId: nextTeamId,
pickNumber: nextPickNumber, pickNumber: currentPickNumber,
triggeredBy: "timer", // Use "timer" to indicate automatic (not commissioner-forced) triggeredBy: "timer",
autodraftSettings, autodraftSettings,
db, db,
chainEnabled: false,
}); });
} else {
console.log( if (!result.success || result.isDraftComplete || !result.nextPickNumber) return;
`[AutodraftChain] Team ${nextTeamId} does not have autodraft enabled, waiting for manual pick`
); currentPickNumber = result.nextPickNumber;
} }
} }
@ -246,31 +249,8 @@ export async function getTopAvailableParticipant(
return null; return null;
} }
// Get top available participant by EV // Handle multiple sports seasons: query each and sort in memory
let query = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
// Filter out drafted participants if any exist
if (draftedIds.length > 0) {
query = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.participants.id, draftedIds)
)
)
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
}
// Handle multiple sports seasons
if (sportsSeasonIds.length > 1) { if (sportsSeasonIds.length > 1) {
// For simplicity, we'll query all and sort in memory
// In production, might want to optimize this
const allParticipants = []; const allParticipants = [];
for (const sportsSeasonId of sportsSeasonIds) { for (const sportsSeasonId of sportsSeasonIds) {
@ -308,6 +288,26 @@ export async function getTopAvailableParticipant(
return allParticipants[0]?.id || null; return allParticipants[0]?.id || null;
} }
// Single sport season
let query = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
if (draftedIds.length > 0) {
query = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.participants.id, draftedIds)
)
)
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
}
const [topParticipant] = await query; const [topParticipant] = await query;
return topParticipant?.id || null; return topParticipant?.id || null;
} }
@ -375,6 +375,7 @@ export async function executeAutoPick(params: {
commissionerUserId?: string; commissionerUserId?: string;
autodraftSettings?: any; autodraftSettings?: any;
db?: ReturnType<typeof database>; db?: ReturnType<typeof database>;
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
}): Promise<{ }): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
@ -518,7 +519,7 @@ export async function executeAutoPick(params: {
pickInRound, pickInRound,
pickedByUserId, pickedByUserId,
pickedByType: "auto", pickedByType: "auto",
// timeRemaining at pick moment: 0 when timer expired, full bank for immediate autodraft // Records the team's bank balance at the moment the pick was made (seconds remaining)
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
}) })
.returning(); .returning();
@ -634,7 +635,8 @@ export async function executeAutoPick(params: {
} }
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { // Only run the chain from the top-level call to prevent recursion
if (!isDraftComplete && params.chainEnabled !== false) {
await checkAndTriggerNextAutodraft({ await checkAndTriggerNextAutodraft({
seasonId, seasonId,
nextPickNumber, nextPickNumber,

View file

@ -56,6 +56,10 @@ export async function action(args: any) {
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
if (totalTeams === 0) {
return Response.json({ error: "No draft slots found for this season" }, { status: 400 });
}
// Calculate which team is on the clock at the rollback pick (snake draft) // Calculate which team is on the clock at the rollback pick (snake draft)
const round = Math.ceil(pickNumber / totalTeams); const round = Math.ceil(pickNumber / totalTeams);
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;
@ -75,21 +79,6 @@ export async function action(args: any) {
) )
); );
// Clear all timers and create a fresh one for the team now on the clock
await db
.delete(schema.draftTimers)
.where(eq(schema.draftTimers.seasonId, seasonId));
const initialTime = season.draftInitialTime || 120;
if (rollbackSlot) {
await db.insert(schema.draftTimers).values({
seasonId,
teamId: rollbackSlot.teamId,
timeRemaining: initialTime,
});
}
// Update season: reset pick number and unpause // Update season: reset pick number and unpause
await db await db
.update(schema.seasons) .update(schema.seasons)
@ -108,15 +97,6 @@ export async function action(args: any) {
pickNumber, pickNumber,
teamId: rollbackSlot?.teamId, teamId: rollbackSlot?.teamId,
}); });
if (rollbackSlot) {
io.to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId: rollbackSlot.teamId,
timeRemaining: initialTime,
currentPickNumber: pickNumber,
});
}
} catch (error) { } catch (error) {
console.error("Socket.IO error:", error); console.error("Socket.IO error:", error);
} }

View file

@ -48,6 +48,15 @@ export async function action(args: any) {
return Response.json({ error: "Draft already started or completed" }, { status: 400 }); return Response.json({ error: "Draft already started or completed" }, { status: 400 });
} }
// Validate draft slots exist before modifying any state
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
});
if (draftSlots.length === 0) {
return Response.json({ error: "No draft slots found for this season" }, { status: 400 });
}
// Update season status to draft // Update season status to draft
await db await db
.update(schema.seasons) .update(schema.seasons)
@ -57,15 +66,6 @@ export async function action(args: any) {
}) })
.where(eq(schema.seasons.id, seasonId)); .where(eq(schema.seasons.id, seasonId));
// Initialize timers for all teams
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
});
if (draftSlots.length === 0) {
return Response.json({ error: "No draft slots found for this season" }, { status: 400 });
}
const initialTime = season.draftInitialTime || 120; const initialTime = season.draftInitialTime || 120;
// Delete existing timers for this season // Delete existing timers for this season

View file

@ -63,8 +63,6 @@ async function updateDraftTimers(): Promise<void> {
return; // No active drafts return; // No active drafts
} }
console.log(`[Timer] Updating timers for ${activeDrafts.length} active draft(s)`);
for (const season of activeDrafts) { for (const season of activeDrafts) {
// Skip if draft is paused // Skip if draft is paused
if (season.draftPaused) { if (season.draftPaused) {
@ -93,7 +91,7 @@ async function updateDraftTimers(): Promise<void> {
} }
const currentDraftSlot = draftSlots.find( const currentDraftSlot = draftSlots.find(
(slot: any) => slot.draftOrder === pickInRound (slot) => slot.draftOrder === pickInRound
); );
if (!currentDraftSlot) { if (!currentDraftSlot) {
@ -111,14 +109,34 @@ async function updateDraftTimers(): Promise<void> {
}); });
if (!timer) { if (!timer) {
console.warn(`[Timer] No timer found for team ${currentTeamId} in season ${season.id}`); 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; continue;
} }
// If timer is at 0 or below, check autodraft settings and trigger auto-pick // If timer is at 0 or below, check autodraft settings and trigger auto-pick
if (timer.timeRemaining <= 0) { if (timer.timeRemaining <= 0) {
console.log( console.log(
`[Timer] ⚠️ Timer at ${timer.timeRemaining} for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})` `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
); );
// Check if team has autodraft enabled // Check if team has autodraft enabled
@ -130,22 +148,21 @@ async function updateDraftTimers(): Promise<void> {
}); });
const shouldAutodraft = autodraftSettings?.isEnabled ?? false; const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
console.log(
`[Timer] Team ${currentTeamId} autodraft: ${shouldAutodraft ? 'ENABLED' : 'DISABLED'}, triggering forced auto-pick`
);
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? autodraftSettings : null); const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? autodraftSettings : null);
continue; // Skip to next draft after triggering pick 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 // Decrement timer
const newTimeRemaining = timer.timeRemaining - 1; const newTimeRemaining = timer.timeRemaining - 1;
console.log(
`[Timer] Season ${season.id}: Team ${currentTeamId} - ${newTimeRemaining}s remaining (pick ${currentPickNumber})`
);
// Update timer in database // Update timer in database
await db await db
.update(schema.draftTimers) .update(schema.draftTimers)
@ -163,13 +180,8 @@ async function updateDraftTimers(): Promise<void> {
currentPickNumber, currentPickNumber,
}); });
// If timer just hit 0, check autodraft settings and trigger auto-pick // If timer just hit 0, trigger auto-pick
if (newTimeRemaining === 0) { if (newTimeRemaining === 0) {
console.log(
`[Timer] Timer expired for team ${currentTeamId} in season ${season.id}, checking autodraft settings`
);
// Check if team has autodraft enabled
const autodraftSettings = await db.query.autodraftSettings.findFirst({ const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and( where: and(
eq(schema.autodraftSettings.seasonId, season.id), eq(schema.autodraftSettings.seasonId, season.id),
@ -178,31 +190,29 @@ async function updateDraftTimers(): Promise<void> {
}); });
const shouldAutodraft = autodraftSettings?.isEnabled ?? false; const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
console.log( const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? autodraftSettings : null);
`[Timer] Autodraft ${shouldAutodraft ? 'enabled' : 'disabled'} for team ${currentTeamId}, triggering auto-pick`
);
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, 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 * Trigger an automatic pick when timer expires.
* Uses the unified executeAutoPick function * 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( async function triggerAutoPick(
seasonId: string, seasonId: string,
teamId: string, teamId: string,
pickNumber: number, pickNumber: number,
autodraftSettings: any | null autodraftSettings: any | null
): Promise<void> { ): Promise<boolean> {
try { try {
console.log(
`[Timer] Triggering auto-pick for team ${teamId} in season ${seasonId}, pick ${pickNumber}`
);
// Execute the autopick using the unified function
const result = await executeAutoPick({ const result = await executeAutoPick({
seasonId, seasonId,
teamId, teamId,
@ -213,12 +223,17 @@ async function triggerAutoPick(
}); });
if (!result.success) { 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}`); console.error(`[Timer] Auto-pick failed: ${result.error}`);
return; return false;
} }
console.log(`[Timer] Auto-pick complete and broadcasted`); return true;
} catch (error) { } catch (error) {
console.error("[Timer] Error in triggerAutoPick:", error); console.error("[Timer] Error in triggerAutoPick:", error);
return false;
} }
} }