feat: Refactor autopick logic to use unified executeAutoPick function and enhance eligibility checks
This commit is contained in:
parent
32980428f2
commit
714ab0f484
5 changed files with 396 additions and 407 deletions
|
|
@ -9,7 +9,7 @@ import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-pick for a team when their timer runs out
|
* Auto-pick for a team when their timer runs out
|
||||||
* 1. Check queue - pick first eligible item if available
|
* 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
|
* 2. If queue empty, pick highest EV participant not drafted from eligible sports
|
||||||
*
|
*
|
||||||
* Updated to respect Omni league draft eligibility rules
|
* Updated to respect Omni league draft eligibility rules
|
||||||
|
|
@ -39,11 +39,18 @@ export async function autoPickForTeam(
|
||||||
allTeams
|
allTeams
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AutoPick] Team ${teamId} eligible sports:`,
|
||||||
|
Array.from(eligibility.eligibleSportIds)
|
||||||
|
);
|
||||||
|
|
||||||
// Check queue first - filter by eligible sports
|
// Check queue first - filter by eligible sports
|
||||||
const queue = await getTeamQueue(teamId);
|
const queue = await getTeamQueue(teamId);
|
||||||
|
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
// Get participant details for queue items to check sport eligibility
|
console.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 queueParticipantIds = queue.map((item) => item.participantId);
|
||||||
const queueParticipants = await db.query.participants.findMany({
|
const queueParticipants = await db.query.participants.findMany({
|
||||||
where: inArray(schema.participants.id, queueParticipantIds),
|
where: inArray(schema.participants.id, queueParticipantIds),
|
||||||
|
|
@ -56,23 +63,67 @@ export async function autoPickForTeam(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try queue items in order, checking both drafted status and eligibility
|
const ineligibleQueueItemIds: string[] = [];
|
||||||
|
|
||||||
|
// Try queue items in order, checking both drafted status and sport eligibility
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
const participant = queueParticipants.find((p) => p.id === item.participantId);
|
const participant = queueParticipants.find((p) => p.id === item.participantId);
|
||||||
if (!participant) continue;
|
if (!participant) {
|
||||||
|
console.log(`[AutoPick] Queue item ${item.id} - participant not found, will remove`);
|
||||||
|
ineligibleQueueItemIds.push(item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sportId = participant.sportsSeason.sport.id;
|
const sportId = participant.sportsSeason.sport.id;
|
||||||
const isEligible = eligibility.eligibleSportIds.has(sportId);
|
const isEligible = eligibility.eligibleSportIds.has(sportId);
|
||||||
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
|
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
|
||||||
|
|
||||||
if (!isDrafted && isEligible) {
|
if (isDrafted) {
|
||||||
return item.participantId;
|
console.log(
|
||||||
|
`[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - already drafted, will remove`
|
||||||
|
);
|
||||||
|
ineligibleQueueItemIds.push(item.id);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isEligible) {
|
||||||
|
console.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
|
||||||
|
console.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));
|
||||||
|
console.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));
|
||||||
|
console.log(
|
||||||
|
`[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue (all items were invalid)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue is empty or all queued players drafted/ineligible
|
// Queue is empty or all queued players drafted/ineligible
|
||||||
// Pick highest EV available from eligible sports
|
// Pick highest EV available from eligible sports
|
||||||
|
console.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`);
|
||||||
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds);
|
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,9 +275,311 @@ export function getTeamForPick(
|
||||||
): string | null {
|
): string | null {
|
||||||
const sortedOrder = [...draftOrder].sort((a, b) => a.draftOrder - b.draftOrder);
|
const sortedOrder = [...draftOrder].sort((a, b) => a.draftOrder - b.draftOrder);
|
||||||
const teamCount = sortedOrder.length;
|
const teamCount = sortedOrder.length;
|
||||||
|
|
||||||
if (teamCount === 0) return null;
|
if (teamCount === 0) return null;
|
||||||
|
|
||||||
const { teamIndex } = calculatePickInfo(pickNumber, teamCount);
|
const { teamIndex } = calculatePickInfo(pickNumber, teamCount);
|
||||||
return sortedOrder[teamIndex]?.teamId || null;
|
return sortedOrder[teamIndex]?.teamId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export async function executeAutoPick(params: {
|
||||||
|
seasonId: string;
|
||||||
|
teamId: string;
|
||||||
|
pickNumber: number;
|
||||||
|
triggeredBy: "commissioner" | "timer";
|
||||||
|
commissionerUserId?: string;
|
||||||
|
autodraftSettings?: any;
|
||||||
|
db?: ReturnType<typeof database>;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
pick?: any;
|
||||||
|
participant?: any;
|
||||||
|
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) {
|
||||||
|
console.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: 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 participantId = await autoPickForTeam(
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
season.draftRounds,
|
||||||
|
allTeamIds
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!participantId) {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate round and pickInRound with snake draft logic
|
||||||
|
const currentRound = Math.ceil(pickNumber / totalTeams);
|
||||||
|
const isEvenRound = currentRound % 2 === 0;
|
||||||
|
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
|
||||||
|
|
||||||
|
// Apply snake draft reversal for even rounds
|
||||||
|
if (isEvenRound) {
|
||||||
|
pickInRound = totalTeams - pickInRound + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine pickedByUserId based on trigger
|
||||||
|
const pickedByUserId = triggeredBy === "commissioner"
|
||||||
|
? (commissionerUserId || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Create the draft pick
|
||||||
|
const [draftPick] = await db
|
||||||
|
.insert(schema.draftPicks)
|
||||||
|
.values({
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
participantId: participantToPick.id,
|
||||||
|
pickNumber,
|
||||||
|
round: currentRound,
|
||||||
|
pickInRound,
|
||||||
|
pickedByUserId,
|
||||||
|
pickedByType: "auto",
|
||||||
|
timeUsed: triggeredBy === "timer" ? (season.draftInitialTime || 120) : undefined,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update season's current pick number and check if draft complete
|
||||||
|
const nextPickNumber = pickNumber + 1;
|
||||||
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.seasons)
|
||||||
|
.set({
|
||||||
|
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
||||||
|
status: isDraftComplete ? "active" : season.status,
|
||||||
|
})
|
||||||
|
.where(eq(schema.seasons.id, seasonId));
|
||||||
|
|
||||||
|
// Add increment to the team that just picked
|
||||||
|
const currentTimer = await db.query.draftTimers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.draftTimers.seasonId, seasonId),
|
||||||
|
eq(schema.draftTimers.teamId, teamId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentTimer) {
|
||||||
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
|
const newTimeRemaining = currentTimer.timeRemaining + incrementTime;
|
||||||
|
await db
|
||||||
|
.update(schema.draftTimers)
|
||||||
|
.set({
|
||||||
|
timeRemaining: newTimeRemaining,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||||
|
|
||||||
|
// Emit timer update to all clients
|
||||||
|
try {
|
||||||
|
const io = (global as any).__socketIO;
|
||||||
|
if (io) {
|
||||||
|
io.to(`draft-${seasonId}`).emit("timer-update", {
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
timeRemaining: newTimeRemaining,
|
||||||
|
currentPickNumber: pickNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AutoPick] Socket.IO timer-update error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle autodraft settings for timer-based picks with "next_pick" mode
|
||||||
|
if (triggeredBy === "timer" && autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") {
|
||||||
|
console.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 {
|
||||||
|
const io = (global as any).__socketIO;
|
||||||
|
if (io) {
|
||||||
|
io.to(`draft-${seasonId}`).emit("autodraft-updated", {
|
||||||
|
teamId,
|
||||||
|
isEnabled: false,
|
||||||
|
mode: autodraftSettings.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AutoPick] Socket.IO autodraft-updated error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit socket events
|
||||||
|
try {
|
||||||
|
const io = (global as any).__socketIO;
|
||||||
|
if (io) {
|
||||||
|
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) {
|
||||||
|
console.error("[AutoPick] Socket.IO events error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pick: draftPick,
|
||||||
|
participant: participantToPick,
|
||||||
|
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
||||||
|
isDraftComplete,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AutoPick] Error in executeAutoPick:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { autoPickForTeam } from "~/models/draft-utils";
|
import { executeAutoPick } from "~/models/draft-utils";
|
||||||
|
|
||||||
export async function action(args: any) {
|
export async function action(args: any) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
|
|
@ -24,7 +24,7 @@ export async function action(args: any) {
|
||||||
|
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Get season details
|
// Get season details to verify commissioner permissions
|
||||||
const season = await db.query.seasons.findFirst({
|
const season = await db.query.seasons.findFirst({
|
||||||
where: eq(schema.seasons.id, seasonId),
|
where: eq(schema.seasons.id, seasonId),
|
||||||
with: {
|
with: {
|
||||||
|
|
@ -41,166 +41,25 @@ export async function action(args: any) {
|
||||||
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
|
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get draft slots to pass team IDs
|
// Execute the autopick using the unified function
|
||||||
const draftSlots = await db.query.draftSlots.findMany({
|
const result = await executeAutoPick({
|
||||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
|
||||||
orderBy: schema.draftSlots.draftOrder,
|
|
||||||
with: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const allTeamIds = draftSlots.map((slot) => slot.teamId);
|
|
||||||
|
|
||||||
// Use autoPickForTeam which now respects eligibility
|
|
||||||
const participantId = await autoPickForTeam(
|
|
||||||
seasonId,
|
seasonId,
|
||||||
teamId,
|
teamId,
|
||||||
season.draftRounds,
|
pickNumber,
|
||||||
allTeamIds
|
triggeredBy: "commissioner",
|
||||||
);
|
commissionerUserId: userId,
|
||||||
|
db,
|
||||||
if (!participantId) {
|
|
||||||
return Response.json({ error: "No eligible participants available to pick" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get participant details
|
|
||||||
const participantToPick = await db.query.participants.findFirst({
|
|
||||||
where: eq(schema.participants.id, participantId),
|
|
||||||
with: {
|
|
||||||
sportsSeason: {
|
|
||||||
with: {
|
|
||||||
sport: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!participantToPick) {
|
if (!result.success) {
|
||||||
return Response.json({ error: "Participant not found" }, { status: 404 });
|
return Response.json({ error: result.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate round and pickInRound
|
return Response.json({
|
||||||
const totalTeams = draftSlots.length;
|
success: true,
|
||||||
const currentRound = Math.ceil(pickNumber / totalTeams);
|
pick: result.pick,
|
||||||
const isEvenRound = currentRound % 2 === 0;
|
participant: result.participant,
|
||||||
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
|
nextPickNumber: result.nextPickNumber,
|
||||||
if (isEvenRound) {
|
isDraftComplete: result.isDraftComplete,
|
||||||
pickInRound = totalTeams - pickInRound + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the draft pick
|
|
||||||
const [draftPick] = await db
|
|
||||||
.insert(schema.draftPicks)
|
|
||||||
.values({
|
|
||||||
seasonId,
|
|
||||||
teamId,
|
|
||||||
participantId: participantToPick.id,
|
|
||||||
pickNumber,
|
|
||||||
round: currentRound,
|
|
||||||
pickInRound,
|
|
||||||
pickedByUserId: userId,
|
|
||||||
pickedByType: "auto",
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Update season's current pick number
|
|
||||||
const nextPickNumber = pickNumber + 1;
|
|
||||||
const totalPicks = totalTeams * season.draftRounds;
|
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.seasons)
|
|
||||||
.set({
|
|
||||||
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
|
||||||
status: isDraftComplete ? "active" : season.status,
|
|
||||||
})
|
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
|
||||||
|
|
||||||
// Add increment to the team that just picked
|
|
||||||
const currentTimer = await db.query.draftTimers.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentTimer) {
|
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
|
||||||
const newTimeRemaining = currentTimer.timeRemaining + incrementTime;
|
|
||||||
await db
|
|
||||||
.update(schema.draftTimers)
|
|
||||||
.set({
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
|
||||||
|
|
||||||
// Emit timer update to all clients
|
|
||||||
try {
|
|
||||||
const io = (global as any).__socketIO;
|
|
||||||
io.to(`draft-${seasonId}`).emit("timer-update", {
|
|
||||||
seasonId,
|
|
||||||
teamId,
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
currentPickNumber: pickNumber,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Socket.IO timer-update error:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify all clients that this participant was removed from queues
|
|
||||||
try {
|
|
||||||
const io = (global as any).__socketIO;
|
|
||||||
io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", {
|
|
||||||
participantId: participantToPick.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Socket.IO participant-removed-from-queues error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
try {
|
|
||||||
const io = (global as any).__socketIO;
|
|
||||||
const team = draftSlots.find((slot) => slot.team.id === teamId)?.team;
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDraftComplete) {
|
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Socket.IO error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
pick: draftPick,
|
|
||||||
participant: participantToPick,
|
|
||||||
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
|
||||||
isDraftComplete,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
253
server/timer.ts
253
server/timer.ts
|
|
@ -1,8 +1,9 @@
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc, asc, inArray, notInArray } from "drizzle-orm";
|
import { eq, and, asc } from "drizzle-orm";
|
||||||
import { getSocketIO } from "./socket";
|
import { getSocketIO } from "./socket";
|
||||||
|
import { executeAutoPick } from "~/models/draft-utils";
|
||||||
|
|
||||||
// Create a dedicated database connection for the timer
|
// Create a dedicated database connection for the timer
|
||||||
const connectionString = process.env.DATABASE_URL;
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
|
@ -186,6 +187,7 @@ async function updateDraftTimers(): Promise<void> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger an automatic pick when timer expires
|
* Trigger an automatic pick when timer expires
|
||||||
|
* Uses the unified executeAutoPick function
|
||||||
*/
|
*/
|
||||||
async function triggerAutoPick(
|
async function triggerAutoPick(
|
||||||
seasonId: string,
|
seasonId: string,
|
||||||
|
|
@ -194,254 +196,25 @@ async function triggerAutoPick(
|
||||||
autodraftSettings: any | null
|
autodraftSettings: any | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const io = getSocketIO();
|
console.log(
|
||||||
|
`[Timer] Triggering auto-pick for team ${teamId} in season ${seasonId}, pick ${pickNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
// Get season details
|
// Execute the autopick using the unified function
|
||||||
const season = await db.query.seasons.findFirst({
|
const result = await executeAutoPick({
|
||||||
where: eq(schema.seasons.id, seasonId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!season) {
|
|
||||||
console.error(`[Timer] Season ${seasonId} not found for auto-pick`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if pick already made (race condition protection)
|
|
||||||
const existingPick = await db.query.draftPicks.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(schema.draftPicks.seasonId, seasonId),
|
|
||||||
eq(schema.draftPicks.pickNumber, pickNumber)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingPick) {
|
|
||||||
console.log(`[Timer] Pick ${pickNumber} already made, skipping auto-pick`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get team's queue
|
|
||||||
const queueItems = await db.query.draftQueue.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(schema.draftQueue.seasonId, seasonId),
|
|
||||||
eq(schema.draftQueue.teamId, teamId)
|
|
||||||
),
|
|
||||||
orderBy: asc(schema.draftQueue.queuePosition),
|
|
||||||
});
|
|
||||||
|
|
||||||
let participantId: string | null = null;
|
|
||||||
|
|
||||||
// Try to pick from queue first
|
|
||||||
if (queueItems.length > 0) {
|
|
||||||
// Get already drafted participant IDs
|
|
||||||
const draftedPicks = await db.query.draftPicks.findMany({
|
|
||||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
|
||||||
});
|
|
||||||
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
|
|
||||||
|
|
||||||
// Find first queued participant that hasn't been drafted
|
|
||||||
const availableQueueItem = queueItems.find(
|
|
||||||
(item: any) => !draftedParticipantIds.includes(item.participantId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (availableQueueItem) {
|
|
||||||
participantId = availableQueueItem.participantId;
|
|
||||||
console.log(`[Timer] Auto-picking from queue: ${participantId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no valid queue item, pick highest EV available participant
|
|
||||||
if (!participantId) {
|
|
||||||
// Get sports season IDs for this season
|
|
||||||
const seasonSports = await db.query.seasonTemplateSports.findMany({
|
|
||||||
where: eq(schema.seasonTemplateSports.templateId, season.templateId!),
|
|
||||||
});
|
|
||||||
const sportsSeasonIds = seasonSports.map((ss: any) => ss.sportsSeasonId);
|
|
||||||
|
|
||||||
if (sportsSeasonIds.length === 0) {
|
|
||||||
console.error(`[Timer] No sports seasons found for season ${seasonId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get already drafted participant IDs
|
|
||||||
const draftedPicks = await db.query.draftPicks.findMany({
|
|
||||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
|
||||||
});
|
|
||||||
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
|
|
||||||
|
|
||||||
// Query available participants from the sports seasons
|
|
||||||
const availableParticipants = await db.query.participants.findMany({
|
|
||||||
where: and(
|
|
||||||
sportsSeasonIds.length === 1
|
|
||||||
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
|
||||||
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds),
|
|
||||||
draftedParticipantIds.length > 0
|
|
||||||
? notInArray(schema.participants.id, draftedParticipantIds)
|
|
||||||
: undefined
|
|
||||||
),
|
|
||||||
orderBy: desc(schema.participants.expectedValue),
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (availableParticipants.length === 0) {
|
|
||||||
console.error(`[Timer] No available participants for auto-pick in season ${seasonId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
participantId = availableParticipants[0].id;
|
|
||||||
console.log(`[Timer] Auto-picking highest EV: ${participantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate round and pick in round
|
|
||||||
const draftSlots = await db.query.draftSlots.findMany({
|
|
||||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
|
||||||
orderBy: asc(schema.draftSlots.draftOrder),
|
|
||||||
});
|
|
||||||
const totalTeams = draftSlots.length;
|
|
||||||
const round = Math.ceil(pickNumber / totalTeams);
|
|
||||||
const pickInRound = ((pickNumber - 1) % totalTeams) + 1;
|
|
||||||
|
|
||||||
// Create the draft pick
|
|
||||||
const [createdPick] = await db.insert(schema.draftPicks).values({
|
|
||||||
seasonId,
|
seasonId,
|
||||||
teamId,
|
teamId,
|
||||||
participantId,
|
|
||||||
pickNumber,
|
pickNumber,
|
||||||
round,
|
triggeredBy: "timer",
|
||||||
pickInRound,
|
autodraftSettings,
|
||||||
pickedByUserId: "", // Auto-pick has no user
|
db,
|
||||||
pickedByType: "auto",
|
|
||||||
timeUsed: season.draftInitialTime || 120, // Used all time
|
|
||||||
createdAt: new Date(),
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
console.log(`[Timer] Auto-pick created: Pick ${pickNumber} - Participant ${participantId}`);
|
|
||||||
|
|
||||||
// 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, participantId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if draft is complete
|
|
||||||
const totalPicks = totalTeams * (season.draftRounds || 1);
|
|
||||||
const allPicks = await db.query.draftPicks.findMany({
|
|
||||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
|
||||||
});
|
|
||||||
const isDraftComplete = allPicks.length >= totalPicks;
|
|
||||||
const nextPickNumber = isDraftComplete ? pickNumber : pickNumber + 1;
|
|
||||||
|
|
||||||
// If autodraft was enabled with "next_pick" mode, disable it now
|
|
||||||
if (autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") {
|
|
||||||
console.log(`[Timer] 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 socket event to notify clients
|
|
||||||
io.to(`draft-${seasonId}`).emit("autodraft-updated", {
|
|
||||||
teamId,
|
|
||||||
isEnabled: false,
|
|
||||||
mode: autodraftSettings.mode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add increment to the team that just picked
|
|
||||||
const pickingTeamTimer = await db.query.draftTimers.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pickingTeamTimer) {
|
if (!result.success) {
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
console.error(`[Timer] Auto-pick failed: ${result.error}`);
|
||||||
const newTimeRemaining = pickingTeamTimer.timeRemaining + incrementTime;
|
|
||||||
await db
|
|
||||||
.update(schema.draftTimers)
|
|
||||||
.set({
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.draftTimers.id, pickingTeamTimer.id));
|
|
||||||
|
|
||||||
// Emit timer update to all clients
|
|
||||||
io.to(`draft-${seasonId}`).emit("timer-update", {
|
|
||||||
seasonId,
|
|
||||||
teamId,
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
currentPickNumber: pickNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDraftComplete) {
|
|
||||||
// Draft complete
|
|
||||||
await db
|
|
||||||
.update(schema.seasons)
|
|
||||||
.set({ status: "active" })
|
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
|
||||||
|
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
|
||||||
console.log(`[Timer] Draft completed for season ${seasonId}`);
|
|
||||||
} else {
|
|
||||||
// Move to next pick
|
|
||||||
await db
|
|
||||||
.update(schema.seasons)
|
|
||||||
.set({ currentPickNumber: nextPickNumber })
|
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the complete pick with all relations (same as API routes do)
|
|
||||||
const completePick = await db
|
|
||||||
.select({
|
|
||||||
id: schema.draftPicks.id,
|
|
||||||
seasonId: schema.draftPicks.seasonId,
|
|
||||||
teamId: schema.draftPicks.teamId,
|
|
||||||
participantId: schema.draftPicks.participantId,
|
|
||||||
pickNumber: schema.draftPicks.pickNumber,
|
|
||||||
round: schema.draftPicks.round,
|
|
||||||
pickInRound: schema.draftPicks.pickInRound,
|
|
||||||
pickedByUserId: schema.draftPicks.pickedByUserId,
|
|
||||||
pickedByType: schema.draftPicks.pickedByType,
|
|
||||||
timeUsed: schema.draftPicks.timeUsed,
|
|
||||||
createdAt: schema.draftPicks.createdAt,
|
|
||||||
team: schema.teams,
|
|
||||||
participant: schema.participants,
|
|
||||||
sport: schema.sports,
|
|
||||||
})
|
|
||||||
.from(schema.draftPicks)
|
|
||||||
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
||||||
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id))
|
|
||||||
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
|
|
||||||
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
|
|
||||||
.where(eq(schema.draftPicks.id, createdPick.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (completePick.length === 0) {
|
|
||||||
console.error(`[Timer] Could not fetch complete pick data`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit pick-made event with the same structure as API routes
|
|
||||||
io.to(`draft-${seasonId}`).emit("pick-made", {
|
|
||||||
pick: {
|
|
||||||
...completePick[0],
|
|
||||||
participant: {
|
|
||||||
...completePick[0].participant,
|
|
||||||
sport: completePick[0].sport,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nextPickNumber,
|
|
||||||
isDraftComplete,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Timer] Auto-pick complete and broadcasted`);
|
console.log(`[Timer] Auto-pick complete and broadcasted`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Timer] Error in triggerAutoPick:", error);
|
console.error("[Timer] Error in triggerAutoPick:", error);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"server/**/*.ts",
|
"server/**/*.ts",
|
||||||
"database/**/*.ts",
|
"database/**/*.ts",
|
||||||
"app/contexts/**/*.ts",
|
"app/contexts/**/*.ts",
|
||||||
|
"app/models/**/*.ts",
|
||||||
|
"app/lib/**/*.ts",
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@
|
||||||
"server.ts",
|
"server.ts",
|
||||||
"server/**/*.ts",
|
"server/**/*.ts",
|
||||||
"database/**/*.ts",
|
"database/**/*.ts",
|
||||||
"app/database/**/*.ts"
|
"app/database/**/*.ts",
|
||||||
|
"app/models/**/*.ts",
|
||||||
|
"app/lib/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue