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[];
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;

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) {
@ -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.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
@ -150,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)
@ -183,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),
@ -198,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,
@ -233,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;
}
}