diff --git a/database.js b/database.js new file mode 100644 index 0000000..0de1653 --- /dev/null +++ b/database.js @@ -0,0 +1,232 @@ +const mysql = require('mysql2/promise'); + +// Database configuration +const dbConfig = { + host: 'oceprod.beyondcloud.solutions', + user: 'appgconnect5_dbuser', + password: 'REqTtHhZCKAlJAnznjLx8ZhOq', + database: 'appgconnect5_db', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}; + +// Create connection pool +const pool = mysql.createPool(dbConfig); + +// Initialize database schema +async function initializeDatabase() { + try { + const connection = await pool.getConnection(); + + // Create players table + await connection.query(` + CREATE TABLE IF NOT EXISTS players ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + total_wins INT DEFAULT 0, + total_losses INT DEFAULT 0, + total_draws INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_username (username) + ) + `); + + // Create active sessions table + await connection.query(` + CREATE TABLE IF NOT EXISTS active_sessions ( + session_id VARCHAR(100) PRIMARY KEY, + player_id INT NOT NULL, + username VARCHAR(50) NOT NULL, + connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE + ) + `); + + // Create games table + await connection.query(` + CREATE TABLE IF NOT EXISTS games ( + id INT AUTO_INCREMENT PRIMARY KEY, + player1_id INT NOT NULL, + player2_id INT NOT NULL, + player1_username VARCHAR(50) NOT NULL, + player2_username VARCHAR(50) NOT NULL, + board_size INT DEFAULT 15, + winner_id INT, + game_state ENUM('pending', 'active', 'completed', 'abandoned') DEFAULT 'pending', + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + FOREIGN KEY (player1_id) REFERENCES players(id), + FOREIGN KEY (player2_id) REFERENCES players(id), + FOREIGN KEY (winner_id) REFERENCES players(id) + ) + `); + + // Create game moves table + await connection.query(` + CREATE TABLE IF NOT EXISTS game_moves ( + id INT AUTO_INCREMENT PRIMARY KEY, + game_id INT NOT NULL, + player_id INT NOT NULL, + row_position INT NOT NULL, + col_position INT NOT NULL, + move_number INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, + FOREIGN KEY (player_id) REFERENCES players(id), + INDEX idx_game (game_id) + ) + `); + + connection.release(); + console.log('✅ Database schema initialized successfully'); + } catch (error) { + console.error('❌ Error initializing database:', error); + throw error; + } +} + +// Database query functions +const db = { + // Create or get player + async createPlayer(username) { + try { + const [result] = await pool.query( + 'INSERT INTO players (username) VALUES (?) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)', + [username] + ); + return result.insertId; + } catch (error) { + console.error('Error creating player:', error); + throw error; + } + }, + + // Get player by username + async getPlayer(username) { + const [rows] = await pool.query( + 'SELECT * FROM players WHERE username = ?', + [username] + ); + return rows[0]; + }, + + // Get player by ID + async getPlayerById(playerId) { + const [rows] = await pool.query( + 'SELECT * FROM players WHERE id = ?', + [playerId] + ); + return rows[0]; + }, + + // Add active session + async addSession(sessionId, playerId, username) { + await pool.query( + 'INSERT INTO active_sessions (session_id, player_id, username) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE last_heartbeat = CURRENT_TIMESTAMP', + [sessionId, playerId, username] + ); + }, + + // Remove session + async removeSession(sessionId) { + await pool.query( + 'DELETE FROM active_sessions WHERE session_id = ?', + [sessionId] + ); + }, + + // Get all active players + async getActivePlayers() { + const [rows] = await pool.query(` + SELECT s.session_id, s.username, p.total_wins, p.total_losses, p.total_draws + FROM active_sessions s + JOIN players p ON s.player_id = p.id + WHERE s.last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE) + `); + return rows; + }, + + // Create new game + async createGame(player1Id, player2Id, player1Username, player2Username, boardSize) { + const [result] = await pool.query( + 'INSERT INTO games (player1_id, player2_id, player1_username, player2_username, board_size, game_state) VALUES (?, ?, ?, ?, ?, ?)', + [player1Id, player2Id, player1Username, player2Username, boardSize, 'active'] + ); + return result.insertId; + }, + + // Record move + async recordMove(gameId, playerId, row, col, moveNumber) { + await pool.query( + 'INSERT INTO game_moves (game_id, player_id, row_position, col_position, move_number) VALUES (?, ?, ?, ?, ?)', + [gameId, playerId, row, col, moveNumber] + ); + }, + + // Complete game + async completeGame(gameId, winnerId) { + await pool.query( + 'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?', + ['completed', winnerId, gameId] + ); + + // Update player stats + if (winnerId) { + // Get game details + const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); + if (game.length > 0) { + const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id; + + // Update winner + await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]); + + // Update loser + await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]); + } + } else { + // Draw - update both players + const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); + if (game.length > 0) { + await pool.query('UPDATE players SET total_draws = total_draws + 1 WHERE id IN (?, ?)', + [game[0].player1_id, game[0].player2_id]); + } + } + }, + + // Abandon game + async abandonGame(gameId, winnerId) { + await pool.query( + 'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?', + ['abandoned', winnerId, gameId] + ); + + // Update stats (winner gets win, other player gets loss) + if (winnerId) { + const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); + if (game.length > 0) { + const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id; + await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]); + await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]); + } + } + }, + + // Update heartbeat + async updateHeartbeat(sessionId) { + await pool.query( + 'UPDATE active_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_id = ?', + [sessionId] + ); + }, + + // Clean up stale sessions + async cleanupStaleSessions() { + await pool.query( + 'DELETE FROM active_sessions WHERE last_heartbeat < DATE_SUB(NOW(), INTERVAL 2 MINUTE)' + ); + } +}; + +module.exports = { pool, initializeDatabase, db }; diff --git a/game.js b/game.js index 36da2e0..bae3212 100644 --- a/game.js +++ b/game.js @@ -74,6 +74,17 @@ class Connect5Game { return; } + // Check if in multiplayer mode and if it's our turn + if (multiplayerClient && multiplayerClient.isMultiplayer) { + if (!multiplayerClient.myTurn) { + return; // Not our turn in multiplayer + } + + // Send move to server + const moveSent = multiplayerClient.makeMove(row, col); + if (!moveSent) return; + } + // Place piece this.board[row][col] = this.currentPlayer; @@ -82,25 +93,28 @@ class Connect5Game { const cell = this.boardElement.children[cellIndex]; cell.classList.add("occupied", this.currentPlayer.toLowerCase()); - // Check for win - if (this.checkWin(row, col)) { - this.gameActive = false; - this.scores[this.currentPlayer]++; - this.updateScores(); - this.showVictoryOverlay(); - return; - } + // In local mode only, check for win/draw and switch player + if (!multiplayerClient || !multiplayerClient.isMultiplayer) { + // Check for win + if (this.checkWin(row, col)) { + this.gameActive = false; + this.scores[this.currentPlayer]++; + this.updateScores(); + this.showVictoryOverlay(); + return; + } - // Check for draw - if (this.checkDraw()) { - this.gameActive = false; - this.statusMessage.textContent = "It's a draw! Board is full."; - return; - } + // Check for draw + if (this.checkDraw()) { + this.gameActive = false; + this.statusMessage.textContent = "It's a draw! Board is full."; + return; + } - // Switch player - this.currentPlayer = this.currentPlayer === "X" ? "O" : "X"; - this.updateStatus(); + // Switch player + this.currentPlayer = this.currentPlayer === "X" ? "O" : "X"; + this.updateStatus(); + } } checkWin(row, col) { @@ -234,6 +248,7 @@ class Connect5Game { } // Initialize game when DOM is loaded +let game; document.addEventListener("DOMContentLoaded", () => { - const game = new Connect5Game(); + game = new Connect5Game(); }); diff --git a/gameManager.js b/gameManager.js new file mode 100644 index 0000000..4fe2183 --- /dev/null +++ b/gameManager.js @@ -0,0 +1,452 @@ +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 + } + + // 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(); + + return { + success: true, + player: { + id: playerId, + username: username, + stats: { + wins: player.total_wins, + losses: player.total_losses, + draws: player.total_draws + } + } + }; + } 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 + setTimeout(async () => { + const stillDisconnected = !this.players.has(socket.id); + if (stillDisconnected && 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); + } + }, 30000); // 30 second grace period + } + } + + // 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, + yourSymbol: gameData.player1Symbol, + boardSize: challenge.boardSize, + yourTurn: true + }); + } + + this.io.to(socket.id).emit('game_started', { + gameId: gameId, + opponent: challenge.challengerUsername, + 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); + } + } +} + +module.exports = GameManager; diff --git a/index.html b/index.html index 583ef8d..2d5bdae 100644 --- a/index.html +++ b/index.html @@ -7,12 +7,15 @@