Files
SerpentRace/SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx
T
2025-11-24 23:28:57 +01:00

836 lines
30 KiB
React

import React, { createContext, useContext, useRef, useState, useCallback, useMemo, useEffect } from 'react';
import { io } from 'socket.io-client';
import { API_CONFIG } from '../api/userApi';
const GameWebSocketContext = createContext(null);
const isDev = import.meta.env.DEV;
const log = (...args) => isDev && console.log(...args);
const warn = (...args) => isDev && console.warn(...args);
const logError = (...args) => console.error(...args);
/**
* Provider that maintains WebSocket connection across page navigation
*/
export const GameWebSocketProvider = ({ children }) => {
const socketRef = useRef(null);
const gameTokenRef = useRef(null);
const [isConnected, setIsConnected] = useState(false);
// Single game object containing all game data
const [gameState, setGameState] = useState(null);
// Structure: {
// gameCode: string,
// boardData: object,
// turnInfo: { currentPlayer, currentPlayerName, turnNumber },
// players: [{ playerId, playerName, isOnline, isReady }],
// playerPositions: { playerName: position },
// connectedPlayers: [],
// readyPlayers: [],
// state: string,
// isGamemaster: boolean
// }
const [error, setError] = useState(null);
const [isGamemaster, setIsGamemaster] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
const [pendingPlayers, setPendingPlayers] = useState([]);
const [approvalStatus, setApprovalStatus] = useState(null);
const [playerIdentifier, setPlayerIdentifier] = useState(null);
const [isMyTurnFlag, setIsMyTurnFlag] = useState(false); // Directly controlled by game:your-turn
const [playerDiceRolls, setPlayerDiceRolls] = useState({});
// Memoized derived values - extract from single game object
const players = useMemo(() => {
return gameState?.players || [];
}, [gameState?.players]);
const playerPositions = useMemo(() => {
return gameState?.playerPositions || {};
}, [gameState?.playerPositions]);
const boardData = useMemo(() => {
return gameState?.boardData || null;
}, [gameState?.boardData]);
const currentTurn = useMemo(() => gameState?.turnInfo?.currentPlayer || null, [gameState?.turnInfo?.currentPlayer]);
const currentTurnName = useMemo(() => gameState?.turnInfo?.currentPlayerName || null, [gameState?.turnInfo?.currentPlayerName]);
// isMyTurn is simply the flag set by game:your-turn event
const isMyTurn = isMyTurnFlag;
/**
* Connect to game WebSocket with a game token
* This maintains the connection even when navigating between pages
*/
const connect = useCallback((gameToken) => {
if (!gameToken) {
warn('⚠️ Cannot connect without game token');
return;
}
// If already connected with same token, don't reconnect
if (socketRef.current?.connected && gameTokenRef.current === gameToken) {
log('✅ Already connected with same token');
return;
}
// Disconnect old socket if exists
if (socketRef.current) {
log('🔌 Disconnecting old socket');
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
}
log('🔌 Connecting to game WebSocket...');
gameTokenRef.current = gameToken;
// Decode token to get player identifier
try {
const payload = JSON.parse(atob(gameToken.split('.')[1]));
const identifier = payload.userId || payload.playerName;
setPlayerIdentifier(identifier);
log('🎮 Player identifier:', identifier);
} catch (err) {
logError('Failed to decode game token:', err);
}
// Connect to /game namespace
socketRef.current = io(`${API_CONFIG.wsURL}/game`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 5000,
});
const socket = socketRef.current;
// Connection handlers
socket.on('connect', () => {
log('✅ Connected to game WebSocket');
setIsConnected(true);
setError(null);
socket.emit('game:join', { gameToken });
});
socket.on('connect_error', (err) => {
logError('❌ Connection error:', err);
setIsConnected(false);
setError(err.message);
// If reconnection fails completely, navigate to home
if (err.message?.includes('timeout') || err.message?.includes('xhr poll error')) {
setTimeout(() => {
if (!socketRef.current?.connected) {
logError('⚠️ Connection failed - redirecting to home');
window.location.href = '/';
}
}, 10000); // Give 10 seconds for reconnection attempts
}
});
socket.on('disconnect', (reason) => {
log('🔌 Disconnected:', reason);
setIsConnected(false);
setIsMyTurnFlag(false); // Clear turn flag on disconnect
});
// Game state handlers
socket.on('game:state', (state) => {
log('📊 Game state received:', state);
if (state?.isGamemaster !== undefined) {
setIsGamemaster(state.isGamemaster);
}
// Merge state into single game object
setGameState(prev => {
// Build players array from various sources
let playersArray = prev?.players || [];
// If we have currentPlayers or players from backend, use them (already have proper structure)
if (state.currentPlayers && Array.isArray(state.currentPlayers) && state.currentPlayers.length > 0) {
playersArray = state.currentPlayers.map(p => ({
playerId: p.playerId || p.id,
playerName: p.playerName || p.name,
name: p.playerName || p.name, // Add name for compatibility
isOnline: p.isOnline !== undefined ? p.isOnline : true,
isReady: p.isReady || false
}));
} else if (state.players && Array.isArray(state.players) && state.players.length > 0) {
playersArray = state.players.map(p => ({
playerId: p.playerId || p.id,
playerName: p.playerName || p.name,
name: p.playerName || p.name, // Add name for compatibility
isOnline: p.isOnline !== undefined ? p.isOnline : true,
isReady: p.isReady || false
}));
}
// If players array is still empty but we have connectedPlayers (array of strings), convert them
else if (playersArray.length === 0 && state.connectedPlayers && Array.isArray(state.connectedPlayers) && state.connectedPlayers.length > 0) {
playersArray = state.connectedPlayers.map((playerName, index) => ({
playerId: `player-${index}`, // Temporary ID until we get the real one
playerName: playerName,
name: playerName, // Add name for compatibility
isOnline: true,
isReady: false
}));
}
// Initialize playerPositions from backend data if joining game in progress
let playerPositions = prev?.playerPositions || {};
// If we don't have positions yet but backend sent players with positions, initialize from them
if (Object.keys(playerPositions).length === 0 && state.players && Array.isArray(state.players)) {
const positionsFromBackend = {};
state.players.forEach(p => {
const playerName = p.playerName || p.name;
if (playerName && p.position !== undefined) {
positionsFromBackend[playerName] = p.position;
}
});
if (Object.keys(positionsFromBackend).length > 0) {
playerPositions = positionsFromBackend;
}
}
const merged = {
...prev,
gameCode: state.gameCode || prev?.gameCode,
boardData: state.boardData || prev?.boardData,
state: state.state || prev?.state,
connectedPlayers: state.connectedPlayers || prev?.connectedPlayers || [],
readyPlayers: state.readyPlayers || prev?.readyPlayers || [],
// Preserve turn info - only turn-changed updates it
turnInfo: prev?.turnInfo || { currentPlayer: null, currentPlayerName: null, turnNumber: 0 },
// Use the built players array
players: playersArray,
// Initialize playerPositions from backend if joining in progress, otherwise preserve
playerPositions: playerPositions
};
return merged;
});
});
socket.on('game:state-update', (state) => {
log('📊 Game state update:', state);
if (state?.isGamemaster !== undefined) {
setIsGamemaster(state.isGamemaster);
}
// Merge state update into single game object
setGameState(prev => ({
...prev,
gameCode: state.gameCode || prev?.gameCode,
boardData: state.boardData || prev?.boardData,
state: state.state || prev?.state,
connectedPlayers: state.connectedPlayers || prev?.connectedPlayers,
readyPlayers: state.readyPlayers || prev?.readyPlayers,
// Preserve turn info and positions
turnInfo: prev?.turnInfo,
players: prev?.players,
playerPositions: prev?.playerPositions
}));
});
socket.on('game:joined', (data) => {
log('✅ Joined game:', data);
if (data.isGamemaster !== undefined) {
setIsGamemaster(data.isGamemaster);
}
// Initialize or update gameState with gameCode and gameId from join confirmation
setGameState(prev => {
if (prev && prev.gameCode) {
// If already has gameCode, just add gameId if missing
return {
...prev,
gameId: data.gameId || prev.gameId,
gameCode: data.gameCode,
playerName: data.playerName,
isAuthenticated: data.isAuthenticated
};
}
return {
...prev,
gameId: data.gameId, // Store gameId from backend
gameCode: data.gameCode,
playerName: data.playerName,
isAuthenticated: data.isAuthenticated
};
});
});
socket.on('game:player-joined', (data) => {
log('👤 Player joined:', data.playerName);
setGameState(prev => {
if (!prev) return prev;
const currentConnected = prev.connectedPlayers || [];
const currentPlayers = prev.players || [];
// Add to connectedPlayers if not already there
const updatedConnected = currentConnected.includes(data.playerName)
? currentConnected
: [...currentConnected, data.playerName];
// Add to players array if not already there (without position - that's in playerPositions)
const playerExists = currentPlayers.some(p =>
p.playerName === data.playerName || p.name === data.playerName
);
const updatedPlayers = playerExists
? currentPlayers
: [...currentPlayers, {
playerId: data.playerId,
playerName: data.playerName,
name: data.playerName, // Add name for compatibility with Lobby display
isOnline: true,
isReady: false
}];
return {
...prev,
connectedPlayers: updatedConnected,
players: updatedPlayers
};
});
window.dispatchEvent(new CustomEvent('game:player-joined', { detail: data }));
});
socket.on('game:player-left', (data) => {
log('👋 Player left:', data.playerName);
setGameState(prev => {
if (!prev) return prev;
return {
...prev,
connectedPlayers: (prev.connectedPlayers || []).filter(p => p !== data.playerName)
};
});
window.dispatchEvent(new CustomEvent('game:player-left', { detail: data }));
});
socket.on('game:player-ready', (data) => {
log('✅ Player ready:', data.playerName);
setGameState(prev => {
if (!prev) return prev;
const readyPlayers = prev.readyPlayers || [];
if (data.ready && !readyPlayers.includes(data.playerName)) {
return { ...prev, readyPlayers: [...readyPlayers, data.playerName] };
} else if (!data.ready) {
return { ...prev, readyPlayers: readyPlayers.filter(p => p !== data.playerName) };
}
return prev;
});
window.dispatchEvent(new CustomEvent('game:player-ready', { detail: data }));
});
socket.on('game:all-ready', (data) => {
log('✅ All players ready! Game can start.');
window.dispatchEvent(new CustomEvent('game:all-ready', { detail: data }));
});
socket.on('game:start', (data) => {
log('🎮 Game started:', data);
setGameStarted(true);
// Update game state with position initialization
setGameState(prev => {
// Initialize all player positions to 0 at game start
const initialPositions = {};
// Use existing players from prev (already set by game:joined/game:state)
const existingPlayers = prev?.players || [];
// Initialize positions for all existing players
existingPlayers.forEach((player) => {
const playerName = player.playerName || player.name;
if (playerName) {
// Initialize ALL positions to 0 - only game:player-arrived will change them
initialPositions[playerName] = 0;
}
});
const updated = {
...prev,
gameCode: data.gameCode || prev?.gameCode,
boardData: data.boardData || prev?.boardData,
state: data.gamePhase || data.state || 'playing',
boardSize: data.boardSize || prev?.boardSize,
// Keep existing players list, initialize positions to 0
players: existingPlayers,
playerPositions: initialPositions,
// Preserve turn info and other data
turnInfo: prev?.turnInfo || { currentPlayer: null, currentPlayerName: null, turnNumber: 0 },
connectedPlayers: prev?.connectedPlayers || [],
readyPlayers: prev?.readyPlayers || []
};
return updated;
});
});
socket.on('game:turn-changed', (data) => {
log('🔄 Turn changed to:', data.currentPlayerName);
// Turn changed means it's NOT my turn anymore
setIsMyTurnFlag(false);
// This is the ONLY place turnInfo should be set
setGameState(prev => ({
...prev,
turnInfo: {
currentPlayer: data.currentPlayer,
currentPlayerName: data.currentPlayerName,
turnNumber: data.turnNumber || prev?.turnInfo?.turnNumber || 0
}
}));
// Force a re-render by logging after state update
setTimeout(() => {
console.log('🔄 [game:turn-changed] State should be updated now');
}, 100);
});
socket.on('game:error', (err) => {
logError('❌ Game error:', err);
setError(err.message);
});
// Approval system handlers
socket.on('game:pending-approval', (data) => {
log('⏳ Pending gamemaster approval:', data);
setApprovalStatus('pending');
setError('Waiting for gamemaster approval...');
});
socket.on('game:player-requesting-join', (data) => {
log('🔔 Player requesting to join:', data.playerName);
setPendingPlayers(prev => {
if (prev.some(p => p.playerName === data.playerName)) return prev;
return [...prev, {
playerName: data.playerName,
isAuthenticated: data.isAuthenticated,
timestamp: data.timestamp
}];
});
});
socket.on('game:approval-granted', (data) => {
log('✅ Join request approved:', data);
setApprovalStatus('approved');
setError(null);
socket.emit('game:join-approved', { gameToken });
});
socket.on('game:approval-denied', (data) => {
logError('❌ Join request denied:', data.reason || data.message);
setApprovalStatus('denied');
setError(data.reason || 'Your request to join was denied');
});
socket.on('game:player-approved', (data) => {
log('✅ Player approved by gamemaster:', data.playerName);
setPendingPlayers(prev => prev.filter(p => p.playerName !== data.playerName));
setGameState(prev => {
if (!prev) return prev;
const currentConnected = prev.connectedPlayers || [];
if (!currentConnected.includes(data.playerName)) {
return {
...prev,
connectedPlayers: [...currentConnected, data.playerName]
};
}
return prev;
});
});
socket.on('game:your-turn', (data) => {
log('🎯 Your turn!', data);
console.log('🎯 [game:your-turn] Received - enabling dice');
// Set flag to true - dice is now enabled
setIsMyTurnFlag(true);
// Emit custom event for GameScreen to display turn notification
window.dispatchEvent(new CustomEvent('game:your-turn', { detail: data }));
});
socket.on('game:dice-rolled', (data) => {
log('🎲 Dice rolled:', data.diceValue, 'by', data.playerName);
// Track the dice roll for this player
setPlayerDiceRolls(prev => ({
...prev,
[data.playerName]: data.diceValue
}));
window.dispatchEvent(new CustomEvent('game:dice-rolled', { detail: data }));
});
socket.on('game:player-moving', (data) => {
log('🚶 Player moving:', data.playerName, 'from', data.fromPosition, 'to', data.toPosition);
window.dispatchEvent(new CustomEvent('game:player-moving', { detail: data }));
});
socket.on('game:player-arrived', (data) => {
log('🎯 Player arrived:', data.playerName, 'at position', data.position, '(' + data.fieldType + ')');
// Update playerPositions in game object - THIS IS THE ONLY PLACE POSITIONS ARE MODIFIED
setGameState(prev => {
if (!prev) return prev;
const updatedPositions = { ...prev.playerPositions, [data.playerName]: data.position };
return {
...prev,
playerPositions: updatedPositions
};
});
window.dispatchEvent(new CustomEvent('game:player-arrived', { detail: data }));
});
socket.on('game:card-drawn', (data) => {
log('🃏 Card drawn:', data.cardType, 'by', data.playerName);
window.dispatchEvent(new CustomEvent('game:card-drawn', { detail: data }));
});
socket.on('game:card-result', (data) => {
log('🎴 Card result:', data.playerName, data.description);
window.dispatchEvent(new CustomEvent('game:card-result', { detail: data }));
});
socket.on('game:answer-timeout', (data) => {
log('⏰ Answer timeout:', data.playerName);
window.dispatchEvent(new CustomEvent('game:answer-timeout', { detail: data }));
});
socket.on('game:answer-result', (data) => {
log('📝 Answer result:', data.correct ? '✅ Correct' : '❌ Wrong', '-', data.playerName);
window.dispatchEvent(new CustomEvent('game:answer-result', { detail: data }));
});
socket.on('game:gamemaster-decision-request', (data) => {
log('👨‍⚖️ Gamemaster decision request:', data.playerName);
window.dispatchEvent(new CustomEvent('game:gamemaster-decision-request', { detail: data }));
});
socket.on('game:gamemaster-decision-result', (data) => {
log('⚖️ Gamemaster decision result:', data.decision, '-', data.playerName);
window.dispatchEvent(new CustomEvent('game:gamemaster-decision-result', { detail: data }));
});
socket.on('game:card-drawn-self', (data) => {
log('🃏 You drew a card:', data.cardType);
window.dispatchEvent(new CustomEvent('game:card-drawn-self', { detail: data }));
});
socket.on('game:joker-activated', (data) => {
log('🃏 Joker activated:', data.playerName);
window.dispatchEvent(new CustomEvent('game:joker-activated', { detail: data }));
});
socket.on('game:extra-turn', (data) => {
log('⭐ Extra turn:', data.playerName);
window.dispatchEvent(new CustomEvent('game:extra-turn', { detail: data }));
});
socket.on('game:turn-lost', (data) => {
log('😞 Turn lost:', data.playerName);
window.dispatchEvent(new CustomEvent('game:turn-lost', { detail: data }));
});
socket.on('game:no-movement', (data) => {
log('⛔ No movement:', data.playerName, '-', data.reason);
window.dispatchEvent(new CustomEvent('game:no-movement', { detail: data }));
});
socket.on('game:penalty-avoided', (data) => {
log('🛡️ Penalty avoided:', data.playerName);
window.dispatchEvent(new CustomEvent('game:penalty-avoided', { detail: data }));
});
socket.on('game:guess-timeout', (data) => {
log('⏰ Guess timeout:', data.playerName);
window.dispatchEvent(new CustomEvent('game:guess-timeout', { detail: data }));
});
socket.on('game:player-guessing', (data) => {
log('🤔 Player guessing:', data.playerName);
window.dispatchEvent(new CustomEvent('game:player-guessing', { detail: data }));
});
socket.on('game:secondary-landing', (data) => {
log('🎯 Secondary landing:', data.playerName, 'on', data.fieldType);
window.dispatchEvent(new CustomEvent('game:secondary-landing', { detail: data }));
});
socket.on('game:player-disconnected', (data) => {
log('⚠️ Player disconnected:', data.playerName);
setGameState(prev => {
if (!prev) return prev;
return {
...prev,
connectedPlayers: (prev.connectedPlayers || []).filter(p => p !== data.playerName)
};
});
window.dispatchEvent(new CustomEvent('game:player-disconnected', { detail: data }));
});
socket.on('game:player-disconnected-during-turn', (data) => {
log('⚠️ Player disconnected during turn:', data.playerName);
window.dispatchEvent(new CustomEvent('game:player-disconnected-during-turn', { detail: data }));
});
socket.on('game:player-disconnected-during-card', (data) => {
log('⚠️ Player disconnected during card:', data.playerName);
window.dispatchEvent(new CustomEvent('game:player-disconnected-during-card', { detail: data }));
});
socket.on('game:guess-result', (data) => {
log('🎯 Guess result:', data);
// Position update will come from game:player-arrived event
window.dispatchEvent(new CustomEvent('game:guess-result', { detail: data }));
});
socket.on('game:joker-complete', (data) => {
log('🃏 Joker complete:', data);
// Position update will come from game:player-arrived event
window.dispatchEvent(new CustomEvent('game:joker-complete', { detail: data }));
});
socket.on('game:luck-consequence', (data) => {
log('🍀 Luck consequence:', data);
// Position update will come from game:player-arrived event
window.dispatchEvent(new CustomEvent('game:luck-consequence', { detail: data }));
});
socket.on('game:ended', (data) => {
log('🏁 Game ended! Winner:', data.winner);
setGameState(prev => ({
...prev,
status: 'finished',
winner: data.winner,
finalScores: data.scores
}));
window.dispatchEvent(new CustomEvent('game:ended', { detail: data }));
// Don't auto-navigate - let the player close the winner modal manually
// They can use the "Vissza a főoldalra" button when ready
});
socket.on('game:extra-turn-remaining', (data) => {
log('⭐ Extra turn remaining:', data);
window.dispatchEvent(new CustomEvent('game:extra-turn-remaining', { detail: data }));
});
socket.on('game:players-skipped', (data) => {
log('⏭️ Players skipped:', data.skippedPlayers);
window.dispatchEvent(new CustomEvent('game:players-skipped', { detail: data }));
});
socket.on('game:cleanup-complete', (data) => {
log('🧹 Cleanup complete:', data);
window.dispatchEvent(new CustomEvent('game:cleanup-complete', { detail: data }));
// Navigate to home after cleanup
setTimeout(() => {
log('🏠 Navigating to home after cleanup');
window.location.href = '/';
}, 2000);
});
}, []);
/**
* Disconnect from WebSocket
*/
const disconnect = useCallback(() => {
if (socketRef.current) {
log('🔌 Disconnecting from game WebSocket');
socketRef.current.removeAllListeners();
socketRef.current.disconnect();
socketRef.current = null;
gameTokenRef.current = null;
setIsConnected(false);
setGameState(null); // Clear entire game object
setError(null);
setIsGamemaster(false);
setGameStarted(false);
setPendingPlayers([]);
setApprovalStatus(null);
}
}, []);
// Action methods
const rollDice = useCallback((diceValue) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot roll dice: not connected');
return false;
}
log('🎲 Rolling dice:', diceValue);
socket.emit('game:dice-roll', { gameCode: gameState?.gameCode, diceValue });
return true;
}, [isConnected, gameState?.gameCode]);
const sendMessage = useCallback((message) => {
const socket = socketRef.current;
if (!socket || !isConnected) return false;
socket.emit('game:chat', { gameCode: gameState?.gameCode, message });
return true;
}, [isConnected, gameState?.gameCode]);
const setReady = useCallback((ready = true) => {
const socket = socketRef.current;
if (!socket || !isConnected) return false;
socket.emit('game:ready', { gameCode: gameState?.gameCode, ready });
return true;
}, [isConnected, gameState?.gameCode]);
const leaveGame = useCallback(() => {
const socket = socketRef.current;
if (!socket || !isConnected) return false;
socket.emit('game:leave', { gameCode: gameState?.gameCode });
return true;
}, [isConnected, gameState?.gameCode]);
const approvePlayer = useCallback((playerName) => {
const socket = socketRef.current;
if (!socket || !isConnected || !isGamemaster) return false;
socket.emit('game:approve-player', { gameCode: gameState?.gameCode, playerName });
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
const rejectPlayer = useCallback((playerName, reason = 'Join request denied') => {
const socket = socketRef.current;
if (!socket || !isConnected || !isGamemaster) return false;
socket.emit('game:reject-player', { gameCode: gameState?.gameCode, playerName, reason });
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
const submitAnswer = useCallback((answer, cardId = null) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot submit answer: not connected');
return false;
}
log('📝 Submitting answer:', answer, 'for card:', cardId);
socket.emit('game:card-answer', {
gameCode: gameState?.gameCode,
answer,
cardId
});
return true;
}, [isConnected, gameState?.gameCode]);
const submitPositionGuess = useCallback((guessedPosition) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot submit position guess: not connected');
return false;
}
log('🎯 Submitting position guess:', guessedPosition);
socket.emit('game:position-guess', { gameCode: gameState?.gameCode, guessedPosition });
return true;
}, [isConnected, gameState?.gameCode]);
const submitJokerPositionGuess = useCallback((guessedPosition) => {
const socket = socketRef.current;
if (!socket || !isConnected) {
warn('⚠️ Cannot submit joker position guess: not connected');
return false;
}
log('🃏🎯 Submitting joker position guess:', guessedPosition);
socket.emit('game:joker-position-guess', { gameCode: gameState?.gameCode, guessedPosition });
return true;
}, [isConnected, gameState?.gameCode]);
const approveJoker = useCallback((requestId) => {
const socket = socketRef.current;
if (!socket || !isConnected || !isGamemaster) {
warn('⚠️ Cannot approve joker: not gamemaster or not connected');
return false;
}
log('✅ Approving joker request:', requestId);
socket.emit('game:gamemaster-decision', {
gameCode: gameState?.gameCode,
requestId,
decision: 'approve'
});
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
const rejectJoker = useCallback((requestId, reason = 'Joker answer rejected') => {
const socket = socketRef.current;
if (!socket || !isConnected || !isGamemaster) {
warn('⚠️ Cannot reject joker: not gamemaster or not connected');
return false;
}
log('❌ Rejecting joker request:', requestId, 'Reason:', reason);
socket.emit('game:gamemaster-decision', {
gameCode: gameState?.gameCode,
requestId,
decision: 'reject',
reason
});
return true;
}, [isConnected, isGamemaster, gameState?.gameCode]);
const addEventListener = useCallback((event, handler) => {
const socket = socketRef.current;
if (socket) {
socket.on(event, handler);
}
}, []);
const removeEventListener = useCallback((event, handler) => {
const socket = socketRef.current;
if (socket) socket.off(event, handler);
}, []);
const value = {
socket: socketRef.current,
isConnected,
gameState, // Single game object with all data
players, // Memoized from gameState.players
playerPositions, // Memoized from gameState.playerPositions (SINGLE SOURCE OF TRUTH for positions)
boardData, // Memoized from gameState.boardData
currentTurn, // Memoized from gameState.turnInfo.currentPlayer
currentTurnName, // Memoized from gameState.turnInfo.currentPlayerName
isMyTurn,
playerIdentifier,
error,
isGamemaster,
gameStarted,
pendingPlayers,
approvalStatus,
playerDiceRolls,
// Connection management
connect,
disconnect,
// Methods
rollDice,
sendMessage,
setReady,
leaveGame,
approvePlayer,
rejectPlayer,
submitAnswer,
submitPositionGuess,
submitJokerPositionGuess,
approveJoker,
rejectJoker,
addEventListener,
removeEventListener,
};
return (
<GameWebSocketContext.Provider value={value}>
{children}
</GameWebSocketContext.Provider>
);
};
/**
* Hook to access the shared WebSocket connection
*/
export const useGameWebSocketContext = () => {
const context = useContext(GameWebSocketContext);
if (!context) {
throw new Error('useGameWebSocketContext must be used within GameWebSocketProvider');
}
return context;
};