final POC

This commit is contained in:
magdo
2025-11-24 23:28:57 +01:00
parent ce02f55a99
commit 6b3446e9b6
49 changed files with 4634 additions and 4620 deletions
@@ -16,42 +16,48 @@ 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);
const [boardData, setBoardData] = 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
// Memoized derived values - extract from single game object
const players = useMemo(() => {
const connectedPlayers = gameState?.connectedPlayers || [];
const currentPlayers = gameState?.currentPlayers || [];
if (currentPlayers.length > 0) {
return currentPlayers;
}
if (connectedPlayers.length > 0) {
return connectedPlayers.map((nameOrObj, index) => {
const playerName = typeof nameOrObj === 'string'
? nameOrObj
: (nameOrObj.playerName || nameOrObj.name || `Player ${index + 1}`);
return {
id: `player-${index}`,
name: playerName,
isOnline: true,
isReady: gameState?.readyPlayers?.includes(playerName) || false,
};
});
}
return [];
}, [gameState?.connectedPlayers, gameState?.currentPlayers, gameState?.readyPlayers]);
return gameState?.players || [];
}, [gameState?.players]);
const currentTurn = useMemo(() => gameState?.currentPlayer || null, [gameState?.currentPlayer]);
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
@@ -79,6 +85,16 @@ export const GameWebSocketProvider = ({ children }) => {
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'],
@@ -102,11 +118,22 @@ export const GameWebSocketProvider = ({ children }) => {
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
@@ -115,7 +142,72 @@ export const GameWebSocketProvider = ({ children }) => {
if (state?.isGamemaster !== undefined) {
setIsGamemaster(state.isGamemaster);
}
setGameState(state);
// 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) => {
@@ -123,7 +215,19 @@ export const GameWebSocketProvider = ({ children }) => {
if (state?.isGamemaster !== undefined) {
setIsGamemaster(state.isGamemaster);
}
setGameState(state);
// 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) => {
@@ -131,6 +235,26 @@ export const GameWebSocketProvider = ({ children }) => {
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) => {
@@ -138,60 +262,128 @@ export const GameWebSocketProvider = ({ children }) => {
setGameState(prev => {
if (!prev) return prev;
const currentConnected = prev.connectedPlayers || [];
if (!currentConnected.includes(data.playerName)) {
return {
...prev,
connectedPlayers: [...currentConnected, data.playerName]
};
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);
// Store board data if provided
if (data.boardData) {
setBoardData(data.boardData);
log('✅ Board data stored from game:start event');
}
// Update game state with turn info
if (data.playerOrder) {
setGameState(prev => ({
...prev,
playerOrder: data.playerOrder,
currentPlayer: data.currentPlayer,
turnSequence: data.playerOrder
}));
}
});
socket.on('game:started', (data) => {
log('🎮 Game started (legacy event):', data);
setGameStarted(true);
});
socket.on('game:player-moved', (moveData) => {
log('🏃 Player moved:', moveData.playerName);
// Update game state with position initialization
setGameState(prev => {
if (!prev?.currentPlayers) return prev;
return {
// 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,
currentPlayers: prev.currentPlayers.map(p =>
p.playerId === moveData.playerId
? { ...p, boardPosition: moveData.newPosition }
: p
),
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);
setGameState(prev => prev ? { ...prev, currentPlayer: data.currentPlayer } : prev);
// 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) => {
@@ -246,6 +438,199 @@ export const GameWebSocketProvider = ({ children }) => {
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);
});
}, []);
/**
@@ -259,8 +644,7 @@ export const GameWebSocketProvider = ({ children }) => {
socketRef.current = null;
gameTokenRef.current = null;
setIsConnected(false);
setGameState(null);
setBoardData(null);
setGameState(null); // Clear entire game object
setError(null);
setIsGamemaster(false);
setGameStarted(false);
@@ -316,9 +700,79 @@ export const GameWebSocketProvider = ({ children }) => {
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);
if (socket) {
socket.on(event, handler);
}
}, []);
const removeEventListener = useCallback((event, handler) => {
@@ -329,15 +783,20 @@ export const GameWebSocketProvider = ({ children }) => {
const value = {
socket: socketRef.current,
isConnected,
gameState,
players,
boardData,
currentTurn,
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,
@@ -348,6 +807,11 @@ export const GameWebSocketProvider = ({ children }) => {
leaveGame,
approvePlayer,
rejectPlayer,
submitAnswer,
submitPositionGuess,
submitJokerPositionGuess,
approveJoker,
rejectJoker,
addEventListener,
removeEventListener,
};