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,50 +19,53 @@ export async function checkAndTriggerNextAutodraft(params: {
draftSlots: any[];
db?: ReturnType<typeof database>;
}): Promise<void> {
const { seasonId, nextPickNumber, totalTeams, draftSlots, db: providedDb } = params;
const { seasonId, totalTeams, draftSlots, db: providedDb } = params;
const db = providedDb || database();
// Calculate which team is next using snake draft logic
const nextRound = Math.ceil(nextPickNumber / totalTeams);
const isNextRoundEven = nextRound % 2 === 0;
let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1;
let currentPickNumber = params.nextPickNumber;
// Apply snake draft reversal for even rounds
if (isNextRoundEven) {
nextPickInRound = totalTeams - nextPickInRound + 1;
}
// Iteratively execute autodraft picks for consecutive teams with autodraft enabled
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;
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
if (!nextDraftSlot) return;
if (isNextRoundEven) {
nextPickInRound = totalTeams - nextPickInRound + 1;
}
const nextTeamId = nextDraftSlot.teamId;
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
if (!nextDraftSlot) return;
// Check if next team has autodraft enabled
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, nextTeamId)
),
});
const nextTeamId = nextDraftSlot.teamId;
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, nextTeamId)
),
});
if (!autodraftSettings?.isEnabled) return;
if (autodraftSettings?.isEnabled) {
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
await executeAutoPick({
const result = await executeAutoPick({
seasonId,
teamId: nextTeamId,
pickNumber: nextPickNumber,
triggeredBy: "timer", // Use "timer" to indicate automatic (not commissioner-forced)
pickNumber: currentPickNumber,
triggeredBy: "timer",
autodraftSettings,
db,
chainEnabled: false,
});
} else {
console.log(
`[AutodraftChain] Team ${nextTeamId} does not have autodraft enabled, waiting for manual pick`
);
if (!result.success || result.isDraftComplete || !result.nextPickNumber) return;
currentPickNumber = result.nextPickNumber;
}
}
@ -245,15 +248,53 @@ export async function getTopAvailableParticipant(
if (sportsSeasonIds.length === 0) {
return null;
}
// Get top available participant by EV
// Handle multiple sports seasons: query each and sort in memory
if (sportsSeasonIds.length > 1) {
const allParticipants = [];
for (const sportsSeasonId of sportsSeasonIds) {
let participantQuery = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (draftedIds.length > 0) {
participantQuery = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
notInArray(schema.participants.id, draftedIds)
)
);
}
const seasonParticipants = await participantQuery;
allParticipants.push(...seasonParticipants);
}
// Sort by EV desc, then name (expectedValue is a decimal string from DB)
allParticipants.sort((a, b) => {
const evA = parseFloat(String(a.expectedValue)) || 0;
const evB = parseFloat(String(b.expectedValue)) || 0;
if (evB !== evA) {
return evB - evA;
}
return a.name.localeCompare(b.name);
});
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);
// Filter out drafted participants if any exist
if (draftedIds.length > 0) {
query = db
.select()
@ -266,48 +307,7 @@ export async function getTopAvailableParticipant(
)
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
}
// Handle multiple sports seasons
if (sportsSeasonIds.length > 1) {
// For simplicity, we'll query all and sort in memory
// In production, might want to optimize this
const allParticipants = [];
for (const sportsSeasonId of sportsSeasonIds) {
let participantQuery = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (draftedIds.length > 0) {
participantQuery = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
notInArray(schema.participants.id, draftedIds)
)
);
}
const seasonParticipants = await participantQuery;
allParticipants.push(...seasonParticipants);
}
// Sort by EV desc, then name (expectedValue is a decimal string from DB)
allParticipants.sort((a, b) => {
const evA = parseFloat(String(a.expectedValue)) || 0;
const evB = parseFloat(String(b.expectedValue)) || 0;
if (evB !== evA) {
return evB - evA;
}
return a.name.localeCompare(b.name);
});
return allParticipants[0]?.id || null;
}
const [topParticipant] = await query;
return topParticipant?.id || null;
}
@ -375,6 +375,7 @@ export async function executeAutoPick(params: {
commissionerUserId?: string;
autodraftSettings?: any;
db?: ReturnType<typeof database>;
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
}): Promise<{
success: boolean;
error?: string;
@ -518,7 +519,7 @@ export async function executeAutoPick(params: {
pickInRound,
pickedByUserId,
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,
})
.returning();
@ -634,7 +635,8 @@ export async function executeAutoPick(params: {
}
// 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({
seasonId,
nextPickNumber,

View file

@ -56,6 +56,10 @@ export async function action(args: any) {
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)
const round = Math.ceil(pickNumber / totalTeams);
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
await db
.update(schema.seasons)
@ -108,15 +97,6 @@ export async function action(args: any) {
pickNumber,
teamId: rollbackSlot?.teamId,
});
if (rollbackSlot) {
io.to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId: rollbackSlot.teamId,
timeRemaining: initialTime,
currentPickNumber: pickNumber,
});
}
} catch (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 });
}
// 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
await db
.update(schema.seasons)
@ -57,15 +66,6 @@ export async function action(args: any) {
})
.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;
// Delete existing timers for this season

View file

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