diff --git a/Emit b/Emit new file mode 100644 index 0000000..e69de29 diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index 4bfda05..3d52324 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -17,5 +17,7 @@ - [Resizable Draft Interface](./devlog/2025-12-16-200500_resizable_draft_ui.md): Completed. Implemented user-resizable pool panel and card sizes with persistence. - [Draft UI Zoom Zone](./devlog/2025-12-16-203000_zoom_zone.md): Completed. Implemented dedicated zoom zone for card preview. - [Host Disconnect Pause](./devlog/2025-12-16-213500_host_disconnect_pause.md): Completed. Specific logic to pause game when host leaves. -- [Anti-Tampering System](./devlog/2025-12-16-215000_anti_tampering.md): Completed. Robust server-side validation using socket session binding and ownership checks. +- [2025-12-16-215000_anti_tampering.md](./devlog/2025-12-16-215000_anti_tampering.md): Implemented server-side validation for game actions. +- [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. diff --git a/docs/development/devlog/2025-12-16-220000_session_persistence.md b/docs/development/devlog/2025-12-16-220000_session_persistence.md new file mode 100644 index 0000000..c383674 --- /dev/null +++ b/docs/development/devlog/2025-12-16-220000_session_persistence.md @@ -0,0 +1,38 @@ +# implementation_plan - Draft Session Persistence and Restoration + +This plan addresses the issue where users are unable to reliably rejoin a draft session as a player after reloading or exiting, often re-entering as a spectator. It ensures robust session synchronization to local storage and handles player "leave" actions safely during active games. + +## User Objectives +- **Session Restoring**: Automatically rejoin the correct session and player seat upon reloading the application. +- **Prevent Accidental Data Loss**: Ensure "Exiting" a room during an active draft does not destroy the player's seat, allowing them to rejoin. +- **Start New Draft**: Maintain the ability for a user to explicitly invalid/abandon an old session to start a new one (handled by creating a new room, which overwrites local storage). + +## Proposed Changes + +### 1. Server-Side: Safer `leaveRoom` Logic +**File**: `src/server/managers/RoomManager.ts` +- Modify `leaveRoom` method. +- **Logic**: + - If `room.status` is `'waiting'`, remove the player (current behavior). + - If `room.status` is `'drafting'`, `'deck_building'`, or `'playing'`, **DO NOT** remove the player from `room.players`. Instead, mark them as `isOffline = true` (similar to a disconnect). + - This ensures that if the user rejoins with the same `playerId`, they find their existing seat instead of being assigned a new "spectator" role. + +### 2. Server-Side: Robust `rejoin_room` Handler +**File**: `src/server/index.ts` +- Update `socket.on('rejoin_room')`. +- **Change**: Implement an acknowledgement `callback` pattern consistent with other socket events. +- **Logic**: + - Accept `{ roomId, playerId }`. + - If successful, invoke `callback({ success: true, room, draftState })`. + - Broadcast `room_update` to other players (to show user is back online). + +### 3. Client-Side: Correct Rejoin Implementation +**File**: `src/client/src/modules/lobby/LobbyManager.tsx` +- **Fix**: In the `rejoin_room` emit call, explicitly include the `playerId`. +- **Enhancement**: Utilize the callback from the server to confirm reconnection before setting state. +- **Exit Handling**: The `handleExitRoom` function clears `localStorage`, which is correct for an explicit "Exit". However, thanks to the server-side change, if the user manually rejoins the same room code, they will reclaim their seat effectively. + +## Verification Plan +1. **Test Reload**: Start a draft, refresh the browser. Verify user auto-rejoins as Player. +2. **Test Exit & Rejoin**: Start a draft, click "Exit Room". Re-enter the Room ID manually. Verify user rejoins as Player (not Spectator). +3. **Test New Draft**: Create a room, start draft. Open new tab (or exit), create NEW room. Verify new room works and old session doesn't interfere. diff --git a/docs/development/devlog/2025-12-16-221000_lobby_improvements.md b/docs/development/devlog/2025-12-16-221000_lobby_improvements.md new file mode 100644 index 0000000..6d64b02 --- /dev/null +++ b/docs/development/devlog/2025-12-16-221000_lobby_improvements.md @@ -0,0 +1,46 @@ +# implementation_plan - Lobby Improvements and Kick Functionality + +This plan addresses user feedback regarding the draft resumption experience, exit button placement, and host management controls. + +## User Objectives +1. **Resume Draft on Re-entry**: Ensure that manually joining a room (after exiting) correctly restores the draft view if a draft is in progress. +2. **Exit Button Placement**: Move the "Exit Room" button to be near the player's name in the lobby sidebar. +3. **Kick Player**: Allow the Host to kick players from the room. + +## Proposed Changes + +### 1. Server-Side: Kick Functionality +**File**: `src/server/managers/RoomManager.ts` +- **Method**: `kickPlayer(roomId, playerId)` +- **Logic**: + - Remove the player from `room.players`. + - If the game is active (drafting/playing), this is a destructive action. We will assume for now it removes them completely (or marks offline? "Kick" usually implies removal). + - *Decision*: If kicked, they are removed. If the game breaks, that's the host's responsibility. + +**File**: `src/server/index.ts` +- **Event**: `kick_player` +- **Logic**: + - Verify requester is Host. + - Call `roomManager.kickPlayer`. + - Broadcast `room_update`. + - Emit `kicked` event to the target socket (to force them to client-side exit). + +### 2. Client-Side: Re-entry Logic Fix +**File**: `src/client/src/modules/lobby/GameRoom.tsx` +- **Logic**: Ensure `GameRoom` correctly initializes or updates `draftState` when receiving new props. +- Add a `useEffect` to update local `draftState` if `initialDraftState` prop changes (though `key` change on component might be better, we'll use `useEffect`). + +### 3. Client-Side: UI Updates +**File**: `src/client/src/modules/lobby/GameRoom.tsx` +- **Sidebar**: + - Update the player list rendering. + - If `p.id === currentPlayerId`, show an **Exit/LogOut** button next to the name. + - If `isMeHost` and `p.id !== me`, show a **Kick/Ban** button next to the name. +- **Handlers**: + - `handleKick(targetId)`: Warning confirmation -> Emit `kick_player`. + - `handleExit()`: Trigger the existing `onExit`. + +## Verification Plan +1. **Test Kick**: Host kicks a player. Player should be removed from list and client should revert to lobby (via socket event). +2. **Test Exit**: Click new Exit button in sidebar. Should leave room. +3. **Test Re-join**: Join the room code again. Should immediately load the Draft View (not the Lobby View). diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx index fb5d096..991532e 100644 --- a/src/client/src/modules/lobby/GameRoom.tsx +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -62,6 +62,24 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl setMessages(initialRoom.messages || []); }, [initialRoom]); + // React to prop updates for draft state (Crucial for resume) + useEffect(() => { + if (initialDraftState) { + setDraftState(initialDraftState); + } + }, [initialDraftState]); + + // Handle kicked event + useEffect(() => { + const socket = socketService.socket; + const onKicked = () => { + alert("You have been kicked from the room."); + onExit(); + }; + socket.on('kicked', onKicked); + return () => { socket.off('kicked', onKicked); }; + }, [onExit]); + // Scroll to bottom of chat useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -243,25 +261,55 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl

Lobby

+ +
{room.players.map(p => { const isReady = (p as any).ready; + const isMe = p.id === currentPlayerId; + return ( -
+
{p.name.substring(0, 2).toUpperCase()}
- - {p.name} + + {p.name} {isMe && '(You)'} {p.role} {p.isHost && • Host} {isReady && room.status === 'deck_building' && • Ready} + {p.isOffline && • Offline}
+ +
+ {isMe && ( + + )} + {isMeHost && !isMe && ( + + )} +
) })} diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx index 9d288f8..73331fd 100644 --- a/src/client/src/modules/lobby/LobbyManager.tsx +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -155,9 +155,19 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => if (savedRoomId && !activeRoom && playerId) { setLoading(true); connect(); - socketService.emitPromise('rejoin_room', { roomId: savedRoomId }) - .then(() => { - // Rejoin logic mostly handled by onRoomUpdate via socket + socketService.emitPromise('rejoin_room', { roomId: savedRoomId, playerId }) + .then((response: any) => { + if (response.success) { + console.log("Rejoined session successfully"); + setActiveRoom(response.room); + if (response.draftState) { + setInitialDraftState(response.draftState); + } + } else { + console.warn("Rejoin failed by server: ", response.message); + localStorage.removeItem('active_room_id'); + setLoading(false); + } }) .catch(err => { console.warn("Reconnection failed", err); diff --git a/src/server/index.ts b/src/server/index.ts index 19aa8a8..b422e5f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -151,7 +151,7 @@ io.on('connection', (socket) => { }); // RE-IMPLEMENTING rejoin_room with playerId - socket.on('rejoin_room', ({ roomId, playerId }) => { + socket.on('rejoin_room', ({ roomId, playerId }, callback) => { socket.join(roomId); if (playerId) { @@ -172,15 +172,29 @@ io.on('connection', (socket) => { resumeRoomTimers(roomId); } + // Prepare Draft State if exists + let currentDraft = null; if (room.status === 'drafting') { - const draft = draftManager.getDraft(roomId); - if (draft) socket.emit('draft_update', draft); + currentDraft = draftManager.getDraft(roomId); + if (currentDraft) socket.emit('draft_update', currentDraft); + } + + // ACK Callback + if (typeof callback === 'function') { + callback({ success: true, room, draftState: currentDraft }); + } + } else { + // Room found but player not in it? Or room not found? + // If room exists but player not in list, it failed. + if (typeof callback === 'function') { + callback({ success: false, message: 'Player not found in room or room closed' }); } } } else { - // Just get room if no playerId? Should rare happen - const room = roomManager.getRoom(roomId); - if (room) socket.emit('room_update', room); + // Missing playerId + if (typeof callback === 'function') { + callback({ success: false, message: 'Missing Player ID' }); + } } }); @@ -202,6 +216,29 @@ io.on('connection', (socket) => { } }); + socket.on('kick_player', ({ roomId, targetId }) => { + const context = getContext(); + if (!context || !context.player.isHost) return; // Verify host + + // Get target socketId before removal to notify them + // Note: getPlayerBySocket works if they are connected. + // We might need to find target in room.players directly. + const room = roomManager.getRoom(roomId); + if (room) { + const target = room.players.find(p => p.id === targetId); + if (target) { + const updatedRoom = roomManager.kickPlayer(roomId, targetId); + if (updatedRoom) { + io.to(roomId).emit('room_update', updatedRoom); + if (target.socketId) { + io.to(target.socketId).emit('kicked', { message: 'You have been kicked by the host.' }); + } + console.log(`Player ${targetId} kicked from room ${roomId} by host.`); + } + } + } + }); + // Secure helper to get player context const getContext = () => roomManager.getPlayerBySocket(socket.id); diff --git a/src/server/managers/RoomManager.ts b/src/server/managers/RoomManager.ts index 18a7d8f..ed2e291 100644 --- a/src/server/managers/RoomManager.ts +++ b/src/server/managers/RoomManager.ts @@ -105,18 +105,32 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return null; - room.players = room.players.filter(p => p.id !== playerId); + if (room.status === 'waiting') { + // Normal logic: Remove player completely + room.players = room.players.filter(p => p.id !== playerId); - // If host leaves, assign new host from remaining players - if (room.players.length === 0) { - this.rooms.delete(roomId); - return null; - } else if (room.hostId === playerId) { - const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0]; - if (nextPlayer) { - room.hostId = nextPlayer.id; - nextPlayer.isHost = true; + // If host leaves, assign new host from remaining players + if (room.players.length === 0) { + this.rooms.delete(roomId); + return null; + } else if (room.hostId === playerId) { + const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0]; + if (nextPlayer) { + room.hostId = nextPlayer.id; + nextPlayer.isHost = true; + } } + } else { + // Game in progress (Drafting/Playing) + // DO NOT REMOVE PLAYER. Just mark offline. + // This allows them to rejoin and reclaim their seat (and deck). + const player = room.players.find(p => p.id === playerId); + if (player) { + player.isOffline = true; + // Note: socketId is already handled by disconnect event usually, but if explicit leave, we should clear it? + player.socketId = undefined; + } + console.log(`Player ${playerId} left active game in room ${roomId}. Marked as offline.`); } return room; } @@ -132,6 +146,16 @@ export class RoomManager { return this.rooms.get(roomId); } + kickPlayer(roomId: string, playerId: string): Room | null { + const room = this.rooms.get(roomId); + if (!room) return null; + + room.players = room.players.filter(p => p.id !== playerId); + + // If game was running, we might need more cleanup, but for now just removal. + return room; + } + addMessage(roomId: string, sender: string, text: string): ChatMessage | null { const room = this.rooms.get(roomId); if (!room) return null;