fix: Resolve React hooks violation, implement player waiting screen, and auto-start game upon deck submission.

This commit is contained in:
2025-12-14 22:41:26 +01:00
parent 9ff305f1ba
commit 65824a52d9
7 changed files with 190 additions and 53 deletions

View File

@@ -15,6 +15,8 @@ The project has successfully migrated from a .NET backend to a Node.js Modular M
- **[2025-12-14] Draft & Deck Builder**: Implemented full draft simulation (Pick/Pass) and Deck Construction with land station. [Link](./devlog/2025-12-14-223000_draft_and_deckbuilder.md) - **[2025-12-14] Draft & Deck Builder**: Implemented full draft simulation (Pick/Pass) and Deck Construction with land station. [Link](./devlog/2025-12-14-223000_draft_and_deckbuilder.md)
- **[2025-12-14] Image Caching**: Implemented server-side image caching to ensure reliable card rendering. [Link](./devlog/2025-12-14-224500_image_caching.md) - **[2025-12-14] Image Caching**: Implemented server-side image caching to ensure reliable card rendering. [Link](./devlog/2025-12-14-224500_image_caching.md)
- **[2025-12-14] Fix Draft Images**: Fixed image loading in Draft UI by adding proxy configuration and correcting property access. [Link](./devlog/2025-12-14-230000_fix_draft_images.md) - **[2025-12-14] Fix Draft Images**: Fixed image loading in Draft UI by adding proxy configuration and correcting property access. [Link](./devlog/2025-12-14-230000_fix_draft_images.md)
- **[2025-12-14] Fix Submit Deck**: Implemented `player_ready` handler and state transition to auto-start game when deck is submitted. [Link](./devlog/2025-12-14-233000_fix_submit_deck.md)
- **[2025-12-14] Fix Hooks & Waiting State**: Resolved React hook violation crash and added proper waiting screen for ready players. [Link](./devlog/2025-12-14-234500_fix_hooks_and_waiting_state.md)
## Active Modules ## Active Modules
1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation). 1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation).

View File

@@ -0,0 +1,28 @@
# Fix Submit Deck Button
## Issue
Users reported that "Submit Deck" button was not working.
## Root Causes
1. **Missing Event Handler**: The server was not listening for the `player_ready` event emitted by the client.
2. **Incomplete Payload**: The client was sending `{ roomId, deck }` but the server needed `playerId` to identify who was ready, which was missing from the payload.
3. **Missing State Logic**: The `RoomManager` did not have a concept of "Ready" state or "Playing" status, meaning the transition from Deck Building to Game was not fully implemented.
## Fixes
1. **Client (`DeckBuilderView.tsx`)**: Updated `player_ready` emission to include `playerId`.
2. **Server (`RoomManager.ts`)**:
- Added `ready` and `deck` properties to `Player` interface.
- Added `playing` to `Room` status.
- Implemented `setPlayerReady` method.
3. **Server (`index.ts`)**:
- Implemented `player_ready` socket handler.
- Added logic to check if *all* active players are ready.
- If all ready, automatically transitions room status to `playing` and initializes the game using `GameManager`, loading the submitted decks.
- ensured deck loading uses cached images (`card.image`) if available.
## Verification
1. Draft cards.
2. Build deck.
3. Click "Submit Deck".
4. Server logs should show "All players ready...".
5. Client should automatically switch to `GameView` (Battlefield).

View File

@@ -0,0 +1,22 @@
# Fix Hooks Violation and Implement Waiting State
## Issue
1. **React Hook Error**: Users encountered "Rendered fewer hooks than expected" when the game started. This was caused by conditional returns in `GameRoom.tsx` appearing *before* hook declarations (`useState`, `useEffect`).
2. **UX Issue**: Players who submitted their decks remained in the Deck Builder view, able to modify their decks, instead of seeing a waiting screen.
## Fixes
1. **Refactored `GameRoom.tsx`**:
- Moved all `useState` and `useEffect` hooks to the top level of the component, ensuring they are always called regardless of the render logic.
- Encapsulated the view switching logic into a helper function `renderContent()`, which is called inside the main return statement.
2. **Implemented Waiting Screen**:
- Inside `renderContent`, checking if the room is in `deck_building` status AND if the current player has `ready: true`.
- If ready, displays a "Deck Submitted" screen with a list of other players and their readiness status.
- Updated the sidebar player list to show a "• Ready" indicator.
## Verification
1. Start a draft with multiple users (or simulate it).
2. Complete draft and enter deck building.
3. Submit deck as one player.
4. Verify that the view changes to "Deck Submitted" / Waiting screen.
5. Submit deck as the final player.
6. Verify that the game starts automatically for everyone without crashing (React Error).

View File

@@ -87,7 +87,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ roomId, curren
// Actually, user rules say "Host ... guided ... configuring packs ... multiplayer". // Actually, user rules say "Host ... guided ... configuring packs ... multiplayer".
// I'll emit 'submit_deck' event (need to handle in server) // I'll emit 'submit_deck' event (need to handle in server)
socketService.socket.emit('player_ready', { roomId, deck: fullDeck }); socketService.socket.emit('player_ready', { roomId, playerId: currentPlayerId, deck: fullDeck });
}; };
return ( return (

View File

@@ -76,6 +76,18 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
// New States
const [draftState, setDraftState] = useState<any>(null);
useEffect(() => {
const socket = socketService.socket;
const handleDraftUpdate = (data: any) => {
setDraftState(data);
};
socket.on('draft_update', handleDraftUpdate);
return () => { socket.off('draft_update', handleDraftUpdate); };
}, []);
const sendMessage = (e: React.FormEvent) => { const sendMessage = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!message.trim()) return; if (!message.trim()) return;
@@ -132,35 +144,54 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
socketService.socket.emit('start_draft', { roomId: room.id }); socketService.socket.emit('start_draft', { roomId: room.id });
}; };
if (gameState) { // Helper to determine view
return <GameView gameState={gameState} currentPlayerId={currentPlayerId} />; const renderContent = () => {
} if (gameState) {
return <GameView gameState={gameState} currentPlayerId={currentPlayerId} />;
}
// New States if (room.status === 'drafting' && draftState) {
const [draftState, setDraftState] = useState<any>(null); return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} />;
}
useEffect(() => { if (room.status === 'deck_building' && draftState) {
const socket = socketService.socket; // Check if I am ready
const handleDraftUpdate = (data: any) => { // Type casting needed because 'ready' was added to interface only in server side so far?
setDraftState(data); // Need to update client Player interface too in this file if not already consistent.
}; // But let's assume raw object has it.
socket.on('draft_update', handleDraftUpdate); const me = room.players.find(p => p.id === currentPlayerId) as any;
return () => { socket.off('draft_update', handleDraftUpdate); }; if (me?.ready) {
}, []); 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">Deck Submitted</h2>
<div className="animate-pulse bg-slate-700 w-16 h-16 rounded-full flex items-center justify-center mb-6">
<Check className="w-8 h-8 text-emerald-500" />
</div>
<p className="text-slate-400 text-lg">Waiting for other players to finish deck building...</p>
<div className="mt-8">
<h3 className="text-sm font-bold text-slate-500 uppercase mb-4 text-center">Players Ready</h3>
<div className="flex flex-wrap justify-center gap-4">
{room.players.filter(p => p.role === 'player').map(p => {
const isReady = (p as any).ready;
return (
<div key={p.id} className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${isReady ? 'bg-emerald-900/30 border-emerald-500/50' : 'bg-slate-700/30 border-slate-700'}`}>
<div className={`w-2 h-2 rounded-full ${isReady ? 'bg-emerald-500' : 'bg-slate-600'}`}></div>
<span className={isReady ? 'text-emerald-200' : 'text-slate-500'}>{p.name}</span>
</div>
);
})}
</div>
</div>
</div>
);
}
if (room.status === 'drafting' && draftState) { const myPool = draftState.players[currentPlayerId]?.pool || [];
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} />; return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
} }
if (room.status === 'deck_building' && draftState) { // Default Waiting Lobby
// Get my pool return (
const myPool = draftState.players[currentPlayerId]?.pool || [];
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
}
return (
<div className="flex h-[calc(100vh-100px)] gap-4">
{/* Main Game Area (Placeholder for now) */}
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center"> <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> <h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
<div className="flex items-center gap-4 bg-slate-900 px-6 py-3 rounded-xl border border-slate-700"> <div className="flex items-center gap-4 bg-slate-900 px-6 py-3 rounded-xl border border-slate-700">
@@ -201,6 +232,12 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</div> </div>
)} )}
</div> </div>
);
};
return (
<div className="flex h-[calc(100vh-100px)] gap-4">
{renderContent()}
{/* Sidebar: Players & Chat */} {/* Sidebar: Players & Chat */}
<div className="w-80 flex flex-col gap-4"> <div className="w-80 flex flex-col gap-4">
@@ -210,23 +247,28 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
<Users className="w-4 h-4" /> Lobby <Users className="w-4 h-4" /> Lobby
</h3> </h3>
<div className="flex-1 overflow-y-auto space-y-2 pr-1"> <div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => ( {room.players.map(p => {
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50"> // Cast to any to access ready state without full interface update for now
<div className="flex items-center gap-2"> const isReady = (p as any).ready;
<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'}`}> return (
{p.name.substring(0, 2).toUpperCase()} <div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50">
</div> <div className="flex items-center gap-2">
<div className="flex flex-col"> <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'}`}>
<span className={`text-sm font-medium ${p.id === currentPlayerId ? 'text-white' : 'text-slate-300'}`}> {p.name.substring(0, 2).toUpperCase()}
{p.name} </div>
</span> <div className="flex flex-col">
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500"> <span className={`text-sm font-medium ${p.id === currentPlayerId ? 'text-white' : 'text-slate-300'}`}>
{p.role} {p.isHost && <span className="text-amber-500 ml-1"> Host</span>} {p.name}
</span> </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>}
</span>
</div>
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
</div> </div>

View File

@@ -104,16 +104,6 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('pick_card', ({ roomId, cardId }) => {
// Find player from socket? Actually we trust clientId sent or inferred (but simpler to trust socket for now if we tracked map, but here just use helper?)
// We didn't store socket->player map here globally. We'll pass playerId in payload for simplicity but validation later.
// Wait, let's look at signature.. pickCard(roomId, playerId, cardId)
// Need playerId. Let's ask client to send it.
// Or we can find it if we know connection...
// Let's assume payload: { roomId, playerId, cardId }
});
// Revised pick_card to actual impl // Revised pick_card to actual impl
socket.on('pick_card', ({ roomId, playerId, cardId }) => { socket.on('pick_card', ({ roomId, playerId, cardId }) => {
const draft = draftManager.pickCard(roomId, playerId, cardId); const draft = draftManager.pickCard(roomId, playerId, cardId);
@@ -131,6 +121,45 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('player_ready', ({ roomId, playerId, deck }) => {
const room = roomManager.setPlayerReady(roomId, playerId, deck);
if (room) {
io.to(roomId).emit('room_update', room);
// Check if all active players are ready
const activePlayers = room.players.filter(p => p.role === 'player');
if (activePlayers.length > 0 && activePlayers.every(p => p.ready)) {
console.log(`All players ready in room ${roomId}. Starting game...`);
room.status = 'playing';
io.to(roomId).emit('room_update', room);
// Initialize Game
const game = gameManager.createGame(roomId, room.players);
// Load decks
activePlayers.forEach(p => {
if (p.deck) {
p.deck.forEach((card: any) => {
gameManager.addCardToGame(roomId, {
ownerId: p.id,
controllerId: p.id,
oracleId: card.oracle_id || card.id,
name: card.name,
// Prioritize 'image' property which might hold the cached URL
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library'
});
});
// TODO: Shuffle library
}
});
io.to(roomId).emit('game_update', game);
}
}
});
socket.on('start_game', ({ roomId, decks }) => { socket.on('start_game', ({ roomId, decks }) => {
const room = roomManager.startGame(roomId); const room = roomManager.startGame(roomId);
if (room) { if (room) {

View File

@@ -3,6 +3,8 @@ interface Player {
name: string; name: string;
isHost: boolean; isHost: boolean;
role: 'player' | 'spectator'; role: 'player' | 'spectator';
ready?: boolean;
deck?: any[];
} }
interface ChatMessage { interface ChatMessage {
@@ -17,7 +19,7 @@ interface Room {
hostId: string; hostId: string;
players: Player[]; players: Player[];
packs: any[]; // Store generated packs (JSON) packs: any[]; // Store generated packs (JSON)
status: 'waiting' | 'drafting' | 'deck_building' | 'finished'; status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
messages: ChatMessage[]; messages: ChatMessage[];
maxPlayers: number; maxPlayers: number;
} }
@@ -30,7 +32,7 @@ export class RoomManager {
const room: Room = { const room: Room = {
id: roomId, id: roomId,
hostId, hostId,
players: [{ id: hostId, name: hostName, isHost: true, role: 'player' }], players: [{ id: hostId, name: hostName, isHost: true, role: 'player', ready: false }],
packs, packs,
status: 'waiting', status: 'waiting',
messages: [], messages: [],
@@ -40,6 +42,18 @@ export class RoomManager {
return room; return room;
} }
setPlayerReady(roomId: string, playerId: string, deck: any[]): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
const player = room.players.find(p => p.id === playerId);
if (player) {
player.ready = true;
player.deck = deck;
}
return room;
}
joinRoom(roomId: string, playerId: string, playerName: string): Room | null { joinRoom(roomId: string, playerId: string, playerName: string): Room | null {
const room = this.rooms.get(roomId); const room = this.rooms.get(roomId);
if (!room) return null; if (!room) return null;