import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm"; import { logger } from "~/lib/logger"; import type { InferSelectModel } from "drizzle-orm"; import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue"; import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick"; import { getParticipantsForSeasonWithSports } from "./participant"; import { getSeasonSportsSimple } from "./season-sport"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; /** * Check if the next team has autodraft enabled and immediately execute their pick * This is called after a pick is made to chain autodraft picks */ type DraftSlot = { teamId: string; draftOrder: number }; export async function checkAndTriggerNextAutodraft(params: { seasonId: string; nextPickNumber: number; totalTeams: number; draftSlots: DraftSlot[]; db?: ReturnType; }): Promise { const { seasonId, totalTeams, draftSlots, db: providedDb } = params; const db = providedDb || database(); let currentPickNumber = params.nextPickNumber; // Cap iterations at totalTeams: in the worst case every team has autodraft enabled, // so we make at most totalTeams consecutive picks before handing back to the timer loop. const maxIterations = params.totalTeams; let iterations = 0; // Iteratively execute autodraft picks for consecutive teams with autodraft enabled while (iterations < maxIterations) { iterations++; const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber, totalTeams); const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); if (!nextDraftSlot) return; 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; logger.log( `[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${currentPickNumber}` ); const result = await executeAutoPick({ seasonId, teamId: nextTeamId, pickNumber: currentPickNumber, triggeredBy: "timer", autodraftSettings, db, chainEnabled: false, }); if (!result.success || result.isDraftComplete || !result.nextPickNumber) return; currentPickNumber = result.nextPickNumber; } } /** * Auto-pick for a team when their timer runs out * 1. Check queue - pick first eligible item if available (cleans up ineligible items) * 2. If queue empty, pick highest EV participant not drafted from eligible sports * * Updated to respect Omni league draft eligibility rules */ export async function autoPickForTeam( seasonId: string, teamId: string, draftRounds: number, allTeamIds: string[], providedDb?: ReturnType, queueOnly?: boolean ) { const db = providedDb || database(); // Calculate eligibility for this team const allPicks = await getDraftPicksWithSports(seasonId, db); const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId, db); const allParticipants = await getParticipantsForSeasonWithSports(seasonId, db); const seasonSports = await getSeasonSportsSimple(seasonId, db); const allTeams = allTeamIds.map((id) => ({ id })); const eligibility = calculateDraftEligibility( teamId, teamPicks, allPicks, allParticipants, seasonSports, draftRounds, allTeams ); logger.log( `[AutoPick] Team ${teamId} eligible sports:`, Array.from(eligibility.eligibleSportIds) ); // Check queue first - filter by eligible sports const queue = await getTeamQueue(teamId, db); if (queue.length > 0) { logger.log(`[AutoPick] Team ${teamId} has ${queue.length} items in queue`); // Get participant details for queue items to check eligibility const queueParticipantIds = queue.map((item) => item.participantId); const queueParticipants = await db.query.participants.findMany({ where: inArray(schema.participants.id, queueParticipantIds), with: { sportsSeason: { with: { sport: true, }, }, }, }); const ineligibleQueueItemIds: string[] = []; // Try queue items in order, checking both drafted status and sport eligibility for (const item of queue) { const participant = queueParticipants.find((p) => p.id === item.participantId); if (!participant) { logger.log(`[AutoPick] Queue item ${item.id} - participant not found, will remove`); ineligibleQueueItemIds.push(item.id); continue; } const sportId = participant.sportsSeason.sport.id; const isEligible = eligibility.eligibleSportIds.has(sportId); const isDrafted = await isParticipantDrafted(seasonId, item.participantId, db); if (isDrafted) { logger.log( `[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - already drafted, will remove` ); ineligibleQueueItemIds.push(item.id); continue; } if (!isEligible) { logger.log( `[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - not eligible for this team, will remove` ); ineligibleQueueItemIds.push(item.id); continue; } // Found a valid pick from queue logger.log( `[AutoPick] Selecting from queue: ${participant.name} (${participant.sportsSeason.sport.name})` ); // Clean up ineligible items from queue before returning if (ineligibleQueueItemIds.length > 0) { await db .delete(schema.draftQueue) .where(inArray(schema.draftQueue.id, ineligibleQueueItemIds)); logger.log(`[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue`); } return item.participantId; } // All queue items were ineligible or drafted - clean them up if (ineligibleQueueItemIds.length > 0) { await db .delete(schema.draftQueue) .where(inArray(schema.draftQueue.id, ineligibleQueueItemIds)); logger.log( `[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue (all items were invalid)` ); } } // Queue is empty or all queued players drafted/ineligible if (queueOnly) { logger.log(`[AutoPick] No valid queue items and queueOnly constraint is active — will not fall back to highest EV`); return null; } // Pick highest EV available from eligible sports logger.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`); return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds, db); } /** * Get the highest EV participant that hasn't been drafted yet * Updated to filter by eligible sports */ export async function getTopAvailableParticipant( seasonId: string, eligibleSportIds?: Set, providedDb?: ReturnType ) { const db = providedDb || database(); // Get all drafted participant IDs const draftedPicks = await db .select({ participantId: schema.draftPicks.participantId }) .from(schema.draftPicks) .where(eq(schema.draftPicks.seasonId, seasonId)); const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId); // Get all participants from season sports, filtered by eligible sports if provided let seasonSportsData; if (eligibleSportIds && eligibleSportIds.size > 0) { // Filter to only eligible sports seasonSportsData = await db .select({ sportsSeasonId: schema.seasonSports.sportsSeasonId, sportId: schema.sports.id, }) .from(schema.seasonSports) .innerJoin( schema.sportsSeasons, eq(schema.seasonSports.sportsSeasonId, schema.sportsSeasons.id) ) .innerJoin( schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id) ) .where(eq(schema.seasonSports.seasonId, seasonId)); // Filter to only eligible sports seasonSportsData = seasonSportsData.filter((s: { sportId: string }) => eligibleSportIds.has(s.sportId) ); } else { // No filtering - get all sports seasonSportsData = await db .select({ sportsSeasonId: schema.seasonSports.sportsSeasonId }) .from(schema.seasonSports) .where(eq(schema.seasonSports.seasonId, seasonId)); } const sportsSeasonIds = seasonSportsData.map( (s: { sportsSeasonId: string }) => s.sportsSeasonId ); if (sportsSeasonIds.length === 0) { return null; } // 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 VORP desc, then name allParticipants.sort((a, b) => { const vorpA = parseFloat(String(a.vorpValue)) || 0; const vorpB = parseFloat(String(b.vorpValue)) || 0; if (vorpB !== vorpA) { return vorpB - vorpA; } 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.vorpValue), schema.participants.name); if (draftedIds.length > 0) { query = db .select() .from(schema.participants) .where( and( eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]), notInArray(schema.participants.id, draftedIds) ) ) .orderBy(desc(schema.participants.vorpValue), schema.participants.name); } const [topParticipant] = await query; return topParticipant?.id || null; } /** * Calculate the current pick based on draft order and round (snake draft). * Returns pickInRound that is already snake-adjusted and matches draftOrder values. */ export function calculatePickInfo( pickNumber: number, teamCount: number ): { round: number; pickInRound: number; teamIndex: number } { const round = Math.ceil(pickNumber / teamCount); const rawPickInRound = ((pickNumber - 1) % teamCount) + 1; // Snake draft: odd rounds go forward, even rounds go backward const isOddRound = round % 2 === 1; const teamIndex = isOddRound ? rawPickInRound - 1 : teamCount - rawPickInRound; const pickInRound = teamIndex + 1; // snake-adjusted, 1-based, matches draftOrder return { round, pickInRound, teamIndex }; } /** * Get the team ID for a given pick number based on draft order */ export function getTeamForPick( pickNumber: number, draftOrder: { teamId: string; draftOrder: number }[] ): string | null { const sortedOrder = [...draftOrder].toSorted((a, b) => a.draftOrder - b.draftOrder); const teamCount = sortedOrder.length; if (teamCount === 0) return null; const { teamIndex } = calculatePickInfo(pickNumber, teamCount); return sortedOrder[teamIndex]?.teamId || null; } /** * After a pick is committed, recalculate draft eligibility for every team and remove * any queued participants whose sport is no longer eligible for that team. * * This handles cases like: a team has a snooker player queued but has now filled their * last flex slot that snooker could have used — the pick should be proactively removed * rather than silently failing or pausing the draft later. * * Returns the per-team removals so the caller can emit socket events. */ export async function pruneIneligibleQueueItems(params: { seasonId: string; draftRounds: number; allTeamIds: string[]; db: ReturnType; }): Promise<{ teamId: string; removedParticipantIds: string[] }[]> { const { seasonId, draftRounds, allTeamIds, db } = params; const [allPicks, allParticipants, seasonSports, allQueues] = await Promise.all([ getDraftPicksWithSports(seasonId, db), getParticipantsForSeasonWithSports(seasonId, db), getSeasonSportsSimple(seasonId, db), getAllQueuesForSeason(seasonId, db), ]); // Build a fast lookup: participantId → sportId const participantSportMap = new Map(); for (const p of allParticipants) { participantSportMap.set(p.id, p.sport.id); } const allTeams = allTeamIds.map((id) => ({ id })); const results: { teamId: string; removedParticipantIds: string[] }[] = []; for (const teamId of allTeamIds) { const queue = allQueues.get(teamId) ?? []; if (queue.length === 0) continue; const teamPicks = allPicks.filter((p) => p.teamId === teamId); const eligibility = calculateDraftEligibility( teamId, teamPicks, allPicks, allParticipants, seasonSports, draftRounds, allTeams ); const ineligible: { id: string; participantId: string }[] = []; for (const item of queue) { const sportId = participantSportMap.get(item.participantId); if (sportId === undefined) { logger.warn( `[QueuePrune] Team ${teamId}: queue item ${item.id} references participant ${item.participantId} not found in season sports — skipping` ); continue; } if (!eligibility.eligibleSportIds.has(sportId)) { ineligible.push({ id: item.id, participantId: item.participantId }); } } if (ineligible.length > 0) { await db .delete(schema.draftQueue) .where(inArray(schema.draftQueue.id, ineligible.map((i) => i.id))); const removedParticipantIds = ineligible.map((i) => i.participantId); results.push({ teamId, removedParticipantIds }); logger.log( `[QueuePrune] Team ${teamId}: removed ${ineligible.length} ineligible items (sport no longer eligible)` ); } } return results; } /** * Execute an autopick for a team - unified function for both commissioner-forced and timer-based autopicks * * Selection Logic: * 1. Uses the team's draft queue first (prioritizes manager's preferences) * 2. Validates each queued participant for: * - Not already drafted * - Eligible based on draft rules (sport eligibility, flex spots, etc.) * 3. Automatically removes ineligible participants from queue * 4. If queue is empty or all items are ineligible, selects highest EV participant from eligible sports * * This ensures autopicks ALWAYS respect draft eligibility rules and cannot make illegal selections. * * @param params.seasonId - The season ID * @param params.teamId - The team making the pick * @param params.pickNumber - The current pick number * @param params.triggeredBy - Who/what triggered the autopick ("commissioner" or "timer") * @param params.commissionerUserId - User ID of commissioner (required if triggeredBy is "commissioner") * @param params.autodraftSettings - Autodraft settings (used for timer-based picks) * @param params.db - Database instance (optional, will use database() if not provided) * @returns Result object with success status and pick data */ type AutodraftSettings = InferSelectModel; export async function executeAutoPick(params: { seasonId: string; teamId: string; pickNumber: number; triggeredBy: "commissioner" | "timer"; commissionerUserId?: string; autodraftSettings?: AutodraftSettings | null; db?: ReturnType; chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion }): Promise<{ success: boolean; error?: string; pick?: InferSelectModel; participant?: InferSelectModel & { sportsSeason: InferSelectModel & { sport: InferSelectModel; }; }; nextPickNumber?: number; isDraftComplete?: boolean; }> { const { seasonId, teamId, pickNumber, triggeredBy, commissionerUserId, autodraftSettings, db: providedDb, } = params; const db = providedDb || database(); try { // Race condition protection - check if pick already made const existingPick = await db.query.draftPicks.findFirst({ where: and( eq(schema.draftPicks.seasonId, seasonId), eq(schema.draftPicks.pickNumber, pickNumber) ), }); if (existingPick) { logger.log(`[AutoPick] Pick ${pickNumber} already made, skipping`); return { success: false, error: "Pick already made", }; } // Get season details const season = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }); if (!season) { return { success: false, error: "Season not found", }; } // Get draft slots to calculate round/pickInRound and get all team IDs const draftSlots = await db.query.draftSlots.findMany({ where: eq(schema.draftSlots.seasonId, seasonId), orderBy: asc(schema.draftSlots.draftOrder), with: { team: true, }, }); const totalTeams = draftSlots.length; if (totalTeams === 0) { return { success: false, error: "No draft slots found", }; } const allTeamIds = draftSlots.map((slot) => slot.teamId); // Use autoPickForTeam to select participant (respects eligibility and queue) const queueOnly = autodraftSettings?.queueOnly ?? false; const participantId = await autoPickForTeam( seasonId, teamId, season.draftRounds, allTeamIds, db, queueOnly ); if (!participantId) { // If queueOnly is set and queue is empty, disable autodraft and return success // (timer will fire again and pick highest EV once autodraft is disabled) if (triggeredBy === "timer" && queueOnly && autodraftSettings) { logger.log(`[AutoPick] Queue empty with queueOnly constraint — disabling autodraft for team ${teamId}`); await db .update(schema.autodraftSettings) .set({ isEnabled: false, updatedAt: new Date() }) .where(eq(schema.autodraftSettings.id, autodraftSettings.id)); try { getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", { teamId, isEnabled: false, mode: autodraftSettings.mode, queueOnly: autodraftSettings.queueOnly, }); } catch (error) { logger.error("[AutoPick] Socket.IO autodraft-updated error (queue-empty shutoff):", error); } return { success: true }; } return { success: false, error: "No eligible participants available to pick", }; } // Get participant details const participantToPick = await db.query.participants.findFirst({ where: eq(schema.participants.id, participantId), with: { sportsSeason: { with: { sport: true, }, }, }, }); if (!participantToPick) { return { success: false, error: "Participant not found", }; } const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams); // Determine pickedByUserId based on trigger const pickedByUserId = triggeredBy === "commissioner" ? (commissionerUserId || "") : ""; // Fetch current timer before pick so we have the time remaining at decision point const incrementTime = season.draftIncrementTime || 30; const currentTimer = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId) ), }); if (!currentTimer) { logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`); } // Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer // ticks racing to the same pick slot are handled atomically at the DB level // rather than relying on the TOCTOU pre-check above. const [draftPick] = await db .insert(schema.draftPicks) .values({ seasonId, teamId, participantId: participantToPick.id, pickNumber, round: currentRound, pickInRound, pickedByUserId, pickedByType: "auto", // Records the team's bank balance at the moment the pick was made (seconds remaining) timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, }) .onConflictDoNothing() .returning(); if (!draftPick) { // Another concurrent path already committed this pick logger.log(`[AutoPick] Pick ${pickNumber} already made (conflict on insert), skipping`); return { success: false, error: "Pick already made" }; } logger.log( `[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}` ); // Calculate next pick info (before updating season) const nextPickNumber = pickNumber + 1; const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; // Update the team's timer after the auto-pick. // Standard mode: reset to the per-pick time (atomic, prevents race with timer loop). // Chess clock mode: add the increment so the team starts their next turn with some time // (without this, a single timeout would permanently freeze their bank at 0). let emitTimeRemaining: number; if (season.draftTimerMode === "standard") { const [updatedTimer] = await db .update(schema.draftTimers) .set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() }) .where( and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId) ) ) .returning(); emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; logger.log( `[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)` ); } else { // Chess clock: add the increment (atomic add, same as a manual pick). const [updatedTimer] = await db .update(schema.draftTimers) .set({ timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`, updatedAt: new Date(), }) .where( and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId) ) ) .returning(); emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; if (!updatedTimer) { await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining }); } logger.log( `[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)` ); } try { getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { seasonId, teamId, timeRemaining: emitTimeRemaining, currentPickNumber: nextPickNumber, overnightPauseActive: false, }); } catch (error) { logger.error("[AutoPick] Socket.IO timer-update error:", error); } // Next team's timer is unchanged — their bank carries forward as-is // Update season's current pick number await db .update(schema.seasons) .set({ currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, status: isDraftComplete ? "active" : season.status, draftCompletedAt: isDraftComplete ? new Date() : undefined, }) .where(eq(schema.seasons.id, seasonId)); // Remove from ALL team queues in this season (participant is now drafted) await db .delete(schema.draftQueue) .where( and( eq(schema.draftQueue.seasonId, seasonId), eq(schema.draftQueue.participantId, participantToPick.id) ) ); // Proactively prune queue items that are now ineligible due to this pick // (e.g. a team queued a snooker player but just filled their last flex slot) try { const prunedQueues = await pruneIneligibleQueueItems({ seasonId, draftRounds: season.draftRounds, allTeamIds, db, }); const io = getSocketIO(); for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) { io.to(`draft-${seasonId}`).emit("queue-eligibility-pruned", { teamId: prunedTeamId, removedParticipantIds, }); } } catch (error) { logger.error("[AutoPick] Error pruning ineligible queue items:", error); } // Handle autodraft settings for timer-based picks with "next_pick" mode if (triggeredBy === "timer" && autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") { logger.log(`[AutoPick] Disabling autodraft for team ${teamId} after next_pick`); await db .update(schema.autodraftSettings) .set({ isEnabled: false, updatedAt: new Date(), }) .where(eq(schema.autodraftSettings.id, autodraftSettings.id)); // Emit autodraft-updated event try { getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", { teamId, isEnabled: false, mode: autodraftSettings.mode, queueOnly: autodraftSettings.queueOnly, }); } catch (error) { logger.error("[AutoPick] Socket.IO autodraft-updated error:", error); } } // Emit socket events try { const io = getSocketIO(); const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; // Emit participant-removed-from-queues event io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", { participantId: participantToPick.id, }); // Emit pick-made event io.to(`draft-${seasonId}`).emit("pick-made", { pick: { ...draftPick, team, participant: { ...participantToPick, sport: participantToPick.sportsSeason.sport, }, sport: participantToPick.sportsSeason.sport, }, nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, isDraftComplete, }); // Emit draft-completed event if applicable if (isDraftComplete) { io.to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); } } catch (error) { logger.error("[AutoPick] Socket.IO events error:", error); } // Check if next team has autodraft enabled and trigger immediately // Only run the chain from the top-level call to prevent recursion if (!isDraftComplete && params.chainEnabled !== false) { await checkAndTriggerNextAutodraft({ seasonId, nextPickNumber, totalTeams, draftSlots, db, }); } return { success: true, pick: draftPick, participant: participantToPick, nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, isDraftComplete, }; } catch (error) { logger.error("[AutoPick] Error in executeAutoPick:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } }