const express = require('express'); const { v4: uuidv4 } = require('uuid'); const DatabaseManager = require('../database/init'); class GameAPI { constructor(db) { this.db = db; this.router = express.Router(); this.setupRoutes(); } setupRoutes() { // Game management this.router.post('/games', this.createGame.bind(this)); this.router.get('/games/:id', this.getGameState.bind(this)); this.router.post('/games/:id/join', this.joinGame.bind(this)); this.router.post('/games/:id/submit-turn', this.submitTurn.bind(this)); this.router.get('/games/:id/status', this.getGameStatus.bind(this)); this.router.post('/games/:id/rematch', this.createRematch.bind(this)); // Player management this.router.post('/players', this.createPlayer.bind(this)); this.router.get('/players/:id', this.getPlayer.bind(this)); // Statistics and leaderboard this.router.get('/leaderboard', this.getLeaderboard.bind(this)); this.router.get('/games/:id/history', this.getGameHistory.bind(this)); this.router.get('/games/:id/replay', this.getGameReplay.bind(this)); // Tournament endpoints (future expansion) this.router.get('/tournaments', this.getTournaments.bind(this)); this.router.post('/tournaments', this.createTournament.bind(this)); // Configuration endpoint this.router.get('/config', this.getConfig.bind(this)); } // Create a new game async createGame(req, res) { try { const { playerName, gameMode = 'standard' } = req.body; if (!playerName || playerName.trim().length === 0) { return res.status(400).json({ error: 'Player name is required' }); } // Create or get player const playerId = await this.getOrCreatePlayer(playerName.trim()); // Create game const gameId = await this.db.createGame(gameMode); // Generate spawn positions (player 1 spawns first) const spawnPositions = this.generateSpawnPositions(); await this.db.addPlayerToGame(gameId, playerId, 1, spawnPositions.player1.x, spawnPositions.player1.y); // Log game creation event await this.db.logGameEvent(gameId, 0, 1, 'game_created', playerId, { game_mode: gameMode, spawn_x: spawnPositions.player1.x, spawn_y: spawnPositions.player1.y }); res.json({ success: true, gameId: gameId, playerId: playerId, playerNumber: 1, inviteLink: `${req.protocol}://${req.get('host')}/game/${gameId}`, spawnPosition: spawnPositions.player1 }); } catch (error) { console.error('Error creating game:', error); res.status(500).json({ error: 'Failed to create game' }); } } // Join an existing game async joinGame(req, res) { try { const { id: gameId } = req.params; const { playerName } = req.body; if (!playerName || playerName.trim().length === 0) { return res.status(400).json({ error: 'Player name is required' }); } // Check if game exists and is waiting for players const gameState = await this.db.getGameState(gameId); if (!gameState) { return res.status(404).json({ error: 'Game not found' }); } if (gameState.game.status !== 'waiting') { return res.status(400).json({ error: 'Game is not accepting new players' }); } if (gameState.players.length >= GAME_CONFIG.MAX_PLAYERS) { return res.status(400).json({ error: 'Game is full' }); } // Create or get player const playerId = await this.getOrCreatePlayer(playerName.trim()); // Check if player is already in the game const existingPlayer = gameState.players.find(p => p.player_id === playerId); if (existingPlayer) { return res.status(400).json({ error: 'You are already in this game' }); } // Add player 2 const spawnPositions = this.generateSpawnPositions(); await this.db.addPlayerToGame(gameId, playerId, 2, spawnPositions.player2.x, spawnPositions.player2.y); // Update game status to playing await this.db.runQuery(` UPDATE games SET status = 'playing', started_at = CURRENT_TIMESTAMP WHERE id = ? `, [gameId]); // Log join event await this.db.logGameEvent(gameId, 0, 2, 'player_joined', playerId, { player_number: 2, spawn_x: spawnPositions.player2.x, spawn_y: spawnPositions.player2.y }); res.json({ success: true, gameId: gameId, playerId: playerId, playerNumber: 2, spawnPosition: spawnPositions.player2 }); } catch (error) { console.error('Error joining game:', error); res.status(500).json({ error: 'Failed to join game' }); } } // Submit turn actions async submitTurn(req, res) { try { const { id: gameId } = req.params; const { playerId, actions } = req.body; if (!playerId || !actions || !Array.isArray(actions)) { return res.status(400).json({ error: 'Invalid turn submission' }); } // Validate game state const gameState = await this.db.getGameState(gameId); if (!gameState || gameState.game.status !== 'playing') { return res.status(400).json({ error: 'Game is not active' }); } // Validate player is in the game const player = gameState.players.find(p => p.player_id === playerId); if (!player) { return res.status(400).json({ error: 'Player not in this game' }); } const currentTurn = gameState.game.current_turn; // Check if player has already submitted for this turn const existingSubmission = await this.db.getRow(` SELECT id FROM turn_submissions WHERE game_id = ? AND player_id = ? AND turn_number = ? `, [gameId, playerId, currentTurn]); if (existingSubmission) { return res.status(400).json({ error: 'Turn already submitted' }); } // Validate actions const validation = this.validateActions(actions); if (!validation.valid) { return res.status(400).json({ error: validation.error }); } // Submit turn await this.db.runQuery(` INSERT INTO turn_submissions (game_id, player_id, turn_number, actions, moves_used, shot_used) VALUES (?, ?, ?, ?, ?, ?) `, [gameId, playerId, currentTurn, JSON.stringify(actions), validation.movesUsed, validation.shotUsed]); // Check if both players have submitted const submissions = await this.db.getAllRows(` SELECT player_id FROM turn_submissions WHERE game_id = ? AND turn_number = ? `, [gameId, currentTurn]); let bothSubmitted = false; if (submissions.length === GAME_CONFIG.MAX_PLAYERS) { // Execute turn await this.executeTurn(gameId, currentTurn); bothSubmitted = true; } res.json({ success: true, turnSubmitted: true, bothPlayersSubmitted: bothSubmitted, waitingForOpponent: !bothSubmitted }); } catch (error) { console.error('Error submitting turn:', error); res.status(500).json({ error: 'Failed to submit turn' }); } } // Get current game status (for polling) async getGameStatus(req, res) { try { const { id: gameId } = req.params; const { playerId } = req.query; const gameState = await this.db.getGameState(gameId); if (!gameState) { return res.status(404).json({ error: 'Game not found' }); } // Get latest events for this turn const currentTurn = gameState.game.current_turn; const events = await this.db.getAllRows(` SELECT * FROM game_events WHERE game_id = ? AND turn_number >= ? ORDER BY turn_number DESC, sequence_number DESC LIMIT 10 `, [gameId, Math.max(0, currentTurn - 1)]); // Check turn submission status let turnStatus = 'waiting_for_submission'; if (playerId) { const mySubmission = await this.db.getRow(` SELECT id FROM turn_submissions WHERE game_id = ? AND player_id = ? AND turn_number = ? `, [gameId, playerId, currentTurn]); const allSubmissions = await this.db.getAllRows(` SELECT player_id FROM turn_submissions WHERE game_id = ? AND turn_number = ? `, [gameId, currentTurn]); if (mySubmission) { turnStatus = allSubmissions.length === 2 ? 'executed' : 'waiting_for_opponent'; } } res.json({ game: gameState.game, players: gameState.players, recentEvents: events, turnStatus: turnStatus, needsUpdate: events.length > 0 }); } catch (error) { console.error('Error getting game status:', error); res.status(500).json({ error: 'Failed to get game status' }); } } // Get complete game state async getGameState(req, res) { try { const { id: gameId } = req.params; const gameState = await this.db.getGameState(gameId); if (!gameState) { return res.status(404).json({ error: 'Game not found' }); } res.json(gameState); } catch (error) { console.error('Error getting game state:', error); res.status(500).json({ error: 'Failed to get game state' }); } } // Create or get existing player async getOrCreatePlayer(username) { // Check if player exists const existingPlayer = await this.db.getRow( 'SELECT id FROM players WHERE username = ?', [username] ); if (existingPlayer) { // Update last active await this.db.runQuery( 'UPDATE players SET last_active = CURRENT_TIMESTAMP WHERE id = ?', [existingPlayer.id] ); return existingPlayer.id; } // Create new player return await this.db.createPlayer(username); } // Generate spawn positions with minimum distance generateSpawnPositions() { const GAME_CONFIG = require('../game-config'); const minDistance = GAME_CONFIG.SPAWN_BUFFER; const gridWidth = GAME_CONFIG.GRID_WIDTH; const gridHeight = GAME_CONFIG.GRID_HEIGHT; // Simple spawn logic: corners with buffer const player1 = { x: minDistance, y: minDistance }; const player2 = { x: gridWidth - minDistance - 1, y: gridHeight - minDistance - 1 }; return { player1, player2 }; } // Validate turn actions validateActions(actions) { const GAME_CONFIG = require('../game-config'); let movesUsed = 0; let shotUsed = false; for (const action of actions) { if (!action.type || !action.direction) { return { valid: false, error: 'Invalid action format' }; } if (!['north', 'south', 'east', 'west'].includes(action.direction)) { return { valid: false, error: 'Invalid direction' }; } if (action.type === 'move') { movesUsed++; if (movesUsed > GAME_CONFIG.MOVES_PER_TURN) { return { valid: false, error: 'Too many moves' }; } } else if (action.type === 'shoot') { if (shotUsed) { return { valid: false, error: 'Only one shot per turn allowed' }; } shotUsed = true; } else { return { valid: false, error: 'Invalid action type' }; } } return { valid: true, movesUsed, shotUsed }; } // Execute turn when both players have submitted async executeTurn(gameId, turnNumber) { console.log(`Executing turn ${turnNumber} for game ${gameId}`); try { // Get current game state const gameState = await this.db.getGameState(gameId); if (!gameState || gameState.game.status !== 'playing') { console.error('Cannot execute turn: game not in playing state'); return; } // Get both players' submitted actions for this turn const submissions = await this.db.getAllRows(` SELECT player_id, actions, moves_used, shot_used FROM turn_submissions WHERE game_id = ? AND turn_number = ? ORDER BY submitted_at ASC `, [gameId, turnNumber]); if (submissions.length !== 2) { console.error(`Expected 2 submissions, got ${submissions.length}`); return; } // Parse actions for both players const playerActions = {}; submissions.forEach(submission => { playerActions[submission.player_id] = JSON.parse(submission.actions); }); // Get current player positions const players = {}; gameState.players.forEach(player => { players[player.player_id] = { id: player.player_id, number: player.player_number, x: player.current_x, y: player.current_y, alive: Boolean(player.is_alive) }; }); // Execute actions sequentially - shots can fire during movement console.log('Executing actions sequentially for all players'); const executionResults = await this.executeSequentialActions(gameId, turnNumber, playerActions, players); // Update player positions in database for (const playerId in players) { const player = players[playerId]; await this.db.runQuery(` UPDATE game_players SET current_x = ?, current_y = ?, is_alive = ? WHERE game_id = ? AND player_id = ? `, [player.x, player.y, player.alive ? 1 : 0, gameId, playerId]); } // Check for game end condition const alivePlayers = Object.values(players).filter(p => p.alive); if (alivePlayers.length <= 1) { // Game ends const winner = alivePlayers.length === 1 ? alivePlayers[0] : null; await this.endGame(gameId, winner, 'combat'); console.log(`Game ${gameId} ended. Winner: ${winner ? winner.id : 'none'}`); } else { // Advance to next turn await this.db.runQuery(` UPDATE games SET current_turn = current_turn + 1, last_action_at = CURRENT_TIMESTAMP WHERE id = ? `, [gameId]); console.log(`Turn ${turnNumber} completed, advanced to turn ${turnNumber + 1}`); } } catch (error) { console.error(`Error executing turn ${turnNumber} for game ${gameId}:`, error); } } // Execute all actions sequentially - allowing shots during movement async executeSequentialActions(gameId, turnNumber, playerActions, players) { // Determine the maximum number of actions any player has const maxActions = Math.max(...Object.values(playerActions).map(actions => actions.length)); // Execute actions step by step for all players simultaneously for (let actionIndex = 0; actionIndex < maxActions; actionIndex++) { console.log(`Executing action step ${actionIndex + 1} of ${maxActions}`); // Store original positions before any actions at this step const originalPositions = {}; for (const playerId in players) { originalPositions[playerId] = { x: players[playerId].x, y: players[playerId].y }; } // STEP 1: Process all shots first (from original positions before movement) for (const playerId in playerActions) { const actions = playerActions[playerId]; const player = players[playerId]; if (!player.alive || actionIndex >= actions.length) continue; const action = actions[actionIndex]; if (action.type === 'shoot') { // Shot fires from original position (before any movement this step) const shooterX = originalPositions[playerId].x; const shooterY = originalPositions[playerId].y; const bulletPath = this.calculateBulletPath({ x: shooterX, y: shooterY }, action.direction); // Log shot fired event with bullet path await this.db.logGameEvent(gameId, turnNumber, player.number, 'shot_fired', playerId, { action_index: actionIndex, shooter_x: shooterX, shooter_y: shooterY, direction: action.direction, bullet_path: bulletPath }); // Check if shot hits any player at their original positions (before movement) const hitTarget = this.checkInstantShotHit({ x: shooterX, y: shooterY, id: playerId }, action.direction, originalPositions, actionIndex, playerActions); if (hitTarget) { // Player hit! Find the actual player object and mark as dead const actualTarget = players[hitTarget.playerId]; actualTarget.alive = false; // Log hit event await this.db.logGameEvent(gameId, turnNumber, player.number, 'player_hit', actualTarget.id, { action_index: actionIndex, shooter_id: player.id, target_id: actualTarget.id, target_x: hitTarget.x, target_y: hitTarget.y, shot_direction: action.direction, bullet_path: bulletPath }); console.log(`Player ${player.id} hit player ${actualTarget.id} at action step ${actionIndex + 1}!`); } else { console.log(`Player ${player.id} shot ${action.direction} from (${shooterX},${shooterY}) - no hit`); } } } // STEP 2: Process all movements (after shots have been resolved) for (const playerId in playerActions) { const actions = playerActions[playerId]; const player = players[playerId]; if (!player.alive || actionIndex >= actions.length) continue; const action = actions[actionIndex]; if (action.type === 'move') { const oldX = player.x; const oldY = player.y; const newPosition = this.calculateNewPosition(player.x, player.y, action.direction); // Validate movement (bounds check) if (this.isValidPosition(newPosition.x, newPosition.y)) { player.x = newPosition.x; player.y = newPosition.y; // Log movement event await this.db.logGameEvent(gameId, turnNumber, player.number, 'player_moved', playerId, { action_index: actionIndex, from_x: oldX, from_y: oldY, to_x: player.x, to_y: player.y, direction: action.direction }); console.log(`Player ${player.id} moved from (${oldX},${oldY}) to (${player.x},${player.y})`); } } } } return { success: true }; } // Helper function to calculate new position based on direction calculateNewPosition(x, y, direction) { const offset = this.getDirectionOffset(direction); return { x: x + offset.x, y: y + offset.y }; } // Helper function to get direction offset getDirectionOffset(direction) { const GAME_CONFIG = require('../game-config'); const directionKey = direction.toUpperCase(); const offset = GAME_CONFIG.DIRECTION_OFFSETS[directionKey]; if (offset) { // Convert row/col format to x/y format for server use return { x: offset.col, y: offset.row }; } return { x: 0, y: 0 }; } // Check if position is within game bounds isValidPosition(x, y) { const GAME_CONFIG = require('../game-config'); return x >= 0 && x < GAME_CONFIG.GRID_WIDTH && y >= 0 && y < GAME_CONFIG.GRID_HEIGHT; } // Calculate complete bullet path for visualization calculateBulletPath(shooter, direction) { const path = []; const offset = this.getDirectionOffset(direction); let currentX = shooter.x + offset.x; let currentY = shooter.y + offset.y; // Follow bullet path until it goes out of bounds while (this.isValidPosition(currentX, currentY)) { path.push({ x: currentX, y: currentY }); currentX += offset.x; currentY += offset.y; } return path; } // Check if an instant shot hits any player at their current positions checkInstantShotHit(shooter, direction, positions, currentActionIndex, playerActions) { console.log(`Checking shot from shooter ${shooter.id} at (${shooter.x},${shooter.y}) direction ${direction}`); const offset = this.getDirectionOffset(direction); let bulletX = shooter.x + offset.x; let bulletY = shooter.y + offset.y; // Follow bullet path and check for hits while (this.isValidPosition(bulletX, bulletY)) { console.log(`Bullet checking position (${bulletX},${bulletY})`); // Check if any player is at this position (excluding the shooter) for (const playerId in positions) { const targetPosition = positions[playerId]; if (playerId !== shooter.id && targetPosition.x === bulletX && targetPosition.y === bulletY) { console.log(`HIT! Player ${playerId} at (${targetPosition.x},${targetPosition.y})`); return { ...targetPosition, playerId: playerId }; } } // Continue bullet path bulletX += offset.x; bulletY += offset.y; } console.log('Shot missed - no players hit'); return null; // No hit } // Check if a shot hits any player (legacy function - kept for compatibility) checkShotHit(shooter, direction, players) { const offset = this.getDirectionOffset(direction); let currentX = shooter.x + offset.x; let currentY = shooter.y + offset.y; // Follow bullet path until it hits something or goes out of bounds while (this.isValidPosition(currentX, currentY)) { // Check if any player is at this position for (const playerId in players) { const player = players[playerId]; if (player.alive && player.id !== shooter.id && player.x === currentX && player.y === currentY) { return player; } } // Continue bullet path currentX += offset.x; currentY += offset.y; } return null; // No hit } // End the game async endGame(gameId, winner, reason) { await this.db.runQuery(` UPDATE games SET status = 'finished', winner_id = ?, end_reason = ?, finished_at = CURRENT_TIMESTAMP, last_action_at = CURRENT_TIMESTAMP WHERE id = ? `, [winner ? winner.id : null, reason, gameId]); // Log game end event await this.db.logGameEvent(gameId, 0, 0, 'game_ended', winner ? winner.id : null, { reason: reason, winner_player_number: winner ? winner.number : null }); } // Additional endpoints for features async createPlayer(req, res) { try { const { username, email } = req.body; const playerId = await this.db.createPlayer(username, email); res.json({ success: true, playerId }); } catch (error) { res.status(500).json({ error: 'Failed to create player' }); } } async getPlayer(req, res) { try { const player = await this.db.getRow('SELECT * FROM players WHERE id = ?', [req.params.id]); if (!player) { return res.status(404).json({ error: 'Player not found' }); } res.json(player); } catch (error) { res.status(500).json({ error: 'Failed to get player' }); } } async getLeaderboard(req, res) { try { const limit = parseInt(req.query.limit) || 10; const leaderboard = await this.db.getLeaderboard(limit); res.json(leaderboard); } catch (error) { res.status(500).json({ error: 'Failed to get leaderboard' }); } } async getGameHistory(req, res) { try { const history = await this.db.getGameHistory(req.params.id); res.json(history); } catch (error) { res.status(500).json({ error: 'Failed to get game history' }); } } async getGameReplay(req, res) { try { const gameState = await this.db.getGameState(req.params.id); const history = await this.db.getGameHistory(req.params.id); res.json({ game: gameState, events: history }); } catch (error) { res.status(500).json({ error: 'Failed to get game replay' }); } } async getTournaments(req, res) { try { const tournaments = await this.db.getAllRows('SELECT * FROM tournaments ORDER BY created_at DESC'); res.json(tournaments); } catch (error) { res.status(500).json({ error: 'Failed to get tournaments' }); } } async createTournament(req, res) { // Tournament creation logic (future feature) res.status(501).json({ error: 'Tournament creation not yet implemented' }); } // Get game configuration async getConfig(req, res) { try { const GAME_CONFIG = require('../game-config'); res.json({ success: true, config: GAME_CONFIG }); } catch (error) { console.error('Error getting config:', error); res.status(500).json({ error: 'Failed to get configuration' }); } } // Create a rematch with the same players async createRematch(req, res) { try { const { id: oldGameId } = req.params; const { playerId, playerName } = req.body; if (!playerId || !playerName) { return res.status(400).json({ error: 'Player ID and name are required' }); } // Get the original game to find both players const originalGame = await this.db.getGameState(oldGameId); if (!originalGame || originalGame.game.status !== 'finished') { return res.status(400).json({ error: 'Can only rematch finished games' }); } // Verify the requesting player was in the original game const requestingPlayer = originalGame.players.find(p => p.player_id === playerId); if (!requestingPlayer) { return res.status(400).json({ error: 'Player was not in the original game' }); } // Find the opponent const opponent = originalGame.players.find(p => p.player_id !== playerId); if (!opponent) { return res.status(400).json({ error: 'Could not find opponent from original game' }); } // Create new game const newGameId = await this.db.createGame('standard'); // Generate spawn positions for new game const spawnPositions = this.generateSpawnPositions(); // Add the requesting player as player 1 await this.db.addPlayerToGame(newGameId, playerId, 1, spawnPositions.player1.x, spawnPositions.player1.y); // Log game creation event await this.db.logGameEvent(newGameId, 0, 1, 'rematch_created', playerId, { original_game_id: oldGameId, opponent_id: opponent.player_id, spawn_x: spawnPositions.player1.x, spawn_y: spawnPositions.player1.y }); // Check if the opponent is currently active (has recent activity) const opponentActiveCheck = await this.db.getRow(` SELECT last_active FROM players WHERE id = ? AND last_active > datetime('now', '-10 minutes') `, [opponent.player_id]); let gameStatus = 'waiting'; let inviteLink = `${req.protocol}://${req.get('host')}/game/${newGameId}`; // If opponent is recently active, try to auto-add them if (opponentActiveCheck) { try { // Add opponent as player 2 await this.db.addPlayerToGame(newGameId, opponent.player_id, 2, spawnPositions.player2.x, spawnPositions.player2.y); // Update game status to playing await this.db.runQuery(` UPDATE games SET status = 'playing', started_at = CURRENT_TIMESTAMP WHERE id = ? `, [newGameId]); // Log opponent auto-join await this.db.logGameEvent(newGameId, 0, 2, 'rematch_auto_joined', opponent.player_id, { spawn_x: spawnPositions.player2.x, spawn_y: spawnPositions.player2.y }); gameStatus = 'playing'; } catch (error) { console.log('Could not auto-add opponent, will wait for manual join:', error.message); } } res.json({ success: true, newGameId: newGameId, playerId: playerId, playerNumber: 1, // The requesting player becomes Player 1 in the rematch status: gameStatus, inviteLink: inviteLink, opponentName: opponent.username, spawnPosition: spawnPositions.player1, message: gameStatus === 'playing' ? 'Rematch started! Both players ready.' : 'Rematch created. Share the link with your opponent.' }); } catch (error) { console.error('Error creating rematch:', error); res.status(500).json({ error: 'Failed to create rematch' }); } } } module.exports = GameAPI;