feat: Implement server-side draft timer with AFK auto-pick and global draft loop, updating client-side timer to reflect server state.
This commit is contained in:
@@ -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.
|
||||
|
||||
32
docs/development/devlog/2025-12-16-222500_draft_timer.md
Normal file
32
docs/development/devlog/2025-12-16-222500_draft_timer.md
Normal file
@@ -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.
|
||||
@@ -15,12 +15,24 @@ export const DraftView: React.FC<DraftViewProps> = ({ 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<number>(() => {
|
||||
|
||||
@@ -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<string, NodeJS.Timeout>();
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user