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.activeGames = new Map(); // gameId -> gameData
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.disconnectTimeouts = new Map(); // playerId -> timeoutId
}
// Validate and register player
@@ -57,17 +60,51 @@ class GameManager {
await this.broadcastActivePlayers();
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) {
console.error('Error registering player:', error);
return { success: false, error: 'Registration failed' };
@@ -95,9 +132,13 @@ class GameManager {
}
// Set timeout for game abandonment
setTimeout(async () => {
const stillDisconnected = !this.players.has(socket.id);
if (stillDisconnected && game.state === 'active') {
const timeoutId = setTimeout(async () => {
// Check if player has reconnected (is in playerSockets)
// 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
await this.db.abandonGame(player.currentGameId, opponentId);
@@ -116,7 +157,11 @@ class GameManager {
this.activeGames.delete(player.currentGameId);
}
this.disconnectTimeouts.delete(player.id);
}, 30000); // 30 second grace period
this.disconnectTimeouts.set(player.id, timeoutId);
}
}
@@ -465,6 +510,14 @@ class GameManager {
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)
const winnerId = game.player1Id === player.id ? game.player2Id : game.player1Id;
@@ -561,6 +614,12 @@ class GameManager {
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
if (rematch.challenged !== player.id) {
socket.emit('error', { message: 'Invalid rematch accept' });
@@ -576,6 +635,13 @@ class GameManager {
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)
try {
const gameId = await this.db.createGame(

View File

@@ -191,6 +191,10 @@ class MultiplayerClient {
this.showMessage(data.message + '. Waiting for reconnection...', 'warning');
});
socket.on('opponent_reconnected', (data) => {
this.showMessage(data.message, 'success');
});
// Send heartbeat every 30 seconds
setInterval(() => {
if (this.socket && this.socket.connected) {
@@ -266,6 +270,12 @@ class MultiplayerClient {
// 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 {
// Registration failed - clear saved username and show modal
localStorage.removeItem('connect5_username');
@@ -431,6 +441,35 @@ class MultiplayerClient {
}
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