diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index c33d6c2..618adda 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -19,50 +19,53 @@ export async function checkAndTriggerNextAutodraft(params: { draftSlots: any[]; db?: ReturnType; }): Promise { - 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; + 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, diff --git a/app/routes/api/draft.rollback.ts b/app/routes/api/draft.rollback.ts index 363102e..9c18dc2 100644 --- a/app/routes/api/draft.rollback.ts +++ b/app/routes/api/draft.rollback.ts @@ -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; diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index addf358..b53fdee 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -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 diff --git a/server/timer.ts b/server/timer.ts index 33e7702..4dd2cbc 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -63,8 +63,6 @@ async function updateDraftTimers(): Promise { 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 { } const currentDraftSlot = draftSlots.find( - (slot: any) => slot.draftOrder === pickInRound + (slot) => slot.draftOrder === pickInRound ); if (!currentDraftSlot) { @@ -138,7 +136,7 @@ async function updateDraftTimers(): Promise { // 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 { }); 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 { 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 { }); 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 { +): Promise { 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; } }