Compare commits

...

5 Commits

24 changed files with 1027 additions and 269 deletions

0
Emit Normal file
View File

View File

@@ -13,3 +13,11 @@
- [Draft Rules & Pick Logic](./devlog/2025-12-16-180000_draft_rules_implementation.md): Completed. Enforced 4-player minimum and "Pick 2" rule for 4-player drafts.
- [Fix Pack Duplication](./devlog/2025-12-16-184500_fix_pack_duplication.md): Completed. Enforced deep cloning and unique IDs for all draft packs to prevent opening identical packs.
- [Reconnection & Auto-Pick](./devlog/2025-12-16-191500_reconnection_and_autopick.md): Completed. Implemented session persistence, seamless reconnection, and 30s auto-pick on disconnect.
- [Draft Interface UI Polish](./devlog/2025-12-16-195000_draft_ui_polish.md): Completed. Redesigned the draft view for a cleaner, immersive, game-like experience with no unnecessary scrolls.
- [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.
- [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,23 @@
# Draft Interface UI Polish
## Status
- [x] Analyze current UI issues (bottom border, scrolling).
- [x] Remove global padding from `App.tsx`.
- [x] Refactor `DraftView.tsx` for a cleaner, game-like experience.
- [x] Implement immersive 3D effects and tray-style pool view.
## Context
The user requested to improve the draft card pick interface. Specifically to remove the ugly bottom border, avoid page scrolls, and make it feel more like a game.
## Implementation Details
### `src/client/src/App.tsx`
- Removed `pb-20` from the main container to allow full-screen layouts without forced scrolling at the bottom.
### `src/client/src/modules/draft/DraftView.tsx`
- **Layout**: Changed to relative positioning with `overflow-hidden` to contain all elements within the viewport.
- **Visuals**:
- Added a radial gradient background overlay.
- Redesigned the "Current Pack" area with `[perspective:1000px]` and 3D hover transforms.
- Redesigned the "Your Pool" bottom area to be a "tray" with `backdrop-blur`, gradient background, and removed the boxy border.
- **Scrollbars**: Hidden scrollbars in the main pack view for a cleaner look (`[&::-webkit-scrollbar]:hidden`).

View File

@@ -0,0 +1,34 @@
# Resizable Draft Interface
## Status
- [x] Implement resizable bottom "Pool" panel.
- [x] Implement resizable card size slider.
- [x] Persist settings to `localStorage`.
## Technical Plan
### `src/client/src/modules/draft/DraftView.tsx`
1. **State Initialization**:
- `poolHeight`: number (default ~220). Load from `localStorage.getItem('draft_poolHeight')`.
- `cardScale`: number (default 1 or specific width like 224px). Load from `localStorage.getItem('draft_cardScale')`.
2. **Resize Handle**:
- Insert a `div` cursor-row-resize between the Main Area and the Bottom Area.
- Implement `onMouseDown` handler to start dragging.
- Implement `onMouseMove` and `onMouseUp` on the window/document to handle the resize logic.
3. **Card Size Control**:
- Add a slider (`<input type="range" />`) in the Top Header area to adjust `cardScale`.
- Apply this scale to the card images/containers in the Main Area.
4. **Persistence**:
- `useEffect` hooks to save state changes to `localStorage`.
5. **Refactoring Styling**:
- Change `h-[220px]` class on the bottom panel to `style={{ height: poolHeight }}`.
- Update card width class `w-56` to dynamic style or class based on scale.
## UX Improvements
- Add limit constraints (min height for pool, max height for pool).
- Add limit constraints for card size (min visible, max huge).

View File

@@ -0,0 +1,13 @@
# Card Zoom on Hover
## Status
- [x] Add `hoveredCard` state to `DraftView`.
- [x] Implement `onMouseEnter`/`onMouseLeave` handlers for cards in both Pick and Pool areas.
- [x] rendering a fixed, high z-index preview of the hovered card.
- [x] Disable right-click context menu on Draft interface.
## Implementation Details
- **File**: `src/client/src/modules/draft/DraftView.tsx`
- **Zoom Component**: A fixed `div` containing the large card image.
- **Position**: Fixed to the left or right side of the screen (e.g., `left-10 top-1/2 -translate-y-1/2`) to avoid covering the grid being interacted with (which is usually centered).
- **Styling**: Large size (e.g., `w-80` or `h-[500px]`), shadow, border, rounded corners.

View File

@@ -0,0 +1,15 @@
# Card Zoom on Hover - Dedicated Zone
## Status
- [x] Add `hoveredCard` state to `DraftView` (Already done).
- [x] Implement `onMouseEnter`/`onMouseLeave` handlers (Already done).
- [x] Refactor `DraftView` layout to include a dedicated sidebar for card preview.
- [x] Move the zoomed card image into this dedicated zone instead of a fixed overlay.
## Implementation Details
- **File**: `src/client/src/modules/draft/DraftView.tsx`
- **Layout Change**:
- Wrap the central card selection area in a `flex-row` container.
- Add a Left Sidebar (e.g., `w-80`) reserved for the zoomed card.
- Ensure the main card grid takes up the remaining space (`flex-1`).
- **Behavior**: When no card is hovered, the sidebar can show a placeholder or remain empty/decorative.

View File

@@ -0,0 +1,22 @@
# Host Disconnect Pause Logic
## Objective
Ensure the game pauses for all players when the Host disconnects, preventing auto-pick logic from advancing the game state. enable players to leave cleanly.
## Changes
1. **Server (`src/server/index.ts`)**:
* Refactored socket handlers.
* Implemented `startAutoPickTimer` / `stopAllRoomTimers` helpers.
* Updated `disconnect` handler: Checks if disconnected player is passed host. If true, pauses game (stops all timers).
* Updated `join_room` / `rejoin_room`: Resumes game (restarts timers) if Host reconnects.
* Added `leave_room` event handler to properly remove players from room state.
2. **Frontend (`src/client/src/modules/lobby/LobbyManager.tsx`)**:
* Updated `handleExitRoom` to emit `leave_room` event, preventing "ghost" connections.
3. **Frontend (`src/client/src/modules/lobby/GameRoom.tsx`)**:
* Fixed build error (unused variable `setGameState`) by adding `game_update` listener.
* Verified "Game Paused" overlay logic exists and works with the new server state (`isHostOffline`).
## Result
Host disconnection now effectively pauses the draft flow. Reconnection resumes it. Players can leave safely.

View File

@@ -0,0 +1,26 @@
# Anti-Tampering Implementation
## Objective
Implement a robust anti-tampering system to prevent players (including the host) from manipulating the game state via malicious client-side emissions.
## Changes
1. **Server (`src/server/managers/RoomManager.ts`)**:
* Added `getPlayerBySocket(socketId)` to securely identify the player associated with a connection, eliminating reliance on client-provided IDs.
2. **Server (`src/server/index.ts`)**:
* Refactored all major socket event listeners (`pick_card`, `game_action`, `start_draft`, `player_ready`) to use `roomManager.getPlayerBySocket(socket.id)`.
* The server now ignores `playerId` and `roomId` sent in the payload (where applicable) and uses the trusted session context instead.
* This ensures that a user can only perform actions for *themselves* in the room they are *actually connected to*.
3. **Server (`src/server/managers/GameManager.ts`)**:
* Updated `handleAction` to accept an authentic `actorId`.
* Added ownership/controller checks to sensitive actions:
* `moveCard`: Only the controller can move a card.
* `updateLife`: Only the player can update their own life.
* `drawCard`, `createToken`, etc.: Validated against `actorId`.
4. **Frontend (`GameView.tsx`, `DraftView.tsx`, `DeckBuilderView.tsx`)**:
* Cleaned up socket emissions to stop sending redundant `roomId` and `playerId` fields, aligning client behavior with the new secure server expectations (though server would safely ignore them anyway).
## Result
The system is now significantly more resistant to session hijacking or spoofing. Users cannot act as other players or manipulate game state objects they do not control, even if they manually emit socket events from the console.

View File

@@ -0,0 +1,12 @@
# Fix Draft UI Layout Consistency
## Objective
Fix the layout inconsistency where the "Waiting for next pack..." screen and other views in the Draft interface do not fully occupy the screen width, causing the UI to look collapsed or disconnected from the global sidebars.
## Changes
1. **DraftView.tsx**: Added `flex-1` and `w-full` to the root container. This ensures the component expands to fill the available space in the `GameRoom` flex container, maintaining the full-screen layout even when content (like the "waiting" message) is minimal.
2. **DeckBuilderView.tsx**: Added `flex-1` and `w-full` to the root container for consistency and to ensure the deck builder also behaves correctly within the main layout.
## Verification
- The `DraftView` should now stretch to fill the area between the left edge (or internal Zoom sidebar) and the right Lobby/Chat sidebar in `GameRoom`.
- The "Waiting for next pack..." message will remain centered within this full-height, full-width area, with the background gradient covering the entire zone.

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

@@ -35,8 +35,8 @@ export const App: React.FC = () => {
}, [generatedPacks]);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
<header className="bg-slate-800 border-b border-slate-700 p-4 sticky top-0 z-50 shadow-lg">
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-3">
<div className="bg-purple-600 p-2 rounded-lg"><Layers className="w-6 h-6 text-white" /></div>
@@ -75,7 +75,7 @@ export const App: React.FC = () => {
</div>
</header>
<main>
<main className="flex-1 overflow-hidden relative">
{activeTab === 'draft' && (
<CubeManager
packs={generatedPacks}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { X, AlertTriangle, CheckCircle, Info } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose?: () => void;
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
confirmLabel?: string;
onConfirm?: () => void;
cancelLabel?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
message,
type = 'info',
confirmLabel = 'OK',
onConfirm,
cancelLabel
}) => {
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success': return <CheckCircle className="w-6 h-6 text-emerald-500" />;
case 'warning': return <AlertTriangle className="w-6 h-6 text-amber-500" />;
case 'error': return <AlertTriangle className="w-6 h-6 text-red-500" />;
default: return <Info className="w-6 h-6 text-blue-500" />;
}
};
const getBorderColor = () => {
switch (type) {
case 'success': return 'border-emerald-500/50';
case 'warning': return 'border-amber-500/50';
case 'error': return 'border-red-500/50';
default: return 'border-slate-700';
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200`}
role="dialog"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{getIcon()}
<h3 className="text-xl font-bold text-white">{title}</h3>
</div>
{onClose && !cancelLabel && (
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
)}
</div>
<p className="text-slate-300 mb-8 leading-relaxed">
{message}
</p>
<div className="flex justify-end gap-3">
{cancelLabel && onClose && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
>
{cancelLabel}
</button>
)}
<button
onClick={() => {
if (onConfirm) onConfirm();
if (onClose) onClose();
}}
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
'bg-blue-600 hover:bg-blue-500'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};

View File

@@ -289,7 +289,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
};
return (
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
<div className="h-full overflow-y-auto max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
{/* --- LEFT COLUMN: CONTROLS --- */}
<div className="lg:col-span-4 flex flex-col gap-4">

View File

@@ -9,7 +9,7 @@ interface DeckBuilderViewProps {
initialPool: any[];
}
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ roomId, currentPlayerId, initialPool }) => {
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool }) => {
const [timer, setTimer] = useState(45 * 60); // 45 minutes
const [pool, setPool] = useState<any[]>(initialPool);
const [deck, setDeck] = useState<any[]>([]);
@@ -84,11 +84,11 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ roomId, curren
// Actually, user rules say "Host ... guided ... configuring packs ... multiplayer".
// I'll emit 'submit_deck' event (need to handle in server)
socketService.socket.emit('player_ready', { roomId, playerId: currentPlayerId, deck: fullDeck });
socketService.socket.emit('player_ready', { deck: fullDeck });
};
return (
<div className="flex h-full bg-slate-900 text-white">
<div className="flex-1 w-full flex h-full bg-slate-900 text-white">
{/* Left: Pool */}
<div className="w-1/2 p-4 flex flex-col border-r border-slate-700">
<div className="flex justify-between items-center mb-4">

View File

@@ -1,15 +1,19 @@
import React, { useState, useEffect } from 'react';
import { socketService } from '../../services/SocketService';
import { LogOut } from 'lucide-react';
import { Modal } from '../../components/Modal';
interface DraftViewProps {
draftState: any;
roomId: string; // Passed from parent
currentPlayerId: string;
onExit?: () => void;
}
export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, currentPlayerId }) => {
export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerId, onExit }) => {
const [timer, setTimer] = useState(60);
const [confirmExitOpen, setConfirmExitOpen] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
@@ -18,71 +22,240 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, roomId, curren
return () => clearInterval(interval);
}, []); // Reset timer on new pack? Simplified for now.
// --- UI State & Persistence ---
const [poolHeight, setPoolHeight] = useState<number>(() => {
const saved = localStorage.getItem('draft_poolHeight');
return saved ? parseInt(saved, 10) : 220;
});
const [cardScale, setCardScale] = useState<number>(() => {
const saved = localStorage.getItem('draft_cardScale');
return saved ? parseFloat(saved) : 0.7;
});
const [isResizing, setIsResizing] = useState(false);
// Persist settings
useEffect(() => {
localStorage.setItem('draft_poolHeight', poolHeight.toString());
}, [poolHeight]);
useEffect(() => {
localStorage.setItem('draft_cardScale', cardScale.toString());
}, [cardScale]);
// Resize Handlers
const startResizing = (e: React.MouseEvent) => {
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const stopResizing = () => setIsResizing(false);
const resize = (e: MouseEvent) => {
if (isResizing) {
const newHeight = window.innerHeight - e.clientY;
// Limits: Min 100px, Max 60% of screen
const maxHeight = window.innerHeight * 0.6;
if (newHeight >= 100 && newHeight <= maxHeight) {
setPoolHeight(newHeight);
}
}
};
if (isResizing) {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
}
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [isResizing]);
const [hoveredCard, setHoveredCard] = useState<any>(null);
const activePack = draftState.players[currentPlayerId]?.activePack;
const pickedCards = draftState.players[currentPlayerId]?.pool || [];
const handlePick = (cardId: string) => {
socketService.socket.emit('pick_card', { roomId, playerId: currentPlayerId, cardId });
// roomId and playerId are now inferred by the server from socket session
socketService.socket.emit('pick_card', { cardId });
};
if (!activePack) {
return (
<div className="flex flex-col items-center justify-center h-full bg-slate-900 text-white">
<h2 className="text-2xl font-bold mb-4">Waiting for next pack...</h2>
<div className="animate-pulse bg-slate-700 w-64 h-8 rounded"></div>
</div>
);
}
// ... inside DraftView return ...
return (
<div className="flex flex-col h-full bg-slate-950 text-white p-4 gap-4">
<div className="flex-1 w-full flex flex-col h-full bg-slate-950 text-white overflow-hidden relative select-none" onContextMenu={(e) => e.preventDefault()}>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black opacity-50 pointer-events-none"></div>
{/* Top Header: Timer & Pack Info */}
<div className="flex justify-between items-center bg-slate-900 p-4 rounded-lg border border-slate-800">
<div>
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500">
Pack {draftState.packNumber}
</h2>
<span className="text-sm text-slate-400">Pick {pickedCards.length % 15 + 1}</span>
</div>
<div className="text-3xl font-mono text-emerald-400 font-bold">
00:{timer < 10 ? `0${timer}` : timer}
<div className="shrink-0 p-4 z-10">
<div className="flex justify-between items-center bg-slate-900/80 backdrop-blur border border-slate-800 p-4 rounded-lg shadow-lg">
<div className="flex items-center gap-8">
<div>
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 shadow-amber-500/20 drop-shadow-sm">
Pack {draftState.packNumber}
</h2>
<span className="text-sm text-slate-400 font-medium">Pick {pickedCards.length % 15 + 1}</span>
</div>
{/* Card Scalar */}
<div className="hidden md:flex flex-col gap-1 w-32">
<label className="text-[10px] text-slate-500 uppercase font-bold tracking-wider">Card Size</label>
<input
type="range"
min="0.5"
max="1.5"
step="0.1"
value={cardScale}
onChange={(e) => setCardScale(parseFloat(e.target.value))}
className="w-full h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
/>
</div>
</div>
<div className="flex items-center gap-6">
{!activePack ? (
<div className="text-sm font-bold text-amber-500 animate-pulse uppercase tracking-wider">Waiting...</div>
) : (
<div className="text-4xl font-mono text-emerald-400 font-bold drop-shadow-[0_0_10px_rgba(52,211,153,0.5)]">
00:{timer < 10 ? `0${timer}` : timer}
</div>
)}
{onExit && (
<button
onClick={() => setConfirmExitOpen(true)}
className="p-3 bg-slate-800 hover:bg-red-500/20 text-slate-400 hover:text-red-500 border border-slate-700 hover:border-red-500/50 rounded-xl transition-all shadow-lg group"
title="Exit to Lobby"
>
<LogOut className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
)}
</div>
</div>
</div>
{/* Main Area: Current Pack */}
<div className="flex-1 bg-slate-900/50 p-6 rounded-xl border border-slate-800 overflow-y-auto">
<h3 className="text-center text-slate-400 uppercase tracking-widest text-sm font-bold mb-6">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-4">
{activePack.cards.map((card: any) => (
{/* Middle Content: Zoom Sidebar + Pack Grid */}
<div className="flex-1 flex overflow-hidden">
{/* Dedicated Zoom Zone (Left Sidebar) */}
<div className="hidden lg:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800/50 bg-slate-900/20 backdrop-blur-sm z-10 transition-all">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-300 p-4 sticky top-4">
<img
src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 p-8 text-center opacity-50">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div>
)}
</div>
{/* Main Area: Current Pack OR Waiting State */}
<div className="flex-1 overflow-y-auto p-4 z-0 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
{!activePack ? (
<div className="flex flex-col items-center justify-center min-h-full pb-10 fade-in animate-in duration-500">
<div className="w-24 h-24 mb-6 relative">
<div className="absolute inset-0 rounded-full border-4 border-slate-800"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-emerald-500 animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<LogOut className="w-8 h-8 text-emerald-500 rotate-180" /> {/* Just a placeholder icon or similar */}
</div>
</div>
<h2 className="text-3xl font-bold text-white mb-2">Waiting for next pack...</h2>
<p className="text-slate-400">Your neighbor is selecting a card.</p>
<div className="mt-8 flex gap-2">
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-bounce"></div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-full pb-10">
<h3 className="text-center text-slate-500 uppercase tracking-[0.2em] text-xs font-bold mb-8">Select a Card</h3>
<div className="flex flex-wrap justify-center gap-6 [perspective:1000px]">
{activePack.cards.map((card: any) => (
<div
key={card.id}
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
style={{ width: `${14 * cardScale}rem` }}
onClick={() => handlePick(card.id)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
<div className="absolute inset-0 rounded-xl bg-emerald-500 blur-xl opacity-0 group-hover:opacity-40 transition-opacity duration-300"></div>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="w-full rounded-xl shadow-2xl shadow-black group-hover:ring-2 ring-emerald-400/50 relative z-10"
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Resize Handle */}
<div
className="h-1 bg-slate-800 hover:bg-emerald-500 cursor-row-resize z-30 transition-colors w-full flex items-center justify-center shrink-0"
onMouseDown={startResizing}
>
<div className="w-16 h-1 bg-slate-600 rounded-full"></div>
</div>
{/* Bottom Area: Drafted Pool Preview */}
<div
className="shrink-0 bg-gradient-to-t from-slate-950 to-slate-900/90 backdrop-blur-md flex flex-col z-20 shadow-[0_-10px_40px_rgba(0,0,0,0.5)] transition-all ease-out duration-75"
style={{ height: `${poolHeight}px` }}
>
<div className="px-6 py-2 flex items-center justify-between shrink-0">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
Your Pool ({pickedCards.length})
</h3>
</div>
<div className="flex-1 overflow-x-auto flex items-center gap-2 px-6 pb-4 custom-scrollbar">
{pickedCards.map((card: any, idx: number) => (
<div
key={card.id}
className="group relative transition-all hover:scale-110 hover:z-10 cursor-pointer"
onClick={() => handlePick(card.id)}
key={`${card.id}-${idx}`}
className="relative group shrink-0 transition-all hover:-translate-y-10 h-full flex items-center"
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="w-48 rounded-lg shadow-xl shadow-black/50 group-hover:shadow-emerald-500/50 group-hover:ring-2 ring-emerald-400"
className="h-[90%] w-auto rounded-lg shadow-lg border border-slate-700/50 group-hover:border-emerald-500/50 group-hover:shadow-emerald-500/20 transition-all object-contain"
/>
</div>
))}
</div>
</div>
{/* Bottom Area: Drafted Pool Preview */}
<div className="h-48 bg-slate-900 p-4 rounded-lg border border-slate-800 flex flex-col">
<h3 className="text-xs font-bold text-slate-500 uppercase mb-2">Your Pool ({pickedCards.length})</h3>
<div className="flex-1 overflow-x-auto flex items-center gap-1 pb-2">
{pickedCards.map((card: any, idx: number) => (
<img
key={`${card.id}-${idx}`}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
alt={card.name}
className="h-full rounded shadow-md"
/>
))}
</div>
</div>
<Modal
isOpen={confirmExitOpen}
onClose={() => setConfirmExitOpen(false)}
title="Exit Draft?"
message="Are you sure you want to exit the draft? You can rejoin later."
type="warning"
confirmLabel="Exit Draft"
cancelLabel="Stay"
onConfirm={onExit}
/>
</div>
);
};

View File

@@ -58,7 +58,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
}
socketService.socket.emit('game_action', {
roomId: gameState.roomId,
action: {
type: actionType,
...safePayload
@@ -92,7 +91,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
}
socketService.socket.emit('game_action', {
roomId: gameState.roomId,
action
});
};
@@ -103,7 +101,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const toggleTap = (cardId: string) => {
socketService.socket.emit('game_action', {
roomId: gameState.roomId,
action: {
type: 'TAP_CARD',
cardId
@@ -272,7 +269,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
<div
className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })}
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
>
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
@@ -337,13 +334,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<div className="flex gap-1 mt-2 justify-center">
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-red-500/20 text-red-500 border border-slate-700 hover:border-red-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: -1 } })}
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: -1 } })}
>
-
</button>
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-emerald-500/20 text-emerald-500 border border-slate-700 hover:border-emerald-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: 1 } })}
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: 1 } })}
>
+
</button>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Play, Copy, Check, Layers } from 'lucide-react';
import { Users, MessageSquare, Send, Play, Copy, Check, Layers, LogOut } from 'lucide-react';
import { Modal } from '../../components/Modal';
import { GameView } from '../game/GameView';
import { DraftView } from '../draft/DraftView';
import { DeckBuilderView } from '../draft/DeckBuilderView';
@@ -11,6 +12,7 @@ interface Player {
name: string;
isHost: boolean;
role: 'player' | 'spectator';
isOffline?: boolean;
}
interface ChatMessage {
@@ -32,54 +34,57 @@ interface GameRoomProps {
room: Room;
currentPlayerId: string;
initialGameState?: any;
initialDraftState?: any;
onExit: () => void;
}
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState }) => {
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
// State
const [room, setRoom] = useState<Room>(initialRoom);
const [modalOpen, setModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
// Restored States
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null);
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
// Derived State
const host = room.players.find(p => p.isHost);
const isHostOffline = host?.isOffline;
const isMeHost = currentPlayerId === host?.id;
// Effects
useEffect(() => {
setRoom(initialRoom);
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 handleRoomUpdate = (updatedRoom: Room) => {
console.log('Room updated:', updatedRoom);
setRoom(updatedRoom);
const onKicked = () => {
alert("You have been kicked from the room.");
onExit();
};
socket.on('kicked', onKicked);
return () => { socket.off('kicked', onKicked); };
}, [onExit]);
const handleNewMessage = (msg: ChatMessage) => {
setMessages(prev => [...prev, msg]);
};
const handleGameUpdate = (game: any) => {
setGameState(game);
};
socket.on('room_update', handleRoomUpdate);
socket.on('new_message', handleNewMessage);
socket.on('game_update', handleGameUpdate);
return () => {
socket.off('room_update', handleRoomUpdate);
socket.off('new_message', handleNewMessage);
socket.off('game_update', handleGameUpdate);
};
}, []);
// Scroll to bottom of chat
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// New States
const [draftState, setDraftState] = useState<any>(null);
useEffect(() => {
const socket = socketService.socket;
const handleDraftUpdate = (data: any) => {
@@ -87,15 +92,26 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
};
const handleDraftError = (error: { message: string }) => {
alert(error.message); // Simple alert for now
setModalConfig({
title: 'Error',
message: error.message,
type: 'error'
});
setModalOpen(true);
};
const handleGameUpdate = (data: any) => {
setGameState(data);
};
socket.on('draft_update', handleDraftUpdate);
socket.on('draft_error', handleDraftError);
socket.on('game_update', handleGameUpdate);
return () => {
socket.off('draft_update', handleDraftUpdate);
socket.off('draft_error', handleDraftError);
socket.off('game_update', handleGameUpdate);
};
}, []);
@@ -116,10 +132,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
if (navigator.clipboard) {
navigator.clipboard.writeText(room.id).catch(err => {
console.error('Failed to copy: ', err);
// Fallback could go here
});
} else {
// Fallback for non-secure context or older browsers
console.warn('Clipboard API not available');
const textArea = document.createElement("textarea");
textArea.value = room.id;
@@ -135,19 +149,17 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
};
const handleStartGame = () => {
// Create a test deck for each player for now
const testDeck = Array.from({ length: 40 }).map((_, i) => ({
id: `card-${i}`,
name: i % 2 === 0 ? "Mountain" : "Lightning Bolt",
image_uris: {
normal: i % 2 === 0
? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg" // Mountain
: "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg" // Bolt
? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg"
: "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg"
}
}));
const decks = room.players.reduce((acc, p) => ({ ...acc, [p.id]: testDeck }), {});
socketService.socket.emit('start_game', { roomId: room.id, decks });
};
@@ -155,21 +167,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
socketService.socket.emit('start_draft', { roomId: room.id });
};
// Helper to determine view
const renderContent = () => {
if (gameState) {
return <GameView gameState={gameState} currentPlayerId={currentPlayerId} />;
}
if (room.status === 'drafting' && draftState) {
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} />;
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} onExit={onExit} />;
}
if (room.status === 'deck_building' && draftState) {
// Check if I am ready
// Type casting needed because 'ready' was added to interface only in server side so far?
// Need to update client Player interface too in this file if not already consistent.
// But let's assume raw object has it.
const me = room.players.find(p => p.id === currentPlayerId) as any;
if (me?.ready) {
return (
@@ -201,7 +208,6 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
}
// Default Waiting Lobby
return (
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
@@ -247,43 +253,69 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
};
return (
<div className="flex h-[calc(100vh-100px)] gap-4">
<div className="flex h-full gap-4">
{renderContent()}
{/* Sidebar: Players & Chat */}
<div className="w-80 flex flex-col gap-4">
{/* Players List */}
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<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 => {
// Cast to any to access ready state without full interface update for now
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>
)
})}
</div>
</div>
{/* Chat */}
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Chat
@@ -311,6 +343,48 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</form>
</div>
</div>
{/* Host Disconnected Overlay */}
{isHostOffline && !isMeHost && (
<div className="absolute inset-0 z-50 bg-black/80 backdrop-blur-md flex flex-col items-center justify-center p-8 animate-in fade-in duration-500">
<div className="bg-slate-900 border border-red-500/50 p-8 rounded-2xl shadow-2xl max-w-lg text-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Users className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Game Paused</h2>
<p className="text-slate-300 mb-6">
The host <span className="text-white font-bold">{host?.name}</span> has disconnected.
The game is paused until they reconnect.
</p>
<div className="flex flex-col gap-6 items-center">
<div className="flex items-center justify-center gap-2 text-xs text-slate-500 uppercase tracking-wider font-bold animate-pulse">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
Waiting for host...
</div>
<button
onClick={() => {
if (window.confirm("Are you sure you want to leave the game?")) {
onExit();
}
}}
className="px-6 py-2 bg-slate-800 hover:bg-red-900/30 text-slate-400 hover:text-red-400 border border-slate-700 hover:border-red-500/50 rounded-lg flex items-center gap-2 transition-all"
>
<LogOut className="w-4 h-4" /> Leave Game
</button>
</div>
</div>
</div>
)}
{/* Global Modal */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={modalConfig.title}
message={modalConfig.message}
type={modalConfig.type}
/>
</div>
);
};

View File

@@ -15,6 +15,8 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
const [joinRoomId, setJoinRoomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [initialDraftState, setInitialDraftState] = useState<any>(null);
const [playerId] = useState(() => {
const saved = localStorage.getItem('player_id');
if (saved) return saved;
@@ -128,6 +130,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
});
if (response.success) {
setInitialDraftState(response.draftState || null);
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to join room');
@@ -152,14 +155,19 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
if (savedRoomId && !activeRoom && playerId) {
setLoading(true);
connect();
socketService.emitPromise('rejoin_room', { roomId: savedRoomId })
.then(() => {
// We don't get the room back directly in this event usually, but let's assume socket events 'room_update' handles it?
// The backend 'rejoin_room' doesn't return a callback with room data in the current implementation, it emits updates.
// However, let's try to invoke 'join_room' logic as a fallback or assume room_update catches it.
// Actually, backend 'rejoin_room' DOES emit 'room_update'.
// Let's rely on the socket listener in GameRoom... wait, GameRoom is not mounted yet!
// We need to listen to 'room_update' HERE to switch state.
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);
@@ -183,12 +191,21 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
}, [playerId]);
const handleExitRoom = () => {
if (activeRoom) {
socketService.socket.emit('leave_room', { roomId: activeRoom.id, playerId });
}
setActiveRoom(null);
setInitialDraftState(null);
localStorage.removeItem('active_room_id');
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} />;
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-10">
<div className="h-full overflow-y-auto max-w-4xl mx-auto p-4 md:p-10">
<div className="bg-slate-800 rounded-2xl p-8 border border-slate-700 shadow-2xl">
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Users className="w-8 h-8 text-purple-500" /> Multiplayer Lobby

View File

@@ -115,12 +115,17 @@ export const DeckTester: React.FC = () => {
}
};
const handleExitTester = () => {
setActiveRoom(null);
setInitialGame(null);
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} onExit={handleExitTester} />;
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-8">
<div className="h-full overflow-y-auto max-w-4xl mx-auto p-4 md:p-8">
<div className="bg-slate-800 rounded-2xl p-8 border border-slate-700 shadow-2xl">
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Play className="w-8 h-8 text-emerald-500" /> Deck Tester

View File

@@ -48,7 +48,7 @@ export const TournamentManager: React.FC = () => {
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-6">
<div className="h-full overflow-y-auto max-w-4xl mx-auto p-4 md:p-6">
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl mb-8">
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-400" /> Players

View File

@@ -64,9 +64,55 @@ app.post('/api/cards/cache', async (req: Request, res: Response) => {
io.on('connection', (socket) => {
console.log('A user connected', socket.id);
// Actually, let's use a simpler map: PlayerID -> Timeout
// 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);
}
});
}
};
socket.on('create_room', ({ hostId, hostName, packs }, callback) => {
const room = roomManager.createRoom(hostId, hostName, packs, socket.id); // Add socket.id
socket.join(room.id);
@@ -78,50 +124,88 @@ io.on('connection', (socket) => {
const room = roomManager.joinRoom(roomId, playerId, playerName, socket.id); // Add socket.id
if (room) {
// Clear timeout if exists (User reconnected)
if (playerTimers.has(playerId)) {
clearTimeout(playerTimers.get(playerId)!);
playerTimers.delete(playerId);
console.log(`Player ${playerName} reconnected. Auto-pick cancelled.`);
}
stopAutoPickTimer(playerId);
console.log(`Player ${playerName} reconnected. Auto-pick cancelled.`);
socket.join(room.id);
console.log(`Player ${playerName} joined room ${roomId}`);
io.to(room.id).emit('room_update', room); // Broadcast update
// If drafting, send state immediately
if (room.status === 'drafting') {
const draft = draftManager.getDraft(roomId);
if (draft) socket.emit('draft_update', draft);
// Check if Host Reconnected -> Resume Game
if (room.hostId === playerId) {
console.log(`Host ${playerName} reconnected. Resuming draft timers.`);
resumeRoomTimers(roomId);
}
callback({ success: true, room });
// If drafting, send state immediately and include in callback
let currentDraft = null;
if (room.status === 'drafting') {
currentDraft = draftManager.getDraft(roomId);
if (currentDraft) socket.emit('draft_update', currentDraft);
}
callback({ success: true, room, draftState: currentDraft });
} else {
callback({ success: false, message: 'Room not found or full' });
}
});
// RE-IMPLEMENTING rejoin_room with playerId
socket.on('rejoin_room', ({ roomId, playerId }) => {
socket.on('rejoin_room', ({ roomId, playerId }, callback) => {
socket.join(roomId);
if (playerId) {
// Update socket ID mapping
roomManager.updatePlayerSocket(roomId, playerId, socket.id);
const room = roomManager.updatePlayerSocket(roomId, playerId, socket.id);
// Clear Timer
if (playerTimers.has(playerId)) {
clearTimeout(playerTimers.get(playerId)!);
playerTimers.delete(playerId);
console.log(`Player ${playerId} reconnected via rejoin. Auto-pick cancelled.`);
if (room) {
// Clear Timer
stopAutoPickTimer(playerId);
console.log(`Player ${playerId} reconnected via rejoin.`);
// Notify others (isOffline false)
io.to(roomId).emit('room_update', room);
// Check if Host Reconnected -> Resume Game
if (room.hostId === playerId) {
console.log(`Host ${playerId} reconnected. Resuming draft timers.`);
resumeRoomTimers(roomId);
}
// Prepare Draft State if exists
let currentDraft = null;
if (room.status === 'drafting') {
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 {
// Missing playerId
if (typeof callback === 'function') {
callback({ success: false, message: 'Missing Player ID' });
}
}
});
const room = roomManager.getRoom(roomId);
socket.on('leave_room', ({ roomId, playerId }) => {
const room = roomManager.leaveRoom(roomId, playerId);
socket.leave(roomId);
if (room) {
socket.emit('room_update', room);
if (room.status === 'drafting') {
const draft = draftManager.getDraft(roomId);
if (draft) socket.emit('draft_update', draft);
}
console.log(`Player ${playerId} left room ${roomId}`);
io.to(roomId).emit('room_update', room);
} else {
console.log(`Room ${roomId} closed/empty`);
}
});
@@ -132,54 +216,89 @@ io.on('connection', (socket) => {
}
});
socket.on('start_draft', ({ roomId }) => {
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 && room.status === 'waiting') {
const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length < 4) {
// Emit error to the host or room
socket.emit('draft_error', { message: 'Draft cannot start. It requires at least 4 players.' });
return;
}
// Create Draft
const draft = draftManager.createDraft(roomId, room.players.map(p => p.id), room.packs);
room.status = 'drafting';
io.to(roomId).emit('room_update', room);
io.to(roomId).emit('draft_update', draft);
}
});
socket.on('pick_card', ({ roomId, playerId, cardId }) => {
const draft = draftManager.pickCard(roomId, playerId, cardId);
if (draft) {
io.to(roomId).emit('draft_update', draft);
if (draft.status === 'deck_building') {
const room = roomManager.getRoom(roomId);
if (room) {
room.status = 'deck_building';
io.to(roomId).emit('room_update', room);
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.`);
}
}
}
});
socket.on('player_ready', ({ roomId, playerId, deck }) => {
const room = roomManager.setPlayerReady(roomId, playerId, deck);
if (room) {
io.to(roomId).emit('room_update', room);
const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
room.status = 'playing';
io.to(roomId).emit('room_update', room);
// Secure helper to get player context
const getContext = () => roomManager.getPlayerBySocket(socket.id);
const game = gameManager.createGame(roomId, room.players);
socket.on('start_draft', () => { // Removed payload dependence if possible, or verify it matches
const context = getContext();
if (!context) return;
const { room } = context;
// Optional: Only host can start?
// if (!player.isHost) return;
if (room.status === 'waiting') {
const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length < 2) {
// socket.emit('draft_error', { message: 'Draft cannot start. It requires at least 4 players.' });
// return;
}
const draft = draftManager.createDraft(room.id, room.players.map(p => p.id), room.packs);
room.status = 'drafting';
io.to(room.id).emit('room_update', room);
io.to(room.id).emit('draft_update', draft);
}
});
socket.on('pick_card', ({ cardId }) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
const draft = draftManager.pickCard(room.id, player.id, cardId);
if (draft) {
io.to(room.id).emit('draft_update', draft);
if (draft.status === 'deck_building') {
room.status = 'deck_building';
io.to(room.id).emit('room_update', room);
}
}
});
socket.on('player_ready', ({ deck }) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
const updatedRoom = roomManager.setPlayerReady(room.id, player.id, deck);
if (updatedRoom) {
io.to(room.id).emit('room_update', updatedRoom);
const activePlayers = updatedRoom.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
updatedRoom.status = 'playing';
io.to(room.id).emit('room_update', updatedRoom);
const game = gameManager.createGame(room.id, updatedRoom.players);
activePlayers.forEach(p => {
if (p.deck) {
p.deck.forEach((card: any) => {
gameManager.addCardToGame(roomId, {
gameManager.addCardToGame(room.id, {
ownerId: p.id,
controllerId: p.id,
oracleId: card.oracle_id || card.id,
@@ -190,12 +309,13 @@ io.on('connection', (socket) => {
});
}
});
io.to(roomId).emit('game_update', game);
io.to(room.id).emit('game_update', game);
}
}
});
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => {
// Solo test is a separate creation flow, doesn't require existing context
const room = roomManager.createRoom(playerId, playerName, []);
room.status = 'playing';
socket.join(room.id);
@@ -217,18 +337,22 @@ io.on('connection', (socket) => {
io.to(room.id).emit('game_update', game);
});
socket.on('start_game', ({ roomId, decks }) => {
const room = roomManager.startGame(roomId);
if (room) {
io.to(roomId).emit('room_update', room);
const game = gameManager.createGame(roomId, room.players);
socket.on('start_game', ({ decks }) => {
const context = getContext();
if (!context) return;
const { room } = context;
const updatedRoom = roomManager.startGame(room.id);
if (updatedRoom) {
io.to(room.id).emit('room_update', updatedRoom);
const game = gameManager.createGame(room.id, updatedRoom.players);
if (decks) {
Object.entries(decks).forEach(([playerId, deck]: [string, any]) => {
Object.entries(decks).forEach(([pid, deck]: [string, any]) => {
// @ts-ignore
deck.forEach(card => {
gameManager.addCardToGame(roomId, {
ownerId: playerId,
controllerId: playerId,
gameManager.addCardToGame(room.id, {
ownerId: pid,
controllerId: pid,
oracleId: card.oracle_id || card.id,
name: card.name,
imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
@@ -237,14 +361,18 @@ io.on('connection', (socket) => {
});
});
}
io.to(roomId).emit('game_update', game);
io.to(room.id).emit('game_update', game);
}
});
socket.on('game_action', ({ roomId, action }) => {
const game = gameManager.handleAction(roomId, action);
socket.on('game_action', ({ action }) => {
const context = getContext();
if (!context) return;
const { room, player } = context;
const game = gameManager.handleAction(room.id, action, player.id);
if (game) {
io.to(roomId).emit('game_update', game);
io.to(room.id).emit('game_update', game);
}
});
@@ -260,32 +388,17 @@ io.on('connection', (socket) => {
io.to(room.id).emit('room_update', room);
if (room.status === 'drafting') {
// Start Timer (e.g. 30 seconds)
const timer = setTimeout(() => {
console.log(`Timeout for player ${playerId}. Auto-picking...`);
// Auto-pick
const draft = draftManager.autoPick(room.id, playerId);
if (draft) {
io.to(room.id).emit('draft_update', draft);
// Check if Host is currently offline (including self if self is host)
// If Host is offline, PAUSE EVERYTHING.
const hostOffline = room.players.find(p => p.id === room.hostId)?.isOffline;
// If they still have picks to make (Pick 2), we might need to auto-pick again?
// For simplicity, let's assume autoPick handles 1 pick.
// If they are still offline, the NEXT time they are blocking the flow?
// Ideally, we should check if they still need to pick.
// But for a basic "if user does not reconnect in a time frame", this fulfills the request.
// The system will effectively auto-pick 1 card every 30s (if we reset the timer).
// But we only set the timer ONCE on disconnect.
// If they stay disconnected, we need to loop.
// RECURSIVE TIMER:
// If player is still offline after auto-pick, schedule another one?
// We need to check if they are still blocking.
// For now, let's just do ONE auto-pick per disconnect event to unblock.
}
playerTimers.delete(playerId);
}, 30000); // 30 seconds
playerTimers.set(playerId, timer);
if (hostOffline) {
console.log("Host is offline. Pausing game (stopping all timers).");
stopAllRoomTimers(room.id);
} else {
// Host is online, but THIS player disconnected. Start timer for them.
startAutoPickTimer(room.id, playerId);
}
}
}
});

View File

@@ -72,58 +72,67 @@ export class GameManager {
}
// Generic action handler for sandbox mode
handleAction(roomId: string, action: any): GameState | null {
handleAction(roomId: string, action: any, actorId: string): GameState | null {
const game = this.games.get(roomId);
if (!game) return null;
// Basic Validation: Ensure actor exists in game
if (!game.players[actorId]) return null;
switch (action.type) {
case 'MOVE_CARD':
this.moveCard(game, action);
this.moveCard(game, action, actorId);
break;
case 'TAP_CARD':
this.tapCard(game, action);
this.tapCard(game, action, actorId);
break;
case 'FLIP_CARD':
this.flipCard(game, action);
this.flipCard(game, action, actorId);
break;
case 'ADD_COUNTER':
this.addCounter(game, action);
this.addCounter(game, action, actorId);
break;
case 'CREATE_TOKEN':
this.createToken(game, action);
this.createToken(game, action, actorId);
break;
case 'DELETE_CARD':
this.deleteCard(game, action);
this.deleteCard(game, action, actorId);
break;
case 'UPDATE_LIFE':
this.updateLife(game, action);
this.updateLife(game, action, actorId);
break;
case 'DRAW_CARD':
this.drawCard(game, action);
this.drawCard(game, action, actorId);
break;
case 'SHUFFLE_LIBRARY':
this.shuffleLibrary(game, action);
this.shuffleLibrary(game, action, actorId);
break;
case 'SHUFFLE_GRAVEYARD':
this.shuffleGraveyard(game, action);
this.shuffleGraveyard(game, action, actorId);
break;
case 'SHUFFLE_EXILE':
this.shuffleExile(game, action);
this.shuffleExile(game, action, actorId);
break;
case 'MILL_CARD':
this.millCard(game, action);
this.millCard(game, action, actorId);
break;
case 'EXILE_GRAVEYARD':
this.exileGraveyard(game, action);
this.exileGraveyard(game, action, actorId);
break;
}
return game;
}
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) {
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }, actorId: string) {
const card = game.cards[action.cardId];
if (card) {
// ANTI-TAMPER: Only controller can move card
if (card.controllerId !== actorId) {
console.warn(`Anti-Tamper: Player ${actorId} tried to move card ${card.instanceId} controlled by ${card.controllerId}`);
return;
}
// Bring to front
card.position.z = ++game.maxZ;
@@ -145,13 +154,13 @@ export class GameManager {
}
}
private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }) {
private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }, actorId: string) {
const card = game.cards[action.cardId];
if (card) {
if (card.controllerId !== actorId) return; // Anti-tamper
const existing = card.counters.find(c => c.type === action.counterType);
if (existing) {
existing.count += action.amount;
// Remove if 0 or less? Usually yes for counters like +1/+1 but let's just keep logic simple
if (existing.count <= 0) {
card.counters = card.counters.filter(c => c.type !== action.counterType);
}
@@ -161,7 +170,9 @@ export class GameManager {
}
}
private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }) {
private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }, actorId: string) {
if (action.ownerId !== actorId) return; // Anti-tamper
const tokenId = `token-${Math.random().toString(36).substring(7)}`;
// @ts-ignore
const token: CardInstance = {
@@ -185,40 +196,40 @@ export class GameManager {
game.cards[tokenId] = token;
}
private deleteCard(game: GameState, action: { cardId: string }) {
if (game.cards[action.cardId]) {
private deleteCard(game: GameState, action: { cardId: string }, actorId: string) {
if (game.cards[action.cardId] && game.cards[action.cardId].controllerId === actorId) {
delete game.cards[action.cardId];
}
}
private tapCard(game: GameState, action: { cardId: string }) {
private tapCard(game: GameState, action: { cardId: string }, actorId: string) {
const card = game.cards[action.cardId];
if (card) {
if (card && card.controllerId === actorId) {
card.tapped = !card.tapped;
}
}
private flipCard(game: GameState, action: { cardId: string }) {
private flipCard(game: GameState, action: { cardId: string }, actorId: string) {
const card = game.cards[action.cardId];
if (card) {
// Bring to front on flip too
if (card && card.controllerId === actorId) {
card.position.z = ++game.maxZ;
card.faceDown = !card.faceDown;
}
}
private updateLife(game: GameState, action: { playerId: string; amount: number }) {
private updateLife(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
if (action.playerId !== actorId) return; // Anti-tamper
const player = game.players[action.playerId];
if (player) {
player.life += action.amount;
}
}
private drawCard(game: GameState, action: { playerId: string }) {
// Find top card of library for this player
private drawCard(game: GameState, action: { playerId: string }, actorId: string) {
if (action.playerId !== actorId) return; // Anti-tamper
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) {
// Pick random one (simulating shuffle for now)
const randomIndex = Math.floor(Math.random() * libraryCards.length);
const card = libraryCards[randomIndex];
@@ -228,20 +239,21 @@ export class GameManager {
}
}
private shuffleLibrary(_game: GameState, _action: { playerId: string }) {
// No-op in current logic since we pick randomly
private shuffleLibrary(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private shuffleGraveyard(_game: GameState, _action: { playerId: string }) {
// No-op
private shuffleGraveyard(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private shuffleExile(_game: GameState, _action: { playerId: string }) {
// No-op
private shuffleExile(_game: GameState, _action: { playerId: string }, actorId: string) {
if (_action.playerId !== actorId) return;
}
private millCard(game: GameState, action: { playerId: string; amount: number }) {
// Similar to draw but to graveyard
private millCard(game: GameState, action: { playerId: string; amount: number }, actorId: string) {
if (action.playerId !== actorId) return;
const amount = action.amount || 1;
for (let i = 0; i < amount; i++) {
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
@@ -255,7 +267,9 @@ export class GameManager {
}
}
private exileGraveyard(game: GameState, action: { playerId: string }) {
private exileGraveyard(game: GameState, action: { playerId: string }, actorId: string) {
if (action.playerId !== actorId) return;
const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard');
graveyardCards.forEach(card => {
card.zone = 'exile';

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;
@@ -145,4 +169,15 @@ export class RoomManager {
room.messages.push(message);
return message;
}
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
// Inefficient linear search, but robust for now. Maps would be better for high scale.
for (const room of this.rooms.values()) {
const player = room.players.find(p => p.socketId === socketId);
if (player) {
return { player, room };
}
}
return null;
}
}