fix: Resolve React hooks violation, implement player waiting screen, and auto-start game upon deck submission.
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
28
docs/development/devlog/2025-12-14-233000_fix_submit_deck.md
Normal file
28
docs/development/devlog/2025-12-14-233000_fix_submit_deck.md
Normal 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).
|
||||||
@@ -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).
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user