monquigrid/api/game-api.js
2025-07-16 23:56:37 +00:00

846 lines
No EOL
33 KiB
JavaScript

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;