Add connection status component and improve socket reconnection handling
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m20s
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m20s
This commit is contained in:
parent
f2712bdcec
commit
301e08b6e6
@ -7,6 +7,7 @@ import HomeScreen from "./components/HomeScreen";
|
|||||||
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
||||||
import VotingScreen from "./components/VotingScreen";
|
import VotingScreen from "./components/VotingScreen";
|
||||||
import ResultsScreen from "./components/ResultsScreen";
|
import ResultsScreen from "./components/ResultsScreen";
|
||||||
|
import ConnectionStatus from "./components/ConnectionStatus";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
|
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
|
||||||
@ -84,12 +85,6 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content-container">
|
<div className="content-container">
|
||||||
{!isConnected && (
|
|
||||||
<div className="connection-status">
|
|
||||||
<p>Connecting to server...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
@ -97,6 +92,9 @@ const App = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{renderGameScreen()}
|
{renderGameScreen()}
|
||||||
|
|
||||||
|
{/* Show connection status component */}
|
||||||
|
<ConnectionStatus />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
55
client/src/common/styles/components/connection-status.sass
Normal file
55
client/src/common/styles/components/connection-status.sass
Normal file
@ -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%)
|
@ -253,12 +253,47 @@
|
|||||||
.voting-actions
|
.voting-actions
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: center
|
justify-content: center
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
margin: 2rem 0
|
margin: 2rem 0
|
||||||
|
|
||||||
.btn
|
.btn
|
||||||
min-width: 180px
|
min-width: 180px
|
||||||
font-size: 1rem
|
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 and information
|
||||||
.voting-status
|
.voting-status
|
||||||
background-color: $card-bg
|
background-color: $card-bg
|
||||||
@ -274,6 +309,17 @@
|
|||||||
font-size: 0.9rem
|
font-size: 0.9rem
|
||||||
color: $text
|
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
|
.auto-advance-notice
|
||||||
margin: 1rem auto
|
margin: 1rem auto
|
||||||
max-width: 400px
|
max-width: 400px
|
||||||
@ -297,6 +343,11 @@
|
|||||||
color: $primary
|
color: $primary
|
||||||
font-weight: bold
|
font-weight: bold
|
||||||
|
|
||||||
|
.offline-badge
|
||||||
|
color: $secondary
|
||||||
|
margin-left: 0.5rem
|
||||||
|
font-size: 0.7rem
|
||||||
|
|
||||||
// Player votes list styling
|
// Player votes list styling
|
||||||
.player-votes
|
.player-votes
|
||||||
background-color: rgba(0, 0, 0, 0.2)
|
background-color: rgba(0, 0, 0, 0.2)
|
||||||
@ -369,6 +420,21 @@
|
|||||||
border-color: rgba(255, 255, 255, 0.1)
|
border-color: rgba(255, 255, 255, 0.1)
|
||||||
opacity: 0.7
|
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
|
@keyframes pixel-blink
|
||||||
0%, 100%
|
0%, 100%
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
@import './components/youtube-embed'
|
@import './components/youtube-embed'
|
||||||
@import './components/youtube-search'
|
@import './components/youtube-search'
|
||||||
@import './components/song-form-overlay'
|
@import './components/song-form-overlay'
|
||||||
|
@import './components/connection-status'
|
||||||
|
|
||||||
// Global styles
|
// Global styles
|
||||||
html, body
|
html, body
|
||||||
|
70
client/src/components/ConnectionStatus.jsx
Normal file
70
client/src/components/ConnectionStatus.jsx
Normal file
@ -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 = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||||
|
message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.';
|
||||||
|
} else if (!isConnected) {
|
||||||
|
statusClass += ' disconnected';
|
||||||
|
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||||
|
message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...';
|
||||||
|
} else if (isReconnecting) {
|
||||||
|
statusClass += ' reconnecting';
|
||||||
|
icon = <FontAwesomeIcon icon={faSpinner} spin />;
|
||||||
|
message = `Verbindungsversuch ${reconnectionAttempts}...`;
|
||||||
|
} else {
|
||||||
|
statusClass += ' connected';
|
||||||
|
icon = <FontAwesomeIcon icon={faWifi} />;
|
||||||
|
message = 'Verbunden mit dem Server';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={statusClass}>
|
||||||
|
<div className="connection-icon">{icon}</div>
|
||||||
|
<div className="connection-message">{message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionStatus;
|
@ -2,15 +2,16 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useGame } from '../context/GameContext';
|
import { useGame } from '../context/GameContext';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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';
|
import YouTubeEmbed from './YouTubeEmbed';
|
||||||
|
|
||||||
function VotingScreen() {
|
function VotingScreen() {
|
||||||
const { lobby, currentPlayer, submitVote, isHost } = useGame();
|
const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
|
||||||
const [hasVoted, setHasVoted] = useState(false);
|
const [hasVoted, setHasVoted] = useState(false);
|
||||||
const [selectedSong, setSelectedSong] = useState(null);
|
const [selectedSong, setSelectedSong] = useState(null);
|
||||||
const [countdown, setCountdown] = useState(null);
|
const [countdown, setCountdown] = useState(null);
|
||||||
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
|
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
|
||||||
|
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
|
||||||
|
|
||||||
// Hole aktuellen Kampf
|
// Hole aktuellen Kampf
|
||||||
const battle = lobby?.currentBattle || null;
|
const battle = lobby?.currentBattle || null;
|
||||||
@ -39,10 +40,10 @@ function VotingScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (battle && battle.votes && currentPlayer) {
|
if (battle && battle.votes && currentPlayer) {
|
||||||
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
|
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
|
||||||
// Da Stimmen als Objekt, nicht als Map gesendet werden
|
setHasVoted(
|
||||||
const votesObj = battle.votes || {};
|
Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
|
||||||
setHasVoted(Object.prototype.hasOwnProperty.call(votesObj, currentPlayer.id) ||
|
battle.votes[currentPlayer.id] !== undefined
|
||||||
Object.keys(votesObj).includes(currentPlayer.id));
|
);
|
||||||
} else {
|
} else {
|
||||||
setHasVoted(false);
|
setHasVoted(false);
|
||||||
}
|
}
|
||||||
@ -59,8 +60,41 @@ function VotingScreen() {
|
|||||||
const handleSubmitVote = async () => {
|
const handleSubmitVote = async () => {
|
||||||
if (!selectedSong || hasVoted) return;
|
if (!selectedSong || hasVoted) return;
|
||||||
|
|
||||||
await submitVote(selectedSong);
|
try {
|
||||||
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
|
// 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
|
// Handle bye round advancement - für automatisches Weiterkommen
|
||||||
@ -69,12 +103,18 @@ function VotingScreen() {
|
|||||||
|
|
||||||
setProcessingByeAdvance(true);
|
setProcessingByeAdvance(true);
|
||||||
try {
|
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) {
|
if (battle && battle.song1 && !battle.song2 && battle.song1.id) {
|
||||||
await submitVote(battle.song1.id);
|
await submitVote(battle.song1.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Fortfahren:", error);
|
console.error("Fehler beim Fortfahren:", error);
|
||||||
|
setOfflineVoteStatus('error');
|
||||||
} finally {
|
} finally {
|
||||||
// Verzögerung, um mehrere Klicks zu verhindern
|
// Verzögerung, um mehrere Klicks zu verhindern
|
||||||
setTimeout(() => setProcessingByeAdvance(false), 1000);
|
setTimeout(() => setProcessingByeAdvance(false), 1000);
|
||||||
@ -258,19 +298,46 @@ function VotingScreen() {
|
|||||||
{!hasVoted && (
|
{!hasVoted && (
|
||||||
<div className="voting-actions">
|
<div className="voting-actions">
|
||||||
<button
|
<button
|
||||||
className="btn primary"
|
className={`btn primary ${isOffline ? 'offline' : ''}`}
|
||||||
onClick={handleSubmitVote}
|
onClick={handleSubmitVote}
|
||||||
disabled={!selectedSong}
|
disabled={!selectedSong}
|
||||||
>
|
>
|
||||||
Abstimmen
|
{isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isOffline && (
|
||||||
|
<div className="offline-notice">
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||||
|
<span>Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offlineVoteStatus === 'queued' && (
|
||||||
|
<div className="offline-vote-status">
|
||||||
|
<span>Stimme gespeichert! Wird gesendet, wenn online.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offlineVoteStatus === 'error' && (
|
||||||
|
<div className="offline-vote-status error">
|
||||||
|
<span>Fehler beim Speichern der Stimme.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="voting-status">
|
<div className="voting-status">
|
||||||
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
|
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
|
||||||
|
|
||||||
|
{isReconnecting && (
|
||||||
|
<div className="reconnecting-notice">
|
||||||
|
<span>Versuche die Verbindung wiederherzustellen...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="votes-count">
|
<div className="votes-count">
|
||||||
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
|
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
|
||||||
|
{isOffline && <span className="offline-badge"> (Offline-Modus)</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Liste der Spielerstimmen */}
|
{/* Liste der Spielerstimmen */}
|
||||||
@ -282,12 +349,20 @@ function VotingScreen() {
|
|||||||
const hasPlayerVoted = battle.votes &&
|
const hasPlayerVoted = battle.votes &&
|
||||||
Object.keys(battle.votes).includes(player.id);
|
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 (
|
return (
|
||||||
<li key={player.id} className={hasPlayerVoted ? 'voted' : 'not-voted'}>
|
<li key={player.id} className={`${hasPlayerVoted ? 'voted' : 'not-voted'} ${isCurrentPlayerOfflineVoted ? 'offline-voted' : ''}`}>
|
||||||
{player.name} {player.id === currentPlayer.id && '(Du)'}
|
{player.name} {player.id === currentPlayer.id && '(Du)'}
|
||||||
{hasPlayerVoted &&
|
{hasPlayerVoted &&
|
||||||
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
|
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
|
||||||
}
|
}
|
||||||
|
{isCurrentPlayerOfflineVoted &&
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -11,35 +11,61 @@ const GameContext = createContext(null);
|
|||||||
export function SocketProvider({ children }) {
|
export function SocketProvider({ children }) {
|
||||||
const [socket, setSocket] = useState(null);
|
const [socket, setSocket] = useState(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
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(() => {
|
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, {
|
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
|
||||||
autoConnect: true,
|
autoConnect: true,
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 1000,
|
||||||
reconnectionDelayMax: 5000,
|
reconnectionDelayMax: 10000,
|
||||||
reconnectionAttempts: 5
|
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
|
// Socket event listeners
|
||||||
socketInstance.on('connect', () => {
|
socketInstance.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
setIsReconnecting(false);
|
||||||
|
setReconnectionAttempts(0);
|
||||||
console.log('Connected to server');
|
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
|
// Try to reconnect to game if we have saved data
|
||||||
const savedGameData = localStorage.getItem('songBattleGame');
|
const savedGameData = localStorage.getItem('songBattleGame');
|
||||||
if (savedGameData) {
|
if (savedGameData) {
|
||||||
try {
|
try {
|
||||||
const { lobbyId, playerName } = JSON.parse(savedGameData);
|
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
|
||||||
if (lobbyId && playerName) {
|
if (lobbyId && playerName) {
|
||||||
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
|
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) {
|
if (response.error) {
|
||||||
console.error('Reconnection failed:', 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 {
|
} else {
|
||||||
console.log('Successfully reconnected to lobby');
|
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);
|
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) => {
|
socketInstance.on('connect_error', (error) => {
|
||||||
console.error('Connection 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);
|
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 () => {
|
return () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
socketInstance.disconnect();
|
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 (
|
return (
|
||||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
<SocketContext.Provider value={{
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
isReconnecting,
|
||||||
|
reconnectionAttempts,
|
||||||
|
isOffline,
|
||||||
|
safeEmit,
|
||||||
|
queueOfflineAction
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
);
|
);
|
||||||
@ -78,7 +279,7 @@ export function SocketProvider({ children }) {
|
|||||||
* Game state provider - manages game state and provides methods for game actions
|
* Game state provider - manages game state and provides methods for game actions
|
||||||
*/
|
*/
|
||||||
export function GameProvider({ children }) {
|
export function GameProvider({ children }) {
|
||||||
const { socket, isConnected } = useContext(SocketContext);
|
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
|
||||||
const [lobby, setLobby] = useState(null);
|
const [lobby, setLobby] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [currentPlayer, setCurrentPlayer] = useState(null);
|
const [currentPlayer, setCurrentPlayer] = useState(null);
|
||||||
@ -86,13 +287,39 @@ export function GameProvider({ children }) {
|
|||||||
// Save game info when lobby is joined
|
// Save game info when lobby is joined
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lobby && currentPlayer) {
|
if (lobby && currentPlayer) {
|
||||||
sessionStorage.setItem('songBattleGame', JSON.stringify({
|
const savedData = {
|
||||||
lobbyId: lobby.id,
|
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]);
|
}, [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
|
// Clear error after 5 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -263,7 +490,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('create_lobby', { playerName }, (response) => {
|
safeEmit('create_lobby', { playerName }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
} else {
|
} else {
|
||||||
@ -284,7 +511,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('join_lobby', { lobbyId, playerName }, (response) => {
|
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
} else {
|
} else {
|
||||||
@ -305,7 +532,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('update_settings', { settings }, (response) => {
|
safeEmit('update_settings', { settings }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
}
|
}
|
||||||
@ -319,7 +546,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('start_game', {}, (response) => {
|
safeEmit('start_game', {}, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
}
|
}
|
||||||
@ -338,7 +565,7 @@ export function GameProvider({ children }) {
|
|||||||
console.log('Current lobby state before adding song:', lobby);
|
console.log('Current lobby state before adding song:', lobby);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
socket.emit('add_song', { song }, (response) => {
|
safeEmit('add_song', { song }, (response) => {
|
||||||
console.log('Song addition response:', response);
|
console.log('Song addition response:', response);
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error('Error adding song:', 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);
|
console.log('Updated lobby that was set:', updatedLobby);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Store the latest state for offline reconciliation
|
||||||
|
updateSavedGameData(updatedLobby);
|
||||||
|
|
||||||
// Resolve with the updated lobby
|
// Resolve with the updated lobby
|
||||||
resolve(updatedLobby);
|
resolve(updatedLobby);
|
||||||
} else {
|
} else {
|
||||||
@ -378,8 +608,7 @@ export function GameProvider({ children }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// Search for songs on YouTube
|
||||||
// Search for songs on YouTube
|
|
||||||
const searchYouTube = (query) => {
|
const searchYouTube = (query) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!socket || !isConnected) {
|
if (!socket || !isConnected) {
|
||||||
@ -388,7 +617,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('search_youtube', { query }, (response) => {
|
safeEmit('search_youtube', { query }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
reject(response.error);
|
reject(response.error);
|
||||||
@ -408,7 +637,7 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('get_video_metadata', { videoId }, (response) => {
|
safeEmit('get_video_metadata', { videoId }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
reject(response.error);
|
reject(response.error);
|
||||||
@ -426,9 +655,12 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('remove_song', { songId }, (response) => {
|
safeEmit('remove_song', { songId }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -440,9 +672,12 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('player_ready', {}, (response) => {
|
safeEmit('player_ready', {}, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -454,9 +689,12 @@ export function GameProvider({ children }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('submit_vote', { songId }, (response) => {
|
safeEmit('submit_vote', { songId }, (response) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error);
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -474,6 +712,8 @@ export function GameProvider({ children }) {
|
|||||||
error,
|
error,
|
||||||
currentPlayer,
|
currentPlayer,
|
||||||
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
||||||
|
isOffline: isOffline,
|
||||||
|
isReconnecting,
|
||||||
createLobby,
|
createLobby,
|
||||||
joinLobby,
|
joinLobby,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
149
server/game.js
149
server/game.js
@ -112,9 +112,10 @@ class GameManager {
|
|||||||
* @param {string} playerId - New ID of the reconnecting player
|
* @param {string} playerId - New ID of the reconnecting player
|
||||||
* @param {string} lobbyId - ID of the lobby
|
* @param {string} lobbyId - ID of the lobby
|
||||||
* @param {string} playerName - Name of the player
|
* @param {string} playerName - Name of the player
|
||||||
|
* @param {Object} lastKnownState - Last known game state from client
|
||||||
* @returns {Object} Lobby data or error
|
* @returns {Object} Lobby data or error
|
||||||
*/
|
*/
|
||||||
handleReconnect(playerId, lobbyId, playerName) {
|
handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) {
|
||||||
// Check if lobby exists
|
// Check if lobby exists
|
||||||
if (!this.lobbies.has(lobbyId)) {
|
if (!this.lobbies.has(lobbyId)) {
|
||||||
return { error: 'Lobby not found' };
|
return { error: 'Lobby not found' };
|
||||||
@ -135,6 +136,65 @@ class GameManager {
|
|||||||
// Update map
|
// Update map
|
||||||
this.playerToLobby.set(playerId, lobbyId);
|
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 };
|
return { lobby, lobbyId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,7 +280,6 @@ class GameManager {
|
|||||||
// Check if player is in a lobby
|
// Check if player is in a lobby
|
||||||
let lobbyId = this.playerToLobby.get(playerId);
|
let lobbyId = this.playerToLobby.get(playerId);
|
||||||
if (!lobbyId) {
|
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...`);
|
console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`);
|
||||||
|
|
||||||
// Search all lobbies for this player
|
// Search all lobbies for this player
|
||||||
@ -266,31 +325,6 @@ class GameManager {
|
|||||||
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
|
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
|
||||||
if (playerIndex === -1) {
|
if (playerIndex === -1) {
|
||||||
console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`);
|
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' };
|
return { error: 'Player not found in lobby' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,9 +553,10 @@ class GameManager {
|
|||||||
* Submit a vote for a song in a battle
|
* Submit a vote for a song in a battle
|
||||||
* @param {string} playerId - ID of the voting player
|
* @param {string} playerId - ID of the voting player
|
||||||
* @param {string} songId - ID of the voted song
|
* @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
|
* @returns {Object} Updated lobby data or error
|
||||||
*/
|
*/
|
||||||
submitVote(playerId, songId) {
|
submitVote(playerId, songId, isRestoredVote = false) {
|
||||||
const lobbyId = this.playerToLobby.get(playerId);
|
const lobbyId = this.playerToLobby.get(playerId);
|
||||||
if (!lobbyId) {
|
if (!lobbyId) {
|
||||||
return { error: 'Player not in a lobby' };
|
return { error: 'Player not in a lobby' };
|
||||||
@ -545,7 +580,7 @@ class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if player has already voted in this battle
|
// 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' };
|
return { error: 'Already voted in this battle' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -559,16 +594,16 @@ class GameManager {
|
|||||||
const player = lobby.players.find(p => p.id === playerId);
|
const player = lobby.players.find(p => p.id === playerId);
|
||||||
const playerName = player ? player.name : 'Unknown Player';
|
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) {
|
if (!lobby.currentBattle.votes) {
|
||||||
lobby.currentBattle.votes = new Map();
|
lobby.currentBattle.votes = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the vote with player name for UI display
|
// Record the vote with player name for UI display
|
||||||
lobby.currentBattle.votes.set(playerId, {
|
lobby.currentBattle.votes[playerId] = {
|
||||||
songId,
|
songId,
|
||||||
playerName
|
playerName
|
||||||
});
|
};
|
||||||
|
|
||||||
// Update vote counts
|
// Update vote counts
|
||||||
if (songId === lobby.currentBattle.song1.id) {
|
if (songId === lobby.currentBattle.song1.id) {
|
||||||
@ -578,7 +613,7 @@ class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a voteCount attribute for easier UI rendering
|
// 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
|
// For bye battles, the host's vote is all that's needed
|
||||||
if (lobby.currentBattle.bye === true && playerId === lobby.hostId) {
|
if (lobby.currentBattle.bye === true && playerId === lobby.hostId) {
|
||||||
@ -605,7 +640,7 @@ class GameManager {
|
|||||||
|
|
||||||
// For regular battles, check if all connected players have voted
|
// For regular battles, check if all connected players have voted
|
||||||
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
|
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) {
|
if (voteCount >= connectedPlayers) {
|
||||||
// Determine winner
|
// Determine winner
|
||||||
@ -680,7 +715,7 @@ class GameManager {
|
|||||||
// For voting state, check if we need to progress
|
// For voting state, check if we need to progress
|
||||||
if (lobby.state === 'VOTING' && lobby.currentBattle) {
|
if (lobby.state === 'VOTING' && lobby.currentBattle) {
|
||||||
// Add null check for votes property
|
// 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;
|
const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
|
||||||
|
|
||||||
if (totalVotes >= remainingPlayers && totalVotes > 0) {
|
if (totalVotes >= remainingPlayers && totalVotes > 0) {
|
||||||
@ -765,7 +800,7 @@ class GameManager {
|
|||||||
winner: null, // Set to null until host advances it
|
winner: null, // Set to null until host advances it
|
||||||
song1Votes: 0,
|
song1Votes: 0,
|
||||||
song2Votes: 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,
|
song1Votes: 0,
|
||||||
song2Votes: 0,
|
song2Votes: 0,
|
||||||
winner: null,
|
winner: null,
|
||||||
votes: new Map()
|
votes: {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -795,8 +830,8 @@ class GameManager {
|
|||||||
// Ensure we create a proper battle object with all required fields
|
// Ensure we create a proper battle object with all required fields
|
||||||
lobby.currentBattle = {
|
lobby.currentBattle = {
|
||||||
...brackets[0],
|
...brackets[0],
|
||||||
// Make sure votes is a Map (it might be serialized incorrectly)
|
// Make sure votes is an object (not a Map)
|
||||||
votes: brackets[0].votes || new Map(),
|
votes: brackets[0].votes || {},
|
||||||
voteCount: 0
|
voteCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -863,24 +898,22 @@ class GameManager {
|
|||||||
winner: null, // Set to null until host advances it
|
winner: null, // Set to null until host advances it
|
||||||
song1Votes: 0,
|
song1Votes: 0,
|
||||||
song2Votes: 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) {
|
||||||
// Create pairs for next round
|
if (i + 1 < nextRoundSongs.length) {
|
||||||
for (let i = 0; i < nextRoundSongs.length; i += 2) {
|
nextRound.push({
|
||||||
if (i + 1 < nextRoundSongs.length) {
|
round,
|
||||||
nextRound.push({
|
song1: nextRoundSongs[i],
|
||||||
round,
|
song2: nextRoundSongs[i + 1],
|
||||||
song1: nextRoundSongs[i],
|
song1Votes: 0,
|
||||||
song2: nextRoundSongs[i + 1],
|
song2Votes: 0,
|
||||||
song1Votes: 0,
|
winner: null,
|
||||||
song2Votes: 0,
|
votes: {}
|
||||||
winner: null,
|
});
|
||||||
votes: new Map()
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update brackets and reset index
|
// Update brackets and reset index
|
||||||
lobby.brackets = nextRound;
|
lobby.brackets = nextRound;
|
||||||
@ -890,9 +923,9 @@ class GameManager {
|
|||||||
if (nextRound.length > 0) {
|
if (nextRound.length > 0) {
|
||||||
lobby.currentBattle = nextRound[0];
|
lobby.currentBattle = nextRound[0];
|
||||||
|
|
||||||
// Ensure votes is initialized as a Map
|
// Ensure votes is initialized as an object
|
||||||
if (!lobby.currentBattle.votes) {
|
if (!lobby.currentBattle.votes) {
|
||||||
lobby.currentBattle.votes = new Map();
|
lobby.currentBattle.votes = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize vote count for UI
|
// Initialize vote count for UI
|
||||||
|
@ -73,9 +73,9 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Attempt to reconnect to a lobby
|
// Attempt to reconnect to a lobby
|
||||||
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => {
|
socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => {
|
||||||
try {
|
try {
|
||||||
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName);
|
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (callback) callback(result);
|
if (callback) callback(result);
|
||||||
@ -337,7 +337,7 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle disconnection
|
// Handle player disconnection
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
try {
|
try {
|
||||||
const result = gameManager.handleDisconnect(socket.id);
|
const result = gameManager.handleDisconnect(socket.id);
|
||||||
@ -355,6 +355,13 @@ io.on('connection', (socket) => {
|
|||||||
console.error('Error handling disconnect:', error);
|
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) => {
|
server.on('error', (error) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user