Files
Connect-5/gameManager.js

738 lines
27 KiB
JavaScript

const Filter = require('bad-words');
const filter = new Filter();
class GameManager {
constructor(io, db) {
this.io = io;
this.db = db;
this.players = new Map(); // socketId -> playerData
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
async registerPlayer(socket, username) {
try {
// Validate username
if (!username || typeof username !== 'string') {
return { success: false, error: 'Invalid username' };
}
// Clean and validate length
username = username.trim();
if (username.length < 3 || username.length > 20) {
return { success: false, error: 'Username must be 3-20 characters' };
}
// Check for profanity
if (filter.isProfane(username)) {
return { success: false, error: 'Please use a family-friendly username' };
}
// Check alphanumeric plus basic chars
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return { success: false, error: 'Username can only contain letters, numbers, underscores, and hyphens' };
}
// Create or get player from database
const playerId = await this.db.createPlayer(username);
const player = await this.db.getPlayerById(playerId);
// Add session to database
await this.db.addSession(socket.id, playerId, username);
// Store in memory
this.players.set(socket.id, {
id: playerId,
username: username,
socketId: socket.id,
currentGameId: null
});
this.playerSockets.set(playerId, socket.id);
// Broadcast updated player list
await this.broadcastActivePlayers();
const response = {
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' };
}
}
// Handle player disconnect
async handleDisconnect(socket) {
const player = this.players.get(socket.id);
if (!player) return;
// Check if player is in an active game
if (player.currentGameId) {
const game = this.activeGames.get(player.currentGameId);
if (game && game.state === 'active') {
// Notify opponent
const opponentId = game.player1Id === player.id ? game.player2Id : game.player1Id;
const opponentSocketId = this.playerSockets.get(opponentId);
if (opponentSocketId) {
this.io.to(opponentSocketId).emit('opponent_disconnected', {
message: `${player.username} disconnected`,
waitTime: 30
});
}
// Set timeout for game abandonment
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);
if (opponentSocketId) {
const opponentPlayer = await this.db.getPlayerById(opponentId);
this.io.to(opponentSocketId).emit('game_ended', {
reason: 'opponent_abandoned',
winner: opponentId,
stats: {
wins: opponentPlayer.total_wins,
losses: opponentPlayer.total_losses,
draws: opponentPlayer.total_draws
}
});
}
this.activeGames.delete(player.currentGameId);
}
this.disconnectTimeouts.delete(player.id);
}, 30000); // 30 second grace period
this.disconnectTimeouts.set(player.id, timeoutId);
}
}
// Remove from active lists
await this.db.removeSession(socket.id);
this.players.delete(socket.id);
this.playerSockets.delete(player.id);
// Broadcast updated player list
await this.broadcastActivePlayers();
}
// Broadcast active players to all connected clients
async broadcastActivePlayers() {
try {
const activePlayers = await this.db.getActivePlayers();
this.io.emit('active_players_update', activePlayers);
} catch (error) {
console.error('Error broadcasting active players:', error);
}
}
// Send challenge
async sendChallenge(socket, targetUsername, boardSize) {
const challenger = this.players.get(socket.id);
if (!challenger) {
return { success: false, error: 'Not registered' };
}
// Find target player
const target = Array.from(this.players.values()).find(p => p.username === targetUsername);
if (!target) {
return { success: false, error: 'Player not found' };
}
if (target.currentGameId) {
return { success: false, error: 'Player is already in a game' };
}
// Create challenge
const challengeId = `${challenger.id}-${target.id}-${Date.now()}`;
this.challenges.set(challengeId, {
id: challengeId,
challengerId: challenger.id,
challengerUsername: challenger.username,
targetId: target.id,
targetUsername: target.username,
boardSize: boardSize,
timestamp: Date.now()
});
// Send challenge to target
this.io.to(target.socketId).emit('challenge_received', {
challengeId: challengeId,
from: challenger.username,
boardSize: boardSize
});
return { success: true, message: 'Challenge sent' };
}
// Accept challenge
async acceptChallenge(socket, challengeId) {
const player = this.players.get(socket.id);
const challenge = this.challenges.get(challengeId);
if (!player || !challenge) {
return { success: false, error: 'Invalid challenge' };
}
if (challenge.targetId !== player.id) {
return { success: false, error: 'Not your challenge' };
}
// Remove challenge from pending
this.challenges.delete(challengeId);
// Create game in database
const gameId = await this.db.createGame(
challenge.challengerId,
challenge.targetId,
challenge.challengerUsername,
challenge.targetUsername,
challenge.boardSize
);
// Randomly assign X and O
const player1IsX = Math.random() < 0.5;
// Create game in memory
const gameData = {
id: gameId,
player1Id: challenge.challengerId,
player2Id: challenge.targetId,
player1Username: challenge.challengerUsername,
player2Username: challenge.targetUsername,
player1Symbol: player1IsX ? 'X' : 'O',
player2Symbol: player1IsX ? 'O' : 'X',
boardSize: challenge.boardSize,
currentTurn: challenge.challengerId, // Challenger goes first
state: 'active',
board: Array(challenge.boardSize).fill(null).map(() => Array(challenge.boardSize).fill(null)),
moveCount: 0
};
this.activeGames.set(gameId, gameData);
// Update players' current game
const challenger = this.players.get(this.playerSockets.get(challenge.challengerId));
const accepter = this.players.get(socket.id);
if (challenger) challenger.currentGameId = gameId;
if (accepter) accepter.currentGameId = gameId;
// Notify both players
const challengerSocket = this.playerSockets.get(challenge.challengerId);
if (challengerSocket) {
this.io.to(challengerSocket).emit('game_started', {
gameId: gameId,
opponent: challenge.targetUsername,
opponentId: challenge.targetId,
yourSymbol: gameData.player1Symbol,
boardSize: challenge.boardSize,
yourTurn: true
});
}
this.io.to(socket.id).emit('game_started', {
gameId: gameId,
opponent: challenge.challengerUsername,
opponentId: challenge.challengerId,
yourSymbol: gameData.player2Symbol,
boardSize: challenge.boardSize,
yourTurn: false
});
return { success: true };
}
// Decline challenge
declineChallenge(socket, challengeId) {
const challenge = this.challenges.get(challengeId);
if (!challenge) return;
this.challenges.delete(challengeId);
// Notify challenger
const challengerSocket = this.playerSockets.get(challenge.challengerId);
if (challengerSocket) {
this.io.to(challengerSocket).emit('challenge_declined', {
by: challenge.targetUsername
});
}
}
// Handle game move
async handleMove(socket, moveData) {
const player = this.players.get(socket.id);
if (!player) return { success: false, error: 'Not registered' };
const game = this.activeGames.get(moveData.gameId);
if (!game) return { success: false, error: 'Game not found' };
if (game.state !== 'active') return { success: false, error: 'Game not active' };
// Validate it's player's turn
if (game.currentTurn !== player.id) {
return { success: false, error: 'Not your turn' };
}
// Validate move
const { row, col } = moveData;
if (row < 0 || row >= game.boardSize || col < 0 || col >= game.boardSize) {
return { success: false, error: 'Invalid position' };
}
if (game.board[row][col] !== null) {
return { success: false, error: 'Cell occupied' };
}
// Determine player's symbol
const playerSymbol = game.player1Id === player.id ? game.player1Symbol : game.player2Symbol;
// Make move
game.board[row][col] = playerSymbol;
game.moveCount++;
// Record move in database
await this.db.recordMove(game.id, player.id, row, col, game.moveCount);
// Broadcast move to opponent
const opponentId = game.player1Id === player.id ? game.player2Id : game.player1Id;
const opponentSocket = this.playerSockets.get(opponentId);
if (opponentSocket) {
this.io.to(opponentSocket).emit('opponent_move', {
row: row,
col: col,
symbol: playerSymbol
});
}
// Check for win
const winner = this.checkWin(game, row, col, playerSymbol);
if (winner) {
game.state = 'completed';
await this.db.completeGame(game.id, player.id);
const winnerPlayer = await this.db.getPlayerById(player.id);
const loserPlayer = await this.db.getPlayerById(opponentId);
// Notify both players
this.io.to(socket.id).emit('game_ended', {
reason: 'win',
winner: player.id,
stats: {
wins: winnerPlayer.total_wins,
losses: winnerPlayer.total_losses,
draws: winnerPlayer.total_draws
}
});
if (opponentSocket) {
this.io.to(opponentSocket).emit('game_ended', {
reason: 'loss',
winner: player.id,
stats: {
wins: loserPlayer.total_wins,
losses: loserPlayer.total_losses,
draws: loserPlayer.total_draws
}
});
}
// Clean up
this.activeGames.delete(game.id);
if (this.players.has(socket.id)) this.players.get(socket.id).currentGameId = null;
if (opponentSocket && this.players.has(opponentSocket)) {
this.players.get(opponentSocket).currentGameId = null;
}
return { success: true, gameOver: true, winner: true };
}
// Check for draw
if (game.moveCount === game.boardSize * game.boardSize) {
game.state = 'completed';
await this.db.completeGame(game.id, null);
const player1Data = await this.db.getPlayerById(game.player1Id);
const player2Data = await this.db.getPlayerById(game.player2Id);
this.io.to(socket.id).emit('game_ended', {
reason: 'draw',
stats: {
wins: player.id === game.player1Id ? player1Data.total_wins : player2Data.total_wins,
losses: player.id === game.player1Id ? player1Data.total_losses : player2Data.total_losses,
draws: player.id === game.player1Id ? player1Data.total_draws : player2Data.total_draws
}
});
if (opponentSocket) {
this.io.to(opponentSocket).emit('game_ended', {
reason: 'draw',
stats: {
wins: opponentId === game.player1Id ? player1Data.total_wins : player2Data.total_wins,
losses: opponentId === game.player1Id ? player1Data.total_losses : player2Data.total_losses,
draws: opponentId === game.player1Id ? player1Data.total_draws : player2Data.total_draws
}
});
}
this.activeGames.delete(game.id);
if (this.players.has(socket.id)) this.players.get(socket.id).currentGameId = null;
if (opponentSocket && this.players.has(opponentSocket)) {
this.players.get(opponentSocket).currentGameId = null;
}
return { success: true, gameOver: true, draw: true };
}
// Switch turn
game.currentTurn = opponentId;
return { success: true, gameOver: false };
}
// Check win condition (same as frontend logic)
checkWin(game, row, col, symbol) {
const directions = [
[0, 1], // Horizontal
[1, 0], // Vertical
[1, 1], // Diagonal \
[1, -1] // Diagonal /
];
for (const [dx, dy] of directions) {
let count = 1;
count += this.countDirection(game, row, col, dx, dy, symbol);
count += this.countDirection(game, row, col, -dx, -dy, symbol);
if (count >= 5) {
return true;
}
}
return false;
}
countDirection(game, row, col, dx, dy, symbol) {
let count = 0;
let r = row + dx;
let c = col + dy;
while (
r >= 0 && r < game.boardSize &&
c >= 0 && c < game.boardSize &&
game.board[r][c] === symbol
) {
count++;
r += dx;
c += dy;
}
return count;
}
// Heartbeat to keep session alive
async heartbeat(socket) {
const player = this.players.get(socket.id);
if (player) {
await this.db.updateHeartbeat(socket.id);
}
}
// Handle player surrender
async handleSurrender(socket, data) {
const player = this.players.get(socket.id);
if (!player) {
socket.emit('error', { message: 'Player not found' });
return;
}
const game = this.activeGames.get(data.gameId);
if (!game) {
socket.emit('error', { message: 'Game not found' });
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;
// Update database - mark as completed with winner
try {
await this.db.abandonGame(data.gameId, winnerId);
// Get updated stats
const loserStats = await this.db.getPlayerById(player.id);
const winnerStats = await this.db.getPlayerById(winnerId);
// Find opponent socket
const opponentSocket = this.playerSockets.get(winnerId);
// Notify both players
socket.emit('game_ended', {
reason: 'surrender',
message: 'You surrendered',
stats: {
wins: loserStats.total_wins,
losses: loserStats.total_losses,
draws: loserStats.total_draws
}
});
if (opponentSocket) {
this.io.to(opponentSocket).emit('game_ended', {
reason: 'win',
message: `${player.username} surrendered`,
stats: {
wins: winnerStats.total_wins,
losses: winnerStats.total_losses,
draws: winnerStats.total_draws
}
});
}
// Clean up
this.activeGames.delete(data.gameId);
if (this.players.has(socket.id)) this.players.get(socket.id).currentGameId = null;
if (opponentSocket && this.players.has(opponentSocket)) {
this.players.get(opponentSocket).currentGameId = null;
}
} catch (error) {
console.error('Error handling surrender:', error);
socket.emit('error', { message: 'Failed to process surrender' });
}
}
// Send rematch request
sendRematch(socket, data) {
const player = this.players.get(socket.id);
if (!player) return;
// Find opponent's socket
const opponentSocket = this.playerSockets.get(data.opponentId);
if (!opponentSocket) {
socket.emit('error', { message: 'Opponent not online' });
return;
}
const rematchId = `rematch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!this.rematches) {
this.rematches = new Map();
}
this.rematches.set(rematchId, {
rematchId,
challenger: player.id,
challengerUsername: player.username,
challenged: data.opponentId,
boardSize: data.boardSize || 15,
timestamp: Date.now()
});
this.io.to(opponentSocket).emit('rematch_request', {
rematchId,
from: player.username,
boardSize: data.boardSize || 15
});
}
// Accept rematch
async acceptRematch(socket, data) {
const player = this.players.get(socket.id);
if (!player) return;
const rematch = this.rematches.get(data.rematchId);
if (!rematch) {
socket.emit('error', { message: 'Rematch request expired' });
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' });
return;
}
// Find challenger's socket
const challengerSocket = this.playerSockets.get(rematch.challenger);
if (!challengerSocket) {
socket.emit('error', { message: 'Challenger no longer online' });
this.rematches.delete(data.rematchId);
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(
rematch.challenger,
player.id,
rematch.challengerUsername,
player.username,
rematch.boardSize
);
// Randomly assign symbols
const player1IsX = Math.random() < 0.5;
const gameState = {
id: gameId,
player1Id: rematch.challenger,
player2Id: player.id,
player1Username: rematch.challengerUsername,
player2Username: player.username,
player1Symbol: player1IsX ? 'X' : 'O',
player2Symbol: player1IsX ? 'O' : 'X',
currentTurn: rematch.challenger,
boardSize: rematch.boardSize,
state: 'active',
board: Array(rematch.boardSize).fill(null).map(() => Array(rematch.boardSize).fill(null)),
moveCount: 0
};
this.activeGames.set(gameId, gameState);
// Update players' current game
const challenger = this.players.get(challengerSocket);
const accepter = this.players.get(socket.id);
if (challenger) challenger.currentGameId = gameId;
if (accepter) accepter.currentGameId = gameId;
// Notify both players
this.io.to(challengerSocket).emit('rematch_accepted', {
gameId: gameId,
opponent: player.username,
opponentId: player.id,
yourSymbol: gameState.player1Symbol,
yourTurn: true,
boardSize: rematch.boardSize
});
this.io.to(socket.id).emit('game_started', {
gameId: gameId,
opponent: rematch.challengerUsername,
opponentId: rematch.challenger,
yourSymbol: gameState.player2Symbol,
yourTurn: false,
boardSize: rematch.boardSize
});
this.rematches.delete(data.rematchId);
} catch (error) {
console.error('Error accepting rematch:', error);
socket.emit('error', { message: 'Failed to start rematch' });
}
}
// Decline rematch
declineRematch(socket, data) {
const player = this.players.get(socket.id);
if (!player) return;
const rematch = this.rematches.get(data.rematchId);
if (!rematch) return;
// Find challenger's socket
const challengerSocket = this.playerSockets.get(rematch.challenger);
if (challengerSocket) {
this.io.to(challengerSocket).emit('rematch_declined', {
by: player.username
});
}
this.rematches.delete(data.rematchId);
}
}
module.exports = GameManager;