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
This commit is contained in:
Claude 2026-02-23 00:58:13 +00:00
parent b251304b4d
commit 6e0db1304d
No known key found for this signature in database
4 changed files with 120 additions and 119 deletions

View file

@ -19,50 +19,53 @@ 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;
let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1;
// Apply snake draft reversal for even rounds // Iteratively execute autodraft picks for consecutive teams with autodraft enabled
if (isNextRoundEven) { while (true) {
nextPickInRound = totalTeams - nextPickInRound + 1; // 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 (isNextRoundEven) {
if (!nextDraftSlot) return; 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 nextTeamId = nextDraftSlot.teamId;
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and( const autodraftSettings = await db.query.autodraftSettings.findFirst({
eq(schema.autodraftSettings.seasonId, seasonId), where: and(
eq(schema.autodraftSettings.teamId, nextTeamId) eq(schema.autodraftSettings.seasonId, seasonId),
), eq(schema.autodraftSettings.teamId, nextTeamId)
}); ),
});
if (!autodraftSettings?.isEnabled) return;
if (autodraftSettings?.isEnabled) {
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;
} }
} }
@ -245,15 +248,53 @@ export async function getTopAvailableParticipant(
if (sportsSeasonIds.length === 0) { if (sportsSeasonIds.length === 0) {
return null; 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 let query = db
.select() .select()
.from(schema.participants) .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])) .where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.expectedValue), schema.participants.name); .orderBy(desc(schema.participants.expectedValue), schema.participants.name);
// Filter out drafted participants if any exist
if (draftedIds.length > 0) { if (draftedIds.length > 0) {
query = db query = db
.select() .select()
@ -266,48 +307,7 @@ export async function getTopAvailableParticipant(
) )
.orderBy(desc(schema.participants.expectedValue), schema.participants.name); .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; 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;

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) {
@ -138,7 +136,7 @@ async function updateDraftTimers(): Promise<void> {
// 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
@ -150,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)
@ -183,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),
@ -198,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,
@ -233,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;
} }
} }