Reliability: Fix disconnect logic, add game restoration, and prevent race conditions

- gameManager.js:
  - Added disconnectTimeouts Map to track/clear pending timeouts correctly
  - Updated handleDisconnect to not abandon game if player reconnects
  - Updated registerPlayer to clear timeouts and restore active game state
  - Added race condition checks to handleSurrender and acceptRematch

- multiplayer.js:
  - Updated handleRegistration to resume active game if data provided
  - Updated startMultiplayerGame to restore board state from server data
This commit is contained in:
2025-12-22 19:25:15 +11:00
parent f879050b0c
commit 69dc70ee06
2 changed files with 117 additions and 12 deletions

View File

@@ -9,7 +9,10 @@ class GameManager {
this.challenges = new Map(); // challengeId -> challengeData this.challenges = new Map(); // challengeId -> challengeData
this.activeGames = new Map(); // gameId -> gameData this.activeGames = new Map(); // gameId -> gameData
this.playerSockets = new Map(); // playerId -> socketId this.playerSockets = new Map(); // playerId -> socketId
this.activeGames = new Map(); // gameId -> gameData
this.playerSockets = new Map(); // playerId -> socketId
this.rematches = new Map(); // rematchId -> rematchData this.rematches = new Map(); // rematchId -> rematchData
this.disconnectTimeouts = new Map(); // playerId -> timeoutId
} }
// Validate and register player // Validate and register player
@@ -57,17 +60,51 @@ class GameManager {
await this.broadcastActivePlayers(); await this.broadcastActivePlayers();
return { return {
success: true,
player: {
id: playerId,
username: username,
stats: {
wins: player.total_wins,
losses: player.total_losses,
draws: player.total_draws
}
} }
},
activeGame: null // Will be populated if they are reconnecting
}; };
// Check if player is in an active game (reconnection)
for (const [gameId, game] of this.activeGames.entries()) {
if (game.state === 'active' && (game.player1Id === playerId || game.player2Id === playerId)) {
// Restore game state for player
const playerEntry = this.players.get(socket.id);
if (playerEntry) {
playerEntry.currentGameId = gameId;
}
// Clear any pending disconnect timeout
if (this.disconnectTimeouts.has(playerId)) {
clearTimeout(this.disconnectTimeouts.get(playerId));
this.disconnectTimeouts.delete(playerId);
}
// Notify opponent of reconnection
const opponentId = game.player1Id === playerId ? game.player2Id : game.player1Id;
const opponentSocket = this.playerSockets.get(opponentId);
if (opponentSocket) {
this.io.to(opponentSocket).emit('opponent_reconnected', {
message: `${username} reconnected!`
});
}
// Add game data to response so client can restore board
response.activeGame = {
gameId: gameId,
opponent: game.player1Id === playerId ? game.player2Username : game.player1Username,
opponentId: game.player1Id === playerId ? game.player2Id : game.player1Id,
yourSymbol: game.player1Id === playerId ? game.player1Symbol : game.player2Symbol,
boardSize: game.boardSize,
yourTurn: game.currentTurn === playerId,
board: game.board,
currentTurnSymbol: game.currentTurn === game.player1Id ? game.player1Symbol : game.player2Symbol
};
break;
}
}
return response;
} catch (error) { } catch (error) {
console.error('Error registering player:', error); console.error('Error registering player:', error);
return { success: false, error: 'Registration failed' }; return { success: false, error: 'Registration failed' };
@@ -95,9 +132,13 @@ class GameManager {
} }
// Set timeout for game abandonment // Set timeout for game abandonment
setTimeout(async () => { const timeoutId = setTimeout(async () => {
const stillDisconnected = !this.players.has(socket.id); // Check if player has reconnected (is in playerSockets)
if (stillDisconnected && game.state === 'active') { // We check this.playerSockets because if they reconnected, registerPlayer
// would have put them back in there.
const hasReconnected = this.playerSockets.has(player.id);
if (!hasReconnected && game.state === 'active') {
// Award win to opponent // Award win to opponent
await this.db.abandonGame(player.currentGameId, opponentId); await this.db.abandonGame(player.currentGameId, opponentId);
@@ -116,7 +157,11 @@ class GameManager {
this.activeGames.delete(player.currentGameId); this.activeGames.delete(player.currentGameId);
} }
this.disconnectTimeouts.delete(player.id);
}, 30000); // 30 second grace period }, 30000); // 30 second grace period
this.disconnectTimeouts.set(player.id, timeoutId);
} }
} }
@@ -465,6 +510,14 @@ class GameManager {
return; return;
} }
if (game.state !== 'active') {
socket.emit('error', { message: 'Game is already ending' });
return;
}
// Set state immediately to prevent double-surrender race conditions
game.state = 'completed';
// Determine winner (the opponent) // Determine winner (the opponent)
const winnerId = game.player1Id === player.id ? game.player2Id : game.player1Id; const winnerId = game.player1Id === player.id ? game.player2Id : game.player1Id;
@@ -561,6 +614,12 @@ class GameManager {
return; return;
} }
// Verify players are not already in a game
if (player.currentGameId) {
socket.emit('error', { message: 'You are already in a game' });
return;
}
// Verify this player is the challenged one // Verify this player is the challenged one
if (rematch.challenged !== player.id) { if (rematch.challenged !== player.id) {
socket.emit('error', { message: 'Invalid rematch accept' }); socket.emit('error', { message: 'Invalid rematch accept' });
@@ -576,6 +635,13 @@ class GameManager {
return; return;
} }
const challenger = this.players.get(challengerSocket);
if (challenger && challenger.currentGameId) {
socket.emit('error', { message: 'Challenger is already in a game' });
this.rematches.delete(data.rematchId);
return;
}
// Create new game (similar to acceptChallenge logic) // Create new game (similar to acceptChallenge logic)
try { try {
const gameId = await this.db.createGame( const gameId = await this.db.createGame(

View File

@@ -191,6 +191,10 @@ class MultiplayerClient {
this.showMessage(data.message + '. Waiting for reconnection...', 'warning'); this.showMessage(data.message + '. Waiting for reconnection...', 'warning');
}); });
socket.on('opponent_reconnected', (data) => {
this.showMessage(data.message, 'success');
});
// Send heartbeat every 30 seconds // Send heartbeat every 30 seconds
setInterval(() => { setInterval(() => {
if (this.socket && this.socket.connected) { if (this.socket && this.socket.connected) {
@@ -266,6 +270,12 @@ class MultiplayerClient {
// Request active players // Request active players
this.socket.emit('request_active_players'); this.socket.emit('request_active_players');
// Check for active game restoration
if (data.activeGame) {
console.log(' restoring active game:', data.activeGame);
this.startMultiplayerGame(data.activeGame);
}
} else { } else {
// Registration failed - clear saved username and show modal // Registration failed - clear saved username and show modal
localStorage.removeItem('connect5_username'); localStorage.removeItem('connect5_username');
@@ -431,6 +441,35 @@ class MultiplayerClient {
} }
console.log(`✅ Game started! You are ${this.mySymbol}, ${this.myTurn ? 'your turn' : 'waiting'}`); console.log(`✅ Game started! You are ${this.mySymbol}, ${this.myTurn ? 'your turn' : 'waiting'}`);
// Restore board state if provided (reconnection)
if (data.board) {
this.game.board = data.board;
// Re-render board
const cells = this.game.boardElement.children;
for (let r = 0; r < data.boardSize; r++) {
for (let c = 0; c < data.boardSize; c++) {
const symbol = data.board[r][c];
if (symbol) {
const index = r * data.boardSize + c;
const cell = cells[index];
if (cell) {
cell.classList.add('occupied', symbol.toLowerCase());
cell.textContent = ''; // CSS handles the X/O appearance
}
}
}
}
// Update current player for game logic
if (data.currentTurnSymbol) {
this.game.currentPlayer = data.currentTurnSymbol;
const currentPlayerDisplay = document.getElementById('currentPlayer');
if (currentPlayerDisplay) {
currentPlayerDisplay.textContent = this.game.currentPlayer;
}
}
}
} }
// Make move in multiplayer game // Make move in multiplayer game