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:
parent
ab3437cd73
commit
a3ec556ecc
4 changed files with 141 additions and 144 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue