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 (
+
+ );
+}
+
+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) => {