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:
2025-12-16 22:10:20 +01:00
parent 33a5fcd501
commit a1cba11d68
5 changed files with 161 additions and 71 deletions

View File

@@ -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.

View 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.

View File

@@ -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>(() => {

View File

@@ -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.
}
}
}

View File

@@ -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 {