brackt/app/models/draft-utils.ts
Chris Parsons 8ee5a8008e feat: sort draft room and auto-pick by VORP instead of expected value
Update draft participant sorting to use vorpValue column instead of expectedValue
in draft room initialization and auto-pick selection for both single-sport and
multi-sport seasons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 02:27:40 +00:00

837 lines
28 KiB
TypeScript

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 } 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<typeof database>;
}): Promise<void> {
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<typeof database>,
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<string>,
providedDb?: ReturnType<typeof database>
) {
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<typeof database>;
}): 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<string, string>();
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<typeof schema.autodraftSettings>;
export async function executeAutoPick(params: {
seasonId: string;
teamId: string;
pickNumber: number;
triggeredBy: "commissioner" | "timer";
commissionerUserId?: string;
autodraftSettings?: AutodraftSettings | null;
db?: ReturnType<typeof database>;
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
}): Promise<{
success: boolean;
error?: string;
pick?: InferSelectModel<typeof schema.draftPicks>;
participant?: InferSelectModel<typeof schema.participants> & {
sportsSeason: InferSelectModel<typeof schema.sportsSeasons> & {
sport: InferSelectModel<typeof schema.sports>;
};
};
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,
});
} 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,
})
.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");
}
} 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",
};
}
}