feat: Enhance session persistence by marking players offline in active games and improving rejoin room with server callbacks.
All checks were successful
Build and Deploy / build (push) Successful in 1m11s

This commit is contained in:
2025-12-16 22:01:36 +01:00
parent 5067f07514
commit 33a5fcd501
8 changed files with 228 additions and 23 deletions

0
Emit Normal file
View File

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,24 @@ export const GameRoom: React.FC<GameRoomProps> = ({ 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<GameRoomProps> = ({ room: initialRoom, currentPl
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby
</h3>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => {
const isReady = (p as any).ready;
const isMe = p.id === currentPlayerId;
return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50">
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
{p.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${p.id === currentPlayerId ? 'text-white' : 'text-slate-300'}`}>
{p.name}
<span className={`text-sm font-medium ${isMe ? 'text-white' : 'text-slate-300'}`}>
{p.name} {isMe && '(You)'}
</span>
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
{p.role} {p.isHost && <span className="text-amber-500 ml-1"> Host</span>}
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 ml-1"> Ready</span>}
{p.isOffline && <span className="text-red-500 ml-1"> Offline</span>}
</span>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{isMe && (
<button
onClick={onExit}
className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-red-400"
title="Leave Room"
>
<LogOut className="w-4 h-4" />
</button>
)}
{isMeHost && !isMe && (
<button
onClick={() => {
if (confirm(`Kick ${p.name}?`)) {
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
}
}}
className="p-1 hover:bg-red-900/50 rounded text-slate-500 hover:text-red-500"
title="Kick Player"
>
<LogOut className="w-4 h-4 rotate-180" />
</button>
)}
</div>
</div>
)
})}

View File

@@ -155,9 +155,19 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ 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);

View File

@@ -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);

View File

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