Liedkampf/client/src/context/GameContext.jsx
Mathias Wagner 8f91e27ca1
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 6m59s
Add Battle Result Screen component and associated styles for displaying battle outcomes
2025-05-14 21:53:44 +02:00

774 lines
24 KiB
JavaScript

// Socket.IO client instance for real-time communication
import { io } from 'socket.io-client';
import { createContext, useContext, useEffect, useState } from 'react';
// Create a Socket.IO context for use throughout the app
const SocketContext = createContext(null);
// Game context for sharing game state
const GameContext = createContext(null);
export function SocketProvider({ children }) {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [reconnectionAttempts, setReconnectionAttempts] = useState(0);
const [offlineActions, setOfflineActions] = useState([]);
const [lastActivityTime, setLastActivityTime] = useState(Date.now());
useEffect(() => {
// Create socket connection on component mount with improved reconnection settings
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
reconnectionAttempts: Infinity, // Keep trying to reconnect indefinitely
timeout: 20000, // Longer timeout for initial connection
randomizationFactor: 0.5 // Add some randomization to reconnection attempts to prevent thundering herd
});
// Socket event listeners
socketInstance.on('connect', () => {
setIsConnected(true);
setIsReconnecting(false);
setReconnectionAttempts(0);
console.log('Connected to server');
// Process any actions that were queued while offline
if (offlineActions.length > 0) {
console.log(`Processing ${offlineActions.length} queued actions from offline mode`);
// Wait a moment to ensure connection is stable
setTimeout(() => {
processOfflineActions(socketInstance);
}, 1000);
}
// Try to reconnect to game if we have saved data
const savedGameData = localStorage.getItem('songBattleGame');
if (savedGameData) {
try {
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
if (lobbyId && playerName) {
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
socketInstance.emit('reconnect_to_lobby', {
lobbyId,
playerName,
lastKnownState // Send last known game state for server reconciliation
}, (response) => {
if (response.error) {
console.error('Reconnection failed:', response.error);
// Don't remove data immediately, we might try again
if (response.error === 'Lobby not found') {
localStorage.removeItem('songBattleGame');
}
} else {
console.log('Successfully reconnected to lobby');
// Update the saved game data with latest state
updateSavedGameData(response.lobby);
}
});
}
} catch (e) {
console.error('Error parsing saved game data:', e);
localStorage.removeItem('songBattleGame');
}
}
});
socketInstance.on('disconnect', (reason) => {
setIsConnected(false);
console.log(`Disconnected from server: ${reason}`);
// If the disconnection is expected, don't try to reconnect
if (reason === 'io client disconnect') {
console.log('Disconnection was initiated by the client');
} else {
// Start reconnection process for unexpected disconnections
setIsReconnecting(true);
}
});
socketInstance.on('connect_error', (error) => {
console.error('Connection error:', error);
setIsReconnecting(true);
});
socketInstance.on('reconnect_attempt', (attemptNumber) => {
console.log(`Reconnection attempt #${attemptNumber}`);
setReconnectionAttempts(attemptNumber);
});
socketInstance.on('reconnect', () => {
console.log('Reconnected to server after interruption');
setIsReconnecting(false);
setReconnectionAttempts(0);
});
socketInstance.on('reconnect_error', (error) => {
console.error('Reconnection error:', error);
});
socketInstance.on('reconnect_failed', () => {
console.error('Failed to reconnect to server after maximum attempts');
setIsReconnecting(false);
});
// Setup a heartbeat to detect silent disconnections
const heartbeatInterval = setInterval(() => {
if (socketInstance && isConnected) {
// Check if we've seen activity recently (within 15 seconds)
const timeSinceLastActivity = Date.now() - lastActivityTime;
if (timeSinceLastActivity > 15000) {
console.log('No recent activity, sending ping to verify connection');
socketInstance.emit('ping', () => {
// Update activity time when we get a response
setLastActivityTime(Date.now());
});
// Set a timeout to check if the ping was successful
setTimeout(() => {
const newTimeSinceLastActivity = Date.now() - lastActivityTime;
if (newTimeSinceLastActivity > 15000) {
console.warn('No response to ping, connection may be dead');
// Force a reconnection attempt
socketInstance.disconnect().connect();
}
}, 5000);
}
}
}, 30000); // Check every 30 seconds
setSocket(socketInstance);
// Function to process queued offline actions
const processOfflineActions = (socket) => {
if (!socket || !offlineActions.length) return;
// Process each action in sequence
const processAction = (index) => {
if (index >= offlineActions.length) {
// All actions processed, clear the queue
setOfflineActions([]);
return;
}
const { action, payload, callback } = offlineActions[index];
console.log(`Processing offline action: ${action}`, payload);
// Emit the action to the server
socket.emit(action, payload, (response) => {
if (response.error) {
console.error(`Error processing offline action ${action}:`, response.error);
} else {
console.log(`Successfully processed offline action: ${action}`);
if (callback) callback(response);
}
// Process the next action
processAction(index + 1);
});
};
// Start processing from the first action
processAction(0);
};
// Update activity timestamp on any received message
const originalOnEvent = socketInstance.onEvent;
socketInstance.onEvent = (packet) => {
setLastActivityTime(Date.now());
originalOnEvent.call(socketInstance, packet);
};
// Function to update saved game data with latest state
const updateSavedGameData = (lobby) => {
if (!lobby) return;
const currentData = localStorage.getItem('songBattleGame');
if (currentData) {
try {
const data = JSON.parse(currentData);
data.lastKnownState = {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
};
localStorage.setItem('songBattleGame', JSON.stringify(data));
} catch (e) {
console.error('Error updating saved game data:', e);
}
}
};
// Clean up on unmount
return () => {
clearInterval(heartbeatInterval);
socketInstance.disconnect();
};
}, []);
// Offline mode detection
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Monitor browser's online/offline status
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Add an action to the offline queue
const queueOfflineAction = (action, payload, callback) => {
console.log(`Queuing offline action: ${action}`, payload);
setOfflineActions(prev => [...prev, { action, payload, callback }]);
// Store in localStorage as fallback
try {
const offlineQueue = JSON.parse(localStorage.getItem('offlineActionQueue') || '[]');
offlineQueue.push({ action, payload, timestamp: Date.now() });
localStorage.setItem('offlineActionQueue', JSON.stringify(offlineQueue));
} catch (e) {
console.error('Error storing offline action in localStorage:', e);
}
};
// Emit an event, queue it if offline
const safeEmit = (eventName, data, callback) => {
// Update activity timestamp
setLastActivityTime(Date.now());
if (socket && isConnected && !isOffline) {
// Online - send immediately
socket.emit(eventName, data, callback);
} else {
// Offline or disconnected - queue for later
queueOfflineAction(eventName, data, callback);
// For specific actions, provide optimistic UI updates
if (eventName === 'submit_vote' && data.songId) {
// Optimistically show the vote locally
// Implementation depends on your UI update mechanism
}
}
};
return (
<SocketContext.Provider value={{
socket,
isConnected,
isReconnecting,
reconnectionAttempts,
isOffline,
safeEmit,
queueOfflineAction
}}>
{children}
</SocketContext.Provider>
);
}
/**
* Game state provider - manages game state and provides methods for game actions
*/
export function GameProvider({ children }) {
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
const [lobby, setLobby] = useState(null);
const [error, setError] = useState(null);
const [currentPlayer, setCurrentPlayer] = useState(null);
// Save game info when lobby is joined
useEffect(() => {
if (lobby && currentPlayer) {
const savedData = {
lobbyId: lobby.id,
playerName: currentPlayer.name,
lastKnownState: {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
}
}, [lobby, currentPlayer]);
// Helper function to update saved game data
const updateSavedGameData = (updatedLobby) => {
if (!updatedLobby || !currentPlayer) return;
const savedData = {
lobbyId: updatedLobby.id,
playerName: currentPlayer.name,
lastKnownState: {
gameState: updatedLobby.state,
currentBattle: updatedLobby.currentBattle,
playerState: {
isReady: updatedLobby.players?.find(p => p.id === currentPlayer.id)?.isReady,
hasVoted: updatedLobby.currentBattle?.votes?.[currentPlayer.id] !== undefined
},
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
};
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
setError(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [error]);
// Socket event handlers for game updates
useEffect(() => {
if (!socket) return;
// Player joined the lobby
const handlePlayerJoined = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Update the lobby with the new player
const updatedPlayers = [...prevLobby.players, {
id: data.playerId,
name: data.playerName,
isConnected: true,
isReady: false,
songCount: 0
}];
return {
...prevLobby,
players: updatedPlayers
};
});
};
// Player reconnected to the lobby
const handlePlayerReconnected = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Update the lobby with the reconnected player
const updatedPlayers = prevLobby.players.map(player => {
if (player.name === data.playerName && !player.isConnected) {
return { ...player, id: data.playerId, isConnected: true };
}
return player;
});
return {
...prevLobby,
players: updatedPlayers
};
});
};
// Player disconnected from the lobby
const handlePlayerDisconnected = (data) => {
setLobby(data.lobby);
};
// Game settings were updated
const handleSettingsUpdated = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
settings: data.settings
};
});
};
// Game started
const handleGameStarted = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: data.state
};
});
};
// Songs were updated
const handleSongsUpdated = (data) => {
setLobby(data.lobby);
};
// Player status changed (ready/not ready)
const handlePlayerStatusChanged = (data) => {
console.log('Player status changed, new lobby state:', data.lobby.state);
setLobby(data.lobby);
// If the state is VOTING and we have a current battle, explicitly log it
if (data.lobby.state === 'VOTING' && data.lobby.currentBattle) {
console.log('Battle ready in player_status_changed:', data.lobby.currentBattle);
}
};
// Vote was submitted
const handleVoteSubmitted = (data) => {
console.log('Vote submitted, updating lobby');
setLobby(data.lobby);
};
// New battle started
const handleNewBattle = (data) => {
console.log('New battle received:', data.battle);
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Ensure we update both the currentBattle and the state
return {
...prevLobby,
currentBattle: data.battle,
state: 'VOTING'
};
});
};
// Handle battle ended and show result screen
const handleBattleEnded = (data) => {
console.log('Battle ended, showing result screen:', data);
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: 'BATTLE',
previousBattle: data.previousBattle
};
});
};
// Game finished
const handleGameFinished = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: 'FINISHED',
finalWinner: data.winner,
battles: data.battles
};
});
};
// Register socket event listeners
socket.on('player_joined', handlePlayerJoined);
socket.on('player_reconnected', handlePlayerReconnected);
socket.on('player_disconnected', handlePlayerDisconnected);
socket.on('settings_updated', handleSettingsUpdated);
socket.on('game_started', handleGameStarted);
socket.on('songs_updated', handleSongsUpdated);
socket.on('player_status_changed', handlePlayerStatusChanged);
socket.on('vote_submitted', handleVoteSubmitted);
socket.on('battle_ended', handleBattleEnded);
socket.on('tournament_started', data => {
console.log('Tournament started event received:', data);
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
});
socket.on('new_battle', handleNewBattle);
socket.on('battle_ended', handleBattleEnded);
socket.on('game_finished', handleGameFinished);
// Clean up listeners on unmount
return () => {
socket.off('player_joined', handlePlayerJoined);
socket.off('player_reconnected', handlePlayerReconnected);
socket.off('player_disconnected', handlePlayerDisconnected);
socket.off('settings_updated', handleSettingsUpdated);
socket.off('game_started', handleGameStarted);
socket.off('songs_updated', handleSongsUpdated);
socket.off('player_status_changed', handlePlayerStatusChanged);
socket.off('vote_submitted', handleVoteSubmitted);
socket.off('new_battle', handleNewBattle);
socket.off('battle_ended', handleBattleEnded);
socket.off('game_finished', handleGameFinished);
};
}, [socket]);
// Create a lobby
const createLobby = (playerName) => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
safeEmit('create_lobby', { playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
setLobby(response.lobby);
setCurrentPlayer({
id: socket.id,
name: playerName,
isHost: true
});
}
});
};
// Join a lobby
const joinLobby = (lobbyId, playerName) => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
setLobby(response.lobby);
setCurrentPlayer({
id: socket.id,
name: playerName,
isHost: response.lobby.hostId === socket.id
});
}
});
};
// Update lobby settings
const updateSettings = (settings) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
safeEmit('update_settings', { settings }, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Start the game
const startGame = () => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
safeEmit('start_game', {}, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Add a song
const addSong = (song) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return Promise.reject('Not connected to server or no active lobby');
}
console.log('Attempting to add song:', song);
console.log('Current player state:', currentPlayer);
console.log('Current lobby state before adding song:', lobby);
return new Promise((resolve, reject) => {
safeEmit('add_song', { song }, (response) => {
console.log('Song addition response:', response);
if (response.error) {
console.error('Error adding song:', response.error);
setError(response.error);
reject(response.error);
} else if (response.lobby) {
// Log detailed lobby state for debugging
console.log('Song added successfully, full lobby response:', response.lobby);
console.log('Current player songs:', response.lobby.players.find(p => p.id === currentPlayer.id)?.songs);
console.log('All players song data:',
response.lobby.players.map(p => ({
name: p.name,
id: p.id,
songCount: p.songCount,
songs: p.songs ? p.songs.map(s => ({id: s.id, title: s.title})) : 'No songs'
}))
);
// Force a deep clone of the lobby to ensure React detects the change
const updatedLobby = JSON.parse(JSON.stringify(response.lobby));
setLobby(updatedLobby);
// Verify the state was updated correctly
setTimeout(() => {
// This won't show the updated state immediately due to React's state update mechanism
console.log('Lobby state after update (may not reflect immediate changes):', lobby);
console.log('Updated lobby that was set:', updatedLobby);
}, 0);
// Store the latest state for offline reconciliation
updateSavedGameData(updatedLobby);
// Resolve with the updated lobby
resolve(updatedLobby);
} else {
console.error('Song addition succeeded but no lobby data was returned');
setError('Failed to update song list');
reject('Failed to update song list');
}
});
});
};
// Search for songs on YouTube
const searchYouTube = (query) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
setError('Not connected to server');
reject('Not connected to server');
return;
}
safeEmit('search_youtube', { query }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
} else {
resolve(response.results);
}
});
});
};
// Get metadata for a YouTube video by ID
const getYouTubeMetadata = (videoId) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
setError('Not connected to server');
reject('Not connected to server');
return;
}
safeEmit('get_video_metadata', { videoId }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
} else {
resolve(response.metadata);
}
});
});
};
// Remove a song
const removeSong = (songId) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
safeEmit('remove_song', { songId }, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
// Set player ready
const setPlayerReady = () => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
safeEmit('player_ready', {}, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
// Submit a vote
const submitVote = (songId) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
safeEmit('submit_vote', { songId }, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
// Leave lobby and clear saved game state
const leaveLobby = () => {
localStorage.removeItem('songBattleGame');
setLobby(null);
setCurrentPlayer(null);
};
// Proceed to next battle after the battle result screen
const proceedToNextBattle = () => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
safeEmit('proceed_to_next_battle', {}, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
return (
<GameContext.Provider value={{
lobby,
error,
currentPlayer,
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
isOffline: isOffline,
isReconnecting,
createLobby,
joinLobby,
updateSettings,
startGame,
addSong,
removeSong,
setPlayerReady,
submitVote,
leaveLobby,
searchYouTube,
getYouTubeMetadata,
proceedToNextBattle
}}>
{children}
</GameContext.Provider>
);
}
// Custom hooks for using contexts
export const useSocket = () => useContext(SocketContext);
export const useGame = () => useContext(GameContext);
// Re-export for better module compatibility
export { SocketContext, GameContext };