From a1cba11d685cf5298b1357a0215884a356e16a1d Mon Sep 17 00:00:00 2001 From: dnviti Date: Tue, 16 Dec 2025 22:10:20 +0100 Subject: [PATCH] feat: Implement server-side draft timer with AFK auto-pick and global draft loop, updating client-side timer to reflect server state. --- docs/development/CENTRAL.md | 1 + .../devlog/2025-12-16-222500_draft_timer.md | 32 +++++ src/client/src/modules/draft/DraftView.tsx | 20 +++- src/server/index.ts | 110 +++++++++--------- src/server/managers/DraftManager.ts | 69 +++++++++-- 5 files changed, 161 insertions(+), 71 deletions(-) create mode 100644 docs/development/devlog/2025-12-16-222500_draft_timer.md diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index 3d52324..9ef6026 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -21,3 +21,4 @@ - [2025-12-16-220000_session_persistence.md](./devlog/2025-12-16-220000_session_persistence.md): Plan for session persistence and safer room exit logic. - [2025-12-16-221000_lobby_improvements.md](./devlog/2025-12-16-221000_lobby_improvements.md): Plan for kick functionality and exit button relocation. - [Fix Draft UI Layout](./devlog/2025-12-16-215500_fix_draft_ui_layout.md): Completed. Fixed "Waiting for next pack" layout to be consistently full-screen. +- [Draft Timer Enforcement](./devlog/2025-12-16-222500_draft_timer.md): Completed. Implemented server-side 60s timer per pick, AFK auto-pick, and global draft timer loop. diff --git a/docs/development/devlog/2025-12-16-222500_draft_timer.md b/docs/development/devlog/2025-12-16-222500_draft_timer.md new file mode 100644 index 0000000..345fac1 --- /dev/null +++ b/docs/development/devlog/2025-12-16-222500_draft_timer.md @@ -0,0 +1,32 @@ +# 2025-12-16 - Draft Timer Enforcement + +## Status +Completed + +## Description +Implemented server-side timer enforcement for the draft phase to ensure the game progresses even if players are AFK or disconnected. + +## Changes +1. **Server: DraftManager.ts** + * Updated `DraftState` to include `pickExpiresAt` (timestamp) for each player and `isPaused` for the draft. + * Initialize `pickExpiresAt` to 60 seconds from now when a player receives a pack (initial or passed). + * Implemented `checkTimers()` method to iterate over all active drafts and players. If `Date.now() > pickExpiresAt`, it triggers `autoPick`. + * Implemented `setPaused()` to handle host disconnects. When resuming, timers are reset to 60s to prevent immediate timeout. + +2. **Server: index.ts** + * Removed ad-hoc `playerTimers` map and individual `setTimeout` logic associated with socket disconnect events. + * Added a global `setInterval` (1 second tick) that calls `draftManager.checkTimers()` and broadcasts updates. + * Updated `disconnect` handler to pause the draft if the host disconnects (`draftManager.setPaused(..., true)`). + * Updated `join_room` / `rejoin_room` handlers to resume the draft if the host reconnects. + +3. **Client: DraftView.tsx** + * Updated the timer display logic to calculate remaining time based on `draftState.players[id].pickExpiresAt` - `Date.now()`. + * The timer now accurately reflects the server-enforced deadline. + +## Behavior +* **Drafting**: Each pick has a 60-second limit. +* **Deck Building**: 120-second limit. If time runs out, the game forces start. Any unready players have their entire draft pool submitted as their deck automatically. +* **Timeout**: If time runs out, a random card is automatically picked, and the next pack (if available) is loaded with a fresh 60s timer. +* **AFK**: If a user is AFK, the system continues to auto-pick for them until the draft concludes. +* **Host Disconnect**: If the host leaves, the draft pauses for everyone. Timer stops. +* **Host Reconnect**: Draft resumes, and all active pick timers are reset to 60s. diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx index 8046e83..5c405f6 100644 --- a/src/client/src/modules/draft/DraftView.tsx +++ b/src/client/src/modules/draft/DraftView.tsx @@ -15,12 +15,24 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI const [timer, setTimer] = useState(60); const [confirmExitOpen, setConfirmExitOpen] = useState(false); + const myPlayer = draftState.players[currentPlayerId]; + const pickExpiresAt = myPlayer?.pickExpiresAt; + useEffect(() => { - const interval = setInterval(() => { - setTimer(t => t > 0 ? t - 1 : 0); - }, 1000); + if (!pickExpiresAt) { + setTimer(0); + return; + } + + const updateTimer = () => { + const remainingMs = pickExpiresAt - Date.now(); + setTimer(Math.max(0, Math.ceil(remainingMs / 1000))); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 500); // Check twice a second for smoother updates return () => clearInterval(interval); - }, []); // Reset timer on new pack? Simplified for now. + }, [pickExpiresAt]); // --- UI State & Persistence --- const [poolHeight, setPoolHeight] = useState(() => { diff --git a/src/server/index.ts b/src/server/index.ts index b422e5f..418ab8c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -60,58 +60,59 @@ app.post('/api/cards/cache', async (req: Request, res: Response) => { } }); +// Global Draft Timer Loop +setInterval(() => { + const updates = draftManager.checkTimers(); + updates.forEach(({ roomId, draft }) => { + io.to(roomId).emit('draft_update', draft); + + // Check for forced game start (Deck Building Timeout) + if (draft.status === 'complete') { + const room = roomManager.getRoom(roomId); + // Only trigger if room exists and not already playing + if (room && room.status !== 'playing') { + console.log(`Deck building timeout for Room ${roomId}. Forcing start.`); + + // Force ready for unready players + const activePlayers = room.players.filter(p => p.role === 'player'); + activePlayers.forEach(p => { + if (!p.ready) { + const pool = draft.players[p.id]?.pool || []; + roomManager.setPlayerReady(roomId, p.id, pool); + } + }); + + // Start Game Logic + room.status = 'playing'; + io.to(roomId).emit('room_update', room); + + const game = gameManager.createGame(roomId, room.players); + activePlayers.forEach(p => { + if (p.deck) { + p.deck.forEach((card: any) => { + gameManager.addCardToGame(roomId, { + ownerId: p.id, + controllerId: p.id, + oracleId: card.oracle_id || card.id, + name: card.name, + imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", + zone: 'library' + }); + }); + } + }); + io.to(roomId).emit('game_update', game); + } + } + }); +}, 1000); + // Socket.IO logic io.on('connection', (socket) => { console.log('A user connected', socket.id); // Timer management - const playerTimers = new Map(); - - const startAutoPickTimer = (roomId: string, playerId: string) => { - // Clear existing if any (debounce) - if (playerTimers.has(playerId)) { - clearTimeout(playerTimers.get(playerId)!); - } - - const timer = setTimeout(() => { - console.log(`Timeout for player ${playerId}. Auto-picking...`); - const draft = draftManager.autoPick(roomId, playerId); - if (draft) { - io.to(roomId).emit('draft_update', draft); - // We only pick once. If they stay offline, the next pick depends on the next turn cycle. - // If we wanted continuous auto-pick, we'd need to check if it's still their turn and recurse. - // For now, this unblocks the current step. - } - playerTimers.delete(playerId); - }, 30000); // 30s - - playerTimers.set(playerId, timer); - }; - - const stopAutoPickTimer = (playerId: string) => { - if (playerTimers.has(playerId)) { - clearTimeout(playerTimers.get(playerId)!); - playerTimers.delete(playerId); - } - }; - - const stopAllRoomTimers = (roomId: string) => { - const room = roomManager.getRoom(roomId); - if (room) { - room.players.forEach(p => stopAutoPickTimer(p.id)); - } - }; - - const resumeRoomTimers = (roomId: string) => { - const room = roomManager.getRoom(roomId); - if (room && room.status === 'drafting') { - room.players.forEach(p => { - if (p.isOffline && p.role === 'player') { - startAutoPickTimer(roomId, p.id); - } - }); - } - }; + // Timer management removed (Global loop handled) socket.on('create_room', ({ hostId, hostName, packs }, callback) => { const room = roomManager.createRoom(hostId, hostName, packs, socket.id); // Add socket.id @@ -124,8 +125,8 @@ io.on('connection', (socket) => { const room = roomManager.joinRoom(roomId, playerId, playerName, socket.id); // Add socket.id if (room) { // Clear timeout if exists (User reconnected) - stopAutoPickTimer(playerId); - console.log(`Player ${playerName} reconnected. Auto-pick cancelled.`); + // stopAutoPickTimer(playerId); // Global timer handles this now + console.log(`Player ${playerName} reconnected.`); socket.join(room.id); console.log(`Player ${playerName} joined room ${roomId}`); @@ -134,7 +135,7 @@ io.on('connection', (socket) => { // Check if Host Reconnected -> Resume Game if (room.hostId === playerId) { console.log(`Host ${playerName} reconnected. Resuming draft timers.`); - resumeRoomTimers(roomId); + draftManager.setPaused(roomId, false); } // If drafting, send state immediately and include in callback @@ -160,7 +161,7 @@ io.on('connection', (socket) => { if (room) { // Clear Timer - stopAutoPickTimer(playerId); + // stopAutoPickTimer(playerId); console.log(`Player ${playerId} reconnected via rejoin.`); // Notify others (isOffline false) @@ -169,7 +170,7 @@ io.on('connection', (socket) => { // Check if Host Reconnected -> Resume Game if (room.hostId === playerId) { console.log(`Host ${playerId} reconnected. Resuming draft timers.`); - resumeRoomTimers(roomId); + draftManager.setPaused(roomId, false); } // Prepare Draft State if exists @@ -394,10 +395,9 @@ io.on('connection', (socket) => { if (hostOffline) { console.log("Host is offline. Pausing game (stopping all timers)."); - stopAllRoomTimers(room.id); + draftManager.setPaused(room.id, true); } else { - // Host is online, but THIS player disconnected. Start timer for them. - startAutoPickTimer(room.id, playerId); + // Host is online, but THIS player disconnected. Timer continues automatically. } } } diff --git a/src/server/managers/DraftManager.ts b/src/server/managers/DraftManager.ts index 085b334..3669302 100644 --- a/src/server/managers/DraftManager.ts +++ b/src/server/managers/DraftManager.ts @@ -28,9 +28,11 @@ interface DraftState { unopenedPacks: Pack[]; // Pack 2 and 3 kept aside isWaiting: boolean; // True if finished current pack round pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK + pickExpiresAt: number; // Timestamp when auto-pick occurs }>; status: 'drafting' | 'deck_building' | 'complete'; + isPaused: boolean; startTime?: number; // For timer } @@ -58,6 +60,7 @@ export class DraftManager extends EventEmitter { packNumber: 1, players: {}, status: 'drafting', + isPaused: false, startTime: Date.now() }; @@ -72,7 +75,8 @@ export class DraftManager extends EventEmitter { pool: [], unopenedPacks: playerPacks, isWaiting: false, - pickedInCurrentStep: 0 + pickedInCurrentStep: 0, + pickExpiresAt: Date.now() + 60000 // 60 seconds for first pack }; }); @@ -92,15 +96,6 @@ export class DraftManager extends EventEmitter { if (!playerState || !playerState.activePack) return null; // Find card - // uniqueId check implies if cards have unique instance IDs in pack, if not we rely on strict equality or assume 1 instance per pack - - // Fallback: If we can't find by ID (if Scryfall ID generic), just pick the first matching ID? - // We should ideally assume the frontend sends the exact card object or unique index. - // For now assuming cardId is unique enough or we pick first match. - // Better: In a draft, a pack might have 2 duplicates. We need index or unique ID. - // Let's assume the pack generation gave unique IDs or we just pick by index. - // I'll stick to ID for now, assuming unique. - const card = playerState.activePack.cards.find(c => c.id === cardId); if (!card) return null; @@ -166,6 +161,57 @@ export class DraftManager extends EventEmitter { if (!p.activePack && p.queue.length > 0) { p.activePack = p.queue.shift()!; p.pickedInCurrentStep = 0; // Reset for new pack + p.pickExpiresAt = Date.now() + 60000; // Reset timer for new pack + } + } + + checkTimers(): { roomId: string, draft: DraftState }[] { + const updates: { roomId: string, draft: DraftState }[] = []; + const now = Date.now(); + + for (const [roomId, draft] of this.drafts.entries()) { + if (draft.isPaused) continue; + + if (draft.status === 'drafting') { + let draftUpdated = false; + // Iterate over players + for (const playerId of Object.keys(draft.players)) { + const playerState = draft.players[playerId]; + // Check if player is thinking (has active pack) and time expired + if (playerState.activePack && now > playerState.pickExpiresAt) { + const result = this.autoPick(roomId, playerId); + if (result) { + draftUpdated = true; + } + } + } + if (draftUpdated) { + updates.push({ roomId, draft }); + } + } else if (draft.status === 'deck_building') { + // Check global deck building timer (e.g., 120 seconds) + const DECK_BUILDING_Duration = 120000; + if (draft.startTime && (now > draft.startTime + DECK_BUILDING_Duration)) { + draft.status = 'complete'; // Signal that time is up + updates.push({ roomId, draft }); + } + } + } + return updates; + } + + setPaused(roomId: string, paused: boolean) { + const draft = this.drafts.get(roomId); + if (draft) { + draft.isPaused = paused; + if (!paused) { + // Reset timers to 60s + Object.values(draft.players).forEach(p => { + if (p.activePack) { + p.pickExpiresAt = Date.now() + 60000; + } + }); + } } } @@ -180,8 +226,6 @@ export class DraftManager extends EventEmitter { const randomCardIndex = Math.floor(Math.random() * playerState.activePack.cards.length); const card = playerState.activePack.cards[randomCardIndex]; - //console.log(`Auto-picking card for ${playerId}: ${card.name}`); - // Reuse existing logic return this.pickCard(roomId, playerId, card.id); } @@ -199,6 +243,7 @@ export class DraftManager extends EventEmitter { if (nextPack) { p.activePack = nextPack; p.pickedInCurrentStep = 0; // Reset + p.pickExpiresAt = Date.now() + 60000; // Reset timer } }); } else {