brackt/server/timer.ts

449 lines
14 KiB
TypeScript

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../database/schema";
import { eq, and, desc, asc, inArray, notInArray } from "drizzle-orm";
import { getSocketIO } from "./socket";
// Create a dedicated database connection for the timer
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required for timer system");
}
const client = postgres(connectionString);
const db = drizzle(client, { schema });
let timerInterval: NodeJS.Timeout | null = null;
/**
* Start the draft timer system
* Runs every second to update all active draft timers
*/
export function startDraftTimerSystem(): void {
if (timerInterval) {
console.log("[Timer] Timer system already running");
return;
}
timerInterval = setInterval(async () => {
try {
await updateDraftTimers();
} catch (error) {
console.error("[Timer] Error updating draft timers:", error);
}
}, 1000);
console.log("[Timer] Draft timer system started");
}
/**
* Stop the draft timer system
*/
export function stopDraftTimerSystem(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
console.log("[Timer] Draft timer system stopped");
}
}
/**
* Update all active draft timers
* Called every second by the timer interval
*/
async function updateDraftTimers(): Promise<void> {
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
if (activeDrafts.length === 0) {
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) {
continue;
}
const currentPickNumber = season.currentPickNumber || 1;
// Get draft slots to determine whose turn it is
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, season.id),
orderBy: asc(schema.draftSlots.draftOrder),
});
const totalTeams = draftSlots.length;
if (totalTeams === 0) continue;
// Calculate current round and pick
const currentRound = Math.ceil(currentPickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
// Snake draft: reverse order on even rounds
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const currentDraftSlot = draftSlots.find(
(slot: any) => slot.draftOrder === pickInRound
);
if (!currentDraftSlot) {
continue;
}
const currentTeamId = currentDraftSlot.teamId;
// Get current team's timer
const timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, season.id),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) {
console.warn(`[Timer] No timer found for team ${currentTeamId} in season ${season.id}`);
continue;
}
// If timer is at 0 or below, check autodraft settings and trigger auto-pick
if (timer.timeRemaining <= 0) {
console.log(
`[Timer] Timer at 0 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),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
if (shouldAutodraft) {
console.log(`[Timer] Autodraft enabled for team ${currentTeamId}, triggering auto-pick`);
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, autodraftSettings);
} else {
console.log(`[Timer] Autodraft disabled for team ${currentTeamId}, triggering regular auto-pick`);
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, null);
}
continue; // Skip to next draft after triggering pick
}
// 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)
.set({
timeRemaining: newTimeRemaining,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, timer.id));
// Emit timer update to all clients in the draft room
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: newTimeRemaining,
currentPickNumber,
});
// If timer just hit 0, check autodraft settings and 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),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, autodraftSettings || null);
}
}
}
/**
* Trigger an automatic pick when timer expires
*/
async function triggerAutoPick(
seasonId: string,
teamId: string,
pickNumber: number,
autodraftSettings: any | null
): Promise<void> {
try {
const io = getSocketIO();
// Get season details
const season = await db.query.seasons.findFirst({
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,
teamId,
participantId,
pickNumber,
round,
pickInRound,
pickedByUserId: "", // Auto-pick has no user
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) {
const incrementTime = season.draftIncrementTime || 30;
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;
}
// 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`);
} catch (error) {
console.error("[Timer] Error in triggerAutoPick:", error);
}
}