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] 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 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
|
||||
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".
|
||||
|
||||
// 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 (
|
||||
|
||||
@@ -76,6 +76,18 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
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 });
|
||||
};
|
||||
|
||||
// Helper to determine view
|
||||
const renderContent = () => {
|
||||
if (gameState) {
|
||||
return <GameView gameState={gameState} currentPlayerId={currentPlayerId} />;
|
||||
}
|
||||
|
||||
// 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); };
|
||||
}, []);
|
||||
|
||||
if (room.status === 'drafting' && draftState) {
|
||||
return <DraftView draftState={draftState} roomId={room.id} currentPlayerId={currentPlayerId} />;
|
||||
}
|
||||
|
||||
if (room.status === 'deck_building' && draftState) {
|
||||
// Get my pool
|
||||
// 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const myPool = draftState.players[currentPlayerId]?.pool || [];
|
||||
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
|
||||
}
|
||||
|
||||
// Default Waiting Lobby
|
||||
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">
|
||||
<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">
|
||||
@@ -201,6 +232,12 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-100px)] gap-4">
|
||||
{renderContent()}
|
||||
|
||||
{/* Sidebar: Players & Chat */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
@@ -210,7 +247,10 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
<Users className="w-4 h-4" /> Lobby
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
{room.players.map(p => (
|
||||
{room.players.map(p => {
|
||||
// Cast to any to access ready state without full interface update for now
|
||||
const isReady = (p as any).ready;
|
||||
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 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'}`}>
|
||||
@@ -222,11 +262,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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
|
||||
socket.on('pick_card', ({ 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 }) => {
|
||||
const room = roomManager.startGame(roomId);
|
||||
if (room) {
|
||||
|
||||
@@ -3,6 +3,8 @@ interface Player {
|
||||
name: string;
|
||||
isHost: boolean;
|
||||
role: 'player' | 'spectator';
|
||||
ready?: boolean;
|
||||
deck?: any[];
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -17,7 +19,7 @@ interface Room {
|
||||
hostId: string;
|
||||
players: Player[];
|
||||
packs: any[]; // Store generated packs (JSON)
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'finished';
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
|
||||
messages: ChatMessage[];
|
||||
maxPlayers: number;
|
||||
}
|
||||
@@ -30,7 +32,7 @@ export class RoomManager {
|
||||
const room: Room = {
|
||||
id: roomId,
|
||||
hostId,
|
||||
players: [{ id: hostId, name: hostName, isHost: true, role: 'player' }],
|
||||
players: [{ id: hostId, name: hostName, isHost: true, role: 'player', ready: false }],
|
||||
packs,
|
||||
status: 'waiting',
|
||||
messages: [],
|
||||
@@ -40,6 +42,18 @@ export class RoomManager {
|
||||
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 {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
Reference in New Issue
Block a user