diff --git a/client/src/App.jsx b/client/src/App.jsx index 9b4d45d..836a30b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -7,6 +7,7 @@ import HomeScreen from "./components/HomeScreen"; import SongSubmissionScreen from "./components/SongSubmissionScreen"; import VotingScreen from "./components/VotingScreen"; import ResultsScreen from "./components/ResultsScreen"; +import ConnectionStatus from "./components/ConnectionStatus"; const App = () => { const [cursorPos, setCursorPos] = useState({x: 0, y: 0}); @@ -84,12 +85,6 @@ const App = () => {
- {!isConnected && ( -
-

Connecting to server...

-
- )} - {error && (

{error}

@@ -97,6 +92,9 @@ const App = () => { )} {renderGameScreen()} + + {/* Show connection status component */} +
) diff --git a/client/src/common/styles/components/connection-status.sass b/client/src/common/styles/components/connection-status.sass new file mode 100644 index 0000000..1e4b3c2 --- /dev/null +++ b/client/src/common/styles/components/connection-status.sass @@ -0,0 +1,55 @@ +// connection-status.sass - Component for displaying network connection status + +.connection-status + position: fixed + bottom: 0 + left: 0 + right: 0 + padding: 0.5rem 1rem + display: flex + align-items: center + justify-content: center + gap: 0.5rem + z-index: 1000 + transition: all 0.3s ease + animation: slideUp 0.3s forwards + font-family: 'Press Start 2P', monospace + font-size: 0.7rem + + &.connected + background-color: rgba($success, 0.9) + color: #fff + animation: slideUp 0.3s forwards, fadeOut 0.5s 2.5s forwards + + &.disconnected + background-color: rgba(#f44336, 0.9) + color: #fff + + &.reconnecting + background-color: rgba(#ff9800, 0.9) + color: #fff + + &.offline + background-color: rgba(#f44336, 0.9) + color: #fff + + .connection-icon + display: flex + align-items: center + justify-content: center + + .connection-message + text-align: center + +@keyframes slideUp + from + transform: translateY(100%) + to + transform: translateY(0) + +@keyframes fadeOut + from + opacity: 1 + to + opacity: 0 + transform: translateY(100%) diff --git a/client/src/common/styles/components/voting-screen.sass b/client/src/common/styles/components/voting-screen.sass index db94b07..7345a67 100644 --- a/client/src/common/styles/components/voting-screen.sass +++ b/client/src/common/styles/components/voting-screen.sass @@ -253,11 +253,46 @@ .voting-actions display: flex justify-content: center + flex-direction: column + align-items: center margin: 2rem 0 .btn min-width: 180px font-size: 1rem + + &.offline + background-color: $secondary + position: relative + + .offline-notice + margin-top: 1rem + padding: 0.5rem + background-color: rgba($secondary, 0.2) + border: 2px solid $secondary + max-width: 400px + text-align: center + font-size: 0.7rem + + svg + margin-right: 0.5rem + color: $secondary + + .offline-vote-status + margin-top: 1rem + padding: 0.5rem + background-color: rgba($success, 0.2) + border: 2px solid $success + max-width: 400px + text-align: center + font-size: 0.7rem + color: $success + animation: pulse-opacity 2s infinite + + &.error + background-color: rgba(#f44336, 0.2) + border-color: #f44336 + color: #f44336 // Voting status and information .voting-status @@ -274,6 +309,17 @@ font-size: 0.9rem color: $text + .reconnecting-notice + margin: 0.5rem auto 1rem auto + padding: 0.5rem + background-color: rgba($secondary, 0.2) + border: 2px solid $secondary + max-width: 400px + text-align: center + font-size: 0.7rem + color: $secondary + animation: pulse-opacity 2s infinite + .auto-advance-notice margin: 1rem auto max-width: 400px @@ -297,6 +343,11 @@ color: $primary font-weight: bold + .offline-badge + color: $secondary + margin-left: 0.5rem + font-size: 0.7rem + // Player votes list styling .player-votes background-color: rgba(0, 0, 0, 0.2) @@ -368,6 +419,21 @@ background-color: rgba(255, 255, 255, 0.05) border-color: rgba(255, 255, 255, 0.1) opacity: 0.7 + + &.offline-voted + background-color: rgba($secondary, 0.1) + border-color: rgba($secondary, 0.5) + + .offline-icon + color: $secondary + margin-left: 0.5rem + animation: pixel-pulse 1.5s infinite + +@keyframes pulse-opacity + 0%, 100% + opacity: 1 + 50% + opacity: 0.5 @keyframes pixel-blink 0%, 100% diff --git a/client/src/common/styles/main.sass b/client/src/common/styles/main.sass index dd00606..2416076 100644 --- a/client/src/common/styles/main.sass +++ b/client/src/common/styles/main.sass @@ -12,6 +12,7 @@ @import './components/youtube-embed' @import './components/youtube-search' @import './components/song-form-overlay' +@import './components/connection-status' // Global styles html, body diff --git a/client/src/components/ConnectionStatus.jsx b/client/src/components/ConnectionStatus.jsx new file mode 100644 index 0000000..06618e0 --- /dev/null +++ b/client/src/components/ConnectionStatus.jsx @@ -0,0 +1,70 @@ +// ConnectionStatus.jsx +import { useState, useEffect } from 'react'; +import { useSocket } from '../context/GameContext'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faWifi, faExclamationTriangle, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +function ConnectionStatus() { + const { socket, isConnected, isReconnecting, reconnectionAttempts } = useSocket(); + const [showStatus, setShowStatus] = useState(false); + const [offline, setOffline] = useState(!navigator.onLine); + + // Monitor browser's online/offline status + useEffect(() => { + const handleOnline = () => setOffline(false); + const handleOffline = () => setOffline(true); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + // Always show status when disconnected, hide after successful connection + useEffect(() => { + if (!isConnected || isReconnecting || offline) { + setShowStatus(true); + } else { + // Hide the status indicator after a delay + const timer = setTimeout(() => { + setShowStatus(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [isConnected, isReconnecting, offline]); + + if (!showStatus) return null; + + let statusClass = 'connection-status'; + let icon, message; + + if (offline) { + statusClass += ' offline'; + icon = ; + message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.'; + } else if (!isConnected) { + statusClass += ' disconnected'; + icon = ; + message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...'; + } else if (isReconnecting) { + statusClass += ' reconnecting'; + icon = ; + message = `Verbindungsversuch ${reconnectionAttempts}...`; + } else { + statusClass += ' connected'; + icon = ; + message = 'Verbunden mit dem Server'; + } + + return ( +
+
{icon}
+
{message}
+
+ ); +} + +export default ConnectionStatus; diff --git a/client/src/components/VotingScreen.jsx b/client/src/components/VotingScreen.jsx index 9bfb82c..a38475c 100644 --- a/client/src/components/VotingScreen.jsx +++ b/client/src/components/VotingScreen.jsx @@ -2,15 +2,16 @@ import { useState, useEffect, useMemo } from 'react'; import { useGame } from '../context/GameContext'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown } from '@fortawesome/free-solid-svg-icons'; +import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import YouTubeEmbed from './YouTubeEmbed'; function VotingScreen() { - const { lobby, currentPlayer, submitVote, isHost } = useGame(); + const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame(); const [hasVoted, setHasVoted] = useState(false); const [selectedSong, setSelectedSong] = useState(null); const [countdown, setCountdown] = useState(null); const [processingByeAdvance, setProcessingByeAdvance] = useState(false); + const [offlineVoteStatus, setOfflineVoteStatus] = useState(null); // Hole aktuellen Kampf const battle = lobby?.currentBattle || null; @@ -39,10 +40,10 @@ function VotingScreen() { useEffect(() => { if (battle && battle.votes && currentPlayer) { // Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert - // Da Stimmen als Objekt, nicht als Map gesendet werden - const votesObj = battle.votes || {}; - setHasVoted(Object.prototype.hasOwnProperty.call(votesObj, currentPlayer.id) || - Object.keys(votesObj).includes(currentPlayer.id)); + setHasVoted( + Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) || + battle.votes[currentPlayer.id] !== undefined + ); } else { setHasVoted(false); } @@ -59,8 +60,41 @@ function VotingScreen() { const handleSubmitVote = async () => { if (!selectedSong || hasVoted) return; - await submitVote(selectedSong); - // setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft + try { + // If offline, show status but still try to submit (it will be queued) + if (isOffline) { + setOfflineVoteStatus('pending'); + } + + await submitVote(selectedSong); + + if (isOffline) { + // In offline mode, optimistically update UI + setHasVoted(true); + setOfflineVoteStatus('queued'); + + // Store vote locally for later sync + try { + const savedVotes = JSON.parse(localStorage.getItem('pendingVotes') || '{}'); + + if (battle) { + savedVotes[`battle_${battle.round}_${battle.song1.id}`] = { + songId: selectedSong, + battleRound: battle.round, + timestamp: Date.now() + }; + localStorage.setItem('pendingVotes', JSON.stringify(savedVotes)); + } + } catch (e) { + console.error("Fehler beim Speichern der Offline-Stimme:", e); + } + } + + // setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft + } catch (error) { + console.error("Fehler bei der Stimmabgabe:", error); + setOfflineVoteStatus('error'); + } }; // Handle bye round advancement - für automatisches Weiterkommen @@ -69,12 +103,18 @@ function VotingScreen() { setProcessingByeAdvance(true); try { - // Nur der Host kann im Bye-Modus weiterschaltenNNULL + // If offline, show notification that the action will be queued + if (isOffline) { + setOfflineVoteStatus('byeQueued'); + } + + // Nur der Host kann im Bye-Modus weiterschalten if (battle && battle.song1 && !battle.song2 && battle.song1.id) { await submitVote(battle.song1.id); } } catch (error) { console.error("Fehler beim Fortfahren:", error); + setOfflineVoteStatus('error'); } finally { // Verzögerung, um mehrere Klicks zu verhindern setTimeout(() => setProcessingByeAdvance(false), 1000); @@ -258,19 +298,46 @@ function VotingScreen() { {!hasVoted && (
+ + {isOffline && ( +
+ + Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist. +
+ )} + + {offlineVoteStatus === 'queued' && ( +
+ Stimme gespeichert! Wird gesendet, wenn online. +
+ )} + + {offlineVoteStatus === 'error' && ( +
+ Fehler beim Speichern der Stimme. +
+ )}
)}

{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}

+ + {isReconnecting && ( +
+ Versuche die Verbindung wiederherzustellen... +
+ )} +
{battle.voteCount || 0} von {lobby?.players?.filter(p => p.isConnected).length || 0} Stimmen + {isOffline && (Offline-Modus)}
{/* Liste der Spielerstimmen */} @@ -282,12 +349,20 @@ function VotingScreen() { const hasPlayerVoted = battle.votes && Object.keys(battle.votes).includes(player.id); + // Highlight current player to show they've voted offline + const isCurrentPlayerOfflineVoted = player.id === currentPlayer.id && + isOffline && + hasVoted; + return ( -
  • +
  • {player.name} {player.id === currentPlayer.id && '(Du)'} {hasPlayerVoted && } + {isCurrentPlayerOfflineVoted && + + }
  • ); })} diff --git a/client/src/context/GameContext.jsx b/client/src/context/GameContext.jsx index 5d72066..c14926e 100644 --- a/client/src/context/GameContext.jsx +++ b/client/src/context/GameContext.jsx @@ -11,35 +11,61 @@ 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 + // 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: 5000, - reconnectionAttempts: 5 + 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 } = JSON.parse(savedGameData); + 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 }, (response) => { + 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); - localStorage.removeItem('songBattleGame'); + // 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); } }); } @@ -50,25 +76,200 @@ export function SocketProvider({ children }) { } }); - socketInstance.on('disconnect', () => { + socketInstance.on('disconnect', (reason) => { setIsConnected(false); - console.log('Disconnected from server'); + 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); - // Clean up socket connection on unmount + // 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 ( - + {children} ); @@ -78,7 +279,7 @@ export function SocketProvider({ children }) { * Game state provider - manages game state and provides methods for game actions */ export function GameProvider({ children }) { - const { socket, isConnected } = useContext(SocketContext); + const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext); const [lobby, setLobby] = useState(null); const [error, setError] = useState(null); const [currentPlayer, setCurrentPlayer] = useState(null); @@ -86,12 +287,38 @@ export function GameProvider({ children }) { // Save game info when lobby is joined useEffect(() => { if (lobby && currentPlayer) { - sessionStorage.setItem('songBattleGame', JSON.stringify({ + const savedData = { lobbyId: lobby.id, - playerName: currentPlayer.name - })); + 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(() => { @@ -263,7 +490,7 @@ export function GameProvider({ children }) { return; } - socket.emit('create_lobby', { playerName }, (response) => { + safeEmit('create_lobby', { playerName }, (response) => { if (response.error) { setError(response.error); } else { @@ -284,7 +511,7 @@ export function GameProvider({ children }) { return; } - socket.emit('join_lobby', { lobbyId, playerName }, (response) => { + safeEmit('join_lobby', { lobbyId, playerName }, (response) => { if (response.error) { setError(response.error); } else { @@ -305,7 +532,7 @@ export function GameProvider({ children }) { return; } - socket.emit('update_settings', { settings }, (response) => { + safeEmit('update_settings', { settings }, (response) => { if (response.error) { setError(response.error); } @@ -319,7 +546,7 @@ export function GameProvider({ children }) { return; } - socket.emit('start_game', {}, (response) => { + safeEmit('start_game', {}, (response) => { if (response.error) { setError(response.error); } @@ -338,7 +565,7 @@ export function GameProvider({ children }) { console.log('Current lobby state before adding song:', lobby); return new Promise((resolve, reject) => { - socket.emit('add_song', { song }, (response) => { + safeEmit('add_song', { song }, (response) => { console.log('Song addition response:', response); if (response.error) { console.error('Error adding song:', response.error); @@ -368,6 +595,9 @@ export function GameProvider({ children }) { 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 { @@ -378,8 +608,7 @@ export function GameProvider({ children }) { }); }); }; - - // Search for songs on YouTube + // Search for songs on YouTube const searchYouTube = (query) => { return new Promise((resolve, reject) => { if (!socket || !isConnected) { @@ -388,7 +617,7 @@ export function GameProvider({ children }) { return; } - socket.emit('search_youtube', { query }, (response) => { + safeEmit('search_youtube', { query }, (response) => { if (response.error) { setError(response.error); reject(response.error); @@ -398,7 +627,7 @@ export function GameProvider({ children }) { }); }); }; - + // Get metadata for a YouTube video by ID const getYouTubeMetadata = (videoId) => { return new Promise((resolve, reject) => { @@ -408,7 +637,7 @@ export function GameProvider({ children }) { return; } - socket.emit('get_video_metadata', { videoId }, (response) => { + safeEmit('get_video_metadata', { videoId }, (response) => { if (response.error) { setError(response.error); reject(response.error); @@ -426,9 +655,12 @@ export function GameProvider({ children }) { return; } - socket.emit('remove_song', { songId }, (response) => { + safeEmit('remove_song', { songId }, (response) => { if (response.error) { setError(response.error); + } else if (response.lobby) { + setLobby(response.lobby); + updateSavedGameData(response.lobby); } }); }; @@ -440,9 +672,12 @@ export function GameProvider({ children }) { return; } - socket.emit('player_ready', {}, (response) => { + safeEmit('player_ready', {}, (response) => { if (response.error) { setError(response.error); + } else if (response.lobby) { + setLobby(response.lobby); + updateSavedGameData(response.lobby); } }); }; @@ -454,9 +689,12 @@ export function GameProvider({ children }) { return; } - socket.emit('submit_vote', { songId }, (response) => { + safeEmit('submit_vote', { songId }, (response) => { if (response.error) { setError(response.error); + } else if (response.lobby) { + setLobby(response.lobby); + updateSavedGameData(response.lobby); } }); }; @@ -474,6 +712,8 @@ export function GameProvider({ children }) { error, currentPlayer, isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId, + isOffline: isOffline, + isReconnecting, createLobby, joinLobby, updateSettings, diff --git a/server/game.js b/server/game.js index b572542..9d21f9b 100644 --- a/server/game.js +++ b/server/game.js @@ -112,9 +112,10 @@ class GameManager { * @param {string} playerId - New ID of the reconnecting player * @param {string} lobbyId - ID of the lobby * @param {string} playerName - Name of the player + * @param {Object} lastKnownState - Last known game state from client * @returns {Object} Lobby data or error */ - handleReconnect(playerId, lobbyId, playerName) { + handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) { // Check if lobby exists if (!this.lobbies.has(lobbyId)) { return { error: 'Lobby not found' }; @@ -135,6 +136,65 @@ class GameManager { // Update map this.playerToLobby.set(playerId, lobbyId); + // Handle state reconciliation if lastKnownState is provided + if (lastKnownState && lastKnownState.gameState && lastKnownState.timestamp) { + console.log(`Reconciling player state based on last known state: ${lastKnownState.gameState} from ${new Date(lastKnownState.timestamp).toISOString()}`); + + // If the client's last known state was VOTING and the current battle has votes + if (lastKnownState.gameState === 'VOTING' && + lastKnownState.currentBattle && + lastKnownState.playerState && + lastKnownState.playerState.hasVoted) { + + // The player already voted in this battle according to their local state + // If we still have the same battle, ensure their vote is counted + if (lobby.currentBattle && + lastKnownState.currentBattle.round === lobby.currentBattle.round) { + + console.log(`Player ${playerName} had voted in battle round ${lastKnownState.currentBattle.round}, checking if vote needs restoration`); + + // Check if their vote is missing + const hasVote = lobby.currentBattle.votes && + (lobby.currentBattle.votes.has(playerId) || + Object.keys(lobby.currentBattle.votes).includes(playerId)); + + // If their vote is missing, we need to determine which song they voted for + if (!hasVote && lastKnownState.currentBattle.votes) { + // Find which song they voted for + const votedSongId = lastKnownState.currentBattle.votes[lobby.players[playerIndex].id]; + if (votedSongId) { + console.log(`Restoring player ${playerName}'s vote for song ${votedSongId}`); + // Recreate their vote + this.submitVote(playerId, votedSongId, true); + } + } + } + } + + // If the client's last known state was SONG_SUBMISSION and they were ready + if (lastKnownState.gameState === 'SONG_SUBMISSION' && + lastKnownState.playerState && + lastKnownState.playerState.isReady && + lobby.state === 'SONG_SUBMISSION' && + !lobby.players[playerIndex].isReady) { + + console.log(`Restoring ready status for player ${playerName}`); + lobby.players[playerIndex].isReady = true; + + // Check if all players are now ready + const allReady = lobby.players.every(p => p.isReady || !p.isConnected); + if (allReady) { + console.log('All players ready after reconnection, starting tournament...'); + this._startTournament(lobby); + return { + lobby, + lobbyId, + tournamentStarted: true + }; + } + } + } + return { lobby, lobbyId }; } @@ -220,7 +280,6 @@ class GameManager { // Check if player is in a lobby let lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { - // If no mapping exists, try to find the player in any lobby console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`); // Search all lobbies for this player @@ -266,31 +325,6 @@ class GameManager { const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex === -1) { console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`); - - // First try: Find the host if this is a host request - if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) { - console.log('[DEBUG] This appears to be the host. Looking for host player...'); - const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId); - if (hostIndex !== -1) { - console.log(`[DEBUG] Found host at index ${hostIndex}`); - return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId); - } - } - - // Second try: Match any connected player as fallback - console.log('[DEBUG] Trying to find any connected player...'); - const connectedIndex = lobby.players.findIndex(p => p.isConnected); - if (connectedIndex !== -1) { - console.log(`[DEBUG] Found connected player at index ${connectedIndex}`); - return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId); - } - - // If we still can't find a match, use the first player as last resort - if (lobby.players.length > 0) { - console.log('[DEBUG] Using first player as last resort'); - return this._addSongToPlayer(0, lobby, lobbyId, song, playerId); - } - return { error: 'Player not found in lobby' }; } @@ -519,9 +553,10 @@ class GameManager { * Submit a vote for a song in a battle * @param {string} playerId - ID of the voting player * @param {string} songId - ID of the voted song + * @param {boolean} isRestoredVote - Whether this is a restored vote during reconnection * @returns {Object} Updated lobby data or error */ - submitVote(playerId, songId) { + submitVote(playerId, songId, isRestoredVote = false) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; @@ -545,7 +580,7 @@ class GameManager { } // Check if player has already voted in this battle - if (lobby.currentBattle.votes && lobby.currentBattle.votes.has(playerId)) { + if (lobby.currentBattle.votes && Object.prototype.hasOwnProperty.call(lobby.currentBattle.votes, playerId) && !isRestoredVote) { return { error: 'Already voted in this battle' }; } @@ -559,16 +594,16 @@ class GameManager { const player = lobby.players.find(p => p.id === playerId); const playerName = player ? player.name : 'Unknown Player'; - // Initialize votes Map if it doesn't exist + // Initialize votes Object if it doesn't exist if (!lobby.currentBattle.votes) { - lobby.currentBattle.votes = new Map(); + lobby.currentBattle.votes = {}; } // Record the vote with player name for UI display - lobby.currentBattle.votes.set(playerId, { + lobby.currentBattle.votes[playerId] = { songId, playerName - }); + }; // Update vote counts if (songId === lobby.currentBattle.song1.id) { @@ -578,7 +613,7 @@ class GameManager { } // Add a voteCount attribute for easier UI rendering - lobby.currentBattle.voteCount = lobby.currentBattle.votes.size; + lobby.currentBattle.voteCount = Object.keys(lobby.currentBattle.votes).length; // For bye battles, the host's vote is all that's needed if (lobby.currentBattle.bye === true && playerId === lobby.hostId) { @@ -605,7 +640,7 @@ class GameManager { // For regular battles, check if all connected players have voted const connectedPlayers = lobby.players.filter(p => p.isConnected).length; - const voteCount = lobby.currentBattle.votes.size; + const voteCount = Object.keys(lobby.currentBattle.votes).length; if (voteCount >= connectedPlayers) { // Determine winner @@ -680,7 +715,7 @@ class GameManager { // For voting state, check if we need to progress if (lobby.state === 'VOTING' && lobby.currentBattle) { // Add null check for votes property - const totalVotes = lobby.currentBattle.votes?.size || 0; + const totalVotes = lobby.currentBattle.votes ? Object.keys(lobby.currentBattle.votes).length : 0; const remainingPlayers = lobby.players.filter(p => p.isConnected).length; if (totalVotes >= remainingPlayers && totalVotes > 0) { @@ -765,7 +800,7 @@ class GameManager { winner: null, // Set to null until host advances it song1Votes: 0, song2Votes: 0, - votes: new Map() // Initialize votes as a Map + votes: {} // Initialize votes as an empty object }); } @@ -779,7 +814,7 @@ class GameManager { song1Votes: 0, song2Votes: 0, winner: null, - votes: new Map() + votes: {} }); } } @@ -795,8 +830,8 @@ class GameManager { // Ensure we create a proper battle object with all required fields lobby.currentBattle = { ...brackets[0], - // Make sure votes is a Map (it might be serialized incorrectly) - votes: brackets[0].votes || new Map(), + // Make sure votes is an object (not a Map) + votes: brackets[0].votes || {}, voteCount: 0 }; @@ -863,24 +898,22 @@ class GameManager { winner: null, // Set to null until host advances it song1Votes: 0, song2Votes: 0, - votes: new Map() // Initialize votes as a Map + votes: {} // Initialize votes as an object }); - } - - // Create pairs for next round - for (let i = 0; i < nextRoundSongs.length; i += 2) { - if (i + 1 < nextRoundSongs.length) { - nextRound.push({ - round, - song1: nextRoundSongs[i], - song2: nextRoundSongs[i + 1], - song1Votes: 0, - song2Votes: 0, - winner: null, - votes: new Map() - }); + } // Create pairs for next round + for (let i = 0; i < nextRoundSongs.length; i += 2) { + if (i + 1 < nextRoundSongs.length) { + nextRound.push({ + round, + song1: nextRoundSongs[i], + song2: nextRoundSongs[i + 1], + song1Votes: 0, + song2Votes: 0, + winner: null, + votes: {} + }); + } } - } // Update brackets and reset index lobby.brackets = nextRound; @@ -890,9 +923,9 @@ class GameManager { if (nextRound.length > 0) { lobby.currentBattle = nextRound[0]; - // Ensure votes is initialized as a Map + // Ensure votes is initialized as an object if (!lobby.currentBattle.votes) { - lobby.currentBattle.votes = new Map(); + lobby.currentBattle.votes = {}; } // Initialize vote count for UI diff --git a/server/index.js b/server/index.js index 0b66bbb..6937a87 100644 --- a/server/index.js +++ b/server/index.js @@ -73,9 +73,9 @@ io.on('connection', (socket) => { }); // Attempt to reconnect to a lobby - socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => { + socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => { try { - const result = gameManager.handleReconnect(socket.id, lobbyId, playerName); + const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState); if (result.error) { if (callback) callback(result); @@ -337,7 +337,7 @@ io.on('connection', (socket) => { } }); - // Handle disconnection + // Handle player disconnection socket.on('disconnect', () => { try { const result = gameManager.handleDisconnect(socket.id); @@ -355,6 +355,13 @@ io.on('connection', (socket) => { console.error('Error handling disconnect:', error); } }); + + // Simple ping handler to help with connection testing + socket.on('ping', (callback) => { + if (callback && typeof callback === 'function') { + callback({ success: true, timestamp: Date.now() }); + } + }); }); server.on('error', (error) => {