From 8d9ef427bebc1d5ddda2f02362d02dca18486e39 Mon Sep 17 00:00:00 2001 From: MegaProxy Date: Sat, 2 Aug 2025 14:02:04 +0000 Subject: [PATCH] feat: implement comprehensive combat system with plugin architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete combat system with instant, turn-based, and tactical combat - Plugin-based architecture with CombatPluginManager for extensibility - Real-time combat events via WebSocket - Fleet vs fleet and fleet vs colony combat support - Comprehensive combat statistics and history tracking - Admin panel for combat management and configuration - Database migrations for combat tables and fleet system - Complete test suite for combat functionality - Combat middleware for validation and logging - Service locator pattern for dependency management Combat system features: • Multiple combat resolution types with plugin support • Real-time combat events and spectator support • Detailed combat logs and casualty calculations • Experience gain and veterancy system for ships • Fleet positioning and tactical formations • Combat configurations and modifiers • Queue system for battle processing • Comprehensive admin controls and monitoring 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/setup-combat.js | 314 ++++ src/controllers/admin/combat.controller.js | 739 +++++++++ src/controllers/api/combat.controller.js | 572 +++++++ src/controllers/player/colony.controller.js | 315 ++++ src/controllers/player/resource.controller.js | 243 +++ .../migrations/004.5_missing_fleet_tables.js | 70 + .../migrations/005_minor_enhancements.js | 64 + .../006_combat_system_enhancement.js | 292 ++++ src/middleware/combat.middleware.js | 581 +++++++ src/routes/admin.js | 9 +- src/routes/admin/combat.js | 345 ++++ src/routes/admin/system.js | 586 +++++++ src/routes/api.js | 9 +- src/routes/api/combat.js | 130 ++ src/routes/debug.js | 244 ++- src/routes/player/colonies.js | 53 + src/routes/player/index.js | 4 +- src/routes/player/resources.js | 54 + src/server.js | 7 + src/services/ServiceLocator.js | 57 + src/services/combat/CombatPluginManager.js | 743 +++++++++ src/services/combat/CombatService.js | 1446 +++++++++++++++++ src/services/galaxy/ColonyService.js | 702 ++++++++ src/services/game-tick.service.js | 37 +- src/services/resource/ResourceService.js | 600 +++++++ src/services/user/PlayerService.js | 26 +- src/services/websocket/GameEventService.js | 893 ++++++++++ src/tests/helpers/test-helpers.js | 479 ++++++ .../combat/combat.integration.test.js | 542 ++++++ .../integration/game-tick.integration.test.js | 388 +++++ .../performance/game-tick.performance.test.js | 417 +++++ .../combat/CombatPluginManager.test.js | 530 ++++++ .../services/combat/CombatService.test.js | 603 +++++++ .../unit/services/game-tick.service.test.js | 687 ++++++++ src/validators/colony.validators.js | 101 ++ src/validators/combat.validators.js | 324 ++++ src/validators/resource.validators.js | 122 ++ 37 files changed, 13302 insertions(+), 26 deletions(-) create mode 100644 scripts/setup-combat.js create mode 100644 src/controllers/admin/combat.controller.js create mode 100644 src/controllers/api/combat.controller.js create mode 100644 src/controllers/player/colony.controller.js create mode 100644 src/controllers/player/resource.controller.js create mode 100644 src/database/migrations/004.5_missing_fleet_tables.js create mode 100644 src/database/migrations/005_minor_enhancements.js create mode 100644 src/database/migrations/006_combat_system_enhancement.js create mode 100644 src/middleware/combat.middleware.js create mode 100644 src/routes/admin/combat.js create mode 100644 src/routes/api/combat.js create mode 100644 src/routes/player/resources.js create mode 100644 src/services/ServiceLocator.js create mode 100644 src/services/combat/CombatPluginManager.js create mode 100644 src/services/combat/CombatService.js create mode 100644 src/services/galaxy/ColonyService.js create mode 100644 src/services/resource/ResourceService.js create mode 100644 src/services/websocket/GameEventService.js create mode 100644 src/tests/helpers/test-helpers.js create mode 100644 src/tests/integration/combat/combat.integration.test.js create mode 100644 src/tests/integration/game-tick.integration.test.js create mode 100644 src/tests/performance/game-tick.performance.test.js create mode 100644 src/tests/unit/services/combat/CombatPluginManager.test.js create mode 100644 src/tests/unit/services/combat/CombatService.test.js create mode 100644 src/tests/unit/services/game-tick.service.test.js create mode 100644 src/validators/colony.validators.js create mode 100644 src/validators/combat.validators.js create mode 100644 src/validators/resource.validators.js diff --git a/scripts/setup-combat.js b/scripts/setup-combat.js new file mode 100644 index 0000000..7a8fc99 --- /dev/null +++ b/scripts/setup-combat.js @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +/** + * Combat System Setup Script + * Initializes combat configurations and sample data + */ + +const db = require('../src/database/connection'); +const logger = require('../src/utils/logger'); + +async function setupCombatSystem() { + try { + console.log('🚀 Setting up combat system...'); + + // Insert default combat configurations + console.log('📝 Adding default combat configurations...'); + + const existingConfigs = await db('combat_configurations').select('id'); + if (existingConfigs.length === 0) { + await db('combat_configurations').insert([ + { + config_name: 'instant_combat', + combat_type: 'instant', + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 5, + damage_variance: 0.15, + experience_gain: 1.0, + casualty_rate_min: 0.05, + casualty_rate_max: 0.75, + loot_multiplier: 1.0, + spectator_limit: 50, + priority: 100 + }), + description: 'Standard instant combat resolution with quick results', + is_active: true, + created_at: new Date(), + updated_at: new Date() + }, + { + config_name: 'turn_based_combat', + combat_type: 'turn_based', + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 10, + max_rounds: 15, + round_duration: 3, + damage_variance: 0.2, + experience_gain: 1.5, + casualty_rate_min: 0.1, + casualty_rate_max: 0.8, + loot_multiplier: 1.2, + spectator_limit: 100, + priority: 150 + }), + description: 'Detailed turn-based combat with round-by-round resolution', + is_active: true, + created_at: new Date(), + updated_at: new Date() + }, + { + config_name: 'tactical_combat', + combat_type: 'tactical', + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 15, + max_rounds: 20, + round_duration: 4, + damage_variance: 0.25, + experience_gain: 2.0, + casualty_rate_min: 0.15, + casualty_rate_max: 0.85, + loot_multiplier: 1.5, + spectator_limit: 200, + priority: 200 + }), + description: 'Advanced tactical combat with positioning and formations', + is_active: true, + created_at: new Date(), + updated_at: new Date() + } + ]); + + console.log('✅ Combat configurations added successfully'); + } else { + console.log('ℹ️ Combat configurations already exist, skipping...'); + } + + // Update combat types table with default plugin reference + console.log('📝 Updating combat types...'); + + const existingCombatTypes = await db('combat_types').select('id'); + if (existingCombatTypes.length === 0) { + await db('combat_types').insert([ + { + name: 'instant_resolution', + description: 'Basic instant combat resolution with detailed logs', + plugin_name: 'instant_combat', + config: JSON.stringify({ + calculate_experience: true, + detailed_logs: true, + enable_spectators: true + }), + is_active: true + }, + { + name: 'turn_based_resolution', + description: 'Turn-based combat with round-by-round progression', + plugin_name: 'turn_based_combat', + config: JSON.stringify({ + calculate_experience: true, + detailed_logs: true, + enable_spectators: true, + show_round_details: true + }), + is_active: true + }, + { + name: 'tactical_resolution', + description: 'Advanced tactical combat with formations and positioning', + plugin_name: 'tactical_combat', + config: JSON.stringify({ + calculate_experience: true, + detailed_logs: true, + enable_spectators: true, + enable_formations: true, + enable_positioning: true + }), + is_active: true + } + ]); + + console.log('✅ Combat types added successfully'); + } else { + console.log('ℹ️ Combat types already exist, skipping...'); + } + + // Ensure combat plugins are properly registered + console.log('📝 Checking combat plugins...'); + + const combatPlugins = await db('plugins').where('plugin_type', 'combat'); + const pluginNames = combatPlugins.map(p => p.name); + + const requiredPlugins = [ + { + name: 'instant_combat', + version: '1.0.0', + description: 'Basic instant combat resolution system', + plugin_type: 'combat', + is_active: true, + config: JSON.stringify({ + damage_variance: 0.15, + experience_gain: 1.0 + }), + dependencies: JSON.stringify([]), + hooks: JSON.stringify(['pre_combat', 'post_combat', 'damage_calculation']) + }, + { + name: 'turn_based_combat', + version: '1.0.0', + description: 'Turn-based combat resolution system with detailed rounds', + plugin_type: 'combat', + is_active: true, + config: JSON.stringify({ + max_rounds: 15, + damage_variance: 0.2, + experience_gain: 1.5 + }), + dependencies: JSON.stringify([]), + hooks: JSON.stringify(['pre_combat', 'post_combat', 'round_start', 'round_end', 'damage_calculation']) + }, + { + name: 'tactical_combat', + version: '1.0.0', + description: 'Advanced tactical combat with formations and positioning', + plugin_type: 'combat', + is_active: true, + config: JSON.stringify({ + enable_formations: true, + enable_positioning: true, + damage_variance: 0.25, + experience_gain: 2.0 + }), + dependencies: JSON.stringify([]), + hooks: JSON.stringify(['pre_combat', 'post_combat', 'formation_change', 'position_update', 'damage_calculation']) + } + ]; + + for (const plugin of requiredPlugins) { + if (!pluginNames.includes(plugin.name)) { + await db('plugins').insert(plugin); + console.log(`✅ Added combat plugin: ${plugin.name}`); + } else { + console.log(`ℹ️ Combat plugin ${plugin.name} already exists`); + } + } + + // Add sample ship designs if none exist (for testing) + console.log('📝 Checking for sample ship designs...'); + + const existingDesigns = await db('ship_designs').where('is_public', true); + if (existingDesigns.length === 0) { + await db('ship_designs').insert([ + { + name: 'Basic Fighter', + ship_class: 'fighter', + hull_type: 'light', + components: JSON.stringify({ + weapons: ['laser_cannon'], + shields: ['basic_shield'], + engines: ['ion_drive'] + }), + stats: JSON.stringify({ + hp: 75, + attack: 12, + defense: 8, + speed: 6 + }), + cost: JSON.stringify({ + scrap: 80, + energy: 40 + }), + build_time: 20, + is_public: true, + is_active: true, + hull_points: 75, + shield_points: 20, + armor_points: 5, + attack_power: 12, + attack_speed: 1.2, + movement_speed: 6, + cargo_capacity: 0, + special_abilities: JSON.stringify([]), + damage_resistances: JSON.stringify({}), + created_at: new Date(), + updated_at: new Date() + }, + { + name: 'Heavy Cruiser', + ship_class: 'cruiser', + hull_type: 'heavy', + components: JSON.stringify({ + weapons: ['plasma_cannon', 'missile_launcher'], + shields: ['reinforced_shield'], + engines: ['fusion_drive'] + }), + stats: JSON.stringify({ + hp: 200, + attack: 25, + defense: 18, + speed: 3 + }), + cost: JSON.stringify({ + scrap: 300, + energy: 180, + rare_elements: 5 + }), + build_time: 120, + is_public: true, + is_active: true, + hull_points: 200, + shield_points: 60, + armor_points: 25, + attack_power: 25, + attack_speed: 0.8, + movement_speed: 3, + cargo_capacity: 50, + special_abilities: JSON.stringify(['heavy_armor', 'shield_boost']), + damage_resistances: JSON.stringify({ + kinetic: 0.1, + energy: 0.05 + }), + created_at: new Date(), + updated_at: new Date() + } + ]); + + console.log('✅ Added sample ship designs'); + } else { + console.log('ℹ️ Ship designs already exist, skipping...'); + } + + console.log('🎉 Combat system setup completed successfully!'); + console.log(''); + console.log('Combat system is now ready for use with:'); + console.log('- 3 combat configurations (instant, turn-based, tactical)'); + console.log('- 3 combat resolution plugins'); + console.log('- Sample ship designs for testing'); + console.log(''); + console.log('You can now:'); + console.log('• Create fleets and initiate combat via /api/combat/initiate'); + console.log('• View combat history via /api/combat/history'); + console.log('• Manage combat system via admin endpoints'); + + } catch (error) { + console.error('❌ Combat system setup failed:', error); + throw error; + } +} + +// Main execution +if (require.main === module) { + setupCombatSystem() + .then(() => { + console.log('✨ Setup completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('💥 Setup failed:', error); + process.exit(1); + }); +} + +module.exports = { setupCombatSystem }; \ No newline at end of file diff --git a/src/controllers/admin/combat.controller.js b/src/controllers/admin/combat.controller.js new file mode 100644 index 0000000..6d45b7b --- /dev/null +++ b/src/controllers/admin/combat.controller.js @@ -0,0 +1,739 @@ +/** + * Admin Combat Controller + * Handles administrative combat management operations + */ + +const CombatService = require('../../services/combat/CombatService'); +const { CombatPluginManager } = require('../../services/combat/CombatPluginManager'); +const GameEventService = require('../../services/websocket/GameEventService'); +const db = require('../../database/connection'); +const logger = require('../../utils/logger'); +const { ValidationError, ConflictError, NotFoundError } = require('../../middleware/error.middleware'); + +class AdminCombatController { + constructor() { + this.combatPluginManager = null; + this.gameEventService = null; + this.combatService = null; + } + + /** + * Initialize controller with dependencies + */ + async initialize(dependencies = {}) { + this.gameEventService = dependencies.gameEventService || new GameEventService(); + this.combatPluginManager = dependencies.combatPluginManager || new CombatPluginManager(); + this.combatService = dependencies.combatService || new CombatService(this.gameEventService, this.combatPluginManager); + + await this.combatPluginManager.initialize('admin-controller-init'); + } + + /** + * Get combat system statistics + * GET /api/admin/combat/statistics + */ + async getCombatStatistics(req, res, next) { + try { + const correlationId = req.correlationId; + + logger.info('Admin combat statistics request', { + correlationId, + adminUser: req.user.id + }); + + if (!this.combatService) { + await this.initialize(); + } + + // Get overall combat statistics + const [ + totalBattles, + activeBattles, + completedToday, + averageDuration, + queueStatus, + playerStats + ] = await Promise.all([ + // Total battles + db('battles').count('* as count').first(), + + // Active battles + db('battles').where('status', 'active').count('* as count').first(), + + // Battles completed today + db('battles') + .where('status', 'completed') + .where('completed_at', '>=', new Date(Date.now() - 24 * 60 * 60 * 1000)) + .count('* as count') + .first(), + + // Average battle duration + db('combat_encounters') + .avg('duration_seconds as avg_duration') + .first(), + + // Combat queue status + db('combat_queue') + .select('queue_status') + .count('* as count') + .groupBy('queue_status'), + + // Top player statistics + db('combat_statistics') + .select([ + 'player_id', + 'battles_won', + 'battles_lost', + 'ships_destroyed', + 'total_experience_gained' + ]) + .orderBy('battles_won', 'desc') + .limit(10) + ]); + + // Combat outcome distribution + const outcomeStats = await db('combat_encounters') + .select('outcome') + .count('* as count') + .groupBy('outcome'); + + // Battle type distribution + const typeStats = await db('battles') + .select('battle_type') + .count('* as count') + .groupBy('battle_type'); + + const statistics = { + overall: { + total_battles: parseInt(totalBattles.count), + active_battles: parseInt(activeBattles.count), + completed_today: parseInt(completedToday.count), + average_duration_seconds: parseFloat(averageDuration.avg_duration) || 0 + }, + queue: queueStatus.reduce((acc, status) => { + acc[status.queue_status] = parseInt(status.count); + return acc; + }, {}), + outcomes: outcomeStats.reduce((acc, outcome) => { + acc[outcome.outcome] = parseInt(outcome.count); + return acc; + }, {}), + battle_types: typeStats.reduce((acc, type) => { + acc[type.battle_type] = parseInt(type.count); + return acc; + }, {}), + top_players: playerStats + }; + + logger.info('Combat statistics retrieved', { + correlationId, + adminUser: req.user.id, + totalBattles: statistics.overall.total_battles + }); + + res.json({ + success: true, + data: statistics + }); + + } catch (error) { + logger.error('Failed to get combat statistics', { + correlationId: req.correlationId, + adminUser: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Get combat queue with detailed information + * GET /api/admin/combat/queue + */ + async getCombatQueue(req, res, next) { + try { + const correlationId = req.correlationId; + const { status, limit = 50, priority_min, priority_max } = req.query; + + logger.info('Admin combat queue request', { + correlationId, + adminUser: req.user.id, + status, + limit + }); + + if (!this.combatService) { + await this.initialize(); + } + + let query = db('combat_queue') + .select([ + 'combat_queue.*', + 'battles.battle_type', + 'battles.location', + 'battles.status as battle_status', + 'battles.participants', + 'battles.estimated_duration' + ]) + .join('battles', 'combat_queue.battle_id', 'battles.id') + .orderBy('combat_queue.priority', 'desc') + .orderBy('combat_queue.scheduled_at', 'asc') + .limit(parseInt(limit)); + + if (status) { + query = query.where('combat_queue.queue_status', status); + } + + if (priority_min) { + query = query.where('combat_queue.priority', '>=', parseInt(priority_min)); + } + + if (priority_max) { + query = query.where('combat_queue.priority', '<=', parseInt(priority_max)); + } + + const queue = await query; + + // Get queue summary + const queueSummary = await db('combat_queue') + .select('queue_status') + .count('* as count') + .groupBy('queue_status'); + + const result = { + queue: queue.map(item => ({ + ...item, + participants: JSON.parse(item.participants), + processing_metadata: item.processing_metadata ? JSON.parse(item.processing_metadata) : null + })), + summary: queueSummary.reduce((acc, item) => { + acc[item.queue_status] = parseInt(item.count); + return acc; + }, {}), + total_in_query: queue.length + }; + + logger.info('Combat queue retrieved', { + correlationId, + adminUser: req.user.id, + queueSize: queue.length + }); + + res.json({ + success: true, + data: result + }); + + } catch (error) { + logger.error('Failed to get combat queue', { + correlationId: req.correlationId, + adminUser: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Force resolve a combat + * POST /api/admin/combat/resolve/:battleId + */ + async forceResolveCombat(req, res, next) { + try { + const correlationId = req.correlationId; + const battleId = parseInt(req.params.battleId); + + logger.info('Admin force resolve combat request', { + correlationId, + adminUser: req.user.id, + battleId + }); + + if (!this.combatService) { + await this.initialize(); + } + + const result = await this.combatService.processCombat(battleId, correlationId); + + // Log admin action + await db('audit_log').insert({ + entity_type: 'battle', + entity_id: battleId, + action: 'force_resolve_combat', + actor_type: 'admin', + actor_id: req.user.id, + changes: JSON.stringify({ + outcome: result.outcome, + duration: result.duration + }), + metadata: JSON.stringify({ + correlation_id: correlationId, + admin_forced: true + }), + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + logger.info('Combat force resolved by admin', { + correlationId, + adminUser: req.user.id, + battleId, + outcome: result.outcome + }); + + res.json({ + success: true, + data: result, + message: 'Combat resolved successfully' + }); + + } catch (error) { + logger.error('Failed to force resolve combat', { + correlationId: req.correlationId, + adminUser: req.user?.id, + battleId: req.params.battleId, + error: error.message, + stack: error.stack + }); + + if (error instanceof NotFoundError) { + return res.status(404).json({ + error: error.message, + code: 'BATTLE_NOT_FOUND' + }); + } + + if (error instanceof ConflictError) { + return res.status(409).json({ + error: error.message, + code: 'BATTLE_CONFLICT' + }); + } + + next(error); + } + } + + /** + * Cancel a battle + * POST /api/admin/combat/cancel/:battleId + */ + async cancelBattle(req, res, next) { + try { + const correlationId = req.correlationId; + const battleId = parseInt(req.params.battleId); + const { reason } = req.body; + + logger.info('Admin cancel battle request', { + correlationId, + adminUser: req.user.id, + battleId, + reason + }); + + // Get battle details + const battle = await db('battles').where('id', battleId).first(); + if (!battle) { + return res.status(404).json({ + error: 'Battle not found', + code: 'BATTLE_NOT_FOUND' + }); + } + + if (battle.status === 'completed' || battle.status === 'cancelled') { + return res.status(409).json({ + error: 'Battle is already completed or cancelled', + code: 'BATTLE_ALREADY_FINISHED' + }); + } + + // Cancel the battle + await db.transaction(async (trx) => { + // Update battle status + await trx('battles') + .where('id', battleId) + .update({ + status: 'cancelled', + result: JSON.stringify({ + outcome: 'cancelled', + reason: reason || 'Cancelled by administrator', + cancelled_by: req.user.id, + cancelled_at: new Date() + }), + completed_at: new Date() + }); + + // Update combat queue + await trx('combat_queue') + .where('battle_id', battleId) + .update({ + queue_status: 'failed', + error_message: `Cancelled by administrator: ${reason || 'No reason provided'}`, + completed_at: new Date() + }); + + // Reset fleet statuses + const participants = JSON.parse(battle.participants); + if (participants.attacker_fleet_id) { + await trx('fleets') + .where('id', participants.attacker_fleet_id) + .update({ + fleet_status: 'idle', + last_updated: new Date() + }); + } + + if (participants.defender_fleet_id) { + await trx('fleets') + .where('id', participants.defender_fleet_id) + .update({ + fleet_status: 'idle', + last_updated: new Date() + }); + } + + // Reset colony siege status + if (participants.defender_colony_id) { + await trx('colonies') + .where('id', participants.defender_colony_id) + .update({ + under_siege: false, + last_updated: new Date() + }); + } + + // Log admin action + await trx('audit_log').insert({ + entity_type: 'battle', + entity_id: battleId, + action: 'cancel_battle', + actor_type: 'admin', + actor_id: req.user.id, + changes: JSON.stringify({ + old_status: battle.status, + new_status: 'cancelled', + reason: reason + }), + metadata: JSON.stringify({ + correlation_id: correlationId, + participants: participants + }), + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + }); + + // Emit WebSocket event + if (this.gameEventService) { + this.gameEventService.emitCombatStatusUpdate(battleId, 'cancelled', { + reason: reason || 'Cancelled by administrator', + cancelled_by: req.user.id + }, correlationId); + } + + logger.info('Battle cancelled by admin', { + correlationId, + adminUser: req.user.id, + battleId, + reason + }); + + res.json({ + success: true, + message: 'Battle cancelled successfully' + }); + + } catch (error) { + logger.error('Failed to cancel battle', { + correlationId: req.correlationId, + adminUser: req.user?.id, + battleId: req.params.battleId, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Get combat configurations + * GET /api/admin/combat/configurations + */ + async getCombatConfigurations(req, res, next) { + try { + const correlationId = req.correlationId; + + logger.info('Admin combat configurations request', { + correlationId, + adminUser: req.user.id + }); + + const configurations = await db('combat_configurations') + .orderBy('combat_type') + .orderBy('config_name'); + + logger.info('Combat configurations retrieved', { + correlationId, + adminUser: req.user.id, + count: configurations.length + }); + + res.json({ + success: true, + data: configurations + }); + + } catch (error) { + logger.error('Failed to get combat configurations', { + correlationId: req.correlationId, + adminUser: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Create or update combat configuration + * POST /api/admin/combat/configurations + * PUT /api/admin/combat/configurations/:configId + */ + async saveCombatConfiguration(req, res, next) { + try { + const correlationId = req.correlationId; + const configId = req.params.configId ? parseInt(req.params.configId) : null; + const configData = req.body; + + logger.info('Admin save combat configuration request', { + correlationId, + adminUser: req.user.id, + configId, + isUpdate: !!configId + }); + + const result = await db.transaction(async (trx) => { + let savedConfig; + + if (configId) { + // Update existing configuration + const existingConfig = await trx('combat_configurations') + .where('id', configId) + .first(); + + if (!existingConfig) { + throw new NotFoundError('Combat configuration not found'); + } + + await trx('combat_configurations') + .where('id', configId) + .update({ + ...configData, + updated_at: new Date() + }); + + savedConfig = await trx('combat_configurations') + .where('id', configId) + .first(); + + // Log admin action + await trx('audit_log').insert({ + entity_type: 'combat_configuration', + entity_id: configId, + action: 'update_combat_configuration', + actor_type: 'admin', + actor_id: req.user.id, + changes: JSON.stringify({ + old_config: existingConfig, + new_config: savedConfig + }), + metadata: JSON.stringify({ + correlation_id: correlationId + }), + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + } else { + // Create new configuration + const [newConfig] = await trx('combat_configurations') + .insert({ + ...configData, + created_at: new Date(), + updated_at: new Date() + }) + .returning('*'); + + savedConfig = newConfig; + + // Log admin action + await trx('audit_log').insert({ + entity_type: 'combat_configuration', + entity_id: savedConfig.id, + action: 'create_combat_configuration', + actor_type: 'admin', + actor_id: req.user.id, + changes: JSON.stringify({ + new_config: savedConfig + }), + metadata: JSON.stringify({ + correlation_id: correlationId + }), + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + } + + return savedConfig; + }); + + logger.info('Combat configuration saved', { + correlationId, + adminUser: req.user.id, + configId: result.id, + configName: result.config_name + }); + + res.status(configId ? 200 : 201).json({ + success: true, + data: result, + message: `Combat configuration ${configId ? 'updated' : 'created'} successfully` + }); + + } catch (error) { + logger.error('Failed to save combat configuration', { + correlationId: req.correlationId, + adminUser: req.user?.id, + configId: req.params.configId, + error: error.message, + stack: error.stack + }); + + if (error instanceof NotFoundError) { + return res.status(404).json({ + error: error.message, + code: 'CONFIG_NOT_FOUND' + }); + } + + if (error instanceof ValidationError) { + return res.status(400).json({ + error: error.message, + code: 'VALIDATION_ERROR' + }); + } + + next(error); + } + } + + /** + * Delete combat configuration + * DELETE /api/admin/combat/configurations/:configId + */ + async deleteCombatConfiguration(req, res, next) { + try { + const correlationId = req.correlationId; + const configId = parseInt(req.params.configId); + + logger.info('Admin delete combat configuration request', { + correlationId, + adminUser: req.user.id, + configId + }); + + const config = await db('combat_configurations') + .where('id', configId) + .first(); + + if (!config) { + return res.status(404).json({ + error: 'Combat configuration not found', + code: 'CONFIG_NOT_FOUND' + }); + } + + // Check if configuration is in use + const inUse = await db('battles') + .where('combat_configuration_id', configId) + .where('status', 'active') + .first(); + + if (inUse) { + return res.status(409).json({ + error: 'Cannot delete configuration that is currently in use', + code: 'CONFIG_IN_USE' + }); + } + + await db.transaction(async (trx) => { + // Delete the configuration + await trx('combat_configurations') + .where('id', configId) + .del(); + + // Log admin action + await trx('audit_log').insert({ + entity_type: 'combat_configuration', + entity_id: configId, + action: 'delete_combat_configuration', + actor_type: 'admin', + actor_id: req.user.id, + changes: JSON.stringify({ + deleted_config: config + }), + metadata: JSON.stringify({ + correlation_id: correlationId + }), + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + }); + + logger.info('Combat configuration deleted', { + correlationId, + adminUser: req.user.id, + configId, + configName: config.config_name + }); + + res.json({ + success: true, + message: 'Combat configuration deleted successfully' + }); + + } catch (error) { + logger.error('Failed to delete combat configuration', { + correlationId: req.correlationId, + adminUser: req.user?.id, + configId: req.params.configId, + error: error.message, + stack: error.stack + }); + + next(error); + } + } +} + +// Export singleton instance and bound methods +const adminCombatController = new AdminCombatController(); + +module.exports = { + AdminCombatController, + + // Export bound methods for route usage + getCombatStatistics: adminCombatController.getCombatStatistics.bind(adminCombatController), + getCombatQueue: adminCombatController.getCombatQueue.bind(adminCombatController), + forceResolveCombat: adminCombatController.forceResolveCombat.bind(adminCombatController), + cancelBattle: adminCombatController.cancelBattle.bind(adminCombatController), + getCombatConfigurations: adminCombatController.getCombatConfigurations.bind(adminCombatController), + saveCombatConfiguration: adminCombatController.saveCombatConfiguration.bind(adminCombatController), + deleteCombatConfiguration: adminCombatController.deleteCombatConfiguration.bind(adminCombatController) +}; \ No newline at end of file diff --git a/src/controllers/api/combat.controller.js b/src/controllers/api/combat.controller.js new file mode 100644 index 0000000..c9e5285 --- /dev/null +++ b/src/controllers/api/combat.controller.js @@ -0,0 +1,572 @@ +/** + * Combat API Controller + * Handles all combat-related HTTP requests including combat initiation, status, and history + */ + +const CombatService = require('../../services/combat/CombatService'); +const { CombatPluginManager } = require('../../services/combat/CombatPluginManager'); +const GameEventService = require('../../services/websocket/GameEventService'); +const logger = require('../../utils/logger'); +const { ValidationError, ConflictError, NotFoundError } = require('../../middleware/error.middleware'); + +class CombatController { + constructor() { + this.combatPluginManager = null; + this.gameEventService = null; + this.combatService = null; + } + + /** + * Initialize controller with dependencies + * @param {Object} dependencies - Service dependencies + */ + async initialize(dependencies = {}) { + this.gameEventService = dependencies.gameEventService || new GameEventService(); + this.combatPluginManager = dependencies.combatPluginManager || new CombatPluginManager(); + this.combatService = dependencies.combatService || new CombatService(this.gameEventService, this.combatPluginManager); + + // Initialize plugin manager + await this.combatPluginManager.initialize('controller-init'); + } + + /** + * Initiate combat between fleets or fleet vs colony + * POST /api/combat/initiate + */ + async initiateCombat(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + const combatData = req.body; + + logger.info('Combat initiation request', { + correlationId, + playerId, + combatData + }); + + // Validate required fields + if (!combatData.attacker_fleet_id) { + return res.status(400).json({ + error: 'Attacker fleet ID is required', + code: 'MISSING_ATTACKER_FLEET' + }); + } + + if (!combatData.location) { + return res.status(400).json({ + error: 'Combat location is required', + code: 'MISSING_LOCATION' + }); + } + + if (!combatData.defender_fleet_id && !combatData.defender_colony_id) { + return res.status(400).json({ + error: 'Either defender fleet or colony must be specified', + code: 'MISSING_DEFENDER' + }); + } + + // Initialize services if not already done + if (!this.combatService) { + await this.initialize(); + } + + // Initiate combat + const result = await this.combatService.initiateCombat(combatData, playerId, correlationId); + + logger.info('Combat initiated successfully', { + correlationId, + playerId, + battleId: result.battleId + }); + + res.status(201).json({ + success: true, + data: result, + message: 'Combat initiated successfully' + }); + + } catch (error) { + logger.error('Combat initiation failed', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError) { + return res.status(400).json({ + error: error.message, + code: 'VALIDATION_ERROR' + }); + } + + if (error instanceof ConflictError) { + return res.status(409).json({ + error: error.message, + code: 'CONFLICT_ERROR' + }); + } + + if (error instanceof NotFoundError) { + return res.status(404).json({ + error: error.message, + code: 'NOT_FOUND' + }); + } + + next(error); + } + } + + /** + * Get active combats for the current player + * GET /api/combat/active + */ + async getActiveCombats(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + + logger.info('Active combats request', { + correlationId, + playerId + }); + + if (!this.combatService) { + await this.initialize(); + } + + const activeCombats = await this.combatService.getActiveCombats(playerId, correlationId); + + logger.info('Active combats retrieved', { + correlationId, + playerId, + count: activeCombats.length + }); + + res.json({ + success: true, + data: { + combats: activeCombats, + count: activeCombats.length + } + }); + + } catch (error) { + logger.error('Failed to get active combats', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Get combat history for the current player + * GET /api/combat/history + */ + async getCombatHistory(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + + // Parse query parameters + const options = { + limit: parseInt(req.query.limit) || 20, + offset: parseInt(req.query.offset) || 0, + outcome: req.query.outcome || null + }; + + // Validate parameters + if (options.limit > 100) { + return res.status(400).json({ + error: 'Limit cannot exceed 100', + code: 'INVALID_LIMIT' + }); + } + + if (options.outcome && !['attacker_victory', 'defender_victory', 'draw'].includes(options.outcome)) { + return res.status(400).json({ + error: 'Invalid outcome filter', + code: 'INVALID_OUTCOME' + }); + } + + logger.info('Combat history request', { + correlationId, + playerId, + options + }); + + if (!this.combatService) { + await this.initialize(); + } + + const history = await this.combatService.getCombatHistory(playerId, options, correlationId); + + logger.info('Combat history retrieved', { + correlationId, + playerId, + count: history.combats.length, + total: history.pagination.total + }); + + res.json({ + success: true, + data: history + }); + + } catch (error) { + logger.error('Failed to get combat history', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Get detailed combat encounter information + * GET /api/combat/encounter/:encounterId + */ + async getCombatEncounter(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + const encounterId = parseInt(req.params.encounterId); + + if (!encounterId || isNaN(encounterId)) { + return res.status(400).json({ + error: 'Valid encounter ID is required', + code: 'INVALID_ENCOUNTER_ID' + }); + } + + logger.info('Combat encounter request', { + correlationId, + playerId, + encounterId + }); + + if (!this.combatService) { + await this.initialize(); + } + + const encounter = await this.combatService.getCombatEncounter(encounterId, playerId, correlationId); + + if (!encounter) { + return res.status(404).json({ + error: 'Combat encounter not found or access denied', + code: 'ENCOUNTER_NOT_FOUND' + }); + } + + logger.info('Combat encounter retrieved', { + correlationId, + playerId, + encounterId + }); + + res.json({ + success: true, + data: encounter + }); + + } catch (error) { + logger.error('Failed to get combat encounter', { + correlationId: req.correlationId, + playerId: req.user?.id, + encounterId: req.params.encounterId, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Get combat statistics for the current player + * GET /api/combat/statistics + */ + async getCombatStatistics(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + + logger.info('Combat statistics request', { + correlationId, + playerId + }); + + if (!this.combatService) { + await this.initialize(); + } + + const statistics = await this.combatService.getCombatStatistics(playerId, correlationId); + + logger.info('Combat statistics retrieved', { + correlationId, + playerId + }); + + res.json({ + success: true, + data: statistics + }); + + } catch (error) { + logger.error('Failed to get combat statistics', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Update fleet positioning for tactical combat + * PUT /api/combat/position/:fleetId + */ + async updateFleetPosition(req, res, next) { + try { + const correlationId = req.correlationId; + const playerId = req.user.id; + const fleetId = parseInt(req.params.fleetId); + const positionData = req.body; + + if (!fleetId || isNaN(fleetId)) { + return res.status(400).json({ + error: 'Valid fleet ID is required', + code: 'INVALID_FLEET_ID' + }); + } + + logger.info('Fleet position update request', { + correlationId, + playerId, + fleetId, + positionData + }); + + if (!this.combatService) { + await this.initialize(); + } + + const result = await this.combatService.updateFleetPosition(fleetId, positionData, playerId, correlationId); + + logger.info('Fleet position updated', { + correlationId, + playerId, + fleetId + }); + + res.json({ + success: true, + data: result, + message: 'Fleet position updated successfully' + }); + + } catch (error) { + logger.error('Failed to update fleet position', { + correlationId: req.correlationId, + playerId: req.user?.id, + fleetId: req.params.fleetId, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError) { + return res.status(400).json({ + error: error.message, + code: 'VALIDATION_ERROR' + }); + } + + if (error instanceof NotFoundError) { + return res.status(404).json({ + error: error.message, + code: 'NOT_FOUND' + }); + } + + next(error); + } + } + + /** + * Get available combat types and configurations + * GET /api/combat/types + */ + async getCombatTypes(req, res, next) { + try { + const correlationId = req.correlationId; + + logger.info('Combat types request', { correlationId }); + + if (!this.combatService) { + await this.initialize(); + } + + const combatTypes = await this.combatService.getAvailableCombatTypes(correlationId); + + logger.info('Combat types retrieved', { + correlationId, + count: combatTypes.length + }); + + res.json({ + success: true, + data: combatTypes + }); + + } catch (error) { + logger.error('Failed to get combat types', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + + next(error); + } + } + + /** + * Force resolve a combat (admin only) + * POST /api/combat/resolve/:battleId + */ + async forceResolveCombat(req, res, next) { + try { + const correlationId = req.correlationId; + const battleId = parseInt(req.params.battleId); + + if (!battleId || isNaN(battleId)) { + return res.status(400).json({ + error: 'Valid battle ID is required', + code: 'INVALID_BATTLE_ID' + }); + } + + logger.info('Force resolve combat request', { + correlationId, + battleId, + adminUser: req.user?.id + }); + + if (!this.combatService) { + await this.initialize(); + } + + const result = await this.combatService.processCombat(battleId, correlationId); + + logger.info('Combat force resolved', { + correlationId, + battleId, + outcome: result.outcome + }); + + res.json({ + success: true, + data: result, + message: 'Combat resolved successfully' + }); + + } catch (error) { + logger.error('Failed to force resolve combat', { + correlationId: req.correlationId, + battleId: req.params.battleId, + error: error.message, + stack: error.stack + }); + + if (error instanceof NotFoundError) { + return res.status(404).json({ + error: error.message, + code: 'NOT_FOUND' + }); + } + + if (error instanceof ConflictError) { + return res.status(409).json({ + error: error.message, + code: 'CONFLICT_ERROR' + }); + } + + next(error); + } + } + + /** + * Get combat queue status (admin only) + * GET /api/combat/queue + */ + async getCombatQueue(req, res, next) { + try { + const correlationId = req.correlationId; + const status = req.query.status || null; + const limit = parseInt(req.query.limit) || 50; + + logger.info('Combat queue request', { + correlationId, + status, + limit, + adminUser: req.user?.id + }); + + if (!this.combatService) { + await this.initialize(); + } + + const queue = await this.combatService.getCombatQueue({ status, limit }, correlationId); + + logger.info('Combat queue retrieved', { + correlationId, + count: queue.length + }); + + res.json({ + success: true, + data: queue + }); + + } catch (error) { + logger.error('Failed to get combat queue', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + + next(error); + } + } +} + +// Export singleton instance +const combatController = new CombatController(); + +module.exports = { + CombatController, + + // Export bound methods for route usage + initiateCombat: combatController.initiateCombat.bind(combatController), + getActiveCombats: combatController.getActiveCombats.bind(combatController), + getCombatHistory: combatController.getCombatHistory.bind(combatController), + getCombatEncounter: combatController.getCombatEncounter.bind(combatController), + getCombatStatistics: combatController.getCombatStatistics.bind(combatController), + updateFleetPosition: combatController.updateFleetPosition.bind(combatController), + getCombatTypes: combatController.getCombatTypes.bind(combatController), + forceResolveCombat: combatController.forceResolveCombat.bind(combatController), + getCombatQueue: combatController.getCombatQueue.bind(combatController) +}; \ No newline at end of file diff --git a/src/controllers/player/colony.controller.js b/src/controllers/player/colony.controller.js new file mode 100644 index 0000000..d858da2 --- /dev/null +++ b/src/controllers/player/colony.controller.js @@ -0,0 +1,315 @@ +/** + * Colony Controller + * Handles colony-related API endpoints for players + */ + +const ColonyService = require('../../services/galaxy/ColonyService'); +const { asyncHandler } = require('../../middleware/error.middleware'); +const logger = require('../../utils/logger'); +const serviceLocator = require('../../services/ServiceLocator'); + +// Create colony service with WebSocket integration +function getColonyService() { + const gameEventService = serviceLocator.get('gameEventService'); + return new ColonyService(gameEventService); +} + +/** + * Create a new colony + * POST /api/player/colonies + */ +const createColony = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + const { name, coordinates, planet_type_id } = req.body; + + logger.info('Colony creation request received', { + correlationId, + playerId, + name, + coordinates, + planet_type_id + }); + + const colonyService = getColonyService(); + const colony = await colonyService.createColony(playerId, { + name, + coordinates, + planet_type_id + }, correlationId); + + logger.info('Colony created successfully', { + correlationId, + playerId, + colonyId: colony.id, + name: colony.name, + coordinates: colony.coordinates + }); + + res.status(201).json({ + success: true, + message: 'Colony created successfully', + data: { + colony + }, + correlationId + }); +}); + +/** + * Get all colonies owned by the player + * GET /api/player/colonies + */ +const getPlayerColonies = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + + logger.info('Player colonies request received', { + correlationId, + playerId + }); + + const colonyService = getColonyService(); + const colonies = await colonyService.getPlayerColonies(playerId, correlationId); + + logger.info('Player colonies retrieved', { + correlationId, + playerId, + colonyCount: colonies.length + }); + + res.status(200).json({ + success: true, + message: 'Colonies retrieved successfully', + data: { + colonies, + count: colonies.length + }, + correlationId + }); +}); + +/** + * Get detailed information about a specific colony + * GET /api/player/colonies/:colonyId + */ +const getColonyDetails = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + const colonyId = parseInt(req.params.colonyId); + + logger.info('Colony details request received', { + correlationId, + playerId, + colonyId + }); + + // Verify colony ownership through the service + const colonyService = getColonyService(); + const colony = await colonyService.getColonyDetails(colonyId, correlationId); + + // Additional ownership check + if (colony.player_id !== playerId) { + logger.warn('Unauthorized colony access attempt', { + correlationId, + playerId, + colonyId, + actualOwnerId: colony.player_id + }); + + return res.status(403).json({ + success: false, + message: 'Access denied to this colony', + correlationId + }); + } + + logger.info('Colony details retrieved', { + correlationId, + playerId, + colonyId, + colonyName: colony.name + }); + + res.status(200).json({ + success: true, + message: 'Colony details retrieved successfully', + data: { + colony + }, + correlationId + }); +}); + +/** + * Construct a building in a colony + * POST /api/player/colonies/:colonyId/buildings + */ +const constructBuilding = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + const colonyId = parseInt(req.params.colonyId); + const { building_type_id } = req.body; + + logger.info('Building construction request received', { + correlationId, + playerId, + colonyId, + building_type_id + }); + + const colonyService = getColonyService(); + const building = await colonyService.constructBuilding( + colonyId, + building_type_id, + playerId, + correlationId + ); + + logger.info('Building constructed successfully', { + correlationId, + playerId, + colonyId, + buildingId: building.id, + building_type_id + }); + + res.status(201).json({ + success: true, + message: 'Building constructed successfully', + data: { + building + }, + correlationId + }); +}); + +/** + * Get available building types + * GET /api/player/buildings/types + */ +const getBuildingTypes = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + + logger.info('Building types request received', { + correlationId + }); + + const colonyService = getColonyService(); + const buildingTypes = await colonyService.getAvailableBuildingTypes(correlationId); + + logger.info('Building types retrieved', { + correlationId, + count: buildingTypes.length + }); + + res.status(200).json({ + success: true, + message: 'Building types retrieved successfully', + data: { + buildingTypes + }, + correlationId + }); +}); + +/** + * Get all planet types for colony creation + * GET /api/player/planets/types + */ +const getPlanetTypes = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + + logger.info('Planet types request received', { + correlationId + }); + + try { + const planetTypes = await require('../../database/connection')('planet_types') + .select('*') + .where('is_active', true) + .orderBy('rarity_weight', 'desc'); + + logger.info('Planet types retrieved', { + correlationId, + count: planetTypes.length + }); + + res.status(200).json({ + success: true, + message: 'Planet types retrieved successfully', + data: { + planetTypes + }, + correlationId + }); + + } catch (error) { + logger.error('Failed to retrieve planet types', { + correlationId, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + message: 'Failed to retrieve planet types', + correlationId + }); + } +}); + +/** + * Get galaxy sectors for reference + * GET /api/player/galaxy/sectors + */ +const getGalaxySectors = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + + logger.info('Galaxy sectors request received', { + correlationId + }); + + try { + const sectors = await require('../../database/connection')('galaxy_sectors') + .select('*') + .orderBy('danger_level', 'asc'); + + logger.info('Galaxy sectors retrieved', { + correlationId, + count: sectors.length + }); + + res.status(200).json({ + success: true, + message: 'Galaxy sectors retrieved successfully', + data: { + sectors + }, + correlationId + }); + + } catch (error) { + logger.error('Failed to retrieve galaxy sectors', { + correlationId, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + message: 'Failed to retrieve galaxy sectors', + correlationId + }); + } +}); + +module.exports = { + createColony, + getPlayerColonies, + getColonyDetails, + constructBuilding, + getBuildingTypes, + getPlanetTypes, + getGalaxySectors +}; \ No newline at end of file diff --git a/src/controllers/player/resource.controller.js b/src/controllers/player/resource.controller.js new file mode 100644 index 0000000..1680411 --- /dev/null +++ b/src/controllers/player/resource.controller.js @@ -0,0 +1,243 @@ +/** + * Resource Controller + * Handles resource-related API endpoints for players + */ + +const ResourceService = require('../../services/resource/ResourceService'); +const { asyncHandler } = require('../../middleware/error.middleware'); +const logger = require('../../utils/logger'); +const serviceLocator = require('../../services/ServiceLocator'); + +// Create resource service with WebSocket integration +function getResourceService() { + const gameEventService = serviceLocator.get('gameEventService'); + return new ResourceService(gameEventService); +} + +/** + * Get player's current resources + * GET /api/player/resources + */ +const getPlayerResources = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + + logger.info('Player resources request received', { + correlationId, + playerId + }); + + const resourceService = getResourceService(); + const resources = await resourceService.getPlayerResources(playerId, correlationId); + + logger.info('Player resources retrieved', { + correlationId, + playerId, + resourceCount: resources.length + }); + + res.status(200).json({ + success: true, + message: 'Resources retrieved successfully', + data: { + resources + }, + correlationId + }); +}); + +/** + * Get player's resource summary (simplified view) + * GET /api/player/resources/summary + */ +const getPlayerResourceSummary = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + + logger.info('Player resource summary request received', { + correlationId, + playerId + }); + + const resourceService = getResourceService(); + const summary = await resourceService.getPlayerResourceSummary(playerId, correlationId); + + logger.info('Player resource summary retrieved', { + correlationId, + playerId, + resourceTypes: Object.keys(summary) + }); + + res.status(200).json({ + success: true, + message: 'Resource summary retrieved successfully', + data: { + resources: summary + }, + correlationId + }); +}); + +/** + * Get player's resource production rates + * GET /api/player/resources/production + */ +const getResourceProduction = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + + logger.info('Resource production request received', { + correlationId, + playerId + }); + + const resourceService = getResourceService(); + const production = await resourceService.calculatePlayerResourceProduction(playerId, correlationId); + + logger.info('Resource production calculated', { + correlationId, + playerId, + productionData: production + }); + + res.status(200).json({ + success: true, + message: 'Resource production retrieved successfully', + data: { + production + }, + correlationId + }); +}); + +/** + * Add resources to player (for testing/admin purposes) + * POST /api/player/resources/add + */ +const addResources = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + const { resources } = req.body; + + // Only allow in development environment + if (process.env.NODE_ENV !== 'development') { + logger.warn('Resource addition attempted in production', { + correlationId, + playerId + }); + + return res.status(403).json({ + success: false, + message: 'Resource addition not allowed in production', + correlationId + }); + } + + logger.info('Resource addition request received', { + correlationId, + playerId, + resources + }); + + const resourceService = getResourceService(); + const updatedResources = await resourceService.addPlayerResources( + playerId, + resources, + correlationId + ); + + logger.info('Resources added successfully', { + correlationId, + playerId, + updatedResources + }); + + res.status(200).json({ + success: true, + message: 'Resources added successfully', + data: { + updatedResources + }, + correlationId + }); +}); + +/** + * Transfer resources between colonies + * POST /api/player/resources/transfer + */ +const transferResources = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + const playerId = req.user.playerId; + const { fromColonyId, toColonyId, resources } = req.body; + + logger.info('Resource transfer request received', { + correlationId, + playerId, + fromColonyId, + toColonyId, + resources + }); + + const resourceService = getResourceService(); + const result = await resourceService.transferResourcesBetweenColonies( + fromColonyId, + toColonyId, + resources, + playerId, + correlationId + ); + + logger.info('Resources transferred successfully', { + correlationId, + playerId, + fromColonyId, + toColonyId, + transferResult: result + }); + + res.status(200).json({ + success: true, + message: 'Resources transferred successfully', + data: result, + correlationId + }); +}); + +/** + * Get all available resource types + * GET /api/player/resources/types + */ +const getResourceTypes = asyncHandler(async (req, res) => { + const correlationId = req.correlationId; + + logger.info('Resource types request received', { + correlationId + }); + + const resourceService = getResourceService(); + const resourceTypes = await resourceService.getResourceTypes(correlationId); + + logger.info('Resource types retrieved', { + correlationId, + count: resourceTypes.length + }); + + res.status(200).json({ + success: true, + message: 'Resource types retrieved successfully', + data: { + resourceTypes + }, + correlationId + }); +}); + +module.exports = { + getPlayerResources, + getPlayerResourceSummary, + getResourceProduction, + addResources, + transferResources, + getResourceTypes +}; \ No newline at end of file diff --git a/src/database/migrations/004.5_missing_fleet_tables.js b/src/database/migrations/004.5_missing_fleet_tables.js new file mode 100644 index 0000000..cbcecca --- /dev/null +++ b/src/database/migrations/004.5_missing_fleet_tables.js @@ -0,0 +1,70 @@ +/** + * Missing Fleet Tables Migration + * Adds fleet-related tables that were missing from previous migrations + */ + +exports.up = function(knex) { + return knex.schema + // Create fleets table + .createTable('fleets', (table) => { + table.increments('id').primary(); + table.integer('player_id').notNullable().references('players.id').onDelete('CASCADE'); + table.string('name', 100).notNullable(); + table.string('current_location', 20).notNullable(); // Coordinates + table.string('destination', 20).nullable(); // If moving + table.string('fleet_status', 20).defaultTo('idle') + .checkIn(['idle', 'moving', 'in_combat', 'constructing', 'repairing']); + table.timestamp('movement_started').nullable(); + table.timestamp('arrival_time').nullable(); + table.timestamp('last_updated').defaultTo(knex.fn.now()); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['player_id']); + table.index(['current_location']); + table.index(['fleet_status']); + table.index(['arrival_time']); + }) + + // Create ship_designs table + .createTable('ship_designs', (table) => { + table.increments('id').primary(); + table.integer('player_id').nullable().references('players.id').onDelete('CASCADE'); // NULL for public designs + table.string('name', 100).notNullable(); + table.string('ship_class', 50).notNullable(); // 'fighter', 'corvette', 'destroyer', 'cruiser', 'battleship' + table.string('hull_type', 50).notNullable(); + table.jsonb('components').notNullable(); // Weapon, shield, engine configurations + table.jsonb('stats').notNullable(); // Calculated stats: hp, attack, defense, speed, etc. + table.jsonb('cost').notNullable(); // Resource cost to build + table.integer('build_time').notNullable(); // In minutes + table.boolean('is_public').defaultTo(false); // Available to all players + table.boolean('is_active').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['player_id']); + table.index(['ship_class']); + table.index(['is_public']); + table.index(['is_active']); + }) + + // Create fleet_ships table + .createTable('fleet_ships', (table) => { + table.increments('id').primary(); + table.integer('fleet_id').notNullable().references('fleets.id').onDelete('CASCADE'); + table.integer('ship_design_id').notNullable().references('ship_designs.id').onDelete('CASCADE'); + table.integer('quantity').notNullable().defaultTo(1); + table.decimal('health_percentage', 5, 2).defaultTo(100.00); + table.integer('experience').defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['fleet_id']); + table.index(['ship_design_id']); + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('fleet_ships') + .dropTableIfExists('ship_designs') + .dropTableIfExists('fleets'); +}; \ No newline at end of file diff --git a/src/database/migrations/005_minor_enhancements.js b/src/database/migrations/005_minor_enhancements.js new file mode 100644 index 0000000..e88ef4e --- /dev/null +++ b/src/database/migrations/005_minor_enhancements.js @@ -0,0 +1,64 @@ +/** + * Minor Schema Enhancements Migration + * Adds missing columns for player tick processing and research facilities + */ + +exports.up = async function(knex) { + // Check if columns exist before adding them + const hasLastTickProcessed = await knex.schema.hasColumn('players', 'last_tick_processed'); + const hasLastTickProcessedAt = await knex.schema.hasColumn('players', 'last_tick_processed_at'); + const hasLastCalculated = await knex.schema.hasColumn('colony_resource_production', 'last_calculated'); + const hasResearchFacilities = await knex.schema.hasTable('research_facilities'); + + let schema = knex.schema; + + // Add columns to players table if they don't exist + if (!hasLastTickProcessed || !hasLastTickProcessedAt) { + schema = schema.alterTable('players', function(table) { + if (!hasLastTickProcessed) { + table.bigInteger('last_tick_processed').nullable(); + } + if (!hasLastTickProcessedAt) { + table.timestamp('last_tick_processed_at').nullable(); + } + }); + } + + // Add last_calculated column to colony_resource_production if it doesn't exist + if (!hasLastCalculated) { + schema = schema.alterTable('colony_resource_production', function(table) { + table.timestamp('last_calculated').defaultTo(knex.fn.now()); + }); + } + + // Create research_facilities table if it doesn't exist + if (!hasResearchFacilities) { + schema = schema.createTable('research_facilities', function(table) { + table.increments('id').primary(); + table.integer('colony_id').notNullable().references('id').inTable('colonies').onDelete('CASCADE'); + table.string('name', 100).notNullable(); + table.string('facility_type', 50).notNullable(); + table.decimal('research_bonus', 3, 2).defaultTo(1.0); + table.jsonb('specialization').nullable(); + table.boolean('is_active').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index('colony_id'); + table.index('is_active'); + }); + } + + return schema; +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('research_facilities') + .alterTable('colony_resource_production', function(table) { + table.dropColumn('last_calculated'); + }) + .alterTable('players', function(table) { + table.dropColumn('last_tick_processed'); + table.dropColumn('last_tick_processed_at'); + }); +}; \ No newline at end of file diff --git a/src/database/migrations/006_combat_system_enhancement.js b/src/database/migrations/006_combat_system_enhancement.js new file mode 100644 index 0000000..4a074c9 --- /dev/null +++ b/src/database/migrations/006_combat_system_enhancement.js @@ -0,0 +1,292 @@ +/** + * Combat System Enhancement Migration + * Adds comprehensive combat tables and enhancements for production-ready combat system + */ + +exports.up = function(knex) { + return knex.schema + // Combat types table - defines different combat resolution types + .createTable('combat_types', (table) => { + table.increments('id').primary(); + table.string('name', 100).unique().notNullable(); + table.text('description'); + table.string('plugin_name', 100); // References plugins table + table.jsonb('config'); + table.boolean('is_active').defaultTo(true); + + table.index(['is_active']); + table.index(['plugin_name']); + }) + + // Main battles table - tracks all combat encounters + .createTable('battles', (table) => { + table.bigIncrements('id').primary(); + table.string('battle_type', 50).notNullable(); // 'fleet_vs_fleet', 'fleet_vs_colony', 'siege' + table.string('location', 20).notNullable(); + table.integer('combat_type_id').references('combat_types.id'); + table.jsonb('participants').notNullable(); // Array of fleet/player IDs + table.string('status', 20).notNullable().defaultTo('pending'); // 'pending', 'active', 'completed', 'cancelled' + table.jsonb('battle_data'); // Additional battle configuration + table.jsonb('result'); // Final battle results + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('completed_at').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['location']); + table.index(['status']); + table.index(['completed_at']); + table.index(['started_at']); + }) + + // Combat encounters table for detailed battle tracking + .createTable('combat_encounters', (table) => { + table.bigIncrements('id').primary(); + table.integer('battle_id').references('battles.id').onDelete('CASCADE'); + table.integer('attacker_fleet_id').references('fleets.id').onDelete('CASCADE').notNullable(); + table.integer('defender_fleet_id').references('fleets.id').onDelete('CASCADE'); + table.integer('defender_colony_id').references('colonies.id').onDelete('CASCADE'); + table.string('encounter_type', 50).notNullable(); // 'fleet_vs_fleet', 'fleet_vs_colony', 'siege' + table.string('location', 20).notNullable(); + table.jsonb('initial_forces').notNullable(); // Starting forces for both sides + table.jsonb('final_forces').notNullable(); // Remaining forces after combat + table.jsonb('casualties').notNullable(); // Detailed casualty breakdown + table.jsonb('combat_log').notNullable(); // Round-by-round combat log + table.decimal('experience_gained', 10, 2).defaultTo(0); + table.jsonb('loot_awarded'); // Resources/items awarded to winner + table.string('outcome', 20).notNullable(); // 'attacker_victory', 'defender_victory', 'draw' + table.integer('duration_seconds').notNullable(); // Combat duration + table.timestamp('started_at').notNullable(); + table.timestamp('completed_at').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['battle_id']); + table.index(['attacker_fleet_id']); + table.index(['defender_fleet_id']); + table.index(['defender_colony_id']); + table.index(['location']); + table.index(['outcome']); + table.index(['started_at']); + }) + + // Combat logs for detailed event tracking + .createTable('combat_logs', (table) => { + table.bigIncrements('id').primary(); + table.bigInteger('encounter_id').references('combat_encounters.id').onDelete('CASCADE').notNullable(); + table.integer('round_number').notNullable(); + table.string('event_type', 50).notNullable(); // 'damage', 'destruction', 'ability_use', 'experience_gain' + table.jsonb('event_data').notNullable(); // Detailed event information + table.timestamp('timestamp').defaultTo(knex.fn.now()); + + table.index(['encounter_id', 'round_number']); + table.index(['event_type']); + table.index(['timestamp']); + }) + + // Combat statistics for analysis and balancing + .createTable('combat_statistics', (table) => { + table.bigIncrements('id').primary(); + table.integer('player_id').references('players.id').onDelete('CASCADE').notNullable(); + table.integer('battles_initiated').defaultTo(0); + table.integer('battles_won').defaultTo(0); + table.integer('battles_lost').defaultTo(0); + table.integer('ships_lost').defaultTo(0); + table.integer('ships_destroyed').defaultTo(0); + table.bigInteger('total_damage_dealt').defaultTo(0); + table.bigInteger('total_damage_received').defaultTo(0); + table.decimal('total_experience_gained', 15, 2).defaultTo(0); + table.jsonb('resources_looted').defaultTo('{}'); + table.timestamp('last_battle').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['player_id']); + table.index(['battles_won']); + table.index(['last_battle']); + }) + + // Ship combat experience and veterancy + .createTable('ship_combat_experience', (table) => { + table.bigIncrements('id').primary(); + table.integer('fleet_id').references('fleets.id').onDelete('CASCADE').notNullable(); + table.integer('ship_design_id').references('ship_designs.id').onDelete('CASCADE').notNullable(); + table.integer('battles_survived').defaultTo(0); + table.integer('enemies_destroyed').defaultTo(0); + table.bigInteger('damage_dealt').defaultTo(0); + table.decimal('experience_points', 15, 2).defaultTo(0); + table.integer('veterancy_level').defaultTo(1); + table.jsonb('combat_bonuses').defaultTo('{}'); // Experience-based bonuses + table.timestamp('last_combat').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.unique(['fleet_id', 'ship_design_id']); + table.index(['fleet_id']); + table.index(['veterancy_level']); + table.index(['last_combat']); + }) + + // Combat configurations for different combat types + .createTable('combat_configurations', (table) => { + table.increments('id').primary(); + table.string('config_name', 100).unique().notNullable(); + table.string('combat_type', 50).notNullable(); // 'instant', 'turn_based', 'real_time' + table.jsonb('config_data').notNullable(); // Combat-specific configuration + table.boolean('is_active').defaultTo(true); + table.string('description', 500); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['combat_type']); + table.index(['is_active']); + }) + + // Combat modifiers for temporary effects + .createTable('combat_modifiers', (table) => { + table.bigIncrements('id').primary(); + table.string('entity_type', 50).notNullable(); // 'fleet', 'colony', 'player' + table.integer('entity_id').notNullable(); + table.string('modifier_type', 50).notNullable(); // 'attack_bonus', 'defense_bonus', 'speed_bonus' + table.decimal('modifier_value', 8, 4).notNullable(); + table.string('source', 100).notNullable(); // 'technology', 'event', 'building', 'experience' + table.timestamp('start_time').defaultTo(knex.fn.now()); + table.timestamp('end_time').nullable(); + table.boolean('is_active').defaultTo(true); + table.jsonb('metadata'); // Additional modifier information + + table.index(['entity_type', 'entity_id']); + table.index(['modifier_type']); + table.index(['is_active']); + table.index(['end_time']); + }) + + // Fleet positioning for tactical combat + .createTable('fleet_positions', (table) => { + table.bigIncrements('id').primary(); + table.integer('fleet_id').references('fleets.id').onDelete('CASCADE').notNullable(); + table.string('location', 20).notNullable(); + table.decimal('position_x', 8, 2).defaultTo(0); + table.decimal('position_y', 8, 2).defaultTo(0); + table.decimal('position_z', 8, 2).defaultTo(0); + table.string('formation', 50).defaultTo('standard'); // 'standard', 'defensive', 'aggressive', 'flanking' + table.jsonb('tactical_settings').defaultTo('{}'); // Formation-specific settings + table.timestamp('last_updated').defaultTo(knex.fn.now()); + + table.unique(['fleet_id']); + table.index(['location']); + table.index(['formation']); + }) + + // Combat queue for processing battles + .createTable('combat_queue', (table) => { + table.bigIncrements('id').primary(); + table.bigInteger('battle_id').references('battles.id').onDelete('CASCADE').notNullable(); + table.string('queue_status', 20).defaultTo('pending'); // 'pending', 'processing', 'completed', 'failed' + table.integer('priority').defaultTo(100); + table.timestamp('scheduled_at').defaultTo(knex.fn.now()); + table.timestamp('started_processing').nullable(); + table.timestamp('completed_at').nullable(); + table.integer('retry_count').defaultTo(0); + table.text('error_message').nullable(); + table.jsonb('processing_metadata'); + + table.index(['queue_status']); + table.index(['priority', 'scheduled_at']); + table.index(['battle_id']); + }) + + // Extend battles table with additional fields + .alterTable('battles', (table) => { + table.integer('combat_configuration_id').references('combat_configurations.id'); + table.jsonb('tactical_settings').defaultTo('{}'); + table.integer('spectator_count').defaultTo(0); + table.jsonb('environmental_effects'); // Weather, nebulae, asteroid fields + table.decimal('estimated_duration', 8, 2); // Estimated battle duration in seconds + }) + + // Extend fleets table with combat-specific fields + .alterTable('fleets', (table) => { + table.decimal('combat_rating', 10, 2).defaultTo(0); // Calculated combat effectiveness + table.integer('total_ship_count').defaultTo(0); + table.jsonb('fleet_composition').defaultTo('{}'); // Ship type breakdown + table.timestamp('last_combat').nullable(); + table.integer('combat_victories').defaultTo(0); + table.integer('combat_defeats').defaultTo(0); + }) + + // Extend ship_designs table with detailed combat stats + .alterTable('ship_designs', (table) => { + table.integer('hull_points').defaultTo(100); + table.integer('shield_points').defaultTo(0); + table.integer('armor_points').defaultTo(0); + table.decimal('attack_power', 8, 2).defaultTo(10); + table.decimal('attack_speed', 6, 2).defaultTo(1.0); // Attacks per second + table.decimal('movement_speed', 6, 2).defaultTo(1.0); + table.integer('cargo_capacity').defaultTo(0); + table.jsonb('special_abilities').defaultTo('[]'); + table.jsonb('damage_resistances').defaultTo('{}'); + }) + + // Colony defense enhancements + .alterTable('colonies', (table) => { + table.integer('defense_rating').defaultTo(0); + table.integer('shield_strength').defaultTo(0); + table.boolean('under_siege').defaultTo(false); + table.timestamp('last_attacked').nullable(); + table.integer('successful_defenses').defaultTo(0); + table.integer('times_captured').defaultTo(0); + }); +}; + +exports.down = function(knex) { + return knex.schema + // Remove added columns first + .alterTable('colonies', (table) => { + table.dropColumn('defense_rating'); + table.dropColumn('shield_strength'); + table.dropColumn('under_siege'); + table.dropColumn('last_attacked'); + table.dropColumn('successful_defenses'); + table.dropColumn('times_captured'); + }) + + .alterTable('ship_designs', (table) => { + table.dropColumn('hull_points'); + table.dropColumn('shield_points'); + table.dropColumn('armor_points'); + table.dropColumn('attack_power'); + table.dropColumn('attack_speed'); + table.dropColumn('movement_speed'); + table.dropColumn('cargo_capacity'); + table.dropColumn('special_abilities'); + table.dropColumn('damage_resistances'); + }) + + .alterTable('fleets', (table) => { + table.dropColumn('combat_rating'); + table.dropColumn('total_ship_count'); + table.dropColumn('fleet_composition'); + table.dropColumn('last_combat'); + table.dropColumn('combat_victories'); + table.dropColumn('combat_defeats'); + }) + + .alterTable('battles', (table) => { + table.dropColumn('combat_configuration_id'); + table.dropColumn('tactical_settings'); + table.dropColumn('spectator_count'); + table.dropColumn('environmental_effects'); + table.dropColumn('estimated_duration'); + }) + + // Drop new tables + .dropTableIfExists('combat_queue') + .dropTableIfExists('fleet_positions') + .dropTableIfExists('combat_modifiers') + .dropTableIfExists('combat_configurations') + .dropTableIfExists('ship_combat_experience') + .dropTableIfExists('combat_statistics') + .dropTableIfExists('combat_logs') + .dropTableIfExists('combat_encounters') + .dropTableIfExists('battles') + .dropTableIfExists('combat_types'); +}; \ No newline at end of file diff --git a/src/middleware/combat.middleware.js b/src/middleware/combat.middleware.js new file mode 100644 index 0000000..85597b0 --- /dev/null +++ b/src/middleware/combat.middleware.js @@ -0,0 +1,581 @@ +/** + * Combat Middleware + * Provides combat-specific middleware functions for authentication, authorization, and validation + */ + +const db = require('../database/connection'); +const logger = require('../utils/logger'); +const { ValidationError, ConflictError, NotFoundError, ForbiddenError } = require('./error.middleware'); +const combatValidators = require('../validators/combat.validators'); + +/** + * Validate combat initiation request + */ +const validateCombatInitiation = (req, res, next) => { + try { + const { error, value } = combatValidators.validateInitiateCombat(req.body); + + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + logger.warn('Combat initiation validation failed', { + correlationId: req.correlationId, + playerId: req.user?.id, + errors: details + }); + + return res.status(400).json({ + error: 'Validation failed', + code: 'COMBAT_VALIDATION_ERROR', + details + }); + } + + req.body = value; + next(); + } catch (error) { + logger.error('Combat validation middleware error', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Validate fleet position update request + */ +const validateFleetPositionUpdate = (req, res, next) => { + try { + const { error, value } = combatValidators.validateUpdateFleetPosition(req.body); + + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + logger.warn('Fleet position validation failed', { + correlationId: req.correlationId, + playerId: req.user?.id, + fleetId: req.params.fleetId, + errors: details + }); + + return res.status(400).json({ + error: 'Validation failed', + code: 'POSITION_VALIDATION_ERROR', + details + }); + } + + req.body = value; + next(); + } catch (error) { + logger.error('Fleet position validation middleware error', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Validate combat history query parameters + */ +const validateCombatHistoryQuery = (req, res, next) => { + try { + const { error, value } = combatValidators.validateCombatHistoryQuery(req.query); + + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + logger.warn('Combat history query validation failed', { + correlationId: req.correlationId, + playerId: req.user?.id, + errors: details + }); + + return res.status(400).json({ + error: 'Invalid query parameters', + code: 'QUERY_VALIDATION_ERROR', + details + }); + } + + req.query = value; + next(); + } catch (error) { + logger.error('Combat history query validation middleware error', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Validate combat queue query parameters (admin only) + */ +const validateCombatQueueQuery = (req, res, next) => { + try { + const { error, value } = combatValidators.validateCombatQueueQuery(req.query); + + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + logger.warn('Combat queue query validation failed', { + correlationId: req.correlationId, + adminUser: req.user?.id, + errors: details + }); + + return res.status(400).json({ + error: 'Invalid query parameters', + code: 'QUERY_VALIDATION_ERROR', + details + }); + } + + req.query = value; + next(); + } catch (error) { + logger.error('Combat queue query validation middleware error', { + correlationId: req.correlationId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Validate parameter IDs (battleId, fleetId, encounterId) + */ +const validateParams = (paramType) => { + return (req, res, next) => { + try { + let validator; + switch (paramType) { + case 'battleId': + validator = combatValidators.validateBattleIdParam; + break; + case 'fleetId': + validator = combatValidators.validateFleetIdParam; + break; + case 'encounterId': + validator = combatValidators.validateEncounterIdParam; + break; + default: + return res.status(500).json({ + error: 'Invalid parameter validation type', + code: 'INTERNAL_ERROR' + }); + } + + const { error, value } = validator(req.params); + + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + logger.warn('Parameter validation failed', { + correlationId: req.correlationId, + paramType, + params: req.params, + errors: details + }); + + return res.status(400).json({ + error: 'Invalid parameter', + code: 'PARAM_VALIDATION_ERROR', + details + }); + } + + req.params = { ...req.params, ...value }; + next(); + } catch (error) { + logger.error('Parameter validation middleware error', { + correlationId: req.correlationId, + paramType, + error: error.message, + stack: error.stack + }); + next(error); + } + }; +}; + +/** + * Check if player owns the specified fleet + */ +const checkFleetOwnership = async (req, res, next) => { + try { + const playerId = req.user.id; + const fleetId = parseInt(req.params.fleetId); + + logger.debug('Checking fleet ownership', { + correlationId: req.correlationId, + playerId, + fleetId + }); + + const fleet = await db('fleets') + .where('id', fleetId) + .where('player_id', playerId) + .first(); + + if (!fleet) { + logger.warn('Fleet ownership check failed', { + correlationId: req.correlationId, + playerId, + fleetId + }); + + return res.status(404).json({ + error: 'Fleet not found or access denied', + code: 'FLEET_NOT_FOUND' + }); + } + + req.fleet = fleet; + next(); + } catch (error) { + logger.error('Fleet ownership check middleware error', { + correlationId: req.correlationId, + playerId: req.user?.id, + fleetId: req.params?.fleetId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Check if player has access to the specified battle + */ +const checkBattleAccess = async (req, res, next) => { + try { + const playerId = req.user.id; + const battleId = parseInt(req.params.battleId); + + logger.debug('Checking battle access', { + correlationId: req.correlationId, + playerId, + battleId + }); + + const battle = await db('battles') + .where('id', battleId) + .first(); + + if (!battle) { + logger.warn('Battle not found', { + correlationId: req.correlationId, + playerId, + battleId + }); + + return res.status(404).json({ + error: 'Battle not found', + code: 'BATTLE_NOT_FOUND' + }); + } + + // Check if player is a participant + const participants = JSON.parse(battle.participants); + let hasAccess = false; + + // Check if player is the attacker + if (participants.attacker_player_id === playerId) { + hasAccess = true; + } + + // Check if player owns the defending fleet + if (participants.defender_fleet_id) { + const defenderFleet = await db('fleets') + .where('id', participants.defender_fleet_id) + .where('player_id', playerId) + .first(); + if (defenderFleet) hasAccess = true; + } + + // Check if player owns the defending colony + if (participants.defender_colony_id) { + const defenderColony = await db('colonies') + .where('id', participants.defender_colony_id) + .where('player_id', playerId) + .first(); + if (defenderColony) hasAccess = true; + } + + if (!hasAccess) { + logger.warn('Battle access denied', { + correlationId: req.correlationId, + playerId, + battleId + }); + + return res.status(403).json({ + error: 'Access denied to this battle', + code: 'BATTLE_ACCESS_DENIED' + }); + } + + req.battle = battle; + next(); + } catch (error) { + logger.error('Battle access check middleware error', { + correlationId: req.correlationId, + playerId: req.user?.id, + battleId: req.params?.battleId, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Check combat cooldown to prevent spam attacks + */ +const checkCombatCooldown = async (req, res, next) => { + try { + const playerId = req.user.id; + const cooldownMinutes = parseInt(process.env.COMBAT_COOLDOWN_MINUTES) || 5; + + logger.debug('Checking combat cooldown', { + correlationId: req.correlationId, + playerId, + cooldownMinutes + }); + + // Check if player has initiated combat recently + const recentCombat = await db('battles') + .join('combat_encounters', 'battles.id', 'combat_encounters.battle_id') + .leftJoin('fleets', 'combat_encounters.attacker_fleet_id', 'fleets.id') + .where('fleets.player_id', playerId) + .where('battles.started_at', '>', new Date(Date.now() - cooldownMinutes * 60 * 1000)) + .orderBy('battles.started_at', 'desc') + .first(); + + if (recentCombat) { + const timeRemaining = Math.ceil((new Date(recentCombat.started_at).getTime() + cooldownMinutes * 60 * 1000 - Date.now()) / 1000); + + logger.warn('Combat cooldown active', { + correlationId: req.correlationId, + playerId, + timeRemaining + }); + + return res.status(429).json({ + error: 'Combat cooldown active', + code: 'COMBAT_COOLDOWN', + timeRemaining, + cooldownMinutes + }); + } + + next(); + } catch (error) { + logger.error('Combat cooldown check middleware error', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Check if fleet is available for combat + */ +const checkFleetAvailability = async (req, res, next) => { + try { + const fleetId = req.body.attacker_fleet_id; + const playerId = req.user.id; + + logger.debug('Checking fleet availability', { + correlationId: req.correlationId, + playerId, + fleetId + }); + + const fleet = await db('fleets') + .where('id', fleetId) + .where('player_id', playerId) + .first(); + + if (!fleet) { + return res.status(404).json({ + error: 'Fleet not found', + code: 'FLEET_NOT_FOUND' + }); + } + + // Check fleet status + if (fleet.fleet_status !== 'idle') { + logger.warn('Fleet not available for combat', { + correlationId: req.correlationId, + playerId, + fleetId, + currentStatus: fleet.fleet_status + }); + + return res.status(409).json({ + error: `Fleet is currently ${fleet.fleet_status} and cannot engage in combat`, + code: 'FLEET_UNAVAILABLE', + currentStatus: fleet.fleet_status + }); + } + + // Check if fleet has ships + const shipCount = await db('fleet_ships') + .where('fleet_id', fleetId) + .sum('quantity as total') + .first(); + + if (!shipCount.total || shipCount.total === 0) { + logger.warn('Fleet has no ships', { + correlationId: req.correlationId, + playerId, + fleetId + }); + + return res.status(400).json({ + error: 'Fleet has no ships available for combat', + code: 'FLEET_EMPTY' + }); + } + + req.attackerFleet = fleet; + next(); + } catch (error) { + logger.error('Fleet availability check middleware error', { + correlationId: req.correlationId, + playerId: req.user?.id, + fleetId: req.body?.attacker_fleet_id, + error: error.message, + stack: error.stack + }); + next(error); + } +}; + +/** + * Rate limiting for combat operations + */ +const combatRateLimit = (maxRequests = 10, windowMinutes = 15) => { + const requests = new Map(); + + return (req, res, next) => { + try { + const playerId = req.user.id; + const now = Date.now(); + const windowMs = windowMinutes * 60 * 1000; + + if (!requests.has(playerId)) { + requests.set(playerId, []); + } + + const playerRequests = requests.get(playerId); + + // Remove old requests outside the window + const validRequests = playerRequests.filter(timestamp => now - timestamp < windowMs); + requests.set(playerId, validRequests); + + // Check if limit exceeded + if (validRequests.length >= maxRequests) { + logger.warn('Combat rate limit exceeded', { + correlationId: req.correlationId, + playerId, + requestCount: validRequests.length, + maxRequests, + windowMinutes + }); + + return res.status(429).json({ + error: 'Rate limit exceeded', + code: 'COMBAT_RATE_LIMIT', + maxRequests, + windowMinutes, + retryAfter: Math.ceil((validRequests[0] + windowMs - now) / 1000) + }); + } + + // Add current request + validRequests.push(now); + requests.set(playerId, validRequests); + + next(); + } catch (error) { + logger.error('Combat rate limit middleware error', { + correlationId: req.correlationId, + playerId: req.user?.id, + error: error.message, + stack: error.stack + }); + next(error); + } + }; +}; + +/** + * Log combat actions for audit trail + */ +const logCombatAction = (action) => { + return (req, res, next) => { + try { + logger.info('Combat action attempted', { + correlationId: req.correlationId, + playerId: req.user?.id, + action, + params: req.params, + body: req.body, + query: req.query, + timestamp: new Date().toISOString() + }); + + next(); + } catch (error) { + logger.error('Combat action logging middleware error', { + correlationId: req.correlationId, + action, + error: error.message, + stack: error.stack + }); + next(error); + } + }; +}; + +module.exports = { + validateCombatInitiation, + validateFleetPositionUpdate, + validateCombatHistoryQuery, + validateCombatQueueQuery, + validateParams, + checkFleetOwnership, + checkBattleAccess, + checkCombatCooldown, + checkFleetAvailability, + combatRateLimit, + logCombatAction +}; \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 2c1de27..4c8aed0 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -40,7 +40,8 @@ router.get('/', (req, res) => { players: '/api/admin/players', system: '/api/admin/system', events: '/api/admin/events', - analytics: '/api/admin/analytics' + analytics: '/api/admin/analytics', + combat: '/api/admin/combat' }, note: 'Administrative access required for all endpoints' }); @@ -336,6 +337,12 @@ systemRoutes.get('/health', // Mount system routes router.use('/system', systemRoutes); +/** + * Combat Management Routes + * /api/admin/combat/* + */ +router.use('/combat', require('./admin/combat')); + /** * Events Management Routes (placeholder) * /api/admin/events/* diff --git a/src/routes/admin/combat.js b/src/routes/admin/combat.js new file mode 100644 index 0000000..1781688 --- /dev/null +++ b/src/routes/admin/combat.js @@ -0,0 +1,345 @@ +/** + * Admin Combat Routes + * Administrative endpoints for combat system management + */ + +const express = require('express'); +const router = express.Router(); + +// Import controllers +const { + getCombatStatistics, + getCombatQueue, + forceResolveCombat, + cancelBattle, + getCombatConfigurations, + saveCombatConfiguration, + deleteCombatConfiguration +} = require('../../controllers/admin/combat.controller'); + +// Import middleware +const { authenticateAdmin } = require('../../middleware/admin.middleware'); +const { + validateCombatQueueQuery, + validateParams, + logCombatAction +} = require('../../middleware/combat.middleware'); +const { validateCombatConfiguration } = require('../../validators/combat.validators'); + +// Apply admin authentication to all routes +router.use(authenticateAdmin); + +/** + * @route GET /api/admin/combat/statistics + * @desc Get comprehensive combat system statistics + * @access Admin + */ +router.get('/statistics', + logCombatAction('admin_get_combat_statistics'), + getCombatStatistics +); + +/** + * @route GET /api/admin/combat/queue + * @desc Get combat queue with filtering options + * @access Admin + */ +router.get('/queue', + logCombatAction('admin_get_combat_queue'), + validateCombatQueueQuery, + getCombatQueue +); + +/** + * @route POST /api/admin/combat/resolve/:battleId + * @desc Force resolve a specific battle + * @access Admin + */ +router.post('/resolve/:battleId', + logCombatAction('admin_force_resolve_combat'), + validateParams('battleId'), + forceResolveCombat +); + +/** + * @route POST /api/admin/combat/cancel/:battleId + * @desc Cancel a battle + * @access Admin + */ +router.post('/cancel/:battleId', + logCombatAction('admin_cancel_battle'), + validateParams('battleId'), + (req, res, next) => { + // Validate cancel reason in request body + const { reason } = req.body; + if (!reason || typeof reason !== 'string' || reason.trim().length < 5) { + return res.status(400).json({ + error: 'Cancel reason is required and must be at least 5 characters', + code: 'INVALID_CANCEL_REASON' + }); + } + next(); + }, + cancelBattle +); + +/** + * @route GET /api/admin/combat/configurations + * @desc Get all combat configurations + * @access Admin + */ +router.get('/configurations', + logCombatAction('admin_get_combat_configurations'), + getCombatConfigurations +); + +/** + * @route POST /api/admin/combat/configurations + * @desc Create new combat configuration + * @access Admin + */ +router.post('/configurations', + logCombatAction('admin_create_combat_configuration'), + (req, res, next) => { + const { error, value } = validateCombatConfiguration(req.body); + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + return res.status(400).json({ + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details + }); + } + req.body = value; + next(); + }, + saveCombatConfiguration +); + +/** + * @route PUT /api/admin/combat/configurations/:configId + * @desc Update existing combat configuration + * @access Admin + */ +router.put('/configurations/:configId', + logCombatAction('admin_update_combat_configuration'), + validateParams('configId'), + (req, res, next) => { + const { error, value } = validateCombatConfiguration(req.body); + if (error) { + const details = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + return res.status(400).json({ + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details + }); + } + req.body = value; + next(); + }, + saveCombatConfiguration +); + +/** + * @route DELETE /api/admin/combat/configurations/:configId + * @desc Delete combat configuration + * @access Admin + */ +router.delete('/configurations/:configId', + logCombatAction('admin_delete_combat_configuration'), + validateParams('configId'), + deleteCombatConfiguration +); + +/** + * @route GET /api/admin/combat/battles + * @desc Get all battles with filtering and pagination + * @access Admin + */ +router.get('/battles', + logCombatAction('admin_get_battles'), + async (req, res, next) => { + try { + const { + status, + battle_type, + location, + limit = 50, + offset = 0, + start_date, + end_date + } = req.query; + + const db = require('../../database/connection'); + const logger = require('../../utils/logger'); + + let query = db('battles') + .select([ + 'battles.*', + 'combat_configurations.config_name', + 'combat_configurations.combat_type' + ]) + .leftJoin('combat_configurations', 'battles.combat_configuration_id', 'combat_configurations.id') + .orderBy('battles.started_at', 'desc') + .limit(parseInt(limit)) + .offset(parseInt(offset)); + + if (status) { + query = query.where('battles.status', status); + } + + if (battle_type) { + query = query.where('battles.battle_type', battle_type); + } + + if (location) { + query = query.where('battles.location', location); + } + + if (start_date) { + query = query.where('battles.started_at', '>=', new Date(start_date)); + } + + if (end_date) { + query = query.where('battles.started_at', '<=', new Date(end_date)); + } + + const battles = await query; + + // Get total count for pagination + let countQuery = db('battles').count('* as total'); + + if (status) countQuery = countQuery.where('status', status); + if (battle_type) countQuery = countQuery.where('battle_type', battle_type); + if (location) countQuery = countQuery.where('location', location); + if (start_date) countQuery = countQuery.where('started_at', '>=', new Date(start_date)); + if (end_date) countQuery = countQuery.where('started_at', '<=', new Date(end_date)); + + const [{ total }] = await countQuery; + + // Parse participants JSON for each battle + const battlesWithParsedParticipants = battles.map(battle => ({ + ...battle, + participants: JSON.parse(battle.participants), + battle_data: battle.battle_data ? JSON.parse(battle.battle_data) : null, + result: battle.result ? JSON.parse(battle.result) : null + })); + + logger.info('Admin battles retrieved', { + correlationId: req.correlationId, + adminUser: req.user.id, + count: battles.length, + total: parseInt(total) + }); + + res.json({ + success: true, + data: { + battles: battlesWithParsedParticipants, + pagination: { + total: parseInt(total), + limit: parseInt(limit), + offset: parseInt(offset), + hasMore: (parseInt(offset) + parseInt(limit)) < parseInt(total) + } + } + }); + + } catch (error) { + next(error); + } + } +); + +/** + * @route GET /api/admin/combat/encounters/:encounterId + * @desc Get detailed combat encounter for admin review + * @access Admin + */ +router.get('/encounters/:encounterId', + logCombatAction('admin_get_combat_encounter'), + validateParams('encounterId'), + async (req, res, next) => { + try { + const encounterId = parseInt(req.params.encounterId); + const db = require('../../database/connection'); + const logger = require('../../utils/logger'); + + // Get encounter with all related data + const encounter = await db('combat_encounters') + .select([ + 'combat_encounters.*', + 'battles.battle_type', + 'battles.participants', + 'battles.started_at as battle_started', + 'battles.completed_at as battle_completed', + 'attacker_fleet.name as attacker_fleet_name', + 'attacker_player.username as attacker_username', + 'defender_fleet.name as defender_fleet_name', + 'defender_player.username as defender_username', + 'defender_colony.name as defender_colony_name', + 'colony_player.username as colony_owner_username' + ]) + .join('battles', 'combat_encounters.battle_id', 'battles.id') + .leftJoin('fleets as attacker_fleet', 'combat_encounters.attacker_fleet_id', 'attacker_fleet.id') + .leftJoin('players as attacker_player', 'attacker_fleet.player_id', 'attacker_player.id') + .leftJoin('fleets as defender_fleet', 'combat_encounters.defender_fleet_id', 'defender_fleet.id') + .leftJoin('players as defender_player', 'defender_fleet.player_id', 'defender_player.id') + .leftJoin('colonies as defender_colony', 'combat_encounters.defender_colony_id', 'defender_colony.id') + .leftJoin('players as colony_player', 'defender_colony.player_id', 'colony_player.id') + .where('combat_encounters.id', encounterId) + .first(); + + if (!encounter) { + return res.status(404).json({ + error: 'Combat encounter not found', + code: 'ENCOUNTER_NOT_FOUND' + }); + } + + // Get combat logs + const combatLogs = await db('combat_logs') + .where('encounter_id', encounterId) + .orderBy('round_number') + .orderBy('timestamp'); + + const detailedEncounter = { + ...encounter, + participants: JSON.parse(encounter.participants), + initial_forces: JSON.parse(encounter.initial_forces), + final_forces: JSON.parse(encounter.final_forces), + casualties: JSON.parse(encounter.casualties), + combat_log: JSON.parse(encounter.combat_log), + loot_awarded: JSON.parse(encounter.loot_awarded), + detailed_logs: combatLogs.map(log => ({ + ...log, + event_data: JSON.parse(log.event_data) + })) + }; + + logger.info('Admin combat encounter retrieved', { + correlationId: req.correlationId, + adminUser: req.user.id, + encounterId + }); + + res.json({ + success: true, + data: detailedEncounter + }); + + } catch (error) { + next(error); + } + } +); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js index e69de29..3f25aac 100644 --- a/src/routes/admin/system.js +++ b/src/routes/admin/system.js @@ -0,0 +1,586 @@ +/** + * Admin System Management Routes + * Provides administrative controls for game tick system, configuration, and monitoring + */ + +const express = require('express'); +const router = express.Router(); +const logger = require('../../utils/logger'); +const { + gameTickService, + getGameTickStatus, + triggerManualTick +} = require('../../services/game-tick.service'); +const db = require('../../database/connection'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Get game tick system status and metrics + * GET /admin/system/tick/status + */ +router.get('/tick/status', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + logger.info('Admin requesting game tick status', { + correlationId, + adminId: req.user?.id, + adminUsername: req.user?.username + }); + + const status = getGameTickStatus(); + + // Get recent tick logs + const recentLogs = await db('game_tick_log') + .select('*') + .orderBy('tick_number', 'desc') + .limit(10); + + // Get performance statistics + const performanceStats = await db('game_tick_log') + .select( + db.raw('AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000) as avg_duration_ms'), + db.raw('COUNT(*) as total_ticks'), + db.raw('COUNT(*) FILTER (WHERE status = \'completed\') as successful_ticks'), + db.raw('COUNT(*) FILTER (WHERE status = \'failed\') as failed_ticks'), + db.raw('MAX(tick_number) as latest_tick') + ) + .where('started_at', '>=', db.raw("NOW() - INTERVAL '24 hours'")) + .first(); + + // Get user group statistics + const userGroupStats = await db('game_tick_log') + .select( + 'user_group', + db.raw('COUNT(*) as tick_count'), + db.raw('AVG(processed_players) as avg_players'), + db.raw('COUNT(*) FILTER (WHERE status = \'failed\') as failures') + ) + .where('started_at', '>=', db.raw("NOW() - INTERVAL '24 hours'")) + .groupBy('user_group') + .orderBy('user_group'); + + res.json({ + success: true, + data: { + service: status, + performance: performanceStats, + userGroups: userGroupStats, + recentLogs: recentLogs.map(log => ({ + id: log.id, + tickNumber: log.tick_number, + userGroup: log.user_group, + status: log.status, + processedPlayers: log.processed_players, + duration: log.performance_metrics?.duration_ms, + startedAt: log.started_at, + completedAt: log.completed_at, + errorMessage: log.error_message + })) + }, + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to get game tick status', { + correlationId, + adminId: req.user?.id, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to retrieve game tick status', + correlationId + }); + } +}); + +/** + * Trigger manual game tick + * POST /admin/system/tick/trigger + */ +router.post('/tick/trigger', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + logger.info('Admin triggering manual game tick', { + correlationId, + adminId: req.user?.id, + adminUsername: req.user?.username + }); + + const result = await triggerManualTick(correlationId); + + // Log admin action + await db('audit_log').insert({ + entity_type: 'game_tick', + entity_id: 0, + action: 'manual_tick_triggered', + actor_type: 'admin', + actor_id: req.user?.id, + changes: { + correlation_id: correlationId, + triggered_by: req.user?.username + }, + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + res.json({ + success: true, + message: 'Manual game tick triggered successfully', + data: result, + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to trigger manual game tick', { + correlationId, + adminId: req.user?.id, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: error.message || 'Failed to trigger manual game tick', + correlationId + }); + } +}); + +/** + * Update game tick configuration + * PUT /admin/system/tick/config + */ +router.put('/tick/config', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + const { + tick_interval_ms, + user_groups_count, + max_retry_attempts, + bonus_tick_threshold, + retry_delay_ms + } = req.body; + + logger.info('Admin updating game tick configuration', { + correlationId, + adminId: req.user?.id, + adminUsername: req.user?.username, + newConfig: req.body + }); + + // Validate configuration values + const validationErrors = []; + + if (tick_interval_ms && (tick_interval_ms < 10000 || tick_interval_ms > 3600000)) { + validationErrors.push('tick_interval_ms must be between 10000 and 3600000 (10 seconds to 1 hour)'); + } + + if (user_groups_count && (user_groups_count < 1 || user_groups_count > 50)) { + validationErrors.push('user_groups_count must be between 1 and 50'); + } + + if (max_retry_attempts && (max_retry_attempts < 1 || max_retry_attempts > 10)) { + validationErrors.push('max_retry_attempts must be between 1 and 10'); + } + + if (validationErrors.length > 0) { + return res.status(400).json({ + success: false, + error: 'Configuration validation failed', + details: validationErrors, + correlationId + }); + } + + // Get current configuration + const currentConfig = await db('game_tick_config') + .where('is_active', true) + .first(); + + if (!currentConfig) { + return res.status(404).json({ + success: false, + error: 'No active game tick configuration found', + correlationId + }); + } + + // Update configuration + const updatedConfig = await db('game_tick_config') + .where('id', currentConfig.id) + .update({ + tick_interval_ms: tick_interval_ms || currentConfig.tick_interval_ms, + user_groups_count: user_groups_count || currentConfig.user_groups_count, + max_retry_attempts: max_retry_attempts || currentConfig.max_retry_attempts, + bonus_tick_threshold: bonus_tick_threshold || currentConfig.bonus_tick_threshold, + retry_delay_ms: retry_delay_ms || currentConfig.retry_delay_ms, + updated_at: new Date() + }) + .returning('*'); + + // Log admin action + await db('audit_log').insert({ + entity_type: 'game_tick_config', + entity_id: currentConfig.id, + action: 'configuration_updated', + actor_type: 'admin', + actor_id: req.user?.id, + changes: { + before: currentConfig, + after: updatedConfig[0], + updated_by: req.user?.username + }, + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + // Reload configuration in the service + await gameTickService.loadConfig(); + + res.json({ + success: true, + message: 'Game tick configuration updated successfully', + data: { + previousConfig: currentConfig, + newConfig: updatedConfig[0] + }, + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to update game tick configuration', { + correlationId, + adminId: req.user?.id, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to update game tick configuration', + correlationId + }); + } +}); + +/** + * Get game tick logs with filtering + * GET /admin/system/tick/logs + */ +router.get('/tick/logs', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + const { + page = 1, + limit = 50, + status, + userGroup, + tickNumber, + startDate, + endDate + } = req.query; + + const pageNum = parseInt(page); + const limitNum = Math.min(parseInt(limit), 100); // Max 100 records per page + const offset = (pageNum - 1) * limitNum; + + let query = db('game_tick_log').select('*'); + + // Apply filters + if (status) { + query = query.where('status', status); + } + + if (userGroup !== undefined) { + query = query.where('user_group', parseInt(userGroup)); + } + + if (tickNumber) { + query = query.where('tick_number', parseInt(tickNumber)); + } + + if (startDate) { + query = query.where('started_at', '>=', new Date(startDate)); + } + + if (endDate) { + query = query.where('started_at', '<=', new Date(endDate)); + } + + // Get total count for pagination + const countQuery = query.clone().clearSelect().count('* as total'); + const [{ total }] = await countQuery; + + // Get paginated results + const logs = await query + .orderBy('tick_number', 'desc') + .orderBy('user_group', 'asc') + .limit(limitNum) + .offset(offset); + + res.json({ + success: true, + data: { + logs: logs.map(log => ({ + id: log.id, + tickNumber: log.tick_number, + userGroup: log.user_group, + status: log.status, + processedPlayers: log.processed_players, + retryCount: log.retry_count, + errorMessage: log.error_message, + performanceMetrics: log.performance_metrics, + startedAt: log.started_at, + completedAt: log.completed_at + })), + pagination: { + page: pageNum, + limit: limitNum, + total: parseInt(total), + pages: Math.ceil(total / limitNum) + } + }, + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to get game tick logs', { + correlationId, + adminId: req.user?.id, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to retrieve game tick logs', + correlationId + }); + } +}); + +/** + * Get system performance metrics + * GET /admin/system/performance + */ +router.get('/performance', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + const { timeRange = '24h' } = req.query; + + let interval; + switch (timeRange) { + case '1h': + interval = "1 hour"; + break; + case '24h': + interval = "24 hours"; + break; + case '7d': + interval = "7 days"; + break; + case '30d': + interval = "30 days"; + break; + default: + interval = "24 hours"; + } + + // Get tick performance metrics + const tickMetrics = await db('game_tick_log') + .select( + db.raw('DATE_TRUNC(\'hour\', started_at) as hour'), + db.raw('COUNT(*) as total_ticks'), + db.raw('COUNT(*) FILTER (WHERE status = \'completed\') as successful_ticks'), + db.raw('COUNT(*) FILTER (WHERE status = \'failed\') as failed_ticks'), + db.raw('AVG(processed_players) as avg_players_processed'), + db.raw('AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000) as avg_duration_ms') + ) + .where('started_at', '>=', db.raw(`NOW() - INTERVAL '${interval}'`)) + .groupBy(db.raw('DATE_TRUNC(\'hour\', started_at)')) + .orderBy('hour'); + + // Get database performance metrics + const dbMetrics = await db.raw(` + SELECT + schemaname, + tablename, + n_tup_ins as inserts, + n_tup_upd as updates, + n_tup_del as deletes, + seq_scan as sequential_scans, + idx_scan as index_scans + FROM pg_stat_user_tables + WHERE schemaname = 'public' + ORDER BY (n_tup_ins + n_tup_upd + n_tup_del) DESC + LIMIT 10 + `); + + // Get active player count + const playerStats = await db('players') + .select( + db.raw('COUNT(*) FILTER (WHERE is_active = true) as active_players'), + db.raw('COUNT(*) FILTER (WHERE last_login >= NOW() - INTERVAL \'24 hours\') as recent_players'), + db.raw('COUNT(*) as total_players') + ) + .first(); + + res.json({ + success: true, + data: { + timeRange, + tickMetrics: tickMetrics.map(metric => ({ + hour: metric.hour, + totalTicks: parseInt(metric.total_ticks), + successfulTicks: parseInt(metric.successful_ticks), + failedTicks: parseInt(metric.failed_ticks), + successRate: metric.total_ticks > 0 ? + ((metric.successful_ticks / metric.total_ticks) * 100).toFixed(2) : 0, + avgPlayersProcessed: parseFloat(metric.avg_players_processed || 0).toFixed(1), + avgDurationMs: parseFloat(metric.avg_duration_ms || 0).toFixed(2) + })), + databaseMetrics: dbMetrics.rows, + playerStats + }, + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to get system performance metrics', { + correlationId, + adminId: req.user?.id, + error: error.message, + stack: error.stack + }); + + res.status(500).json({ + success: false, + error: 'Failed to retrieve performance metrics', + correlationId + }); + } +}); + +/** + * Stop game tick service + * POST /admin/system/tick/stop + */ +router.post('/tick/stop', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + logger.warn('Admin stopping game tick service', { + correlationId, + adminId: req.user?.id, + adminUsername: req.user?.username + }); + + gameTickService.stop(); + + // Log admin action + await db('audit_log').insert({ + entity_type: 'game_tick', + entity_id: 0, + action: 'service_stopped', + actor_type: 'admin', + actor_id: req.user?.id, + changes: { + correlation_id: correlationId, + stopped_by: req.user?.username, + timestamp: new Date().toISOString() + }, + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + res.json({ + success: true, + message: 'Game tick service stopped successfully', + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to stop game tick service', { + correlationId, + adminId: req.user?.id, + error: error.message + }); + + res.status(500).json({ + success: false, + error: 'Failed to stop game tick service', + correlationId + }); + } +}); + +/** + * Start game tick service + * POST /admin/system/tick/start + */ +router.post('/tick/start', async (req, res) => { + const correlationId = req.correlationId || uuidv4(); + + try { + logger.info('Admin starting game tick service', { + correlationId, + adminId: req.user?.id, + adminUsername: req.user?.username + }); + + await gameTickService.initialize(); + + // Log admin action + await db('audit_log').insert({ + entity_type: 'game_tick', + entity_id: 0, + action: 'service_started', + actor_type: 'admin', + actor_id: req.user?.id, + changes: { + correlation_id: correlationId, + started_by: req.user?.username, + timestamp: new Date().toISOString() + }, + ip_address: req.ip, + user_agent: req.get('User-Agent') + }); + + res.json({ + success: true, + message: 'Game tick service started successfully', + data: gameTickService.getStatus(), + timestamp: new Date().toISOString(), + correlationId + }); + + } catch (error) { + logger.error('Failed to start game tick service', { + correlationId, + adminId: req.user?.id, + error: error.message + }); + + res.status(500).json({ + success: false, + error: error.message || 'Failed to start game tick service', + correlationId + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js index e04937c..c1ccc2f 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -39,7 +39,8 @@ router.get('/', (req, res) => { colonies: '/api/colonies', fleets: '/api/fleets', research: '/api/research', - galaxy: '/api/galaxy' + galaxy: '/api/galaxy', + combat: '/api/combat' } } }); @@ -162,6 +163,12 @@ playerRoutes.put('/notifications/read', // Mount player routes router.use('/player', playerRoutes); +/** + * Combat Routes + * /api/combat/* + */ +router.use('/combat', require('./api/combat')); + /** * Game Feature Routes * These will be expanded with actual game functionality diff --git a/src/routes/api/combat.js b/src/routes/api/combat.js new file mode 100644 index 0000000..fd349e0 --- /dev/null +++ b/src/routes/api/combat.js @@ -0,0 +1,130 @@ +/** + * Combat API Routes + * Defines all combat-related endpoints for players + */ + +const express = require('express'); +const router = express.Router(); + +// Import controllers +const { + initiateCombat, + getActiveCombats, + getCombatHistory, + getCombatEncounter, + getCombatStatistics, + updateFleetPosition, + getCombatTypes, + forceResolveCombat +} = require('../../controllers/api/combat.controller'); + +// Import middleware +const { authenticatePlayer } = require('../../middleware/auth.middleware'); +const { + validateCombatInitiation, + validateFleetPositionUpdate, + validateCombatHistoryQuery, + validateParams, + checkFleetOwnership, + checkBattleAccess, + checkCombatCooldown, + checkFleetAvailability, + combatRateLimit, + logCombatAction +} = require('../../middleware/combat.middleware'); + +// Apply authentication to all combat routes +router.use(authenticatePlayer); + +/** + * @route POST /api/combat/initiate + * @desc Initiate combat between fleets or fleet vs colony + * @access Private + */ +router.post('/initiate', + logCombatAction('initiate_combat'), + combatRateLimit(5, 15), // Max 5 combat initiations per 15 minutes + checkCombatCooldown, + validateCombatInitiation, + checkFleetAvailability, + initiateCombat +); + +/** + * @route GET /api/combat/active + * @desc Get active combats for the current player + * @access Private + */ +router.get('/active', + logCombatAction('get_active_combats'), + getActiveCombats +); + +/** + * @route GET /api/combat/history + * @desc Get combat history for the current player + * @access Private + */ +router.get('/history', + logCombatAction('get_combat_history'), + validateCombatHistoryQuery, + getCombatHistory +); + +/** + * @route GET /api/combat/encounter/:encounterId + * @desc Get detailed combat encounter information + * @access Private + */ +router.get('/encounter/:encounterId', + logCombatAction('get_combat_encounter'), + validateParams('encounterId'), + getCombatEncounter +); + +/** + * @route GET /api/combat/statistics + * @desc Get combat statistics for the current player + * @access Private + */ +router.get('/statistics', + logCombatAction('get_combat_statistics'), + getCombatStatistics +); + +/** + * @route PUT /api/combat/position/:fleetId + * @desc Update fleet positioning for tactical combat + * @access Private + */ +router.put('/position/:fleetId', + logCombatAction('update_fleet_position'), + validateParams('fleetId'), + checkFleetOwnership, + validateFleetPositionUpdate, + updateFleetPosition +); + +/** + * @route GET /api/combat/types + * @desc Get available combat types and configurations + * @access Private + */ +router.get('/types', + logCombatAction('get_combat_types'), + getCombatTypes +); + +/** + * @route POST /api/combat/resolve/:battleId + * @desc Force resolve a combat (emergency use only) + * @access Private (requires special permission) + */ +router.post('/resolve/:battleId', + logCombatAction('force_resolve_combat'), + validateParams('battleId'), + checkBattleAccess, + forceResolveCombat +); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/debug.js b/src/routes/debug.js index 463ad8f..f88cfbf 100644 --- a/src/routes/debug.js +++ b/src/routes/debug.js @@ -35,7 +35,10 @@ router.get('/', (req, res) => { websocket: '/debug/websocket', system: '/debug/system', logs: '/debug/logs', - player: '/debug/player/:playerId' + player: '/debug/player/:playerId', + colonies: '/debug/colonies', + resources: '/debug/resources', + gameEvents: '/debug/game-events' } }); }); @@ -311,4 +314,243 @@ router.get('/test/:scenario', (req, res) => { } }); +/** + * Colony Debug Information + */ +router.get('/colonies', async (req, res) => { + try { + const { playerId, limit = 10 } = req.query; + + let query = db('colonies') + .select([ + 'colonies.*', + 'planet_types.name as planet_type_name', + 'galaxy_sectors.name as sector_name', + 'players.username' + ]) + .leftJoin('planet_types', 'colonies.planet_type_id', 'planet_types.id') + .leftJoin('galaxy_sectors', 'colonies.sector_id', 'galaxy_sectors.id') + .leftJoin('players', 'colonies.player_id', 'players.id') + .orderBy('colonies.founded_at', 'desc') + .limit(parseInt(limit)); + + if (playerId) { + query = query.where('colonies.player_id', parseInt(playerId)); + } + + const colonies = await query; + + // Get building counts for each colony + const coloniesWithBuildings = await Promise.all(colonies.map(async (colony) => { + const buildingCount = await db('colony_buildings') + .where('colony_id', colony.id) + .count('* as count') + .first(); + + const resourceProduction = await db('colony_resource_production') + .select([ + 'resource_types.name as resource_name', + 'colony_resource_production.production_rate', + 'colony_resource_production.current_stored' + ]) + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', colony.id) + .where('colony_resource_production.production_rate', '>', 0); + + return { + ...colony, + buildingCount: parseInt(buildingCount.count) || 0, + resourceProduction + }; + })); + + res.json({ + colonies: coloniesWithBuildings, + totalCount: coloniesWithBuildings.length, + filters: { playerId, limit }, + correlationId: req.correlationId + }); + + } catch (error) { + logger.error('Colony debug error:', error); + res.status(500).json({ + error: error.message, + correlationId: req.correlationId + }); + } +}); + +/** + * Resource Debug Information + */ +router.get('/resources', async (req, res) => { + try { + const { playerId } = req.query; + + // Get resource types + const resourceTypes = await db('resource_types') + .where('is_active', true) + .orderBy('category') + .orderBy('name'); + + let resourceSummary = {}; + + if (playerId) { + // Get specific player resources + const playerResources = await db('player_resources') + .select([ + 'player_resources.*', + 'resource_types.name as resource_name', + 'resource_types.category' + ]) + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', parseInt(playerId)); + + resourceSummary.playerResources = playerResources; + + // Get player's colony resource production + const colonyProduction = await db('colony_resource_production') + .select([ + 'colonies.name as colony_name', + 'resource_types.name as resource_name', + 'colony_resource_production.production_rate', + 'colony_resource_production.current_stored' + ]) + .join('colonies', 'colony_resource_production.colony_id', 'colonies.id') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colonies.player_id', parseInt(playerId)) + .where('colony_resource_production.production_rate', '>', 0); + + resourceSummary.colonyProduction = colonyProduction; + } else { + // Get global resource statistics + const totalResources = await db('player_resources') + .select([ + 'resource_types.name as resource_name', + db.raw('SUM(player_resources.amount) as total_amount'), + db.raw('COUNT(player_resources.id) as player_count'), + db.raw('AVG(player_resources.amount) as average_amount') + ]) + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .groupBy('resource_types.id', 'resource_types.name') + .orderBy('resource_types.name'); + + resourceSummary.globalStats = totalResources; + } + + res.json({ + resourceTypes, + ...resourceSummary, + filters: { playerId }, + correlationId: req.correlationId + }); + + } catch (error) { + logger.error('Resource debug error:', error); + res.status(500).json({ + error: error.message, + correlationId: req.correlationId + }); + } +}); + +/** + * Game Events Debug Information + */ +router.get('/game-events', (req, res) => { + try { + const serviceLocator = require('../services/ServiceLocator'); + const gameEventService = serviceLocator.get('gameEventService'); + + if (!gameEventService) { + return res.json({ + status: 'not_available', + message: 'Game event service not initialized', + correlationId: req.correlationId + }); + } + + const connectedPlayers = gameEventService.getConnectedPlayerCount(); + + // Get room information + const io = gameEventService.io; + const rooms = Array.from(io.sockets.adapter.rooms.entries()).map(([roomName, socketSet]) => ({ + name: roomName, + socketCount: socketSet.size, + type: roomName.includes(':') ? roomName.split(':')[0] : 'unknown' + })); + + res.json({ + status: 'active', + connectedPlayers, + rooms: { + total: rooms.length, + breakdown: rooms + }, + eventTypes: [ + 'colony_created', + 'building_constructed', + 'resources_updated', + 'resource_production', + 'colony_status_update', + 'error', + 'notification', + 'player_status_change', + 'system_announcement' + ], + correlationId: req.correlationId + }); + + } catch (error) { + logger.error('Game events debug error:', error); + res.status(500).json({ + error: error.message, + correlationId: req.correlationId + }); + } +}); + +/** + * Add resources to a player (for testing) + */ +router.post('/add-resources', async (req, res) => { + try { + const { playerId, resources } = req.body; + + if (!playerId || !resources) { + return res.status(400).json({ + error: 'playerId and resources are required', + correlationId: req.correlationId + }); + } + + const serviceLocator = require('../services/ServiceLocator'); + const ResourceService = require('../services/resource/ResourceService'); + const gameEventService = serviceLocator.get('gameEventService'); + const resourceService = new ResourceService(gameEventService); + + const updatedResources = await resourceService.addPlayerResources( + playerId, + resources, + req.correlationId + ); + + res.json({ + success: true, + message: 'Resources added successfully', + playerId, + addedResources: resources, + updatedResources, + correlationId: req.correlationId + }); + + } catch (error) { + logger.error('Add resources debug error:', error); + res.status(500).json({ + error: error.message, + correlationId: req.correlationId + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/src/routes/player/colonies.js b/src/routes/player/colonies.js index e69de29..f282df3 100644 --- a/src/routes/player/colonies.js +++ b/src/routes/player/colonies.js @@ -0,0 +1,53 @@ +/** + * Player Colony Routes + * Handles all colony-related endpoints for players + */ + +const express = require('express'); +const router = express.Router(); + +const { + createColony, + getPlayerColonies, + getColonyDetails, + constructBuilding, + getBuildingTypes, + getPlanetTypes, + getGalaxySectors +} = require('../../controllers/player/colony.controller'); + +const { validateRequest } = require('../../middleware/validation.middleware'); +const { + createColonySchema, + constructBuildingSchema, + colonyIdParamSchema +} = require('../../validators/colony.validators'); + +// Colony CRUD operations +router.post('/', + validateRequest(createColonySchema), + createColony +); + +router.get('/', + getPlayerColonies +); + +router.get('/:colonyId', + validateRequest(colonyIdParamSchema, 'params'), + getColonyDetails +); + +// Building operations +router.post('/:colonyId/buildings', + validateRequest(colonyIdParamSchema, 'params'), + validateRequest(constructBuildingSchema), + constructBuilding +); + +// Reference data endpoints +router.get('/ref/building-types', getBuildingTypes); +router.get('/ref/planet-types', getPlanetTypes); +router.get('/ref/galaxy-sectors', getGalaxySectors); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/player/index.js b/src/routes/player/index.js index 3713388..642b504 100644 --- a/src/routes/player/index.js +++ b/src/routes/player/index.js @@ -4,7 +4,7 @@ const express = require('express'); const { authenticateToken, optionalAuth } = require('../../middleware/auth'); -const { asyncHandler } = require('../../middleware/error-handler'); +const { asyncHandler } = require('../../middleware/error.middleware'); const router = express.Router(); @@ -12,6 +12,7 @@ const router = express.Router(); const authRoutes = require('./auth'); const profileRoutes = require('./profile'); const coloniesRoutes = require('./colonies'); +const resourcesRoutes = require('./resources'); const fleetsRoutes = require('./fleets'); const researchRoutes = require('./research'); const galaxyRoutes = require('./galaxy'); @@ -25,6 +26,7 @@ router.use('/galaxy', optionalAuth('player'), galaxyRoutes); // Protected routes (authentication required) router.use('/profile', authenticateToken('player'), profileRoutes); router.use('/colonies', authenticateToken('player'), coloniesRoutes); +router.use('/resources', authenticateToken('player'), resourcesRoutes); router.use('/fleets', authenticateToken('player'), fleetsRoutes); router.use('/research', authenticateToken('player'), researchRoutes); router.use('/events', authenticateToken('player'), eventsRoutes); diff --git a/src/routes/player/resources.js b/src/routes/player/resources.js new file mode 100644 index 0000000..f0d14cd --- /dev/null +++ b/src/routes/player/resources.js @@ -0,0 +1,54 @@ +/** + * Player Resource Routes + * Handles all resource-related endpoints for players + */ + +const express = require('express'); +const router = express.Router(); + +const { + getPlayerResources, + getPlayerResourceSummary, + getResourceProduction, + addResources, + transferResources, + getResourceTypes +} = require('../../controllers/player/resource.controller'); + +const { validateRequest } = require('../../middleware/validation.middleware'); +const { + transferResourcesSchema, + addResourcesSchema, + resourceQuerySchema +} = require('../../validators/resource.validators'); + +// Resource information endpoints +router.get('/', + validateRequest(resourceQuerySchema, 'query'), + getPlayerResources +); + +router.get('/summary', + getPlayerResourceSummary +); + +router.get('/production', + getResourceProduction +); + +// Resource manipulation endpoints +router.post('/transfer', + validateRequest(transferResourcesSchema), + transferResources +); + +// Development/testing endpoints +router.post('/add', + validateRequest(addResourcesSchema), + addResources +); + +// Reference data endpoints +router.get('/types', getResourceTypes); + +module.exports = router; \ No newline at end of file diff --git a/src/server.js b/src/server.js index b12a586..33452a5 100644 --- a/src/server.js +++ b/src/server.js @@ -50,6 +50,13 @@ async function initializeSystems() { io = await initializeWebSocket(server); logger.info('WebSocket systems initialized'); + // Initialize service locator with WebSocket service + const serviceLocator = require('./services/ServiceLocator'); + const GameEventService = require('./services/websocket/GameEventService'); + const gameEventService = new GameEventService(io); + serviceLocator.register('gameEventService', gameEventService); + logger.info('Service locator initialized'); + // Initialize game systems await initializeGameSystems(); logger.info('Game systems initialized'); diff --git a/src/services/ServiceLocator.js b/src/services/ServiceLocator.js new file mode 100644 index 0000000..e033720 --- /dev/null +++ b/src/services/ServiceLocator.js @@ -0,0 +1,57 @@ +/** + * Service Locator + * Manages service instances and dependency injection + */ + +class ServiceLocator { + constructor() { + this.services = new Map(); + } + + /** + * Register a service instance + * @param {string} name - Service name + * @param {Object} instance - Service instance + */ + register(name, instance) { + this.services.set(name, instance); + } + + /** + * Get a service instance + * @param {string} name - Service name + * @returns {Object} Service instance + */ + get(name) { + return this.services.get(name); + } + + /** + * Check if a service is registered + * @param {string} name - Service name + * @returns {boolean} True if service is registered + */ + has(name) { + return this.services.has(name); + } + + /** + * Clear all services + */ + clear() { + this.services.clear(); + } + + /** + * Get all registered service names + * @returns {Array} Array of service names + */ + getServiceNames() { + return Array.from(this.services.keys()); + } +} + +// Create singleton instance +const serviceLocator = new ServiceLocator(); + +module.exports = serviceLocator; \ No newline at end of file diff --git a/src/services/combat/CombatPluginManager.js b/src/services/combat/CombatPluginManager.js new file mode 100644 index 0000000..a3463ce --- /dev/null +++ b/src/services/combat/CombatPluginManager.js @@ -0,0 +1,743 @@ +/** + * Combat Plugin Manager + * Manages combat resolution plugins for different combat types and strategies + */ + +const db = require('../../database/connection'); +const logger = require('../../utils/logger'); +const { ValidationError, ServiceError } = require('../../middleware/error.middleware'); + +class CombatPluginManager { + constructor() { + this.plugins = new Map(); + this.hooks = new Map(); + this.initialized = false; + } + + /** + * Initialize the plugin manager and load active plugins + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async initialize(correlationId) { + try { + logger.info('Initializing Combat Plugin Manager', { correlationId }); + + // Load active combat plugins from database + const activePlugins = await db('plugins') + .where('plugin_type', 'combat') + .where('is_active', true) + .orderBy('name'); + + for (const pluginData of activePlugins) { + await this.loadPlugin(pluginData, correlationId); + } + + this.initialized = true; + + logger.info('Combat Plugin Manager initialized', { + correlationId, + loadedPlugins: this.plugins.size, + availableHooks: Array.from(this.hooks.keys()) + }); + + } catch (error) { + logger.error('Failed to initialize Combat Plugin Manager', { + correlationId, + error: error.message, + stack: error.stack + }); + throw new ServiceError('Failed to initialize combat plugin system', error); + } + } + + /** + * Load a combat plugin + * @param {Object} pluginData - Plugin data from database + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async loadPlugin(pluginData, correlationId) { + try { + logger.info('Loading combat plugin', { + correlationId, + pluginName: pluginData.name, + version: pluginData.version + }); + + let plugin; + + // Load built-in plugins + switch (pluginData.name) { + case 'instant_combat': + plugin = new InstantCombatPlugin(pluginData.config || {}); + break; + case 'turn_based_combat': + plugin = new TurnBasedCombatPlugin(pluginData.config || {}); + break; + case 'tactical_combat': + plugin = new TacticalCombatPlugin(pluginData.config || {}); + break; + default: + logger.warn('Unknown combat plugin', { + correlationId, + pluginName: pluginData.name + }); + return; + } + + // Validate plugin interface + this.validatePluginInterface(plugin, pluginData.name); + + // Register plugin + this.plugins.set(pluginData.name, plugin); + + // Register plugin hooks + if (pluginData.hooks && Array.isArray(pluginData.hooks)) { + for (const hook of pluginData.hooks) { + if (!this.hooks.has(hook)) { + this.hooks.set(hook, []); + } + this.hooks.get(hook).push({ + plugin: pluginData.name, + handler: plugin[hook] ? plugin[hook].bind(plugin) : null + }); + } + } + + logger.info('Combat plugin loaded successfully', { + correlationId, + pluginName: pluginData.name, + hooksRegistered: pluginData.hooks?.length || 0 + }); + + } catch (error) { + logger.error('Failed to load combat plugin', { + correlationId, + pluginName: pluginData.name, + error: error.message, + stack: error.stack + }); + } + } + + /** + * Resolve combat using appropriate plugin + * @param {Object} battle - Battle data + * @param {Object} forces - Combat forces + * @param {Object} config - Combat configuration + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async resolveCombat(battle, forces, config, correlationId) { + try { + if (!this.initialized) { + await this.initialize(correlationId); + } + + logger.info('Resolving combat with plugin system', { + correlationId, + battleId: battle.id, + combatType: config.combat_type + }); + + // Determine which plugin to use + const pluginName = this.getPluginForCombatType(config.combat_type); + const plugin = this.plugins.get(pluginName); + + if (!plugin) { + logger.warn('No plugin found for combat type, using fallback', { + correlationId, + combatType: config.combat_type, + requestedPlugin: pluginName + }); + return await this.fallbackCombatResolver(battle, forces, config, correlationId); + } + + // Execute pre-combat hooks + await this.executeHooks('pre_combat', { battle, forces, config }, correlationId); + + // Resolve combat using plugin + const result = await plugin.resolveCombat(battle, forces, config, correlationId); + + // Execute post-combat hooks + await this.executeHooks('post_combat', { battle, forces, config, result }, correlationId); + + logger.info('Combat resolved successfully', { + correlationId, + battleId: battle.id, + plugin: pluginName, + outcome: result.outcome, + duration: result.duration + }); + + return result; + + } catch (error) { + logger.error('Combat resolution failed', { + correlationId, + battleId: battle.id, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to resolve combat', error); + } + } + + /** + * Execute hooks for a specific event + * @param {string} hookName - Hook name + * @param {Object} data - Hook data + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async executeHooks(hookName, data, correlationId) { + const hookHandlers = this.hooks.get(hookName) || []; + + for (const handler of hookHandlers) { + try { + if (handler.handler) { + await handler.handler(data, correlationId); + } + } catch (error) { + logger.error('Hook execution failed', { + correlationId, + hookName, + plugin: handler.plugin, + error: error.message + }); + } + } + } + + /** + * Get plugin name for combat type + * @param {string} combatType - Combat type + * @returns {string} Plugin name + */ + getPluginForCombatType(combatType) { + const typeMapping = { + 'instant': 'instant_combat', + 'turn_based': 'turn_based_combat', + 'tactical': 'tactical_combat', + 'real_time': 'tactical_combat' // Use tactical plugin for real-time + }; + + return typeMapping[combatType] || 'instant_combat'; + } + + /** + * Validate plugin interface + * @param {Object} plugin - Plugin instance + * @param {string} pluginName - Plugin name + * @throws {ValidationError} If plugin interface is invalid + */ + validatePluginInterface(plugin, pluginName) { + const requiredMethods = ['resolveCombat']; + + for (const method of requiredMethods) { + if (typeof plugin[method] !== 'function') { + throw new ValidationError(`Plugin ${pluginName} missing required method: ${method}`); + } + } + } + + /** + * Fallback combat resolver + * @param {Object} battle - Battle data + * @param {Object} forces - Combat forces + * @param {Object} config - Combat configuration + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async fallbackCombatResolver(battle, forces, config, correlationId) { + logger.info('Using fallback combat resolver', { correlationId, battleId: battle.id }); + + const fallbackPlugin = new InstantCombatPlugin({}); + return await fallbackPlugin.resolveCombat(battle, forces, config, correlationId); + } + + /** + * Register a new plugin dynamically + * @param {string} name - Plugin name + * @param {Object} plugin - Plugin instance + * @param {Array} hooks - Hook names + * @returns {void} + */ + registerPlugin(name, plugin, hooks = []) { + this.validatePluginInterface(plugin, name); + this.plugins.set(name, plugin); + + // Register hooks + for (const hook of hooks) { + if (!this.hooks.has(hook)) { + this.hooks.set(hook, []); + } + this.hooks.get(hook).push({ + plugin: name, + handler: plugin[hook] ? plugin[hook].bind(plugin) : null + }); + } + + logger.info('Plugin registered dynamically', { + pluginName: name, + hooksRegistered: hooks.length + }); + } +} + +/** + * Base Combat Plugin Class + * All combat plugins should extend this class + */ +class BaseCombatPlugin { + constructor(config = {}) { + this.config = config; + } + + /** + * Resolve combat - must be implemented by subclasses + * @param {Object} battle - Battle data + * @param {Object} forces - Combat forces + * @param {Object} config - Combat configuration + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async resolveCombat(battle, forces, config, correlationId) { + throw new Error('resolveCombat method must be implemented by subclass'); + } + + /** + * Calculate base combat rating for a force + * @param {Object} force - Force data (fleet or colony) + * @returns {number} Combat rating + */ + calculateCombatRating(force) { + if (force.fleet) { + return force.fleet.total_combat_rating || 0; + } else if (force.colony) { + return force.colony.total_defense_rating || 0; + } + return 0; + } + + /** + * Apply random variance to a value + * @param {number} baseValue - Base value + * @param {number} variance - Variance percentage (0-1) + * @returns {number} Value with variance applied + */ + applyVariance(baseValue, variance = 0.1) { + const factor = 1 + (Math.random() - 0.5) * variance * 2; + return baseValue * factor; + } + + /** + * Generate combat log entry + * @param {number} round - Round number + * @param {string} eventType - Event type + * @param {string} description - Event description + * @param {Object} data - Additional event data + * @returns {Object} Combat log entry + */ + createLogEntry(round, eventType, description, data = {}) { + return { + round, + event: eventType, + description, + timestamp: new Date(), + ...data + }; + } +} + +/** + * Instant Combat Plugin + * Resolves combat immediately with simple calculations + */ +class InstantCombatPlugin extends BaseCombatPlugin { + async resolveCombat(battle, forces, config, correlationId) { + logger.info('Resolving instant combat', { correlationId, battleId: battle.id }); + + const attackerRating = this.calculateCombatRating(forces.attacker); + const defenderRating = this.calculateCombatRating(forces.defender); + + // Apply variance + const effectiveAttackerRating = this.applyVariance(attackerRating, this.config.damage_variance || 0.1); + const effectiveDefenderRating = this.applyVariance(defenderRating, this.config.damage_variance || 0.1); + + const totalRating = effectiveAttackerRating + effectiveDefenderRating; + const attackerWinChance = totalRating > 0 ? effectiveAttackerRating / totalRating : 0.5; + + const attackerWins = Math.random() < attackerWinChance; + const outcome = attackerWins ? 'attacker_victory' : 'defender_victory'; + + // Calculate casualties + const casualties = this.calculateInstantCasualties(forces, attackerWins); + + // Generate combat log + const combatLog = [ + this.createLogEntry(1, 'combat_start', 'Combat initiated', { + attacker_strength: effectiveAttackerRating, + defender_strength: effectiveDefenderRating, + win_chance: attackerWinChance + }), + this.createLogEntry(1, 'combat_resolution', `${outcome.replace('_', ' ')}`, { + winner: attackerWins ? 'attacker' : 'defender' + }) + ]; + + const experienceGained = Math.floor((attackerRating + defenderRating) / 100) * (this.config.experience_gain || 1.0); + + return { + outcome, + casualties, + experience_gained: experienceGained, + combat_log: combatLog, + duration: Math.floor(Math.random() * 60) + 30, // 30-90 seconds + final_forces: this.calculateFinalForces(forces, casualties), + loot: this.calculateLoot(forces, attackerWins) + }; + } + + calculateInstantCasualties(forces, attackerWins) { + const casualties = { + attacker: { ships: {}, total_ships: 0 }, + defender: { ships: {}, total_ships: 0, buildings: {} } + }; + + // Winner loses 5-25%, loser loses 30-70% + const winnerLossRate = 0.05 + Math.random() * 0.2; + const loserLossRate = 0.3 + Math.random() * 0.4; + + const attackerLossRate = attackerWins ? winnerLossRate : loserLossRate; + const defenderLossRate = attackerWins ? loserLossRate : winnerLossRate; + + // Apply casualties to attacker fleet + if (forces.attacker.fleet && forces.attacker.fleet.ships) { + forces.attacker.fleet.ships.forEach(ship => { + const losses = Math.floor(ship.quantity * attackerLossRate); + if (losses > 0) { + casualties.attacker.ships[ship.design_name] = losses; + casualties.attacker.total_ships += losses; + } + }); + } + + // Apply casualties to defender + if (forces.defender.fleet && forces.defender.fleet.ships) { + forces.defender.fleet.ships.forEach(ship => { + const losses = Math.floor(ship.quantity * defenderLossRate); + if (losses > 0) { + casualties.defender.ships[ship.design_name] = losses; + casualties.defender.total_ships += losses; + } + }); + } + + // Apply building damage if colony and attacker wins + if (forces.defender.colony && attackerWins && forces.defender.colony.defense_buildings) { + const buildingDamageRate = 0.1 + Math.random() * 0.2; + forces.defender.colony.defense_buildings.forEach(building => { + const damage = Math.floor(building.health_percentage * buildingDamageRate); + if (damage > 0) { + casualties.defender.buildings[building.building_name] = damage; + } + }); + } + + return casualties; + } + + calculateFinalForces(forces, casualties) { + const finalForces = JSON.parse(JSON.stringify(forces)); + + // Apply ship losses + ['attacker', 'defender'].forEach(side => { + if (finalForces[side].fleet && finalForces[side].fleet.ships) { + finalForces[side].fleet.ships.forEach(ship => { + const losses = casualties[side].ships[ship.design_name] || 0; + ship.quantity = Math.max(0, ship.quantity - losses); + }); + } + }); + + // Apply building damage + if (finalForces.defender.colony && finalForces.defender.colony.defense_buildings) { + finalForces.defender.colony.defense_buildings.forEach(building => { + const damage = casualties.defender.buildings[building.building_name] || 0; + building.health_percentage = Math.max(0, building.health_percentage - damage); + }); + } + + return finalForces; + } + + calculateLoot(forces, attackerWins) { + if (!attackerWins) return {}; + + const loot = {}; + const baseAmount = Math.floor(Math.random() * 500) + 100; + + loot.scrap = baseAmount; + loot.energy = Math.floor(baseAmount * 0.6); + + if (forces.defender.colony) { + loot.data_cores = Math.floor(Math.random() * 5) + 1; + if (Math.random() < 0.15) { + loot.rare_elements = Math.floor(Math.random() * 3) + 1; + } + } + + return loot; + } +} + +/** + * Turn-Based Combat Plugin + * Resolves combat in discrete rounds with detailed calculations + */ +class TurnBasedCombatPlugin extends BaseCombatPlugin { + async resolveCombat(battle, forces, config, correlationId) { + logger.info('Resolving turn-based combat', { correlationId, battleId: battle.id }); + + const maxRounds = this.config.max_rounds || 10; + const combatLog = []; + let round = 1; + + // Initialize combat state + const combatState = this.initializeCombatState(forces); + + combatLog.push(this.createLogEntry(0, 'combat_start', 'Turn-based combat initiated', { + attacker_ships: combatState.attacker.totalShips, + defender_ships: combatState.defender.totalShips, + max_rounds: maxRounds + })); + + // Combat rounds + while (round <= maxRounds && !this.isCombatOver(combatState)) { + const roundResult = await this.processRound(combatState, round, correlationId); + combatLog.push(...roundResult.log); + round++; + } + + // Determine outcome + const outcome = this.determineTurnBasedOutcome(combatState); + const casualties = this.calculateTurnBasedCasualties(forces, combatState); + + combatLog.push(this.createLogEntry(round - 1, 'combat_end', `Combat ended: ${outcome}`, { + total_rounds: round - 1, + attacker_survivors: combatState.attacker.totalShips, + defender_survivors: combatState.defender.totalShips + })); + + return { + outcome, + casualties, + experience_gained: Math.floor((round - 1) * 50), + combat_log: combatLog, + duration: (round - 1) * 30, // 30 seconds per round + final_forces: this.calculateFinalForces(forces, casualties), + loot: this.calculateLoot(forces, outcome === 'attacker_victory') + }; + } + + initializeCombatState(forces) { + const state = { + attacker: { totalShips: 0, effectiveStrength: 0 }, + defender: { totalShips: 0, effectiveStrength: 0 } + }; + + // Calculate initial state + if (forces.attacker.fleet && forces.attacker.fleet.ships) { + state.attacker.totalShips = forces.attacker.fleet.ships.reduce((sum, ship) => sum + ship.quantity, 0); + state.attacker.effectiveStrength = forces.attacker.fleet.total_combat_rating || 0; + } + + if (forces.defender.fleet && forces.defender.fleet.ships) { + state.defender.totalShips = forces.defender.fleet.ships.reduce((sum, ship) => sum + ship.quantity, 0); + state.defender.effectiveStrength = forces.defender.fleet.total_combat_rating || 0; + } else if (forces.defender.colony) { + state.defender.totalShips = 1; // Represent colony as single entity + state.defender.effectiveStrength = forces.defender.colony.total_defense_rating || 0; + } + + return state; + } + + async processRound(combatState, round, correlationId) { + const log = []; + + // Attacker attacks defender + const attackerDamage = this.applyVariance(combatState.attacker.effectiveStrength * 0.1, 0.2); + const defenderLosses = Math.floor(attackerDamage / 100); + + combatState.defender.totalShips = Math.max(0, combatState.defender.totalShips - defenderLosses); + combatState.defender.effectiveStrength *= (combatState.defender.totalShips / (combatState.defender.totalShips + defenderLosses)); + + log.push(this.createLogEntry(round, 'attack', 'Attacker strikes', { + damage: attackerDamage, + defender_losses: defenderLosses, + defender_remaining: combatState.defender.totalShips + })); + + // Defender counterattacks if still alive + if (combatState.defender.totalShips > 0) { + const defenderDamage = this.applyVariance(combatState.defender.effectiveStrength * 0.1, 0.2); + const attackerLosses = Math.floor(defenderDamage / 100); + + combatState.attacker.totalShips = Math.max(0, combatState.attacker.totalShips - attackerLosses); + combatState.attacker.effectiveStrength *= (combatState.attacker.totalShips / (combatState.attacker.totalShips + attackerLosses)); + + log.push(this.createLogEntry(round, 'counterattack', 'Defender counterattacks', { + damage: defenderDamage, + attacker_losses: attackerLosses, + attacker_remaining: combatState.attacker.totalShips + })); + } + + return { log }; + } + + isCombatOver(combatState) { + return combatState.attacker.totalShips <= 0 || combatState.defender.totalShips <= 0; + } + + determineTurnBasedOutcome(combatState) { + if (combatState.attacker.totalShips <= 0 && combatState.defender.totalShips <= 0) { + return 'draw'; + } else if (combatState.attacker.totalShips > 0) { + return 'attacker_victory'; + } else { + return 'defender_victory'; + } + } + + calculateTurnBasedCasualties(forces, combatState) { + // Calculate casualties based on ships remaining vs initial + const casualties = { + attacker: { ships: {}, total_ships: 0 }, + defender: { ships: {}, total_ships: 0, buildings: {} } + }; + + // Calculate attacker casualties + if (forces.attacker.fleet && forces.attacker.fleet.ships) { + const initialShips = forces.attacker.fleet.ships.reduce((sum, ship) => sum + ship.quantity, 0); + const remainingRatio = combatState.attacker.totalShips / initialShips; + + forces.attacker.fleet.ships.forEach(ship => { + const remaining = Math.floor(ship.quantity * remainingRatio); + const losses = ship.quantity - remaining; + if (losses > 0) { + casualties.attacker.ships[ship.design_name] = losses; + casualties.attacker.total_ships += losses; + } + }); + } + + // Calculate defender casualties + if (forces.defender.fleet && forces.defender.fleet.ships) { + const initialShips = forces.defender.fleet.ships.reduce((sum, ship) => sum + ship.quantity, 0); + const remainingRatio = combatState.defender.totalShips / initialShips; + + forces.defender.fleet.ships.forEach(ship => { + const remaining = Math.floor(ship.quantity * remainingRatio); + const losses = ship.quantity - remaining; + if (losses > 0) { + casualties.defender.ships[ship.design_name] = losses; + casualties.defender.total_ships += losses; + } + }); + } else if (forces.defender.colony && combatState.defender.totalShips <= 0) { + // Colony was destroyed or heavily damaged + if (forces.defender.colony.defense_buildings) { + forces.defender.colony.defense_buildings.forEach(building => { + const damage = Math.floor(building.health_percentage * (0.3 + Math.random() * 0.4)); + casualties.defender.buildings[building.building_name] = damage; + }); + } + } + + return casualties; + } + + calculateFinalForces(forces, casualties) { + const finalForces = JSON.parse(JSON.stringify(forces)); + + // Apply ship casualties + ['attacker', 'defender'].forEach(side => { + if (finalForces[side].fleet && finalForces[side].fleet.ships) { + finalForces[side].fleet.ships.forEach(ship => { + const losses = casualties[side].ships[ship.design_name] || 0; + ship.quantity = Math.max(0, ship.quantity - losses); + }); + } + }); + + // Apply building damage + if (finalForces.defender.colony && finalForces.defender.colony.defense_buildings) { + finalForces.defender.colony.defense_buildings.forEach(building => { + const damage = casualties.defender.buildings[building.building_name] || 0; + building.health_percentage = Math.max(0, building.health_percentage - damage); + }); + } + + return finalForces; + } + + calculateLoot(forces, attackerWins) { + if (!attackerWins) return {}; + + const loot = {}; + const baseAmount = Math.floor(Math.random() * 800) + 200; + + loot.scrap = baseAmount; + loot.energy = Math.floor(baseAmount * 0.7); + + if (forces.defender.colony) { + loot.data_cores = Math.floor(Math.random() * 8) + 2; + if (Math.random() < 0.2) { + loot.rare_elements = Math.floor(Math.random() * 5) + 1; + } + } + + return loot; + } +} + +/** + * Tactical Combat Plugin + * Advanced combat with formations, positioning, and tactical decisions + */ +class TacticalCombatPlugin extends BaseCombatPlugin { + async resolveCombat(battle, forces, config, correlationId) { + logger.info('Resolving tactical combat', { correlationId, battleId: battle.id }); + + // For MVP, use enhanced turn-based system + // Future versions can implement full tactical positioning + const turnBasedPlugin = new TurnBasedCombatPlugin(this.config); + const result = await turnBasedPlugin.resolveCombat(battle, forces, config, correlationId); + + // Add tactical bonuses based on formations and positioning + result.experience_gained *= 1.5; // Tactical combat gives more experience + result.duration *= 1.2; // Takes longer than simple turn-based + + // Enhanced loot for tactical victory + if (result.loot && Object.keys(result.loot).length > 0) { + Object.keys(result.loot).forEach(resource => { + result.loot[resource] = Math.floor(result.loot[resource] * 1.3); + }); + } + + return result; + } +} + +module.exports = { + CombatPluginManager, + BaseCombatPlugin, + InstantCombatPlugin, + TurnBasedCombatPlugin, + TacticalCombatPlugin +}; \ No newline at end of file diff --git a/src/services/combat/CombatService.js b/src/services/combat/CombatService.js new file mode 100644 index 0000000..ecf89d2 --- /dev/null +++ b/src/services/combat/CombatService.js @@ -0,0 +1,1446 @@ +/** + * Combat Service + * Handles all combat-related business logic including fleet battles, colony sieges, and combat resolution + */ + +const db = require('../../database/connection'); +const logger = require('../../utils/logger'); +const { ValidationError, ConflictError, NotFoundError, ServiceError } = require('../../middleware/error.middleware'); + +class CombatService { + constructor(gameEventService = null, combatPluginManager = null) { + this.gameEventService = gameEventService; + this.combatPluginManager = combatPluginManager; + this.activeCombats = new Map(); // Track ongoing combats + } + + /** + * Initiate combat between fleets or fleet vs colony + * @param {Object} combatData - Combat initiation data + * @param {number} combatData.attacker_fleet_id - Attacking fleet ID + * @param {number|null} combatData.defender_fleet_id - Defending fleet ID (null for colony) + * @param {number|null} combatData.defender_colony_id - Defending colony ID (null for fleet) + * @param {string} combatData.location - Combat location coordinates + * @param {string} combatData.combat_type - Type of combat ('instant', 'turn_based', 'real_time') + * @param {number} attackerPlayerId - Attacking player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat initiation result + */ + async initiateCombat(combatData, attackerPlayerId, correlationId) { + try { + const { attacker_fleet_id, defender_fleet_id, defender_colony_id, location, combat_type = 'instant' } = combatData; + + logger.info('Combat initiation started', { + correlationId, + attackerPlayerId, + attacker_fleet_id, + defender_fleet_id, + defender_colony_id, + location, + combat_type + }); + + // Validate combat data + await this.validateCombatInitiation(combatData, attackerPlayerId, correlationId); + + // Check if any participants are already in combat + const conflictCheck = await this.checkCombatConflicts(attacker_fleet_id, defender_fleet_id, defender_colony_id); + if (conflictCheck.hasConflict) { + throw new ConflictError(`Combat participant already engaged: ${conflictCheck.reason}`); + } + + // Get combat configuration + const combatConfig = await this.getCombatConfiguration(combat_type); + if (!combatConfig) { + throw new ValidationError('Invalid combat type specified'); + } + + // Database transaction for atomic combat creation + const combat = await db.transaction(async (trx) => { + // Create battle record + const [battle] = await trx('battles').insert({ + battle_type: defender_colony_id ? 'fleet_vs_colony' : 'fleet_vs_fleet', + location, + combat_type_id: combatConfig.id, + participants: JSON.stringify({ + attacker_fleet_id, + defender_fleet_id, + defender_colony_id, + attacker_player_id: attackerPlayerId + }), + status: 'preparing', + battle_data: JSON.stringify({ + combat_phase: 'preparation', + preparation_time: combatConfig.config_data.preparation_time || 30 + }), + combat_configuration_id: combatConfig.id, + tactical_settings: JSON.stringify({}), + spectator_count: 0, + estimated_duration: combatConfig.config_data.estimated_duration || 60, + started_at: new Date(), + created_at: new Date() + }).returning('*'); + + // Update fleet statuses to 'in_combat' + await trx('fleets') + .whereIn('id', [attacker_fleet_id, defender_fleet_id].filter(Boolean)) + .update({ + fleet_status: 'in_combat', + last_updated: new Date() + }); + + // Update colony status if defending colony + if (defender_colony_id) { + await trx('colonies') + .where('id', defender_colony_id) + .update({ + under_siege: true, + last_updated: new Date() + }); + } + + // Add to combat queue for processing + await trx('combat_queue').insert({ + battle_id: battle.id, + queue_status: 'pending', + priority: combatConfig.config_data.priority || 100, + scheduled_at: new Date(), + processing_metadata: JSON.stringify({ + combat_type, + auto_resolve: combatConfig.config_data.auto_resolve || true + }) + }); + + logger.info('Combat initiated successfully', { + correlationId, + battleId: battle.id, + attackerPlayerId, + combatType: combat_type + }); + + return battle; + }); + + // Add to active combats tracking + this.activeCombats.set(combat.id, { + battleId: combat.id, + status: 'preparing', + participants: JSON.parse(combat.participants), + startedAt: combat.started_at + }); + + // Emit WebSocket event for combat initiation + if (this.gameEventService) { + await this.gameEventService.emitCombatInitiated(combat, correlationId); + } + + // Auto-resolve combat if configured + if (combatConfig.config_data.auto_resolve) { + setTimeout(() => { + this.processCombat(combat.id, correlationId).catch(error => { + logger.error('Auto-resolve combat failed', { + correlationId, + battleId: combat.id, + error: error.message + }); + }); + }, (combatConfig.config_data.preparation_time || 5) * 1000); + } + + return { + battleId: combat.id, + status: combat.status, + estimatedDuration: combat.estimated_duration, + preparationTime: combatConfig.config_data.preparation_time || 30 + }; + + } catch (error) { + logger.error('Combat initiation failed', { + correlationId, + attackerPlayerId, + combatData, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError || error instanceof ConflictError || error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to initiate combat', error); + } + } + + /** + * Process combat resolution using configured plugin + * @param {number} battleId - Battle ID to process + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async processCombat(battleId, correlationId) { + try { + logger.info('Processing combat', { + correlationId, + battleId + }); + + // Get battle data + const battle = await this.getBattleById(battleId); + if (!battle) { + throw new NotFoundError('Battle not found'); + } + + if (battle.status !== 'preparing' && battle.status !== 'active') { + throw new ConflictError('Battle is not in a processable state'); + } + + // Get combat forces + const combatForces = await this.getCombatForces(battle, correlationId); + + // Get combat configuration + const combatConfig = await this.getCombatConfiguration(null, battle.combat_configuration_id); + + // Database transaction for combat resolution + const result = await db.transaction(async (trx) => { + // Update battle status to active + await trx('battles') + .where('id', battleId) + .update({ + status: 'active', + battle_data: JSON.stringify({ + combat_phase: 'resolution', + processing_started: new Date() + }) + }); + + // Resolve combat using plugin system + const combatResult = await this.resolveCombat(battle, combatForces, combatConfig, trx, correlationId); + + // Create combat encounter record + const [encounter] = await trx('combat_encounters').insert({ + battle_id: battleId, + attacker_fleet_id: combatForces.attacker.fleet?.id || null, + defender_fleet_id: combatForces.defender.fleet?.id || null, + defender_colony_id: combatForces.defender.colony?.id || null, + encounter_type: battle.battle_type, + location: battle.location, + initial_forces: JSON.stringify(combatForces.initial), + final_forces: JSON.stringify(combatResult.final_forces), + casualties: JSON.stringify(combatResult.casualties), + combat_log: JSON.stringify(combatResult.combat_log), + experience_gained: combatResult.experience_gained || 0, + loot_awarded: JSON.stringify(combatResult.loot || {}), + outcome: combatResult.outcome, + duration_seconds: combatResult.duration || 60, + started_at: battle.started_at, + completed_at: new Date() + }).returning('*'); + + // Update battle with final result + await trx('battles') + .where('id', battleId) + .update({ + status: 'completed', + result: JSON.stringify(combatResult), + completed_at: new Date() + }); + + // Apply combat results to fleets and colonies + await this.applyCombatResults(combatResult, combatForces, trx, correlationId); + + // Update combat statistics + await this.updateCombatStatistics(combatResult, combatForces, trx, correlationId); + + // Update combat queue + await trx('combat_queue') + .where('battle_id', battleId) + .update({ + queue_status: 'completed', + completed_at: new Date() + }); + + logger.info('Combat processed successfully', { + correlationId, + battleId, + encounterId: encounter.id, + outcome: combatResult.outcome, + duration: combatResult.duration + }); + + return { + battleId, + encounterId: encounter.id, + outcome: combatResult.outcome, + casualties: combatResult.casualties, + experience: combatResult.experience_gained, + loot: combatResult.loot, + duration: combatResult.duration + }; + }); + + // Remove from active combats + this.activeCombats.delete(battleId); + + // Emit WebSocket event for combat completion + if (this.gameEventService) { + await this.gameEventService.emitCombatCompleted(result, correlationId); + } + + return result; + + } catch (error) { + logger.error('Combat processing failed', { + correlationId, + battleId, + error: error.message, + stack: error.stack + }); + + // Update combat queue with error + await db('combat_queue') + .where('battle_id', battleId) + .update({ + queue_status: 'failed', + error_message: error.message, + completed_at: new Date() + }) + .catch(dbError => { + logger.error('Failed to update combat queue error', { + correlationId, + battleId, + dbError: dbError.message + }); + }); + + if (error instanceof ValidationError || error instanceof ConflictError || error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to process combat', error); + } + } + + /** + * Get combat history for a player + * @param {number} playerId - Player ID + * @param {Object} options - Query options + * @param {number} options.limit - Result limit + * @param {number} options.offset - Result offset + * @param {string} options.outcome - Filter by outcome + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat history + */ + async getCombatHistory(playerId, options = {}, correlationId) { + try { + const { limit = 50, offset = 0, outcome } = options; + + logger.info('Fetching combat history', { + correlationId, + playerId, + limit, + offset, + outcome + }); + + let query = db('combat_encounters') + .select([ + 'combat_encounters.*', + 'battles.battle_type', + 'battles.location', + 'attacker_fleet.name as attacker_fleet_name', + 'defender_fleet.name as defender_fleet_name', + 'defender_colony.name as defender_colony_name' + ]) + .join('battles', 'combat_encounters.battle_id', 'battles.id') + .leftJoin('fleets as attacker_fleet', 'combat_encounters.attacker_fleet_id', 'attacker_fleet.id') + .leftJoin('fleets as defender_fleet', 'combat_encounters.defender_fleet_id', 'defender_fleet.id') + .leftJoin('colonies as defender_colony', 'combat_encounters.defender_colony_id', 'defender_colony.id') + .where(function() { + this.where('attacker_fleet.player_id', playerId) + .orWhere('defender_fleet.player_id', playerId) + .orWhere('defender_colony.player_id', playerId); + }) + .orderBy('combat_encounters.completed_at', 'desc') + .limit(limit) + .offset(offset); + + if (outcome) { + query = query.where('combat_encounters.outcome', outcome); + } + + const combats = await query; + + // Get total count for pagination + let countQuery = db('combat_encounters') + .join('battles', 'combat_encounters.battle_id', 'battles.id') + .leftJoin('fleets as attacker_fleet', 'combat_encounters.attacker_fleet_id', 'attacker_fleet.id') + .leftJoin('fleets as defender_fleet', 'combat_encounters.defender_fleet_id', 'defender_fleet.id') + .leftJoin('colonies as defender_colony', 'combat_encounters.defender_colony_id', 'defender_colony.id') + .where(function() { + this.where('attacker_fleet.player_id', playerId) + .orWhere('defender_fleet.player_id', playerId) + .orWhere('defender_colony.player_id', playerId); + }) + .count('* as total'); + + if (outcome) { + countQuery = countQuery.where('combat_encounters.outcome', outcome); + } + + const [{ total }] = await countQuery; + + logger.info('Combat history retrieved', { + correlationId, + playerId, + combatCount: combats.length, + totalCombats: parseInt(total) + }); + + return { + combats, + pagination: { + total: parseInt(total), + limit, + offset, + hasMore: (offset + limit) < parseInt(total) + } + }; + + } catch (error) { + logger.error('Failed to fetch combat history', { + correlationId, + playerId, + options, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve combat history', error); + } + } + + /** + * Get active combats for a player + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Active combats + */ + async getActiveCombats(playerId, correlationId) { + try { + logger.info('Fetching active combats', { + correlationId, + playerId + }); + + const activeCombats = await db('battles') + .select([ + 'battles.*', + 'attacker_fleet.name as attacker_fleet_name', + 'defender_fleet.name as defender_fleet_name', + 'defender_colony.name as defender_colony_name' + ]) + .leftJoin('fleets as attacker_fleet', + db.raw("JSON_EXTRACT(battles.participants, '$.attacker_fleet_id')"), + 'attacker_fleet.id') + .leftJoin('fleets as defender_fleet', + db.raw("JSON_EXTRACT(battles.participants, '$.defender_fleet_id')"), + 'defender_fleet.id') + .leftJoin('colonies as defender_colony', + db.raw("JSON_EXTRACT(battles.participants, '$.defender_colony_id')"), + 'defender_colony.id') + .where(function() { + this.where('attacker_fleet.player_id', playerId) + .orWhere('defender_fleet.player_id', playerId) + .orWhere('defender_colony.player_id', playerId); + }) + .whereIn('battles.status', ['preparing', 'active']) + .orderBy('battles.started_at', 'desc'); + + logger.info('Active combats retrieved', { + correlationId, + playerId, + activeCount: activeCombats.length + }); + + return activeCombats; + + } catch (error) { + logger.error('Failed to fetch active combats', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve active combats', error); + } + } + + // Helper methods + + /** + * Validate combat initiation data + * @param {Object} combatData - Combat data to validate + * @param {number} attackerPlayerId - Attacking player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async validateCombatInitiation(combatData, attackerPlayerId, correlationId) { + const { attacker_fleet_id, defender_fleet_id, defender_colony_id, location } = combatData; + + // Validate attacker fleet + const attackerFleet = await db('fleets') + .where('id', attacker_fleet_id) + .where('player_id', attackerPlayerId) + .where('fleet_status', 'idle') + .first(); + + if (!attackerFleet) { + throw new ValidationError('Invalid attacker fleet or fleet not available for combat'); + } + + // Validate location matches fleet location + if (attackerFleet.current_location !== location) { + throw new ValidationError('Fleet must be at the specified location to initiate combat'); + } + + // Validate defender (either fleet or colony) + if (!defender_fleet_id && !defender_colony_id) { + throw new ValidationError('Must specify either defender fleet or defender colony'); + } + + if (defender_fleet_id && defender_colony_id) { + throw new ValidationError('Cannot specify both defender fleet and defender colony'); + } + + if (defender_fleet_id) { + const defenderFleet = await db('fleets') + .where('id', defender_fleet_id) + .where('current_location', location) + .first(); + + if (!defenderFleet) { + throw new ValidationError('Defender fleet not found at specified location'); + } + + // Check if attacking own fleet + if (defenderFleet.player_id === attackerPlayerId) { + throw new ValidationError('Cannot attack your own fleet'); + } + } + + if (defender_colony_id) { + const defenderColony = await db('colonies') + .where('id', defender_colony_id) + .where('coordinates', location) + .first(); + + if (!defenderColony) { + throw new ValidationError('Defender colony not found at specified location'); + } + + // Check if attacking own colony + if (defenderColony.player_id === attackerPlayerId) { + throw new ValidationError('Cannot attack your own colony'); + } + } + } + + /** + * Check for combat conflicts with existing battles + * @param {number} attackerFleetId - Attacker fleet ID + * @param {number|null} defenderFleetId - Defender fleet ID + * @param {number|null} defenderColonyId - Defender colony ID + * @returns {Promise} Conflict check result + */ + async checkCombatConflicts(attackerFleetId, defenderFleetId, defenderColonyId) { + // Check if any fleet is already in combat + const fleetsInCombat = await db('fleets') + .whereIn('id', [attackerFleetId, defenderFleetId].filter(Boolean)) + .where('fleet_status', 'in_combat'); + + if (fleetsInCombat.length > 0) { + return { + hasConflict: true, + reason: `Fleet ${fleetsInCombat[0].id} is already in combat` + }; + } + + // Check if colony is under siege + if (defenderColonyId) { + const colonyUnderSiege = await db('colonies') + .where('id', defenderColonyId) + .where('under_siege', true) + .first(); + + if (colonyUnderSiege) { + return { + hasConflict: true, + reason: `Colony ${defenderColonyId} is already under siege` + }; + } + } + + return { hasConflict: false }; + } + + /** + * Get combat configuration by type or ID + * @param {string|null} combatType - Combat type name + * @param {number|null} configId - Configuration ID + * @returns {Promise} Combat configuration + */ + async getCombatConfiguration(combatType = null, configId = null) { + try { + let query = db('combat_configurations').where('is_active', true); + + if (configId) { + query = query.where('id', configId); + } else if (combatType) { + query = query.where('combat_type', combatType); + } else { + // Default to instant combat + query = query.where('combat_type', 'instant'); + } + + return await query.first(); + } catch (error) { + logger.error('Failed to get combat configuration', { error: error.message }); + return null; + } + } + + /** + * Get battle by ID with full details + * @param {number} battleId - Battle ID + * @returns {Promise} Battle data + */ + async getBattleById(battleId) { + try { + return await db('battles') + .where('id', battleId) + .first(); + } catch (error) { + logger.error('Failed to get battle', { battleId, error: error.message }); + return null; + } + } + + /** + * Get combat forces for battle resolution + * @param {Object} battle - Battle data + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat forces + */ + async getCombatForces(battle, correlationId) { + const participants = JSON.parse(battle.participants); + const forces = { + attacker: {}, + defender: {}, + initial: {} + }; + + // Get attacker fleet + if (participants.attacker_fleet_id) { + forces.attacker.fleet = await this.getFleetCombatData(participants.attacker_fleet_id); + } + + // Get defender fleet or colony + if (participants.defender_fleet_id) { + forces.defender.fleet = await this.getFleetCombatData(participants.defender_fleet_id); + } else if (participants.defender_colony_id) { + forces.defender.colony = await this.getColonyCombatData(participants.defender_colony_id); + } + + // Save initial forces snapshot + forces.initial = JSON.parse(JSON.stringify({ attacker: forces.attacker, defender: forces.defender })); + + return forces; + } + + /** + * Get fleet combat data including ships and stats + * @param {number} fleetId - Fleet ID + * @returns {Promise} Fleet combat data + */ + async getFleetCombatData(fleetId) { + const fleet = await db('fleets') + .where('id', fleetId) + .first(); + + if (!fleet) return null; + + const ships = await db('fleet_ships') + .select([ + 'fleet_ships.*', + 'ship_designs.name as design_name', + 'ship_designs.ship_class', + 'ship_designs.hull_points', + 'ship_designs.shield_points', + 'ship_designs.armor_points', + 'ship_designs.attack_power', + 'ship_designs.attack_speed', + 'ship_designs.movement_speed', + 'ship_designs.special_abilities' + ]) + .join('ship_designs', 'fleet_ships.ship_design_id', 'ship_designs.id') + .where('fleet_ships.fleet_id', fleetId); + + // Get combat experience for ships + const experience = await db('ship_combat_experience') + .where('fleet_id', fleetId); + + const experienceMap = {}; + experience.forEach(exp => { + experienceMap[exp.ship_design_id] = exp; + }); + + // Calculate fleet combat rating + let totalCombatRating = 0; + const shipDetails = ships.map(ship => { + const exp = experienceMap[ship.ship_design_id] || {}; + const veterancyBonus = (exp.veterancy_level || 1) * 0.1; + const effectiveAttack = ship.attack_power * (1 + veterancyBonus); + const effectiveHp = (ship.hull_points + ship.shield_points + ship.armor_points) * (1 + veterancyBonus); + + const shipRating = (effectiveAttack * ship.attack_speed + effectiveHp) * ship.quantity; + totalCombatRating += shipRating; + + return { + ...ship, + experience: exp, + effective_attack: effectiveAttack, + effective_hp: effectiveHp, + combat_rating: shipRating + }; + }); + + return { + ...fleet, + ships: shipDetails, + total_combat_rating: totalCombatRating + }; + } + + /** + * Get colony combat data including defenses + * @param {number} colonyId - Colony ID + * @returns {Promise} Colony combat data + */ + async getColonyCombatData(colonyId) { + const colony = await db('colonies') + .where('id', colonyId) + .first(); + + if (!colony) return null; + + // Get defensive buildings + const defenseBuildings = await db('colony_buildings') + .select([ + 'colony_buildings.*', + 'building_types.name as building_name', + 'building_types.special_effects' + ]) + .join('building_types', 'colony_buildings.building_type_id', 'building_types.id') + .where('colony_buildings.colony_id', colonyId) + .where('building_types.category', 'military'); + + // Calculate total defense rating + let totalDefenseRating = colony.defense_rating || 0; + defenseBuildings.forEach(building => { + const effects = building.special_effects || {}; + const defenseBonus = effects.defense_rating || 0; + totalDefenseRating += defenseBonus * building.level * (building.health_percentage / 100); + }); + + return { + ...colony, + defense_buildings: defenseBuildings, + total_defense_rating: totalDefenseRating, + effective_hp: totalDefenseRating * 10 + (colony.shield_strength || 0) + }; + } + + /** + * Resolve combat using plugin system + * @param {Object} battle - Battle data + * @param {Object} forces - Combat forces + * @param {Object} config - Combat configuration + * @param {Object} trx - Database transaction + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async resolveCombat(battle, forces, config, trx, correlationId) { + if (this.combatPluginManager) { + return await this.combatPluginManager.resolveCombat(battle, forces, config, correlationId); + } + + // Fallback instant combat resolver + return await this.instantCombatResolver(battle, forces, config, correlationId); + } + + /** + * Instant combat resolver (fallback implementation) + * @param {Object} battle - Battle data + * @param {Object} forces - Combat forces + * @param {Object} config - Combat configuration + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat result + */ + async instantCombatResolver(battle, forces, config, correlationId) { + const attackerRating = forces.attacker.fleet?.total_combat_rating || 0; + const defenderRating = forces.defender.fleet?.total_combat_rating || forces.defender.colony?.total_defense_rating || 0; + + const totalRating = attackerRating + defenderRating; + const attackerWinChance = totalRating > 0 ? attackerRating / totalRating : 0.5; + + // Add some randomness + const randomFactor = 0.1; // 10% randomness + const roll = Math.random(); + const adjustedChance = attackerWinChance + (Math.random() - 0.5) * randomFactor; + + const attackerWins = roll < adjustedChance; + const outcome = attackerWins ? 'attacker_victory' : 'defender_victory'; + + // Calculate casualties + const casualties = this.calculateCasualties(forces, attackerWins, correlationId); + + // Calculate experience gain + const experienceGained = Math.floor((attackerRating + defenderRating) / 100); + + // Generate combat log + const combatLog = [ + { + round: 1, + event: 'combat_start', + description: 'Combat initiated', + attacker_strength: attackerRating, + defender_strength: defenderRating + }, + { + round: 1, + event: 'combat_resolution', + description: `${outcome.replace('_', ' ')}`, + winner: attackerWins ? 'attacker' : 'defender' + } + ]; + + return { + outcome, + casualties, + experience_gained: experienceGained, + combat_log: combatLog, + duration: Math.floor(Math.random() * 120) + 30, // 30-150 seconds + final_forces: this.calculateFinalForces(forces, casualties), + loot: this.calculateLoot(forces, attackerWins, correlationId) + }; + } + + /** + * Calculate combat casualties + * @param {Object} forces - Combat forces + * @param {boolean} attackerWins - Whether attacker won + * @param {string} correlationId - Request correlation ID + * @returns {Object} Casualty breakdown + */ + calculateCasualties(forces, attackerWins, correlationId) { + const casualties = { + attacker: { ships: {}, total_ships: 0 }, + defender: { ships: {}, total_ships: 0, buildings: {} } + }; + + // Calculate ship losses (winner loses 10-30%, loser loses 40-80%) + const attackerLossRate = attackerWins ? (0.1 + Math.random() * 0.2) : (0.4 + Math.random() * 0.4); + const defenderLossRate = attackerWins ? (0.4 + Math.random() * 0.4) : (0.1 + Math.random() * 0.2); + + // Attacker casualties + if (forces.attacker.fleet && forces.attacker.fleet.ships) { + forces.attacker.fleet.ships.forEach(ship => { + const losses = Math.floor(ship.quantity * attackerLossRate); + if (losses > 0) { + casualties.attacker.ships[ship.design_name] = losses; + casualties.attacker.total_ships += losses; + } + }); + } + + // Defender casualties + if (forces.defender.fleet && forces.defender.fleet.ships) { + forces.defender.fleet.ships.forEach(ship => { + const losses = Math.floor(ship.quantity * defenderLossRate); + if (losses > 0) { + casualties.defender.ships[ship.design_name] = losses; + casualties.defender.total_ships += losses; + } + }); + } + + // Colony building damage + if (forces.defender.colony && attackerWins) { + const buildingDamageRate = 0.1 + Math.random() * 0.3; // 10-40% damage + if (forces.defender.colony.defense_buildings) { + forces.defender.colony.defense_buildings.forEach(building => { + const damage = Math.floor(building.health_percentage * buildingDamageRate); + if (damage > 0) { + casualties.defender.buildings[building.building_name] = damage; + } + }); + } + } + + return casualties; + } + + /** + * Calculate final forces after combat + * @param {Object} forces - Initial forces + * @param {Object} casualties - Combat casualties + * @returns {Object} Final forces + */ + calculateFinalForces(forces, casualties) { + const finalForces = JSON.parse(JSON.stringify(forces)); + + // Apply attacker casualties + if (finalForces.attacker.fleet && finalForces.attacker.fleet.ships) { + finalForces.attacker.fleet.ships.forEach(ship => { + const losses = casualties.attacker.ships[ship.design_name] || 0; + ship.quantity = Math.max(0, ship.quantity - losses); + }); + } + + // Apply defender casualties + if (finalForces.defender.fleet && finalForces.defender.fleet.ships) { + finalForces.defender.fleet.ships.forEach(ship => { + const losses = casualties.defender.ships[ship.design_name] || 0; + ship.quantity = Math.max(0, ship.quantity - losses); + }); + } + + // Apply building damage + if (finalForces.defender.colony && finalForces.defender.colony.defense_buildings) { + finalForces.defender.colony.defense_buildings.forEach(building => { + const damage = casualties.defender.buildings[building.building_name] || 0; + building.health_percentage = Math.max(0, building.health_percentage - damage); + }); + } + + return finalForces; + } + + /** + * Calculate loot from combat + * @param {Object} forces - Combat forces + * @param {boolean} attackerWins - Whether attacker won + * @param {string} correlationId - Request correlation ID + * @returns {Object} Loot awarded + */ + calculateLoot(forces, attackerWins, correlationId) { + if (!attackerWins) return {}; + + const loot = {}; + + // Base loot from combat + const baseLoot = Math.floor(Math.random() * 1000) + 100; + loot.scrap = baseLoot; + loot.energy = Math.floor(baseLoot * 0.5); + + // Additional loot from colony raids + if (forces.defender.colony) { + loot.data_cores = Math.floor(Math.random() * 10) + 1; + if (Math.random() < 0.1) { // 10% chance for rare elements + loot.rare_elements = Math.floor(Math.random() * 5) + 1; + } + } + + return loot; + } + + /** + * Apply combat results to fleets and colonies + * @param {Object} result - Combat result + * @param {Object} forces - Combat forces + * @param {Object} trx - Database transaction + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async applyCombatResults(result, forces, trx, correlationId) { + // Update fleet ship quantities + for (const side of ['attacker', 'defender']) { + const fleet = forces[side].fleet; + if (fleet && result.casualties[side].ships) { + for (const ship of fleet.ships) { + const losses = result.casualties[side].ships[ship.design_name] || 0; + if (losses > 0) { + const newQuantity = Math.max(0, ship.quantity - losses); + await trx('fleet_ships') + .where('id', ship.id) + .update({ + quantity: newQuantity, + health_percentage: newQuantity > 0 ? + Math.max(20, ship.health_percentage - Math.floor(Math.random() * 30)) : + 0 + }); + } + } + + // Update fleet status and statistics + const isDestroyed = result.final_forces[side].fleet.ships.every(ship => ship.quantity === 0); + const newStatus = isDestroyed ? 'destroyed' : 'idle'; + + await trx('fleets') + .where('id', fleet.id) + .update({ + fleet_status: newStatus, + last_combat: new Date(), + last_updated: new Date(), + combat_victories: side === result.outcome.split('_')[0] ? + db.raw('combat_victories + 1') : db.raw('combat_victories'), + combat_defeats: side !== result.outcome.split('_')[0] ? + db.raw('combat_defeats + 1') : db.raw('combat_defeats') + }); + } + } + + // Update colony if involved + if (forces.defender.colony) { + const colony = forces.defender.colony; + const buildingDamage = result.casualties.defender.buildings || {}; + + // Apply building damage + for (const building of colony.defense_buildings) { + const damage = buildingDamage[building.building_name] || 0; + if (damage > 0) { + await trx('colony_buildings') + .where('id', building.id) + .update({ + health_percentage: Math.max(0, building.health_percentage - damage) + }); + } + } + + // Update colony status + await trx('colonies') + .where('id', colony.id) + .update({ + under_siege: false, + last_attacked: new Date(), + successful_defenses: result.outcome === 'defender_victory' ? + db.raw('successful_defenses + 1') : db.raw('successful_defenses'), + times_captured: result.outcome === 'attacker_victory' ? + db.raw('times_captured + 1') : db.raw('times_captured') + }); + } + + // Award loot to winner + if (result.loot && Object.keys(result.loot).length > 0) { + const winnerId = result.outcome === 'attacker_victory' ? + forces.attacker.fleet.player_id : + (forces.defender.fleet ? forces.defender.fleet.player_id : forces.defender.colony.player_id); + + for (const [resourceName, amount] of Object.entries(result.loot)) { + await trx('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', winnerId) + .where('resource_types.name', resourceName) + .increment('player_resources.amount', amount) + .update('player_resources.last_updated', new Date()); + } + } + } + + /** + * Update combat statistics for participants + * @param {Object} result - Combat result + * @param {Object} forces - Combat forces + * @param {Object} trx - Database transaction + * @param {string} correlationId - Request correlation ID + * @returns {Promise} + */ + async updateCombatStatistics(result, forces, trx, correlationId) { + const participants = []; + + // Collect all participants + if (forces.attacker.fleet) { + participants.push({ + playerId: forces.attacker.fleet.player_id, + side: 'attacker', + isWinner: result.outcome === 'attacker_victory' + }); + } + + if (forces.defender.fleet) { + participants.push({ + playerId: forces.defender.fleet.player_id, + side: 'defender', + isWinner: result.outcome === 'defender_victory' + }); + } else if (forces.defender.colony) { + participants.push({ + playerId: forces.defender.colony.player_id, + side: 'defender', + isWinner: result.outcome === 'defender_victory' + }); + } + + // Update statistics for each participant + for (const participant of participants) { + const stats = { + battles_initiated: participant.side === 'attacker' ? 1 : 0, + battles_won: participant.isWinner ? 1 : 0, + battles_lost: participant.isWinner ? 0 : 1, + ships_lost: result.casualties[participant.side].total_ships || 0, + ships_destroyed: result.casualties[participant.side === 'attacker' ? 'defender' : 'attacker'].total_ships || 0, + total_experience_gained: participant.isWinner ? result.experience_gained : 0, + last_battle: new Date() + }; + + await trx('combat_statistics') + .where('player_id', participant.playerId) + .update({ + battles_initiated: db.raw(`battles_initiated + ${stats.battles_initiated}`), + battles_won: db.raw(`battles_won + ${stats.battles_won}`), + battles_lost: db.raw(`battles_lost + ${stats.battles_lost}`), + ships_lost: db.raw(`ships_lost + ${stats.ships_lost}`), + ships_destroyed: db.raw(`ships_destroyed + ${stats.ships_destroyed}`), + total_experience_gained: db.raw(`total_experience_gained + ${stats.total_experience_gained}`), + last_battle: stats.last_battle, + updated_at: new Date() + }); + + // Insert if no existing record + const existingStats = await trx('combat_statistics') + .where('player_id', participant.playerId) + .first(); + + if (!existingStats) { + await trx('combat_statistics').insert({ + player_id: participant.playerId, + ...stats, + created_at: new Date(), + updated_at: new Date() + }); + } + } + } + + /** + * Get combat encounter details + * @param {number} encounterId - Encounter ID + * @param {number} playerId - Player ID for access control + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat encounter details + */ + async getCombatEncounter(encounterId, playerId, correlationId) { + try { + logger.info('Fetching combat encounter', { + correlationId, + encounterId, + playerId + }); + + const encounter = await db('combat_encounters') + .select([ + 'combat_encounters.*', + 'battles.battle_type', + 'battles.location', + 'attacker_fleet.name as attacker_fleet_name', + 'attacker_fleet.player_id as attacker_player_id', + 'defender_fleet.name as defender_fleet_name', + 'defender_fleet.player_id as defender_player_id', + 'defender_colony.name as defender_colony_name', + 'defender_colony.player_id as defender_colony_player_id' + ]) + .join('battles', 'combat_encounters.battle_id', 'battles.id') + .leftJoin('fleets as attacker_fleet', 'combat_encounters.attacker_fleet_id', 'attacker_fleet.id') + .leftJoin('fleets as defender_fleet', 'combat_encounters.defender_fleet_id', 'defender_fleet.id') + .leftJoin('colonies as defender_colony', 'combat_encounters.defender_colony_id', 'defender_colony.id') + .where('combat_encounters.id', encounterId) + .first(); + + if (!encounter) { + return null; + } + + // Check if player has access to this encounter + const hasAccess = encounter.attacker_player_id === playerId || + encounter.defender_player_id === playerId || + encounter.defender_colony_player_id === playerId; + + if (!hasAccess) { + return null; + } + + // Get combat logs + const combatLogs = await db('combat_logs') + .where('encounter_id', encounterId) + .orderBy('round_number') + .orderBy('timestamp'); + + logger.info('Combat encounter retrieved', { + correlationId, + encounterId, + playerId, + logCount: combatLogs.length + }); + + return { + ...encounter, + combat_logs: combatLogs + }; + + } catch (error) { + logger.error('Failed to fetch combat encounter', { + correlationId, + encounterId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve combat encounter', error); + } + } + + /** + * Get combat statistics for a player + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat statistics + */ + async getCombatStatistics(playerId, correlationId) { + try { + logger.info('Fetching combat statistics', { + correlationId, + playerId + }); + + let statistics = await db('combat_statistics') + .where('player_id', playerId) + .first(); + + // Create default statistics if none exist + if (!statistics) { + statistics = { + player_id: playerId, + battles_initiated: 0, + battles_won: 0, + battles_lost: 0, + ships_lost: 0, + ships_destroyed: 0, + total_damage_dealt: 0, + total_damage_received: 0, + total_experience_gained: 0, + resources_looted: {}, + last_battle: null + }; + } + + // Calculate derived statistics + const totalBattles = statistics.battles_won + statistics.battles_lost; + const winRate = totalBattles > 0 ? (statistics.battles_won / totalBattles * 100).toFixed(1) : 0; + const killDeathRatio = statistics.ships_lost > 0 ? + (statistics.ships_destroyed / statistics.ships_lost).toFixed(2) : + statistics.ships_destroyed; + + logger.info('Combat statistics retrieved', { + correlationId, + playerId, + totalBattles, + winRate + }); + + return { + ...statistics, + derived_stats: { + total_battles: totalBattles, + win_rate_percentage: parseFloat(winRate), + kill_death_ratio: parseFloat(killDeathRatio), + average_experience_per_battle: totalBattles > 0 ? + (statistics.total_experience_gained / totalBattles).toFixed(1) : 0 + } + }; + + } catch (error) { + logger.error('Failed to fetch combat statistics', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve combat statistics', error); + } + } + + /** + * Update fleet position for tactical combat + * @param {number} fleetId - Fleet ID + * @param {Object} positionData - Position and formation data + * @param {number} playerId - Player ID for authorization + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Updated position data + */ + async updateFleetPosition(fleetId, positionData, playerId, correlationId) { + try { + const { position_x, position_y, position_z, formation, tactical_settings } = positionData; + + logger.info('Updating fleet position', { + correlationId, + fleetId, + playerId, + formation + }); + + // Verify fleet ownership + const fleet = await db('fleets') + .where('id', fleetId) + .where('player_id', playerId) + .first(); + + if (!fleet) { + throw new NotFoundError('Fleet not found or access denied'); + } + + // Validate formation type + const validFormations = ['standard', 'defensive', 'aggressive', 'flanking', 'escort']; + if (formation && !validFormations.includes(formation)) { + throw new ValidationError('Invalid formation type'); + } + + // Update fleet position + const result = await db.transaction(async (trx) => { + // Insert or update fleet position + const existingPosition = await trx('fleet_positions') + .where('fleet_id', fleetId) + .first(); + + const positionUpdateData = { + fleet_id: fleetId, + location: fleet.current_location, + position_x: position_x || 0, + position_y: position_y || 0, + position_z: position_z || 0, + formation: formation || 'standard', + tactical_settings: JSON.stringify(tactical_settings || {}), + last_updated: new Date() + }; + + if (existingPosition) { + await trx('fleet_positions') + .where('fleet_id', fleetId) + .update(positionUpdateData); + } else { + await trx('fleet_positions').insert(positionUpdateData); + } + + return positionUpdateData; + }); + + logger.info('Fleet position updated', { + correlationId, + fleetId, + formation: result.formation + }); + + return result; + + } catch (error) { + logger.error('Failed to update fleet position', { + correlationId, + fleetId, + playerId, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError || error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to update fleet position', error); + } + } + + /** + * Get available combat types and configurations + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Available combat types + */ + async getAvailableCombatTypes(correlationId) { + try { + logger.info('Fetching available combat types', { correlationId }); + + const combatTypes = await db('combat_configurations') + .where('is_active', true) + .orderBy('combat_type') + .orderBy('config_name'); + + logger.info('Combat types retrieved', { + correlationId, + count: combatTypes.length + }); + + return combatTypes; + + } catch (error) { + logger.error('Failed to fetch combat types', { + correlationId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve combat types', error); + } + } + + /** + * Get combat queue status + * @param {Object} options - Query options + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Combat queue entries + */ + async getCombatQueue(options = {}, correlationId) { + try { + const { status, limit = 50 } = options; + + logger.info('Fetching combat queue', { + correlationId, + status, + limit + }); + + let query = db('combat_queue') + .select([ + 'combat_queue.*', + 'battles.battle_type', + 'battles.location', + 'battles.status as battle_status' + ]) + .join('battles', 'combat_queue.battle_id', 'battles.id') + .orderBy('combat_queue.priority', 'desc') + .orderBy('combat_queue.scheduled_at', 'asc') + .limit(limit); + + if (status) { + query = query.where('combat_queue.queue_status', status); + } + + const queue = await query; + + logger.info('Combat queue retrieved', { + correlationId, + count: queue.length + }); + + return queue; + + } catch (error) { + logger.error('Failed to fetch combat queue', { + correlationId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve combat queue', error); + } + } +} + +module.exports = CombatService; \ No newline at end of file diff --git a/src/services/galaxy/ColonyService.js b/src/services/galaxy/ColonyService.js new file mode 100644 index 0000000..2a4e3aa --- /dev/null +++ b/src/services/galaxy/ColonyService.js @@ -0,0 +1,702 @@ +/** + * Colony Service + * Handles all colony-related business logic including creation, management, and building operations + */ + +const db = require('../../database/connection'); +const logger = require('../../utils/logger'); +const { ValidationError, ConflictError, NotFoundError, ServiceError } = require('../../middleware/error.middleware'); + +class ColonyService { + constructor(gameEventService = null) { + this.gameEventService = gameEventService; + } + /** + * Create a new colony + * @param {number} playerId - Player ID + * @param {Object} colonyData - Colony creation data + * @param {string} colonyData.name - Colony name + * @param {string} colonyData.coordinates - Galaxy coordinates + * @param {number} colonyData.planet_type_id - Planet type ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Created colony data + */ + async createColony(playerId, colonyData, correlationId) { + try { + const { name, coordinates, planet_type_id } = colonyData; + + logger.info('Colony creation initiated', { + correlationId, + playerId, + name, + coordinates, + planet_type_id + }); + + // Validate input data + await this.validateColonyData({ name, coordinates, planet_type_id }); + + // Check if coordinates are already taken + const existingColony = await this.getColonyByCoordinates(coordinates); + if (existingColony) { + throw new ConflictError('Coordinates already occupied'); + } + + // Check player colony limit + const playerColonyCount = await this.getPlayerColonyCount(playerId); + const maxColonies = parseInt(process.env.MAX_COLONIES_PER_PLAYER) || 10; + if (playerColonyCount >= maxColonies) { + throw new ConflictError(`Maximum number of colonies reached (${maxColonies})`); + } + + // Validate planet type exists + const planetType = await this.getPlanetTypeById(planet_type_id); + if (!planetType) { + throw new ValidationError('Invalid planet type'); + } + + // Get sector from coordinates + const sectorCoordinates = this.extractSectorFromCoordinates(coordinates); + const sector = await this.getSectorByCoordinates(sectorCoordinates); + + // Database transaction for atomic operation + const colony = await db.transaction(async (trx) => { + // Create colony + const [newColony] = await trx('colonies') + .insert({ + player_id: playerId, + name: name.trim(), + coordinates: coordinates.toUpperCase(), + sector_id: sector?.id || null, + planet_type_id: planet_type_id, + population: 100, // Starting population + max_population: planetType.max_population, + morale: 100, + loyalty: 100, + founded_at: new Date(), + last_updated: new Date() + }) + .returning('*'); + + // Create initial buildings (Command Center is required) + await this.createInitialBuildings(newColony.id, trx); + + // Initialize colony resource production tracking + await this.initializeColonyResources(newColony.id, planetType, trx); + + // Update player colony count + await trx('player_stats') + .where('player_id', playerId) + .increment('colonies_count', 1); + + logger.info('Colony created successfully', { + correlationId, + colonyId: newColony.id, + playerId, + name: newColony.name, + coordinates: newColony.coordinates + }); + + return newColony; + }); + + // Return colony with additional data + const colonyDetails = await this.getColonyDetails(colony.id, correlationId); + + // Emit WebSocket event for colony creation + if (this.gameEventService) { + this.gameEventService.emitColonyCreated(playerId, colonyDetails, correlationId); + } + + return colonyDetails; + + } catch (error) { + logger.error('Colony creation failed', { + correlationId, + playerId, + colonyData, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError || error instanceof ConflictError) { + throw error; + } + throw new ServiceError('Failed to create colony', error); + } + } + + /** + * Get colonies owned by a player + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} List of player colonies + */ + async getPlayerColonies(playerId, correlationId) { + try { + logger.info('Fetching player colonies', { + correlationId, + playerId + }); + + const colonies = await db('colonies') + .select([ + 'colonies.*', + 'planet_types.name as planet_type_name', + 'planet_types.description as planet_type_description', + 'galaxy_sectors.name as sector_name', + 'galaxy_sectors.danger_level' + ]) + .leftJoin('planet_types', 'colonies.planet_type_id', 'planet_types.id') + .leftJoin('galaxy_sectors', 'colonies.sector_id', 'galaxy_sectors.id') + .where('colonies.player_id', playerId) + .orderBy('colonies.founded_at', 'asc'); + + // Get building counts for each colony + const coloniesWithBuildings = await Promise.all(colonies.map(async (colony) => { + const buildingCount = await db('colony_buildings') + .where('colony_id', colony.id) + .count('* as count') + .first(); + + return { + ...colony, + buildingCount: parseInt(buildingCount.count) || 0 + }; + })); + + logger.info('Player colonies retrieved', { + correlationId, + playerId, + colonyCount: colonies.length + }); + + return coloniesWithBuildings; + + } catch (error) { + logger.error('Failed to fetch player colonies', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve player colonies', error); + } + } + + /** + * Get detailed colony information + * @param {number} colonyId - Colony ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Detailed colony data + */ + async getColonyDetails(colonyId, correlationId) { + try { + logger.info('Fetching colony details', { + correlationId, + colonyId + }); + + // Get colony basic information + const colony = await db('colonies') + .select([ + 'colonies.*', + 'planet_types.name as planet_type_name', + 'planet_types.description as planet_type_description', + 'planet_types.base_resources', + 'planet_types.resource_modifiers', + 'galaxy_sectors.name as sector_name', + 'galaxy_sectors.danger_level', + 'galaxy_sectors.description as sector_description' + ]) + .leftJoin('planet_types', 'colonies.planet_type_id', 'planet_types.id') + .leftJoin('galaxy_sectors', 'colonies.sector_id', 'galaxy_sectors.id') + .where('colonies.id', colonyId) + .first(); + + if (!colony) { + throw new NotFoundError('Colony not found'); + } + + // Get colony buildings + const buildings = await db('colony_buildings') + .select([ + 'colony_buildings.*', + 'building_types.name as building_name', + 'building_types.description as building_description', + 'building_types.category', + 'building_types.max_level', + 'building_types.base_cost', + 'building_types.base_production', + 'building_types.special_effects' + ]) + .join('building_types', 'colony_buildings.building_type_id', 'building_types.id') + .where('colony_buildings.colony_id', colonyId) + .orderBy('building_types.category') + .orderBy('building_types.name'); + + // Get colony resource production + const resources = await db('colony_resource_production') + .select([ + 'colony_resource_production.*', + 'resource_types.name as resource_name', + 'resource_types.description as resource_description', + 'resource_types.category as resource_category' + ]) + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', colonyId); + + const colonyDetails = { + ...colony, + buildings: buildings || [], + resources: resources || [] + }; + + logger.info('Colony details retrieved', { + correlationId, + colonyId, + buildingCount: buildings.length, + resourceCount: resources.length + }); + + return colonyDetails; + + } catch (error) { + logger.error('Failed to fetch colony details', { + correlationId, + colonyId, + error: error.message, + stack: error.stack + }); + + if (error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to retrieve colony details', error); + } + } + + /** + * Construct a building in a colony + * @param {number} colonyId - Colony ID + * @param {number} buildingTypeId - Building type ID + * @param {number} playerId - Player ID (for authorization) + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Construction result + */ + async constructBuilding(colonyId, buildingTypeId, playerId, correlationId) { + try { + logger.info('Building construction initiated', { + correlationId, + colonyId, + buildingTypeId, + playerId + }); + + // Verify colony ownership + const colony = await this.verifyColonyOwnership(colonyId, playerId); + if (!colony) { + throw new NotFoundError('Colony not found or access denied'); + } + + // Check if building already exists + const existingBuilding = await db('colony_buildings') + .where('colony_id', colonyId) + .where('building_type_id', buildingTypeId) + .first(); + + if (existingBuilding) { + throw new ConflictError('Building already exists in this colony'); + } + + // Get building type information + const buildingType = await db('building_types') + .where('id', buildingTypeId) + .where('is_active', true) + .first(); + + if (!buildingType) { + throw new ValidationError('Invalid building type'); + } + + // Check if building is unique and already exists + if (buildingType.is_unique) { + const existingUniqueBuilding = await db('colony_buildings') + .where('colony_id', colonyId) + .where('building_type_id', buildingTypeId) + .first(); + + if (existingUniqueBuilding) { + throw new ConflictError('This building type can only be built once per colony'); + } + } + + // Check prerequisites (TODO: Implement prerequisite checking) + // await this.checkBuildingPrerequisites(colonyId, buildingType.prerequisites); + + // Check resource costs + const canAfford = await this.checkBuildingCosts(playerId, buildingType.base_cost); + if (!canAfford.canAfford) { + throw new ValidationError('Insufficient resources', { missing: canAfford.missing }); + } + + // Database transaction for atomic operation + const result = await db.transaction(async (trx) => { + // Deduct resources from player + await this.deductResources(playerId, buildingType.base_cost, trx); + + // Create building (instant construction for MVP) + const [newBuilding] = await trx('colony_buildings') + .insert({ + colony_id: colonyId, + building_type_id: buildingTypeId, + level: 1, + health_percentage: 100, + is_under_construction: false, + created_at: new Date(), + updated_at: new Date() + }) + .returning('*'); + + // Update colony resource production if this building produces resources + if (buildingType.base_production && Object.keys(buildingType.base_production).length > 0) { + await this.updateColonyResourceProduction(colonyId, buildingType, trx); + } + + logger.info('Building constructed successfully', { + correlationId, + colonyId, + buildingId: newBuilding.id, + buildingTypeId, + playerId + }); + + // Emit WebSocket event for building construction + if (this.gameEventService) { + this.gameEventService.emitBuildingConstructed(playerId, colonyId, newBuilding, correlationId); + } + + return newBuilding; + }); + + return result; + + } catch (error) { + logger.error('Building construction failed', { + correlationId, + colonyId, + buildingTypeId, + playerId, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError || error instanceof ConflictError || error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to construct building', error); + } + } + + /** + * Get available building types + * @param {string} correlationId - Request correlation ID + * @returns {Promise} List of available building types + */ + async getAvailableBuildingTypes(correlationId) { + try { + logger.info('Fetching available building types', { correlationId }); + + const buildingTypes = await db('building_types') + .select('*') + .where('is_active', true) + .orderBy('category') + .orderBy('name'); + + logger.info('Building types retrieved', { + correlationId, + count: buildingTypes.length + }); + + return buildingTypes; + + } catch (error) { + logger.error('Failed to fetch building types', { + correlationId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve building types', error); + } + } + + // Helper methods + + /** + * Validate colony creation data + * @param {Object} data - Colony data to validate + * @returns {Promise} + */ + async validateColonyData(data) { + const { name, coordinates, planet_type_id } = data; + + if (!name || typeof name !== 'string' || name.trim().length < 3 || name.trim().length > 50) { + throw new ValidationError('Colony name must be between 3 and 50 characters'); + } + + if (!this.isValidCoordinates(coordinates)) { + throw new ValidationError('Invalid coordinates format. Expected format: A3-91-X'); + } + + if (!planet_type_id || typeof planet_type_id !== 'number' || planet_type_id < 1) { + throw new ValidationError('Valid planet type ID is required'); + } + } + + /** + * Validate galaxy coordinates format + * @param {string} coordinates - Coordinates to validate + * @returns {boolean} True if valid + */ + isValidCoordinates(coordinates) { + // Format: A3-91-X (Letter+Number-Number-Letter) + const coordinatePattern = /^[A-Z]\d+-\d+-[A-Z]$/; + return coordinatePattern.test(coordinates); + } + + /** + * Extract sector coordinates from full coordinates + * @param {string} coordinates - Full coordinates (e.g., "A3-91-X") + * @returns {string} Sector coordinates (e.g., "A3") + */ + extractSectorFromCoordinates(coordinates) { + const match = coordinates.match(/^([A-Z]\d+)-/); + return match ? match[1] : null; + } + + /** + * Get colony by coordinates + * @param {string} coordinates - Galaxy coordinates + * @returns {Promise} Colony data or null + */ + async getColonyByCoordinates(coordinates) { + try { + return await db('colonies') + .where('coordinates', coordinates.toUpperCase()) + .first(); + } catch (error) { + logger.error('Failed to find colony by coordinates', { error: error.message }); + return null; + } + } + + /** + * Get player colony count + * @param {number} playerId - Player ID + * @returns {Promise} Number of colonies owned by player + */ + async getPlayerColonyCount(playerId) { + try { + const result = await db('colonies') + .where('player_id', playerId) + .count('* as count') + .first(); + return parseInt(result.count) || 0; + } catch (error) { + logger.error('Failed to get player colony count', { error: error.message }); + return 0; + } + } + + /** + * Get planet type by ID + * @param {number} planetTypeId - Planet type ID + * @returns {Promise} Planet type data or null + */ + async getPlanetTypeById(planetTypeId) { + try { + return await db('planet_types') + .where('id', planetTypeId) + .where('is_active', true) + .first(); + } catch (error) { + logger.error('Failed to find planet type', { error: error.message }); + return null; + } + } + + /** + * Get sector by coordinates + * @param {string} coordinates - Sector coordinates + * @returns {Promise} Sector data or null + */ + async getSectorByCoordinates(coordinates) { + try { + return await db('galaxy_sectors') + .where('coordinates', coordinates) + .first(); + } catch (error) { + logger.error('Failed to find sector', { error: error.message }); + return null; + } + } + + /** + * Create initial buildings for a new colony + * @param {number} colonyId - Colony ID + * @param {Object} trx - Database transaction + * @returns {Promise} + */ + async createInitialBuildings(colonyId, trx) { + // Get Command Center building type + const commandCenter = await trx('building_types') + .where('name', 'Command Center') + .first(); + + if (commandCenter) { + await trx('colony_buildings').insert({ + colony_id: colonyId, + building_type_id: commandCenter.id, + level: 1, + health_percentage: 100, + is_under_construction: false, + created_at: new Date(), + updated_at: new Date() + }); + } + } + + /** + * Initialize colony resource production tracking + * @param {number} colonyId - Colony ID + * @param {Object} planetType - Planet type data + * @param {Object} trx - Database transaction + * @returns {Promise} + */ + async initializeColonyResources(colonyId, planetType, trx) { + // Get all resource types + const resourceTypes = await trx('resource_types') + .where('is_active', true); + + const baseResources = planetType.base_resources || {}; + const modifiers = planetType.resource_modifiers || {}; + + // Initialize production tracking for each resource type + for (const resourceType of resourceTypes) { + const resourceName = resourceType.name; + const initialStored = baseResources[resourceName] || 0; + const modifier = modifiers[resourceName] || 1.0; + + await trx('colony_resource_production').insert({ + colony_id: colonyId, + resource_type_id: resourceType.id, + production_rate: 0, // Will be calculated based on buildings + consumption_rate: 0, + current_stored: initialStored, + storage_capacity: 10000, // Default storage capacity + last_calculated: new Date() + }); + } + } + + /** + * Verify colony ownership + * @param {number} colonyId - Colony ID + * @param {number} playerId - Player ID + * @returns {Promise} Colony data if owned by player + */ + async verifyColonyOwnership(colonyId, playerId) { + try { + return await db('colonies') + .where('id', colonyId) + .where('player_id', playerId) + .first(); + } catch (error) { + logger.error('Failed to verify colony ownership', { error: error.message }); + return null; + } + } + + /** + * Check if player can afford building costs + * @param {number} playerId - Player ID + * @param {Object} costs - Resource costs + * @returns {Promise} Affordability result + */ + async checkBuildingCosts(playerId, costs) { + try { + const result = { canAfford: true, missing: {} }; + + // Get player resources + const playerResources = await db('player_resources') + .select([ + 'player_resources.amount', + 'resource_types.name as resource_name' + ]) + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId); + + const resourceMap = {}; + playerResources.forEach(resource => { + resourceMap[resource.resource_name] = parseInt(resource.amount); + }); + + // Check each cost requirement + for (const [resourceName, requiredAmount] of Object.entries(costs)) { + const available = resourceMap[resourceName] || 0; + if (available < requiredAmount) { + result.canAfford = false; + result.missing[resourceName] = requiredAmount - available; + } + } + + return result; + + } catch (error) { + logger.error('Failed to check building costs', { error: error.message }); + return { canAfford: false, missing: {} }; + } + } + + /** + * Deduct resources from player + * @param {number} playerId - Player ID + * @param {Object} costs - Resource costs to deduct + * @param {Object} trx - Database transaction + * @returns {Promise} + */ + async deductResources(playerId, costs, trx) { + for (const [resourceName, amount] of Object.entries(costs)) { + await trx('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId) + .where('resource_types.name', resourceName) + .decrement('player_resources.amount', amount) + .update('player_resources.last_updated', new Date()); + } + } + + /** + * Update colony resource production after building construction + * @param {number} colonyId - Colony ID + * @param {Object} buildingType - Building type data + * @param {Object} trx - Database transaction + * @returns {Promise} + */ + async updateColonyResourceProduction(colonyId, buildingType, trx) { + if (!buildingType.base_production) return; + + for (const [resourceName, productionAmount] of Object.entries(buildingType.base_production)) { + await trx('colony_resource_production') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', colonyId) + .where('resource_types.name', resourceName) + .increment('colony_resource_production.production_rate', productionAmount) + .update('colony_resource_production.last_calculated', new Date()); + } + } +} + +module.exports = ColonyService; \ No newline at end of file diff --git a/src/services/game-tick.service.js b/src/services/game-tick.service.js index 107bd46..b447446 100644 --- a/src/services/game-tick.service.js +++ b/src/services/game-tick.service.js @@ -130,10 +130,39 @@ class GameTickService { const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); - logger.info('Game tick completed', { - tickNumber, - duration: `${duration}ms`, - }); + this.isProcessing = false; + this.processingStartTime = null; + + // Store last tick metrics + this.lastTickMetrics = { + tickNumber, + duration, + completedAt: endTime, + userGroupsProcessed: this.config.user_groups_count || 10, + failedGroups: this.failedUserGroups.size + }; + + logger.info('Game tick completed', { + tickNumber, + duration: `${duration}ms`, + userGroupsProcessed: this.config.user_groups_count || 10, + failedGroups: this.failedUserGroups.size, + metrics: this.lastTickMetrics + }); + + // Emit system-wide tick completion event + if (this.gameEventService) { + this.gameEventService.emitSystemAnnouncement( + `Game tick ${tickNumber} completed`, + 'info', + { + tickNumber, + duration, + timestamp: endTime.toISOString() + }, + `tick-${tickNumber}-completed` + ); + } } /** diff --git a/src/services/resource/ResourceService.js b/src/services/resource/ResourceService.js new file mode 100644 index 0000000..23da428 --- /dev/null +++ b/src/services/resource/ResourceService.js @@ -0,0 +1,600 @@ +/** + * Resource Service + * Handles all resource-related business logic including player resources, production calculations, and transfers + */ + +const db = require('../../database/connection'); +const logger = require('../../utils/logger'); +const { ValidationError, NotFoundError, ServiceError } = require('../../middleware/error.middleware'); + +class ResourceService { + constructor(gameEventService = null) { + this.gameEventService = gameEventService; + } + /** + * Initialize player resources when they register + * @param {number} playerId - Player ID + * @param {Object} trx - Database transaction + * @returns {Promise} + */ + async initializePlayerResources(playerId, trx) { + try { + logger.info('Initializing player resources', { playerId }); + + // Get all active resource types + const resourceTypes = await trx('resource_types') + .where('is_active', true) + .orderBy('id'); + + // Starting resources configuration + const startingResources = { + scrap: parseInt(process.env.STARTING_RESOURCES_SCRAP) || 1000, + energy: parseInt(process.env.STARTING_RESOURCES_ENERGY) || 500, + data_cores: 0, + rare_elements: 0 + }; + + // Create player resource entries + const resourceEntries = resourceTypes.map(resourceType => ({ + player_id: playerId, + resource_type_id: resourceType.id, + amount: startingResources[resourceType.name] || 0, + storage_capacity: null, // Unlimited by default + last_updated: new Date() + })); + + await trx('player_resources').insert(resourceEntries); + + logger.info('Player resources initialized successfully', { + playerId, + resourceCount: resourceEntries.length + }); + + } catch (error) { + logger.error('Failed to initialize player resources', { + playerId, + error: error.message, + stack: error.stack + }); + throw new ServiceError('Failed to initialize player resources', error); + } + } + + /** + * Get player resources + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Player resource data + */ + async getPlayerResources(playerId, correlationId) { + try { + logger.info('Fetching player resources', { + correlationId, + playerId + }); + + const resources = await db('player_resources') + .select([ + 'player_resources.*', + 'resource_types.name as resource_name', + 'resource_types.description', + 'resource_types.category', + 'resource_types.max_storage as type_max_storage', + 'resource_types.decay_rate', + 'resource_types.trade_value', + 'resource_types.is_tradeable' + ]) + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId) + .where('resource_types.is_active', true) + .orderBy('resource_types.category') + .orderBy('resource_types.name'); + + logger.info('Player resources retrieved', { + correlationId, + playerId, + resourceCount: resources.length + }); + + return resources; + + } catch (error) { + logger.error('Failed to fetch player resources', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve player resources', error); + } + } + + /** + * Get player resource summary (simplified view) + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Resource summary + */ + async getPlayerResourceSummary(playerId, correlationId) { + try { + logger.info('Fetching player resource summary', { + correlationId, + playerId + }); + + const resources = await this.getPlayerResources(playerId, correlationId); + + const summary = {}; + resources.forEach(resource => { + summary[resource.resource_name] = { + amount: parseInt(resource.amount) || 0, + category: resource.category, + storageCapacity: resource.storage_capacity, + isAtCapacity: resource.storage_capacity && resource.amount >= resource.storage_capacity + }; + }); + + logger.info('Player resource summary retrieved', { + correlationId, + playerId, + resourceTypes: Object.keys(summary) + }); + + return summary; + + } catch (error) { + logger.error('Failed to fetch player resource summary', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve player resource summary', error); + } + } + + /** + * Calculate total resource production across all player colonies + * @param {number} playerId - Player ID + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Production rates by resource type + */ + async calculatePlayerResourceProduction(playerId, correlationId) { + try { + logger.info('Calculating player resource production', { + correlationId, + playerId + }); + + // Get all player colonies with their resource production + const productionData = await db('colony_resource_production') + .select([ + 'resource_types.name as resource_name', + db.raw('SUM(colony_resource_production.production_rate) as total_production'), + db.raw('SUM(colony_resource_production.consumption_rate) as total_consumption'), + db.raw('SUM(colony_resource_production.current_stored) as total_stored') + ]) + .join('colonies', 'colony_resource_production.colony_id', 'colonies.id') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colonies.player_id', playerId) + .where('resource_types.is_active', true) + .groupBy('resource_types.id', 'resource_types.name') + .orderBy('resource_types.name'); + + const productionSummary = {}; + productionData.forEach(data => { + const netProduction = parseInt(data.total_production) - parseInt(data.total_consumption); + productionSummary[data.resource_name] = { + production: parseInt(data.total_production) || 0, + consumption: parseInt(data.total_consumption) || 0, + netProduction: netProduction, + storedInColonies: parseInt(data.total_stored) || 0 + }; + }); + + logger.info('Player resource production calculated', { + correlationId, + playerId, + productionSummary + }); + + return productionSummary; + + } catch (error) { + logger.error('Failed to calculate player resource production', { + correlationId, + playerId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to calculate resource production', error); + } + } + + /** + * Add resources to a player's stockpile + * @param {number} playerId - Player ID + * @param {Object} resources - Resources to add (resourceName: amount) + * @param {string} correlationId - Request correlation ID + * @param {Object} trx - Optional database transaction + * @returns {Promise} Updated resource amounts + */ + async addPlayerResources(playerId, resources, correlationId, trx = null) { + try { + logger.info('Adding resources to player', { + correlationId, + playerId, + resources + }); + + const dbContext = trx || db; + const updatedResources = {}; + + for (const [resourceName, amount] of Object.entries(resources)) { + if (amount <= 0) continue; + + // Update resource amount + const result = await dbContext('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId) + .where('resource_types.name', resourceName) + .increment('player_resources.amount', amount) + .update('player_resources.last_updated', new Date()) + .returning(['player_resources.amount', 'resource_types.name']); + + if (result.length > 0) { + updatedResources[resourceName] = parseInt(result[0].amount); + } + } + + logger.info('Resources added successfully', { + correlationId, + playerId, + updatedResources + }); + + // Emit WebSocket event for resource update + if (this.gameEventService) { + this.gameEventService.emitResourcesUpdated(playerId, updatedResources, 'admin_addition', correlationId); + } + + return updatedResources; + + } catch (error) { + logger.error('Failed to add player resources', { + correlationId, + playerId, + resources, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to add player resources', error); + } + } + + /** + * Deduct resources from a player's stockpile + * @param {number} playerId - Player ID + * @param {Object} resources - Resources to deduct (resourceName: amount) + * @param {string} correlationId - Request correlation ID + * @param {Object} trx - Optional database transaction + * @returns {Promise} Updated resource amounts + */ + async deductPlayerResources(playerId, resources, correlationId, trx = null) { + try { + logger.info('Deducting resources from player', { + correlationId, + playerId, + resources + }); + + const dbContext = trx || db; + + // First check if player has enough resources + const canAfford = await this.checkResourceAffordability(playerId, resources, correlationId, dbContext); + if (!canAfford.canAfford) { + throw new ValidationError('Insufficient resources', { missing: canAfford.missing }); + } + + const updatedResources = {}; + + for (const [resourceName, amount] of Object.entries(resources)) { + if (amount <= 0) continue; + + // Deduct resource amount + const result = await dbContext('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId) + .where('resource_types.name', resourceName) + .decrement('player_resources.amount', amount) + .update('player_resources.last_updated', new Date()) + .returning(['player_resources.amount', 'resource_types.name']); + + if (result.length > 0) { + updatedResources[resourceName] = parseInt(result[0].amount); + } + } + + logger.info('Resources deducted successfully', { + correlationId, + playerId, + updatedResources + }); + + // Emit WebSocket event for resource update + if (this.gameEventService) { + this.gameEventService.emitResourcesUpdated(playerId, updatedResources, 'resource_spent', correlationId); + } + + return updatedResources; + + } catch (error) { + logger.error('Failed to deduct player resources', { + correlationId, + playerId, + resources, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError) { + throw error; + } + throw new ServiceError('Failed to deduct player resources', error); + } + } + + /** + * Check if player can afford specific resource costs + * @param {number} playerId - Player ID + * @param {Object} costs - Resource costs to check + * @param {string} correlationId - Request correlation ID + * @param {Object} dbContext - Database context (for transactions) + * @returns {Promise} Affordability result + */ + async checkResourceAffordability(playerId, costs, correlationId, dbContext = db) { + try { + const result = { canAfford: true, missing: {} }; + + // Get player resources + const playerResources = await dbContext('player_resources') + .select([ + 'player_resources.amount', + 'resource_types.name as resource_name' + ]) + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', playerId); + + const resourceMap = {}; + playerResources.forEach(resource => { + resourceMap[resource.resource_name] = parseInt(resource.amount); + }); + + // Check each cost requirement + for (const [resourceName, requiredAmount] of Object.entries(costs)) { + const available = resourceMap[resourceName] || 0; + if (available < requiredAmount) { + result.canAfford = false; + result.missing[resourceName] = requiredAmount - available; + } + } + + return result; + + } catch (error) { + logger.error('Failed to check resource affordability', { + correlationId, + playerId, + costs, + error: error.message + }); + + return { canAfford: false, missing: {} }; + } + } + + /** + * Transfer resources between colonies + * @param {number} fromColonyId - Source colony ID + * @param {number} toColonyId - Destination colony ID + * @param {Object} resources - Resources to transfer + * @param {number} playerId - Player ID (for authorization) + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Transfer result + */ + async transferResourcesBetweenColonies(fromColonyId, toColonyId, resources, playerId, correlationId) { + try { + logger.info('Transferring resources between colonies', { + correlationId, + fromColonyId, + toColonyId, + resources, + playerId + }); + + // Verify both colonies belong to the player + const [fromColony, toColony] = await Promise.all([ + db('colonies').where('id', fromColonyId).where('player_id', playerId).first(), + db('colonies').where('id', toColonyId).where('player_id', playerId).first() + ]); + + if (!fromColony || !toColony) { + throw new NotFoundError('One or both colonies not found or access denied'); + } + + // Execute transfer in transaction + const result = await db.transaction(async (trx) => { + for (const [resourceName, amount] of Object.entries(resources)) { + if (amount <= 0) continue; + + // Check if source colony has enough resources + const sourceResource = await trx('colony_resource_production') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', fromColonyId) + .where('resource_types.name', resourceName) + .first(); + + if (!sourceResource || sourceResource.current_stored < amount) { + throw new ValidationError(`Insufficient ${resourceName} in source colony`); + } + + // Transfer resources + await trx('colony_resource_production') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', fromColonyId) + .where('resource_types.name', resourceName) + .decrement('colony_resource_production.current_stored', amount) + .update('colony_resource_production.last_calculated', new Date()); + + await trx('colony_resource_production') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.colony_id', toColonyId) + .where('resource_types.name', resourceName) + .increment('colony_resource_production.current_stored', amount) + .update('colony_resource_production.last_calculated', new Date()); + } + + return { success: true, transferred: resources }; + }); + + logger.info('Resources transferred successfully', { + correlationId, + fromColonyId, + toColonyId, + resources, + playerId + }); + + return result; + + } catch (error) { + logger.error('Failed to transfer resources between colonies', { + correlationId, + fromColonyId, + toColonyId, + resources, + playerId, + error: error.message, + stack: error.stack + }); + + if (error instanceof ValidationError || error instanceof NotFoundError) { + throw error; + } + throw new ServiceError('Failed to transfer resources', error); + } + } + + /** + * Get all resource types available in the game + * @param {string} correlationId - Request correlation ID + * @returns {Promise} List of resource types + */ + async getResourceTypes(correlationId) { + try { + logger.info('Fetching resource types', { correlationId }); + + const resourceTypes = await db('resource_types') + .select('*') + .where('is_active', true) + .orderBy('category') + .orderBy('name'); + + logger.info('Resource types retrieved', { + correlationId, + count: resourceTypes.length + }); + + return resourceTypes; + + } catch (error) { + logger.error('Failed to fetch resource types', { + correlationId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to retrieve resource types', error); + } + } + + /** + * Process resource production for all colonies (called by game tick) + * @param {string} correlationId - Request correlation ID + * @returns {Promise} Processing result + */ + async processResourceProduction(correlationId) { + try { + logger.info('Processing resource production', { correlationId }); + + let processedColonies = 0; + let totalResourcesProduced = 0; + + // Get all active colonies with production + const productionEntries = await db('colony_resource_production') + .select([ + 'colony_resource_production.*', + 'colonies.player_id', + 'resource_types.name as resource_name' + ]) + .join('colonies', 'colony_resource_production.colony_id', 'colonies.id') + .join('resource_types', 'colony_resource_production.resource_type_id', 'resource_types.id') + .where('colony_resource_production.production_rate', '>', 0) + .where('resource_types.is_active', true); + + // Process in batches to avoid overwhelming the database + const batchSize = 50; + for (let i = 0; i < productionEntries.length; i += batchSize) { + const batch = productionEntries.slice(i, i + batchSize); + + await db.transaction(async (trx) => { + for (const entry of batch) { + // Calculate production since last update + const timeSinceLastUpdate = new Date() - new Date(entry.last_calculated); + const hoursElapsed = timeSinceLastUpdate / (1000 * 60 * 60); + const productionAmount = Math.floor(entry.production_rate * hoursElapsed); + + if (productionAmount > 0) { + // Add to colony storage + await trx('colony_resource_production') + .where('id', entry.id) + .increment('current_stored', productionAmount) + .update('last_calculated', new Date()); + + totalResourcesProduced += productionAmount; + } + } + }); + + processedColonies += batch.length; + } + + logger.info('Resource production processed', { + correlationId, + processedColonies, + totalResourcesProduced + }); + + return { + success: true, + processedColonies, + totalResourcesProduced + }; + + } catch (error) { + logger.error('Failed to process resource production', { + correlationId, + error: error.message, + stack: error.stack + }); + + throw new ServiceError('Failed to process resource production', error); + } + } +} + +module.exports = ResourceService; \ No newline at end of file diff --git a/src/services/user/PlayerService.js b/src/services/user/PlayerService.js index c17398c..7dc1fde 100644 --- a/src/services/user/PlayerService.js +++ b/src/services/user/PlayerService.js @@ -9,8 +9,12 @@ const { generatePlayerToken, generateRefreshToken } = require('../../utils/jwt') const { validateEmail, validateUsername } = require('../../utils/validation'); const logger = require('../../utils/logger'); const { ValidationError, ConflictError, NotFoundError, AuthenticationError } = require('../../middleware/error.middleware'); +const ResourceService = require('../resource/ResourceService'); class PlayerService { + constructor() { + this.resourceService = new ResourceService(); + } /** * Register a new player * @param {Object} playerData - Player registration data @@ -62,15 +66,8 @@ class PlayerService { }) .returning(['id', 'email', 'username', 'is_active', 'is_verified', 'created_at']); - // Create initial player resources - await trx('player_resources').insert({ - player_id: newPlayer.id, - scrap: parseInt(process.env.STARTING_RESOURCES_SCRAP) || 1000, - energy: parseInt(process.env.STARTING_RESOURCES_ENERGY) || 500, - research_points: 0, - created_at: new Date(), - updated_at: new Date() - }); + // Initialize player resources using ResourceService + await this.resourceService.initializePlayerResources(newPlayer.id, trx); // Create initial player stats await trx('player_stats').insert({ @@ -244,10 +241,7 @@ class PlayerService { } // Get player resources - const resources = await db('player_resources') - .select(['scrap', 'energy', 'research_points']) - .where('player_id', playerId) - .first(); + const resources = await this.resourceService.getPlayerResourceSummary(playerId, correlationId); // Get player stats const stats = await db('player_stats') @@ -268,11 +262,7 @@ class PlayerService { isVerified: player.is_verified, createdAt: player.created_at, lastLoginAt: player.last_login_at, - resources: resources || { - scrap: 0, - energy: 0, - researchPoints: 0 - }, + resources: resources || {}, stats: stats || { coloniesCount: 0, fleetsCount: 0, diff --git a/src/services/websocket/GameEventService.js b/src/services/websocket/GameEventService.js new file mode 100644 index 0000000..8517b7b --- /dev/null +++ b/src/services/websocket/GameEventService.js @@ -0,0 +1,893 @@ +/** + * Game Event Service + * Handles WebSocket event broadcasting for real-time game updates + */ + +const logger = require('../../utils/logger'); + +class GameEventService { + constructor(io) { + this.io = io; + } + + /** + * Emit colony creation event + * @param {number} playerId - Player ID + * @param {Object} colony - Colony data + * @param {string} correlationId - Request correlation ID + */ + emitColonyCreated(playerId, colony, correlationId) { + try { + const eventData = { + type: 'colony_created', + data: { + colony: { + id: colony.id, + name: colony.name, + coordinates: colony.coordinates, + planetType: colony.planet_type_name, + foundedAt: colony.founded_at + } + }, + timestamp: new Date().toISOString(), + correlationId + }; + + // Send to the player who created the colony + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to anyone watching this sector + if (colony.sector_id) { + this.io.to(`sector:${colony.sector_id}`).emit('game_event', eventData); + } + + logger.info('Colony creation event emitted', { + correlationId, + playerId, + colonyId: colony.id, + colonyName: colony.name + }); + + } catch (error) { + logger.error('Failed to emit colony creation event', { + correlationId, + playerId, + colonyId: colony.id, + error: error.message + }); + } + } + + /** + * Emit building construction event + * @param {number} playerId - Player ID + * @param {number} colonyId - Colony ID + * @param {Object} building - Building data + * @param {string} correlationId - Request correlation ID + */ + emitBuildingConstructed(playerId, colonyId, building, correlationId) { + try { + const eventData = { + type: 'building_constructed', + data: { + colonyId, + building: { + id: building.id, + buildingTypeId: building.building_type_id, + level: building.level, + constructedAt: building.created_at + } + }, + timestamp: new Date().toISOString(), + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to anyone watching this colony + this.io.to(`colony:${colonyId}`).emit('game_event', eventData); + + logger.info('Building construction event emitted', { + correlationId, + playerId, + colonyId, + buildingId: building.id + }); + + } catch (error) { + logger.error('Failed to emit building construction event', { + correlationId, + playerId, + colonyId, + buildingId: building.id, + error: error.message + }); + } + } + + /** + * Emit resource update event + * @param {number} playerId - Player ID + * @param {Object} resourceChanges - Resource changes + * @param {string} reason - Reason for resource change + * @param {string} correlationId - Request correlation ID + */ + emitResourcesUpdated(playerId, resourceChanges, reason, correlationId) { + try { + const eventData = { + type: 'resources_updated', + data: { + reason, + changes: resourceChanges, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + logger.debug('Resource update event emitted', { + correlationId, + playerId, + reason, + resourceChanges + }); + + } catch (error) { + logger.error('Failed to emit resource update event', { + correlationId, + playerId, + reason, + error: error.message + }); + } + } + + /** + * Emit resource production tick event + * @param {number} playerId - Player ID + * @param {number} colonyId - Colony ID + * @param {Object} productionData - Production data + * @param {string} correlationId - Request correlation ID + */ + emitResourceProduction(playerId, colonyId, productionData, correlationId) { + try { + const eventData = { + type: 'resource_production', + data: { + colonyId, + production: productionData, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to anyone watching this colony + this.io.to(`colony:${colonyId}`).emit('game_event', eventData); + + logger.debug('Resource production event emitted', { + correlationId, + playerId, + colonyId, + productionData + }); + + } catch (error) { + logger.error('Failed to emit resource production event', { + correlationId, + playerId, + colonyId, + error: error.message + }); + } + } + + /** + * Emit colony status update event + * @param {number} playerId - Player ID + * @param {number} colonyId - Colony ID + * @param {Object} statusUpdate - Status update data + * @param {string} correlationId - Request correlation ID + */ + emitColonyStatusUpdate(playerId, colonyId, statusUpdate, correlationId) { + try { + const eventData = { + type: 'colony_status_update', + data: { + colonyId, + update: statusUpdate, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to anyone watching this colony + this.io.to(`colony:${colonyId}`).emit('game_event', eventData); + + logger.debug('Colony status update event emitted', { + correlationId, + playerId, + colonyId, + update: statusUpdate + }); + + } catch (error) { + logger.error('Failed to emit colony status update event', { + correlationId, + playerId, + colonyId, + error: error.message + }); + } + } + + /** + * Emit error event to player + * @param {number} playerId - Player ID + * @param {string} errorType - Type of error + * @param {string} message - Error message + * @param {Object} details - Additional error details + * @param {string} correlationId - Request correlation ID + */ + emitErrorEvent(playerId, errorType, message, details = {}, correlationId) { + try { + const eventData = { + type: 'error', + data: { + errorType, + message, + details, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + logger.warn('Error event emitted to player', { + correlationId, + playerId, + errorType, + message + }); + + } catch (error) { + logger.error('Failed to emit error event', { + correlationId, + playerId, + errorType, + error: error.message + }); + } + } + + /** + * Emit notification event to player + * @param {number} playerId - Player ID + * @param {Object} notification - Notification data + * @param {string} correlationId - Request correlation ID + */ + emitNotification(playerId, notification, correlationId) { + try { + const eventData = { + type: 'notification', + data: { + notification: { + ...notification, + timestamp: new Date().toISOString() + } + }, + correlationId + }; + + // Send to the player + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + logger.debug('Notification event emitted', { + correlationId, + playerId, + notificationType: notification.type + }); + + } catch (error) { + logger.error('Failed to emit notification event', { + correlationId, + playerId, + error: error.message + }); + } + } + + /** + * Emit player status change event + * @param {number} playerId - Player ID + * @param {string} status - New status + * @param {Array} relevantPlayers - Players who should be notified + * @param {string} correlationId - Request correlation ID + */ + emitPlayerStatusChange(playerId, status, relevantPlayers = [], correlationId) { + try { + const eventData = { + type: 'player_status_change', + data: { + playerId, + status, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to relevant players (allies, faction members, etc.) + relevantPlayers.forEach(targetPlayerId => { + this.io.to(`player:${targetPlayerId}`).emit('game_event', eventData); + }); + + logger.debug('Player status change event emitted', { + correlationId, + playerId, + status, + notifiedPlayers: relevantPlayers.length + }); + + } catch (error) { + logger.error('Failed to emit player status change event', { + correlationId, + playerId, + status, + error: error.message + }); + } + } + + /** + * Emit system-wide announcement + * @param {string} message - Announcement message + * @param {string} type - Announcement type (info, warning, emergency) + * @param {Object} metadata - Additional metadata + * @param {string} correlationId - Request correlation ID + */ + emitSystemAnnouncement(message, type = 'info', metadata = {}, correlationId) { + try { + const eventData = { + type: 'system_announcement', + data: { + message, + announcementType: type, + metadata, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Broadcast to all connected players + this.io.emit('game_event', eventData); + + logger.info('System announcement emitted', { + correlationId, + announcementType: type, + message + }); + + } catch (error) { + logger.error('Failed to emit system announcement', { + correlationId, + message, + error: error.message + }); + } + } + + /** + * Get connected player count + * @returns {number} Number of connected players + */ + getConnectedPlayerCount() { + try { + const sockets = this.io.sockets.sockets; + let authenticatedCount = 0; + + sockets.forEach(socket => { + if (socket.authenticated && socket.playerId) { + authenticatedCount++; + } + }); + + return authenticatedCount; + + } catch (error) { + logger.error('Failed to get connected player count', { + error: error.message + }); + return 0; + } + } + + /** + * Get players in a specific room + * @param {string} roomName - Room name + * @returns {Array} Array of player IDs in the room + */ + getPlayersInRoom(roomName) { + try { + const room = this.io.sockets.adapter.rooms.get(roomName); + if (!room) return []; + + const playerIds = []; + room.forEach(socketId => { + const socket = this.io.sockets.sockets.get(socketId); + if (socket && socket.authenticated && socket.playerId) { + playerIds.push(socket.playerId); + } + }); + + return playerIds; + + } catch (error) { + logger.error('Failed to get players in room', { + roomName, + error: error.message + }); + return []; + } + } + + // === COMBAT-SPECIFIC EVENTS === + + /** + * Emit combat initiation event + * @param {Object} battle - Battle data + * @param {string} correlationId - Request correlation ID + */ + async emitCombatInitiated(battle, correlationId) { + try { + const participants = JSON.parse(battle.participants); + const eventData = { + type: 'combat_initiated', + data: { + battleId: battle.id, + battleType: battle.battle_type, + location: battle.location, + status: battle.status, + estimatedDuration: battle.estimated_duration, + participants: { + attacker_fleet_id: participants.attacker_fleet_id, + defender_fleet_id: participants.defender_fleet_id, + defender_colony_id: participants.defender_colony_id + }, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to attacking player + if (participants.attacker_player_id) { + this.io.to(`player:${participants.attacker_player_id}`).emit('combat_event', eventData); + } + + // Send to defending player + const defendingPlayerId = await this.getDefendingPlayerId(participants); + if (defendingPlayerId && defendingPlayerId !== participants.attacker_player_id) { + this.io.to(`player:${defendingPlayerId}`).emit('combat_event', eventData); + } + + // Send to location spectators + this.io.to(`location:${battle.location}`).emit('combat_event', eventData); + + // Send to battle room for spectators + this.io.to(`battle:${battle.id}`).emit('combat_event', eventData); + + logger.info('Combat initiation event emitted', { + correlationId, + battleId: battle.id, + battleType: battle.battle_type, + location: battle.location + }); + + } catch (error) { + logger.error('Failed to emit combat initiation event', { + correlationId, + battleId: battle.id, + error: error.message, + stack: error.stack + }); + } + } + + /** + * Emit combat status update event + * @param {number} battleId - Battle ID + * @param {string} status - New battle status + * @param {Object} updateData - Additional update data + * @param {string} correlationId - Request correlation ID + */ + emitCombatStatusUpdate(battleId, status, updateData = {}, correlationId) { + try { + const eventData = { + type: 'combat_status_update', + data: { + battleId, + status, + updateData, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to battle room (participants and spectators) + this.io.to(`battle:${battleId}`).emit('combat_event', eventData); + + logger.debug('Combat status update event emitted', { + correlationId, + battleId, + status + }); + + } catch (error) { + logger.error('Failed to emit combat status update event', { + correlationId, + battleId, + status, + error: error.message + }); + } + } + + /** + * Emit combat round update event (for turn-based combat) + * @param {number} battleId - Battle ID + * @param {number} round - Round number + * @param {Object} roundData - Round data + * @param {string} correlationId - Request correlation ID + */ + emitCombatRoundUpdate(battleId, round, roundData, correlationId) { + try { + const eventData = { + type: 'combat_round_update', + data: { + battleId, + round, + roundData, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to battle room + this.io.to(`battle:${battleId}`).emit('combat_event', eventData); + + logger.debug('Combat round update event emitted', { + correlationId, + battleId, + round + }); + + } catch (error) { + logger.error('Failed to emit combat round update event', { + correlationId, + battleId, + round, + error: error.message + }); + } + } + + /** + * Emit combat completed event + * @param {Object} result - Combat result + * @param {string} correlationId - Request correlation ID + */ + async emitCombatCompleted(result, correlationId) { + try { + const eventData = { + type: 'combat_completed', + data: { + battleId: result.battleId, + encounterId: result.encounterId, + outcome: result.outcome, + casualties: result.casualties, + experience: result.experience, + loot: result.loot, + duration: result.duration, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to battle room + this.io.to(`battle:${result.battleId}`).emit('combat_event', eventData); + + // Send detailed results to participants only + const participantData = { + ...eventData, + data: { + ...eventData.data, + detailed_casualties: result.casualties, + detailed_loot: result.loot + } + }; + + const participants = await this.getBattleParticipants(result.battleId); + participants.forEach(playerId => { + this.io.to(`player:${playerId}`).emit('combat_event', participantData); + }); + + logger.info('Combat completed event emitted', { + correlationId, + battleId: result.battleId, + outcome: result.outcome, + duration: result.duration + }); + + } catch (error) { + logger.error('Failed to emit combat completed event', { + correlationId, + battleId: result.battleId, + error: error.message, + stack: error.stack + }); + } + } + + /** + * Emit fleet movement event + * @param {number} playerId - Player ID + * @param {Object} fleet - Fleet data + * @param {string} fromLocation - Origin location + * @param {string} toLocation - Destination location + * @param {string} correlationId - Request correlation ID + */ + emitFleetMovement(playerId, fleet, fromLocation, toLocation, correlationId) { + try { + const eventData = { + type: 'fleet_movement', + data: { + fleetId: fleet.id, + fleetName: fleet.name, + playerId, + fromLocation, + toLocation, + arrivalTime: fleet.arrival_time, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to fleet owner + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to origin location watchers + this.io.to(`location:${fromLocation}`).emit('game_event', eventData); + + // Send to destination location watchers + if (fromLocation !== toLocation) { + this.io.to(`location:${toLocation}`).emit('game_event', eventData); + } + + logger.debug('Fleet movement event emitted', { + correlationId, + fleetId: fleet.id, + fromLocation, + toLocation + }); + + } catch (error) { + logger.error('Failed to emit fleet movement event', { + correlationId, + fleetId: fleet.id, + error: error.message + }); + } + } + + /** + * Emit fleet status change event + * @param {number} playerId - Player ID + * @param {Object} fleet - Fleet data + * @param {string} oldStatus - Previous status + * @param {string} newStatus - New status + * @param {string} correlationId - Request correlation ID + */ + emitFleetStatusChange(playerId, fleet, oldStatus, newStatus, correlationId) { + try { + const eventData = { + type: 'fleet_status_change', + data: { + fleetId: fleet.id, + fleetName: fleet.name, + playerId, + oldStatus, + newStatus, + location: fleet.current_location, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to fleet owner + this.io.to(`player:${playerId}`).emit('game_event', eventData); + + // Send to location watchers + this.io.to(`location:${fleet.current_location}`).emit('game_event', eventData); + + logger.debug('Fleet status change event emitted', { + correlationId, + fleetId: fleet.id, + oldStatus, + newStatus + }); + + } catch (error) { + logger.error('Failed to emit fleet status change event', { + correlationId, + fleetId: fleet.id, + error: error.message + }); + } + } + + /** + * Emit colony under siege event + * @param {number} playerId - Colony owner player ID + * @param {Object} colony - Colony data + * @param {number} attackerFleetId - Attacking fleet ID + * @param {string} correlationId - Request correlation ID + */ + emitColonyUnderSiege(playerId, colony, attackerFleetId, correlationId) { + try { + const eventData = { + type: 'colony_under_siege', + data: { + colonyId: colony.id, + colonyName: colony.name, + coordinates: colony.coordinates, + attackerFleetId, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to colony owner + this.io.to(`player:${playerId}`).emit('combat_event', eventData); + + // Send to location watchers + this.io.to(`location:${colony.coordinates}`).emit('combat_event', eventData); + + // Send to colony watchers + this.io.to(`colony:${colony.id}`).emit('combat_event', eventData); + + logger.info('Colony under siege event emitted', { + correlationId, + colonyId: colony.id, + attackerFleetId + }); + + } catch (error) { + logger.error('Failed to emit colony under siege event', { + correlationId, + colonyId: colony.id, + error: error.message + }); + } + } + + /** + * Emit spectator joined combat event + * @param {number} battleId - Battle ID + * @param {number} spectatorCount - Current spectator count + * @param {string} correlationId - Request correlation ID + */ + emitSpectatorJoined(battleId, spectatorCount, correlationId) { + try { + const eventData = { + type: 'spectator_joined', + data: { + battleId, + spectatorCount, + timestamp: new Date().toISOString() + }, + correlationId + }; + + // Send to battle room + this.io.to(`battle:${battleId}`).emit('combat_event', eventData); + + logger.debug('Spectator joined event emitted', { + correlationId, + battleId, + spectatorCount + }); + + } catch (error) { + logger.error('Failed to emit spectator joined event', { + correlationId, + battleId, + error: error.message + }); + } + } + + // Helper methods for combat events + + /** + * Get defending player ID from battle participants + * @param {Object} participants - Battle participants + * @returns {Promise} Defending player ID + */ + async getDefendingPlayerId(participants) { + try { + if (participants.defender_fleet_id) { + const db = require('../../database/connection'); + const fleet = await db('fleets') + .select('player_id') + .where('id', participants.defender_fleet_id) + .first(); + return fleet?.player_id || null; + } + + if (participants.defender_colony_id) { + const db = require('../../database/connection'); + const colony = await db('colonies') + .select('player_id') + .where('id', participants.defender_colony_id) + .first(); + return colony?.player_id || null; + } + + return null; + } catch (error) { + logger.error('Failed to get defending player ID', { + participants, + error: error.message + }); + return null; + } + } + + /** + * Get battle participants (player IDs) + * @param {number} battleId - Battle ID + * @returns {Promise} Array of participant player IDs + */ + async getBattleParticipants(battleId) { + try { + const db = require('../../database/connection'); + const battle = await db('battles') + .select('participants') + .where('id', battleId) + .first(); + + if (!battle) return []; + + const participants = JSON.parse(battle.participants); + const playerIds = []; + + if (participants.attacker_player_id) { + playerIds.push(participants.attacker_player_id); + } + + const defendingPlayerId = await this.getDefendingPlayerId(participants); + if (defendingPlayerId && !playerIds.includes(defendingPlayerId)) { + playerIds.push(defendingPlayerId); + } + + return playerIds; + } catch (error) { + logger.error('Failed to get battle participants', { + battleId, + error: error.message + }); + return []; + } + } +} + +module.exports = GameEventService; \ No newline at end of file diff --git a/src/tests/helpers/test-helpers.js b/src/tests/helpers/test-helpers.js new file mode 100644 index 0000000..a731d81 --- /dev/null +++ b/src/tests/helpers/test-helpers.js @@ -0,0 +1,479 @@ +/** + * Test Helpers + * Utility functions for setting up test data + */ + +const db = require('../../database/connection'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcrypt'); + +/** + * Create a test user with authentication token + * @param {string} email - User email + * @param {string} username - Username + * @param {string} password - Password (optional, defaults to 'testpassword') + * @returns {Promise} User and token + */ +async function createTestUser(email, username, password = 'testpassword') { + try { + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user + const [user] = await db('players').insert({ + email, + username, + password_hash: hashedPassword, + email_verified: true, + user_group: Math.floor(Math.random() * 10), + is_active: true, + created_at: new Date(), + updated_at: new Date() + }).returning('*'); + + // Generate JWT token + const token = jwt.sign( + { id: user.id, username: user.username }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '24h' } + ); + + // Initialize player resources + const resourceTypes = await db('resource_types').where('is_active', true); + for (const resourceType of resourceTypes) { + await db('player_resources').insert({ + player_id: user.id, + resource_type_id: resourceType.id, + amount: 10000, // Generous amount for testing + storage_capacity: 50000, + last_updated: new Date() + }); + } + + // Initialize combat statistics + await db('combat_statistics').insert({ + player_id: user.id, + battles_initiated: 0, + battles_won: 0, + battles_lost: 0, + ships_lost: 0, + ships_destroyed: 0, + total_damage_dealt: 0, + total_damage_received: 0, + total_experience_gained: 0, + resources_looted: JSON.stringify({}), + created_at: new Date(), + updated_at: new Date() + }); + + return { user, token }; + } catch (error) { + console.error('Failed to create test user:', error); + throw error; + } +} + +/** + * Create a test fleet with ships + * @param {number} playerId - Owner player ID + * @param {string} name - Fleet name + * @param {string} location - Fleet location + * @returns {Promise} Created fleet + */ +async function createTestFleet(playerId, name, location) { + try { + // Get or create a basic ship design + let shipDesign = await db('ship_designs') + .where('name', 'Test Fighter') + .where('is_public', true) + .first(); + + if (!shipDesign) { + [shipDesign] = await db('ship_designs').insert({ + name: 'Test Fighter', + ship_class: 'fighter', + hull_type: 'light', + components: JSON.stringify({ + weapons: ['basic_laser'], + shields: ['basic_shield'], + engines: ['basic_engine'] + }), + stats: JSON.stringify({ + hp: 100, + attack: 15, + defense: 10, + speed: 5 + }), + cost: JSON.stringify({ + scrap: 100, + energy: 50 + }), + build_time: 30, + is_public: true, + is_active: true, + hull_points: 100, + shield_points: 25, + armor_points: 10, + attack_power: 15, + attack_speed: 1.0, + movement_speed: 5, + cargo_capacity: 0, + special_abilities: JSON.stringify([]), + damage_resistances: JSON.stringify({}), + created_at: new Date(), + updated_at: new Date() + }).returning('*'); + } + + // Create fleet + const [fleet] = await db('fleets').insert({ + player_id: playerId, + name, + current_location: location, + destination: null, + fleet_status: 'idle', + combat_rating: 150, + total_ship_count: 10, + fleet_composition: JSON.stringify({ + 'Test Fighter': 10 + }), + combat_victories: 0, + combat_defeats: 0, + movement_started: null, + arrival_time: null, + last_updated: new Date(), + created_at: new Date() + }).returning('*'); + + // Add ships to fleet + await db('fleet_ships').insert({ + fleet_id: fleet.id, + ship_design_id: shipDesign.id, + quantity: 10, + health_percentage: 100, + experience: 0, + created_at: new Date() + }); + + // Create initial combat experience record + await db('ship_combat_experience').insert({ + fleet_id: fleet.id, + ship_design_id: shipDesign.id, + battles_survived: 0, + enemies_destroyed: 0, + damage_dealt: 0, + experience_points: 0, + veterancy_level: 1, + combat_bonuses: JSON.stringify({}), + created_at: new Date(), + updated_at: new Date() + }); + + return fleet; + } catch (error) { + console.error('Failed to create test fleet:', error); + throw error; + } +} + +/** + * Create a test colony with basic buildings + * @param {number} playerId - Owner player ID + * @param {string} name - Colony name + * @param {string} coordinates - Colony coordinates + * @param {number} planetTypeId - Planet type ID (optional) + * @returns {Promise} Created colony + */ +async function createTestColony(playerId, name, coordinates, planetTypeId = null) { + try { + // Get planet type + if (!planetTypeId) { + const planetType = await db('planet_types') + .where('is_active', true) + .first(); + planetTypeId = planetType?.id || 1; + } + + // Get sector + const sectorCoordinates = coordinates.split('-').slice(0, 2).join('-'); + let sector = await db('galaxy_sectors') + .where('coordinates', sectorCoordinates) + .first(); + + if (!sector) { + [sector] = await db('galaxy_sectors').insert({ + name: `Test Sector ${sectorCoordinates}`, + coordinates: sectorCoordinates, + description: 'Test sector for integration tests', + danger_level: 3, + special_rules: JSON.stringify({}), + created_at: new Date() + }).returning('*'); + } + + // Create colony + const [colony] = await db('colonies').insert({ + player_id: playerId, + name, + coordinates, + sector_id: sector.id, + planet_type_id: planetTypeId, + population: 1000, + max_population: 10000, + morale: 100, + loyalty: 100, + defense_rating: 50, + shield_strength: 25, + under_siege: false, + successful_defenses: 0, + times_captured: 0, + founded_at: new Date(), + last_updated: new Date() + }).returning('*'); + + // Add basic buildings + const commandCenter = await db('building_types') + .where('name', 'Command Center') + .first(); + + if (commandCenter) { + await db('colony_buildings').insert({ + colony_id: colony.id, + building_type_id: commandCenter.id, + level: 1, + health_percentage: 100, + is_under_construction: false, + created_at: new Date(), + updated_at: new Date() + }); + } + + // Add defense grid for combat testing + const defenseGrid = await db('building_types') + .where('name', 'Defense Grid') + .first(); + + if (defenseGrid) { + await db('colony_buildings').insert({ + colony_id: colony.id, + building_type_id: defenseGrid.id, + level: 2, + health_percentage: 100, + is_under_construction: false, + created_at: new Date(), + updated_at: new Date() + }); + } + + // Initialize colony resource production + const resourceTypes = await db('resource_types').where('is_active', true); + for (const resourceType of resourceTypes) { + await db('colony_resource_production').insert({ + colony_id: colony.id, + resource_type_id: resourceType.id, + production_rate: 10, + consumption_rate: 5, + current_stored: 1000, + storage_capacity: 10000, + last_calculated: new Date() + }); + } + + return colony; + } catch (error) { + console.error('Failed to create test colony:', error); + throw error; + } +} + +/** + * Create test combat configuration + * @param {string} name - Configuration name + * @param {string} type - Combat type + * @param {Object} config - Configuration data + * @returns {Promise} Created configuration + */ +async function createTestCombatConfig(name, type, config = {}) { + try { + const [combatConfig] = await db('combat_configurations').insert({ + config_name: name, + combat_type: type, + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 1, + max_rounds: 5, + round_duration: 2, + damage_variance: 0.1, + experience_gain: 1.0, + casualty_rate_min: 0.1, + casualty_rate_max: 0.7, + loot_multiplier: 1.0, + spectator_limit: 100, + priority: 100, + ...config + }), + description: `Test ${type} combat configuration`, + is_active: true, + created_at: new Date(), + updated_at: new Date() + }).returning('*'); + + return combatConfig; + } catch (error) { + console.error('Failed to create test combat config:', error); + throw error; + } +} + +/** + * Create a completed combat encounter for testing history + * @param {number} attackerFleetId - Attacker fleet ID + * @param {number} defenderFleetId - Defender fleet ID (optional) + * @param {number} defenderColonyId - Defender colony ID (optional) + * @param {string} outcome - Combat outcome + * @returns {Promise} Created encounter + */ +async function createTestCombatEncounter(attackerFleetId, defenderFleetId = null, defenderColonyId = null, outcome = 'attacker_victory') { + try { + // Create battle + const [battle] = await db('battles').insert({ + battle_type: defenderColonyId ? 'fleet_vs_colony' : 'fleet_vs_fleet', + location: 'A3-91-X', + combat_type_id: 1, + participants: JSON.stringify({ + attacker_fleet_id: attackerFleetId, + defender_fleet_id: defenderFleetId, + defender_colony_id: defenderColonyId + }), + status: 'completed', + battle_data: JSON.stringify({}), + result: JSON.stringify({ outcome }), + started_at: new Date(Date.now() - 300000), // 5 minutes ago + completed_at: new Date(), + created_at: new Date() + }).returning('*'); + + // Create encounter + const [encounter] = await db('combat_encounters').insert({ + battle_id: battle.id, + attacker_fleet_id: attackerFleetId, + defender_fleet_id: defenderFleetId, + defender_colony_id: defenderColonyId, + encounter_type: battle.battle_type, + location: battle.location, + initial_forces: JSON.stringify({ + attacker: { ships: 10 }, + defender: { ships: 8 } + }), + final_forces: JSON.stringify({ + attacker: { ships: outcome === 'attacker_victory' ? 7 : 2 }, + defender: { ships: outcome === 'defender_victory' ? 6 : 0 } + }), + casualties: JSON.stringify({ + attacker: { ships: {}, total_ships: outcome === 'attacker_victory' ? 3 : 8 }, + defender: { ships: {}, total_ships: outcome === 'defender_victory' ? 2 : 8 } + }), + combat_log: JSON.stringify([ + { round: 1, event: 'combat_start', description: 'Combat initiated' }, + { round: 1, event: 'combat_resolution', description: `${outcome.replace('_', ' ')}` } + ]), + experience_gained: 100, + loot_awarded: JSON.stringify(outcome === 'attacker_victory' ? { scrap: 500, energy: 250 } : {}), + outcome, + duration_seconds: 90, + started_at: battle.started_at, + completed_at: battle.completed_at, + created_at: new Date() + }).returning('*'); + + return { battle, encounter }; + } catch (error) { + console.error('Failed to create test combat encounter:', error); + throw error; + } +} + +/** + * Wait for a condition to be met + * @param {Function} condition - Function that returns true when condition is met + * @param {number} timeout - Timeout in milliseconds + * @param {number} interval - Check interval in milliseconds + * @returns {Promise} + */ +async function waitForCondition(condition, timeout = 5000, interval = 100) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (await condition()) { + return; + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error(`Condition not met within ${timeout}ms`); +} + +/** + * Clean up all test data + */ +async function cleanupTestData() { + try { + // Delete in order to respect foreign key constraints + await db('combat_logs').del(); + await db('combat_encounters').del(); + await db('combat_queue').del(); + await db('battles').del(); + await db('ship_combat_experience').del(); + await db('fleet_ships').del(); + await db('fleet_positions').del(); + await db('fleets').del(); + await db('colony_resource_production').del(); + await db('colony_buildings').del(); + await db('colonies').del(); + await db('player_resources').del(); + await db('combat_statistics').del(); + await db('players').where('email', 'like', '%@test.com').del(); + await db('combat_configurations').where('config_name', 'like', 'test_%').del(); + await db('ship_designs').where('name', 'like', 'Test %').del(); + await db('galaxy_sectors').where('name', 'like', 'Test Sector%').del(); + } catch (error) { + console.error('Failed to cleanup test data:', error); + } +} + +/** + * Reset combat-related data between tests + */ +async function resetCombatData() { + try { + await db('combat_logs').del(); + await db('combat_encounters').del(); + await db('combat_queue').del(); + await db('battles').del(); + + // Reset fleet statuses + await db('fleets').update({ + fleet_status: 'idle', + last_combat: null + }); + + // Reset colony siege status + await db('colonies').update({ + under_siege: false, + last_attacked: null + }); + } catch (error) { + console.error('Failed to reset combat data:', error); + } +} + +module.exports = { + createTestUser, + createTestFleet, + createTestColony, + createTestCombatConfig, + createTestCombatEncounter, + waitForCondition, + cleanupTestData, + resetCombatData +}; \ No newline at end of file diff --git a/src/tests/integration/combat/combat.integration.test.js b/src/tests/integration/combat/combat.integration.test.js new file mode 100644 index 0000000..26cf8d6 --- /dev/null +++ b/src/tests/integration/combat/combat.integration.test.js @@ -0,0 +1,542 @@ +/** + * Combat System Integration Tests + * Tests complete combat flows from API to database + */ + +const request = require('supertest'); +const app = require('../../../app'); +const db = require('../../../database/connection'); +const { createTestUser, createTestFleet, createTestColony, cleanupTestData } = require('../../helpers/test-helpers'); + +describe('Combat System Integration', () => { + let authToken; + let testPlayer; + let attackerFleet; + let defenderFleet; + let defenderColony; + let defenderPlayer; + + beforeAll(async () => { + // Run migrations and seeds if needed + try { + await db.migrate.latest(); + } catch (error) { + // Migrations might already be up to date + } + + // Create test players + const playerResult = await createTestUser('attacker@test.com', 'AttackerPlayer'); + testPlayer = playerResult.user; + authToken = playerResult.token; + + const defenderResult = await createTestUser('defender@test.com', 'DefenderPlayer'); + defenderPlayer = defenderResult.user; + + // Create test fleets + attackerFleet = await createTestFleet(testPlayer.id, 'Attack Fleet', 'A3-91-X'); + defenderFleet = await createTestFleet(defenderPlayer.id, 'Defense Fleet', 'A3-91-X'); + + // Create test colony + defenderColony = await createTestColony(defenderPlayer.id, 'Defended Colony', 'A3-91-Y'); + + // Initialize combat configurations + await db('combat_configurations').insert({ + config_name: 'test_instant', + combat_type: 'instant', + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 1, // 1 second for testing + damage_variance: 0.1, + experience_gain: 1.0 + }), + is_active: true, + description: 'Test instant combat configuration' + }); + }); + + afterAll(async () => { + await cleanupTestData(); + await db.destroy(); + }); + + beforeEach(async () => { + // Reset fleet statuses + await db('fleets') + .whereIn('id', [attackerFleet.id, defenderFleet.id]) + .update({ + fleet_status: 'idle', + last_updated: new Date() + }); + + // Reset colony siege status + await db('colonies') + .where('id', defenderColony.id) + .update({ + under_siege: false, + last_updated: new Date() + }); + + // Clean up previous battles + await db('combat_queue').del(); + await db('combat_logs').del(); + await db('combat_encounters').del(); + await db('battles').del(); + }); + + describe('Fleet vs Fleet Combat', () => { + it('should successfully initiate and resolve fleet vs fleet combat', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X', + combat_type: 'instant' + }; + + // Initiate combat + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('battleId'); + expect(response.body.data.status).toBe('preparing'); + + const battleId = response.body.data.battleId; + + // Wait for auto-resolution + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check battle was resolved + const battle = await db('battles') + .where('id', battleId) + .first(); + + expect(battle.status).toBe('completed'); + expect(battle.result).toBeDefined(); + + const result = JSON.parse(battle.result); + expect(['attacker_victory', 'defender_victory', 'draw']).toContain(result.outcome); + + // Check combat encounter was created + const encounter = await db('combat_encounters') + .where('battle_id', battleId) + .first(); + + expect(encounter).toBeDefined(); + expect(encounter.attacker_fleet_id).toBe(attackerFleet.id); + expect(encounter.defender_fleet_id).toBe(defenderFleet.id); + expect(encounter.outcome).toBe(result.outcome); + + // Check fleets returned to idle status + const updatedFleets = await db('fleets') + .whereIn('id', [attackerFleet.id, defenderFleet.id]); + + updatedFleets.forEach(fleet => { + expect(['idle', 'destroyed']).toContain(fleet.fleet_status); + }); + }); + + it('should track combat statistics', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X', + combat_type: 'instant' + }; + + await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(201); + + // Wait for resolution + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check combat statistics were updated + const attackerStats = await db('combat_statistics') + .where('player_id', testPlayer.id) + .first(); + + expect(attackerStats).toBeDefined(); + expect(attackerStats.battles_initiated).toBe(1); + expect(attackerStats.battles_won + attackerStats.battles_lost).toBe(1); + + const defenderStats = await db('combat_statistics') + .where('player_id', defenderPlayer.id) + .first(); + + expect(defenderStats).toBeDefined(); + expect(defenderStats.battles_won + defenderStats.battles_lost).toBe(1); + }); + + it('should prevent combat with own fleet', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: attackerFleet.id, // Same fleet + location: 'A3-91-X', + combat_type: 'instant' + }; + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(400); + + expect(response.body.error).toContain('Cannot attack your own fleet'); + }); + + it('should prevent combat when fleet not at location', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'B2-50-Y', // Different location + combat_type: 'instant' + }; + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(400); + + expect(response.body.error).toContain('Fleet must be at the specified location'); + }); + }); + + describe('Fleet vs Colony Combat', () => { + it('should successfully initiate and resolve fleet vs colony combat', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_colony_id: defenderColony.id, + location: 'A3-91-Y', + combat_type: 'instant' + }; + + // Update attacker fleet location + await db('fleets') + .where('id', attackerFleet.id) + .update({ current_location: 'A3-91-Y' }); + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(201); + + expect(response.body.success).toBe(true); + const battleId = response.body.data.battleId; + + // Wait for auto-resolution + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check colony siege status was updated during combat + const encounter = await db('combat_encounters') + .where('battle_id', battleId) + .first(); + + expect(encounter).toBeDefined(); + expect(encounter.attacker_fleet_id).toBe(attackerFleet.id); + expect(encounter.defender_colony_id).toBe(defenderColony.id); + + // Check colony is no longer under siege after combat + const updatedColony = await db('colonies') + .where('id', defenderColony.id) + .first(); + + expect(updatedColony.under_siege).toBe(false); + }); + + it('should award bonus loot for successful colony raids', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_colony_id: defenderColony.id, + location: 'A3-91-Y', + combat_type: 'instant' + }; + + await db('fleets') + .where('id', attackerFleet.id) + .update({ current_location: 'A3-91-Y' }); + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(201); + + const battleId = response.body.data.battleId; + + // Wait for resolution + await new Promise(resolve => setTimeout(resolve, 2000)); + + const encounter = await db('combat_encounters') + .where('battle_id', battleId) + .first(); + + if (encounter.outcome === 'attacker_victory') { + const loot = JSON.parse(encounter.loot_awarded); + expect(loot).toHaveProperty('data_cores'); + expect(loot.data_cores).toBeGreaterThan(0); + } + }); + }); + + describe('Combat History and Statistics', () => { + beforeEach(async () => { + // Create some combat history + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X', + combat_type: 'instant' + }; + + await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData); + + // Wait for resolution + await new Promise(resolve => setTimeout(resolve, 2000)); + }); + + it('should retrieve combat history', async () => { + const response = await request(app) + .get('/api/combat/history') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('combats'); + expect(response.body.data).toHaveProperty('pagination'); + expect(response.body.data.combats.length).toBeGreaterThan(0); + }); + + it('should filter combat history by outcome', async () => { + const response = await request(app) + .get('/api/combat/history?outcome=attacker_victory') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + response.body.data.combats.forEach(combat => { + expect(combat.outcome).toBe('attacker_victory'); + }); + }); + + it('should retrieve combat statistics', async () => { + const response = await request(app) + .get('/api/combat/statistics') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('battles_initiated'); + expect(response.body.data).toHaveProperty('battles_won'); + expect(response.body.data).toHaveProperty('battles_lost'); + expect(response.body.data).toHaveProperty('derived_stats'); + expect(response.body.data.derived_stats).toHaveProperty('win_rate_percentage'); + }); + + it('should retrieve active combats', async () => { + // Initiate combat but don't wait for resolution + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X', + combat_type: 'instant' + }; + + await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData); + + const response = await request(app) + .get('/api/combat/active') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveProperty('combats'); + expect(response.body.data).toHaveProperty('count'); + }); + }); + + describe('Fleet Positioning', () => { + it('should update fleet position successfully', async () => { + const positionData = { + position_x: 100, + position_y: 50, + position_z: 0, + formation: 'aggressive', + tactical_settings: { + engagement_range: 'close', + target_priority: 'closest' + } + }; + + const response = await request(app) + .put(`/api/combat/position/${attackerFleet.id}`) + .set('Authorization', `Bearer ${authToken}`) + .send(positionData) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.formation).toBe('aggressive'); + + // Verify in database + const position = await db('fleet_positions') + .where('fleet_id', attackerFleet.id) + .first(); + + expect(position).toBeDefined(); + expect(position.formation).toBe('aggressive'); + expect(position.position_x).toBe(100); + }); + + it('should reject invalid formation types', async () => { + const positionData = { + formation: 'invalid_formation' + }; + + const response = await request(app) + .put(`/api/combat/position/${attackerFleet.id}`) + .set('Authorization', `Bearer ${authToken}`) + .send(positionData) + .expect(400); + + expect(response.body.error).toContain('Validation failed'); + }); + + it('should prevent updating position of fleet not owned by player', async () => { + const positionData = { + formation: 'defensive' + }; + + const response = await request(app) + .put(`/api/combat/position/${defenderFleet.id}`) + .set('Authorization', `Bearer ${authToken}`) + .send(positionData) + .expect(404); + + expect(response.body.error).toContain('Fleet not found or access denied'); + }); + }); + + describe('Combat Types and Configurations', () => { + it('should retrieve available combat types', async () => { + const response = await request(app) + .get('/api/combat/types') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + }); + + it('should use different combat types', async () => { + // Test turn-based combat + await db('combat_configurations').insert({ + config_name: 'test_turn_based', + combat_type: 'turn_based', + config_data: JSON.stringify({ + auto_resolve: true, + preparation_time: 1, + max_rounds: 3 + }), + is_active: true + }); + + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X', + combat_type: 'turn_based' + }; + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + .expect(201); + + expect(response.body.success).toBe(true); + + // Wait for resolution + await new Promise(resolve => setTimeout(resolve, 3000)); + + const battleId = response.body.data.battleId; + const encounter = await db('combat_encounters') + .where('battle_id', battleId) + .first(); + + // Turn-based combat should have more detailed logs + const combatLog = JSON.parse(encounter.combat_log); + expect(combatLog.length).toBeGreaterThan(2); + }); + }); + + describe('Error Handling and Validation', () => { + it('should handle missing required fields', async () => { + const incompleteData = { + attacker_fleet_id: attackerFleet.id, + // Missing defender and location + }; + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(incompleteData) + .expect(400); + + expect(response.body.error).toBeDefined(); + }); + + it('should handle invalid fleet IDs', async () => { + const invalidData = { + attacker_fleet_id: 99999, + defender_fleet_id: 99998, + location: 'A3-91-X' + }; + + const response = await request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(invalidData) + .expect(400); + + expect(response.body.error).toContain('Invalid attacker fleet'); + }); + + it('should enforce rate limiting', async () => { + const combatData = { + attacker_fleet_id: attackerFleet.id, + defender_fleet_id: defenderFleet.id, + location: 'A3-91-X' + }; + + // Make multiple rapid requests + const promises = Array(15).fill().map(() => + request(app) + .post('/api/combat/initiate') + .set('Authorization', `Bearer ${authToken}`) + .send(combatData) + ); + + const results = await Promise.allSettled(promises); + + // Some requests should be rate limited + const rateLimitedResponses = results.filter(result => + result.status === 'fulfilled' && result.value.status === 429 + ); + + expect(rateLimitedResponses.length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/integration/game-tick.integration.test.js b/src/tests/integration/game-tick.integration.test.js new file mode 100644 index 0000000..b66206c --- /dev/null +++ b/src/tests/integration/game-tick.integration.test.js @@ -0,0 +1,388 @@ +/** + * Game Tick Integration Tests + * End-to-end tests for the complete game tick system with real database interactions + */ + +const request = require('supertest'); +const app = require('../../app'); +const db = require('../../database/connection'); +const { gameTickService, initializeGameTick } = require('../../services/game-tick.service'); +const redisClient = require('../../utils/redis'); + +describe('Game Tick Integration Tests', () => { + let testPlayer; + let testColony; + let testBuilding; + let authToken; + + beforeAll(async () => { + // Set up test database + await db.migrate.latest(); + await db.seed.run(); + + // Create test player + const [player] = await db('players').insert({ + username: 'ticktest', + email: 'ticktest@example.com', + password_hash: '$2b$10$test.hash', + user_group: 0, + is_active: true, + email_verified: true + }).returning('*'); + + testPlayer = player; + + // Create test colony + const [colony] = await db('colonies').insert({ + player_id: testPlayer.id, + name: 'Test Colony', + coordinates: 'TEST-01-A', + planet_type_id: 1, // Terran + population: 1000 + }).returning('*'); + + testColony = colony; + + // Create test building under construction + const [building] = await db('colony_buildings').insert({ + colony_id: testColony.id, + building_type_id: 1, // Command Center + level: 1, + is_under_construction: true, + construction_started: new Date(), + construction_completes: new Date(Date.now() + 1000) // Complete in 1 second + }).returning('*'); + + testBuilding = building; + + // Initialize player resources + const resourceTypes = await db('resource_types').select('*'); + for (const resourceType of resourceTypes) { + await db('player_resources').insert({ + player_id: testPlayer.id, + resource_type_id: resourceType.id, + amount: 1000 + }); + } + + // Initialize game tick service + await initializeGameTick(); + }); + + afterAll(async () => { + await gameTickService.stop(); + await db.destroy(); + await redisClient.quit(); + }); + + beforeEach(async () => { + // Clean up any test data modifications + jest.clearAllMocks(); + }); + + describe('Complete Tick Processing Flow', () => { + it('should process a full game tick for a player', async () => { + const initialTick = gameTickService.currentTick; + const correlationId = 'integration-test-tick'; + + // Trigger a manual tick + await gameTickService.processPlayerTick( + initialTick + 1, + testPlayer.id, + correlationId + ); + + // Verify player was updated + const updatedPlayer = await db('players') + .where('id', testPlayer.id) + .first(); + + expect(updatedPlayer.last_tick_processed).toBe(initialTick + 1); + expect(updatedPlayer.last_tick_processed_at).toBeTruthy(); + }); + + it('should complete building construction during tick', async (done) => { + // Wait for construction to complete (1 second) + setTimeout(async () => { + try { + const correlationId = 'building-completion-test'; + + await gameTickService.processBuildingConstruction( + testPlayer.id, + gameTickService.currentTick + 1, + correlationId, + db + ); + + // Check that building is no longer under construction + const completedBuilding = await db('colony_buildings') + .where('id', testBuilding.id) + .first(); + + expect(completedBuilding.is_under_construction).toBe(false); + expect(completedBuilding.construction_completes).toBe(null); + + done(); + } catch (error) { + done(error); + } + }, 1500); + }); + + it('should process resource production correctly', async () => { + // Create production buildings + await db('colony_buildings').insert({ + colony_id: testColony.id, + building_type_id: 2, // Salvage Yard + level: 2, + is_under_construction: false + }); + + await db('colony_buildings').insert({ + colony_id: testColony.id, + building_type_id: 3, // Power Plant + level: 1, + is_under_construction: false + }); + + const correlationId = 'resource-production-test'; + + // Get initial resources + const initialResources = await db('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', testPlayer.id) + .select('resource_types.name', 'player_resources.amount'); + + const initialScrap = initialResources.find(r => r.name === 'scrap')?.amount || 0; + const initialEnergy = initialResources.find(r => r.name === 'energy')?.amount || 0; + + // Process resource production + const result = await gameTickService.processResourceProduction( + testPlayer.id, + gameTickService.currentTick + 1, + correlationId, + db + ); + + expect(result.status).toBe('success'); + expect(result.coloniesProcessed).toBe(1); + expect(result.totalResourcesProduced).toBeDefined(); + + // Verify resources were added to player + const finalResources = await db('player_resources') + .join('resource_types', 'player_resources.resource_type_id', 'resource_types.id') + .where('player_resources.player_id', testPlayer.id) + .select('resource_types.name', 'player_resources.amount'); + + const finalScrap = finalResources.find(r => r.name === 'scrap')?.amount || 0; + const finalEnergy = finalResources.find(r => r.name === 'energy')?.amount || 0; + + // Resources should have increased (if production is positive) + if (result.totalResourcesProduced.scrap > 0) { + expect(finalScrap).toBeGreaterThan(initialScrap); + } + if (result.totalResourcesProduced.energy > 0) { + expect(finalEnergy).toBeGreaterThan(initialEnergy); + } + }); + }); + + describe('Database Performance and Concurrency', () => { + it('should handle concurrent player processing', async () => { + // Create additional test players + const players = []; + for (let i = 0; i < 5; i++) { + const [player] = await db('players').insert({ + username: `concurrent-test-${i}`, + email: `concurrent-test-${i}@example.com`, + password_hash: '$2b$10$test.hash', + user_group: 0, + is_active: true, + email_verified: true + }).returning('*'); + players.push(player); + } + + const tickNumber = gameTickService.currentTick + 1; + const promises = players.map(player => + gameTickService.processPlayerTick( + tickNumber, + player.id, + `concurrent-test-${player.id}` + ) + ); + + // All players should process successfully + const results = await Promise.allSettled(promises); + const successfulResults = results.filter(r => r.status === 'fulfilled'); + + expect(successfulResults.length).toBe(players.length); + + // Verify all players were updated + const updatedPlayers = await db('players') + .whereIn('id', players.map(p => p.id)) + .where('last_tick_processed', tickNumber); + + expect(updatedPlayers.length).toBe(players.length); + + // Clean up + await db('players').whereIn('id', players.map(p => p.id)).del(); + }); + + it('should handle Redis lock contention', async () => { + const tickNumber = gameTickService.currentTick + 1; + const correlationId1 = 'lock-test-1'; + const correlationId2 = 'lock-test-2'; + + // Start two concurrent processing attempts for the same player + const promise1 = gameTickService.processPlayerTick( + tickNumber, + testPlayer.id, + correlationId1 + ); + + const promise2 = gameTickService.processPlayerTick( + tickNumber, + testPlayer.id, + correlationId2 + ); + + const [result1, result2] = await Promise.allSettled([promise1, promise2]); + + // One should succeed, one should skip due to lock + const successes = [result1, result2].filter(r => r.status === 'fulfilled'); + expect(successes.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Error Recovery and Resilience', () => { + it('should handle individual player failures gracefully', async () => { + // Create a player with invalid data that will cause processing to fail + const [problematicPlayer] = await db('players').insert({ + username: 'problematic-player', + email: 'problematic@example.com', + password_hash: '$2b$10$test.hash', + user_group: 0, + is_active: true, + email_verified: true + }).returning('*'); + + // Create colony with invalid planet_type_id + await db('colonies').insert({ + player_id: problematicPlayer.id, + name: 'Problematic Colony', + coordinates: 'PROB-01-A', + planet_type_id: 9999, // Invalid planet type + population: 1000 + }); + + const tickNumber = gameTickService.currentTick + 1; + + // Processing should handle the error gracefully + await expect( + gameTickService.processPlayerTick( + tickNumber, + problematicPlayer.id, + 'error-recovery-test' + ) + ).rejects.toThrow(); // Should throw error but not crash the service + + // Service should still be operational for other players + await expect( + gameTickService.processPlayerTick( + tickNumber, + testPlayer.id, + 'normal-processing-test' + ) + ).resolves.not.toThrow(); + + // Clean up + await db('colonies').where('player_id', problematicPlayer.id).del(); + await db('players').where('id', problematicPlayer.id).del(); + }); + }); + + describe('Performance Metrics and Monitoring', () => { + it('should track performance metrics during processing', async () => { + const initialMetrics = gameTickService.getStatus().metrics; + + await gameTickService.processPlayerTick( + gameTickService.currentTick + 1, + testPlayer.id, + 'performance-test' + ); + + const finalMetrics = gameTickService.getStatus().metrics; + + // Metrics should be updated + expect(finalMetrics.totalPlayersProcessed).toBeGreaterThanOrEqual( + initialMetrics.totalPlayersProcessed + ); + }); + + it('should log tick processing activities', async () => { + const tickNumber = gameTickService.currentTick + 1; + + await gameTickService.processPlayerTick( + tickNumber, + testPlayer.id, + 'logging-test' + ); + + // Check that appropriate log entries were created + // This would require examining the logger mock calls in a real test + expect(true).toBe(true); // Placeholder for actual logging verification + }); + }); + + describe('Real-time WebSocket Integration', () => { + it('should emit appropriate WebSocket events during processing', async () => { + // This test would require a mock WebSocket service + // For now, we verify the service can process without WebSocket events + + const serviceWithoutWebSocket = require('../../services/game-tick.service').gameTickService; + serviceWithoutWebSocket.gameEventService = null; + + await expect( + serviceWithoutWebSocket.processPlayerTick( + gameTickService.currentTick + 1, + testPlayer.id, + 'websocket-test' + ) + ).resolves.not.toThrow(); + }); + }); + + describe('Configuration Management', () => { + it('should reload configuration when updated', async () => { + const initialConfig = gameTickService.config; + + // Update configuration in database + await db('game_tick_config') + .where('is_active', true) + .update({ + tick_interval_ms: 90000, + user_groups_count: 15 + }); + + // Reload configuration + await gameTickService.loadConfig(); + + const newConfig = gameTickService.config; + + expect(newConfig.tick_interval_ms).toBe(90000); + expect(newConfig.user_groups_count).toBe(15); + expect(newConfig.tick_interval_ms).not.toBe(initialConfig.tick_interval_ms); + + // Restore original configuration + await db('game_tick_config') + .where('is_active', true) + .update({ + tick_interval_ms: initialConfig.tick_interval_ms, + user_groups_count: initialConfig.user_groups_count + }); + + await gameTickService.loadConfig(); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/performance/game-tick.performance.test.js b/src/tests/performance/game-tick.performance.test.js new file mode 100644 index 0000000..3810b32 --- /dev/null +++ b/src/tests/performance/game-tick.performance.test.js @@ -0,0 +1,417 @@ +/** + * Game Tick Performance Benchmark Tests + * Tests the performance characteristics of the game tick system under various loads + */ + +const { performance } = require('perf_hooks'); +const db = require('../../database/connection'); +const { gameTickService, initializeGameTick } = require('../../services/game-tick.service'); + +describe('Game Tick Performance Benchmarks', () => { + const PERFORMANCE_THRESHOLDS = { + SINGLE_PLAYER_PROCESSING_MS: 100, // Single player should process in under 100ms + BATCH_PROCESSING_MS_PER_PLAYER: 50, // Batch processing should be under 50ms per player + USER_GROUP_PROCESSING_MS: 5000, // User group should process in under 5 seconds + MEMORY_LEAK_THRESHOLD_MB: 50 // Memory should not grow by more than 50MB + }; + + let testPlayers = []; + let testColonies = []; + let initialMemoryUsage; + + beforeAll(async () => { + // Set up test database + await db.migrate.latest(); + await db.seed.run(); + + // Initialize game tick service + await initializeGameTick(); + + // Record initial memory usage + initialMemoryUsage = process.memoryUsage(); + + console.log('Creating test data for performance benchmarks...'); + + // Create test players with varying complexity + for (let i = 0; i < 100; i++) { + const [player] = await db('players').insert({ + username: `perf-test-${i}`, + email: `perf-test-${i}@example.com`, + password_hash: '$2b$10$test.hash', + user_group: i % 10, // Distribute across 10 user groups + is_active: true, + email_verified: true + }).returning('*'); + + testPlayers.push(player); + + // Create 1-5 colonies per player + const colonyCount = Math.floor(Math.random() * 5) + 1; + for (let j = 0; j < colonyCount; j++) { + const [colony] = await db('colonies').insert({ + player_id: player.id, + name: `Colony ${j + 1}`, + coordinates: `PERF-${i.toString().padStart(2, '0')}-${String.fromCharCode(65 + j)}`, + planet_type_id: (j % 6) + 1, // Cycle through planet types + population: Math.floor(Math.random() * 5000) + 1000 + }).returning('*'); + + testColonies.push(colony); + + // Add buildings to colonies + const buildingCount = Math.floor(Math.random() * 8) + 2; + for (let k = 0; k < buildingCount; k++) { + await db('colony_buildings').insert({ + colony_id: colony.id, + building_type_id: (k % 8) + 1, + level: Math.floor(Math.random() * 5) + 1, + is_under_construction: false + }); + } + + // Initialize colony resource production + const resourceTypes = await db('resource_types').select('*'); + for (const resourceType of resourceTypes) { + await db('colony_resource_production').insert({ + colony_id: colony.id, + resource_type_id: resourceType.id, + production_rate: Math.floor(Math.random() * 50) + 10, + consumption_rate: Math.floor(Math.random() * 20), + current_stored: Math.floor(Math.random() * 1000) + 100, + storage_capacity: 5000 + }); + } + } + + // Initialize player resources + const resourceTypes = await db('resource_types').select('*'); + for (const resourceType of resourceTypes) { + await db('player_resources').insert({ + player_id: player.id, + resource_type_id: resourceType.id, + amount: Math.floor(Math.random() * 10000) + 1000 + }); + } + } + + console.log(`Created ${testPlayers.length} players with ${testColonies.length} colonies`); + }); + + afterAll(async () => { + // Clean up test data + await db('colony_resource_production').whereIn('colony_id', testColonies.map(c => c.id)).del(); + await db('colony_buildings').whereIn('colony_id', testColonies.map(c => c.id)).del(); + await db('colonies').whereIn('id', testColonies.map(c => c.id)).del(); + await db('player_resources').whereIn('player_id', testPlayers.map(p => p.id)).del(); + await db('players').whereIn('id', testPlayers.map(p => p.id)).del(); + + await gameTickService.stop(); + await db.destroy(); + }); + + describe('Single Player Processing Performance', () => { + it('should process a single player within performance threshold', async () => { + const player = testPlayers[0]; + const tickNumber = 1; + const correlationId = 'single-player-perf-test'; + + const startTime = performance.now(); + + await gameTickService.processPlayerTick(tickNumber, player.id, correlationId); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`Single player processing took ${duration.toFixed(2)}ms`); + + expect(duration).toBeLessThan(PERFORMANCE_THRESHOLDS.SINGLE_PLAYER_PROCESSING_MS); + }); + + it('should process complex player with many colonies efficiently', async () => { + // Find player with most colonies + const playerColonyCounts = await db('colonies') + .select('player_id') + .count('* as colony_count') + .whereIn('player_id', testPlayers.map(p => p.id)) + .groupBy('player_id') + .orderBy('colony_count', 'desc') + .first(); + + const complexPlayer = testPlayers.find(p => p.id === playerColonyCounts.player_id); + const tickNumber = 2; + const correlationId = 'complex-player-perf-test'; + + const startTime = performance.now(); + + await gameTickService.processPlayerTick(tickNumber, complexPlayer.id, correlationId); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`Complex player (${playerColonyCounts.colony_count} colonies) processing took ${duration.toFixed(2)}ms`); + + // More lenient threshold for complex players + expect(duration).toBeLessThan(PERFORMANCE_THRESHOLDS.SINGLE_PLAYER_PROCESSING_MS * 2); + }); + }); + + describe('Batch Processing Performance', () => { + it('should process multiple players efficiently in parallel', async () => { + const batchSize = 10; + const playerBatch = testPlayers.slice(0, batchSize); + const tickNumber = 3; + + const startTime = performance.now(); + + const promises = playerBatch.map(player => + gameTickService.processPlayerTick(tickNumber, player.id, `batch-perf-test-${player.id}`) + ); + + await Promise.allSettled(promises); + + const endTime = performance.now(); + const duration = endTime - startTime; + const durationPerPlayer = duration / batchSize; + + console.log(`Batch processing ${batchSize} players took ${duration.toFixed(2)}ms (${durationPerPlayer.toFixed(2)}ms per player)`); + + expect(durationPerPlayer).toBeLessThan(PERFORMANCE_THRESHOLDS.BATCH_PROCESSING_MS_PER_PLAYER); + }); + + it('should maintain performance with larger batches', async () => { + const batchSize = 25; + const playerBatch = testPlayers.slice(10, 10 + batchSize); + const tickNumber = 4; + + const startTime = performance.now(); + + const promises = playerBatch.map(player => + gameTickService.processPlayerTick(tickNumber, player.id, `large-batch-perf-test-${player.id}`) + ); + + await Promise.allSettled(promises); + + const endTime = performance.now(); + const duration = endTime - startTime; + const durationPerPlayer = duration / batchSize; + + console.log(`Large batch processing ${batchSize} players took ${duration.toFixed(2)}ms (${durationPerPlayer.toFixed(2)}ms per player)`); + + // Slightly more lenient for larger batches due to concurrency overhead + expect(durationPerPlayer).toBeLessThan(PERFORMANCE_THRESHOLDS.BATCH_PROCESSING_MS_PER_PLAYER * 1.5); + }); + }); + + describe('User Group Processing Performance', () => { + it('should process an entire user group within threshold', async () => { + const userGroup = 0; + const tickNumber = 5; + + // Get players in this user group + const userGroupPlayers = testPlayers.filter(p => p.user_group === userGroup); + + console.log(`Processing user group ${userGroup} with ${userGroupPlayers.length} players`); + + const startTime = performance.now(); + + await gameTickService.processUserGroupTick(tickNumber, userGroup); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`User group ${userGroup} processing took ${duration.toFixed(2)}ms`); + + expect(duration).toBeLessThan(PERFORMANCE_THRESHOLDS.USER_GROUP_PROCESSING_MS); + + // Verify all players in the group were processed + const processedPlayers = await db('players') + .where('user_group', userGroup) + .where('last_tick_processed', tickNumber); + + expect(processedPlayers.length).toBe(userGroupPlayers.length); + }); + }); + + describe('Resource Production Performance', () => { + it('should calculate resource production efficiently', async () => { + const player = testPlayers[0]; + const tickNumber = 6; + const correlationId = 'resource-production-perf-test'; + + const startTime = performance.now(); + + const result = await gameTickService.processResourceProduction( + player.id, tickNumber, correlationId, db + ); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`Resource production processing took ${duration.toFixed(2)}ms`); + + expect(result.status).toBe('success'); + expect(duration).toBeLessThan(50); // Should be very fast + }); + + it('should handle resource production for player with many colonies', async () => { + // Find player with most colonies + const playerColonyCounts = await db('colonies') + .select('player_id') + .count('* as colony_count') + .whereIn('player_id', testPlayers.map(p => p.id)) + .groupBy('player_id') + .orderBy('colony_count', 'desc') + .first(); + + const complexPlayer = testPlayers.find(p => p.id === playerColonyCounts.player_id); + const tickNumber = 7; + const correlationId = 'complex-resource-production-perf-test'; + + const startTime = performance.now(); + + const result = await gameTickService.processResourceProduction( + complexPlayer.id, tickNumber, correlationId, db + ); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`Complex resource production (${playerColonyCounts.colony_count} colonies) took ${duration.toFixed(2)}ms`); + + expect(result.status).toBe('success'); + expect(duration).toBeLessThan(100); // Still should be fast + }); + }); + + describe('Database Performance', () => { + it('should handle concurrent database operations efficiently', async () => { + const batchSize = 20; + const playerBatch = testPlayers.slice(20, 20 + batchSize); + const tickNumber = 8; + + // Monitor database connection pool + const initialConnections = db.client.pool.numUsed(); + + const startTime = performance.now(); + + const promises = playerBatch.map(player => + gameTickService.processPlayerTick(tickNumber, player.id, `db-perf-test-${player.id}`) + ); + + await Promise.allSettled(promises); + + const endTime = performance.now(); + const duration = endTime - startTime; + + const finalConnections = db.client.pool.numUsed(); + + console.log(`Database concurrent operations took ${duration.toFixed(2)}ms`); + console.log(`DB connections: ${initialConnections} -> ${finalConnections}`); + + // Should not exhaust connection pool + expect(finalConnections).toBeLessThanOrEqual(db.client.pool.max || 10); + }); + }); + + describe('Memory Usage', () => { + it('should not have significant memory leaks during processing', async () => { + const batchSize = 30; + const playerBatch = testPlayers.slice(30, 30 + batchSize); + const tickNumber = 9; + + // Force garbage collection if available + if (global.gc) { + global.gc(); + } + + const startMemory = process.memoryUsage(); + + // Process multiple batches to stress test memory usage + for (let batch = 0; batch < 3; batch++) { + const promises = playerBatch.map(player => + gameTickService.processPlayerTick(tickNumber + batch, player.id, `memory-test-${batch}-${player.id}`) + ); + + await Promise.allSettled(promises); + } + + // Force garbage collection again + if (global.gc) { + global.gc(); + } + + const endMemory = process.memoryUsage(); + const memoryGrowthMB = (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024; + + console.log(`Memory growth after processing: ${memoryGrowthMB.toFixed(2)}MB`); + console.log(`Heap used: ${(endMemory.heapUsed / 1024 / 1024).toFixed(2)}MB`); + + expect(memoryGrowthMB).toBeLessThan(PERFORMANCE_THRESHOLDS.MEMORY_LEAK_THRESHOLD_MB); + }); + }); + + describe('Service Metrics and Monitoring', () => { + it('should maintain accurate performance metrics', async () => { + const initialMetrics = gameTickService.getStatus().metrics; + const batchSize = 15; + const playerBatch = testPlayers.slice(60, 60 + batchSize); + const tickNumber = 10; + + const promises = playerBatch.map(player => + gameTickService.processPlayerTick(tickNumber, player.id, `metrics-test-${player.id}`) + ); + + await Promise.allSettled(promises); + + const finalMetrics = gameTickService.getStatus().metrics; + + expect(finalMetrics.totalPlayersProcessed).toBeGreaterThan(initialMetrics.totalPlayersProcessed); + expect(finalMetrics.averageTickDuration).toBeGreaterThan(0); + }); + + it('should provide comprehensive status information quickly', async () => { + const startTime = performance.now(); + + const status = gameTickService.getStatus(); + + const endTime = performance.now(); + const duration = endTime - startTime; + + console.log(`Status retrieval took ${duration.toFixed(2)}ms`); + + expect(duration).toBeLessThan(10); // Status should be instant + expect(status).toHaveProperty('initialized'); + expect(status).toHaveProperty('metrics'); + expect(status).toHaveProperty('config'); + }); + }); + + describe('Scalability Tests', () => { + it('should handle processing all test players in reasonable time', async () => { + const tickNumber = 11; + + console.log(`Processing all ${testPlayers.length} players...`); + + const startTime = performance.now(); + + // Process in user groups as the system would do + const userGroupPromises = []; + for (let userGroup = 0; userGroup < 10; userGroup++) { + userGroupPromises.push( + gameTickService.processUserGroupTick(tickNumber, userGroup) + ); + } + + await Promise.allSettled(userGroupPromises); + + const endTime = performance.now(); + const duration = endTime - startTime; + const durationPerPlayer = duration / testPlayers.length; + + console.log(`All ${testPlayers.length} players processed in ${duration.toFixed(2)}ms (${durationPerPlayer.toFixed(2)}ms per player)`); + + // Should scale reasonably + expect(durationPerPlayer).toBeLessThan(PERFORMANCE_THRESHOLDS.BATCH_PROCESSING_MS_PER_PLAYER * 2); + }, 30000); // 30 second timeout for this test + }); +}); \ No newline at end of file diff --git a/src/tests/unit/services/combat/CombatPluginManager.test.js b/src/tests/unit/services/combat/CombatPluginManager.test.js new file mode 100644 index 0000000..d5c3f1d --- /dev/null +++ b/src/tests/unit/services/combat/CombatPluginManager.test.js @@ -0,0 +1,530 @@ +/** + * Combat Plugin Manager Unit Tests + * Tests for combat plugin system and resolution strategies + */ + +const { + CombatPluginManager, + InstantCombatPlugin, + TurnBasedCombatPlugin, + TacticalCombatPlugin +} = require('../../../../services/combat/CombatPluginManager'); +const db = require('../../../../database/connection'); +const logger = require('../../../../utils/logger'); + +// Mock dependencies +jest.mock('../../../../database/connection'); +jest.mock('../../../../utils/logger'); + +describe('CombatPluginManager', () => { + let pluginManager; + + beforeEach(() => { + jest.clearAllMocks(); + pluginManager = new CombatPluginManager(); + + // Mock logger + logger.info = jest.fn(); + logger.error = jest.fn(); + logger.warn = jest.fn(); + logger.debug = jest.fn(); + }); + + describe('initialize', () => { + it('should initialize and load active plugins', async () => { + const mockPlugins = [ + { + id: 1, + name: 'instant_combat', + version: '1.0.0', + plugin_type: 'combat', + is_active: true, + config: { damage_variance: 0.1 }, + hooks: ['pre_combat', 'post_combat'] + }, + { + id: 2, + name: 'turn_based_combat', + version: '1.0.0', + plugin_type: 'combat', + is_active: true, + config: { max_rounds: 10 }, + hooks: ['pre_combat', 'post_combat'] + } + ]; + + const mockQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis() + }; + db.mockReturnValue(mockQuery); + mockQuery.mockResolvedValue(mockPlugins); + + await pluginManager.initialize('test-correlation'); + + expect(pluginManager.initialized).toBe(true); + expect(pluginManager.plugins.size).toBe(2); + expect(pluginManager.plugins.has('instant_combat')).toBe(true); + expect(pluginManager.plugins.has('turn_based_combat')).toBe(true); + }); + + it('should handle initialization errors gracefully', async () => { + const error = new Error('Database connection failed'); + db.mockImplementation(() => { + throw error; + }); + + await expect(pluginManager.initialize('test-correlation')) + .rejects + .toThrow('Failed to initialize combat plugin system'); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to initialize Combat Plugin Manager', + expect.objectContaining({ + error: error.message + }) + ); + }); + }); + + describe('resolveCombat', () => { + const mockBattle = { + id: 100, + battle_type: 'fleet_vs_fleet', + location: 'A3-91-X' + }; + + const mockForces = { + attacker: { + fleet: { + id: 1, + total_combat_rating: 150, + ships: [{ design_name: 'Fighter', quantity: 10 }] + } + }, + defender: { + fleet: { + id: 2, + total_combat_rating: 120, + ships: [{ design_name: 'Cruiser', quantity: 5 }] + } + } + }; + + const mockConfig = { + id: 1, + combat_type: 'instant', + config_data: { auto_resolve: true } + }; + + beforeEach(() => { + pluginManager.initialized = true; + }); + + it('should resolve combat using appropriate plugin', async () => { + const mockPlugin = { + resolveCombat: jest.fn().mockResolvedValue({ + outcome: 'attacker_victory', + casualties: { attacker: { ships: {}, total_ships: 2 }, defender: { ships: {}, total_ships: 8 } }, + experience_gained: 100, + combat_log: [], + duration: 60 + }) + }; + + pluginManager.plugins.set('instant_combat', mockPlugin); + pluginManager.executeHooks = jest.fn(); + + const result = await pluginManager.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('attacker_victory'); + expect(mockPlugin.resolveCombat).toHaveBeenCalledWith(mockBattle, mockForces, mockConfig, 'test-correlation'); + expect(pluginManager.executeHooks).toHaveBeenCalledWith('pre_combat', expect.any(Object), 'test-correlation'); + expect(pluginManager.executeHooks).toHaveBeenCalledWith('post_combat', expect.any(Object), 'test-correlation'); + }); + + it('should use fallback resolver when plugin not found', async () => { + pluginManager.fallbackCombatResolver = jest.fn().mockResolvedValue({ + outcome: 'attacker_victory', + casualties: {}, + experience_gained: 50, + duration: 30 + }); + + const result = await pluginManager.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('attacker_victory'); + expect(pluginManager.fallbackCombatResolver).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'No plugin found for combat type, using fallback', + expect.objectContaining({ + combatType: 'instant' + }) + ); + }); + + it('should initialize if not already initialized', async () => { + pluginManager.initialized = false; + pluginManager.initialize = jest.fn(); + pluginManager.fallbackCombatResolver = jest.fn().mockResolvedValue({ + outcome: 'draw', + casualties: {}, + experience_gained: 0, + duration: 15 + }); + + await pluginManager.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(pluginManager.initialize).toHaveBeenCalledWith('test-correlation'); + }); + }); + + describe('executeHooks', () => { + it('should execute all registered hooks for an event', async () => { + const mockHandler1 = jest.fn(); + const mockHandler2 = jest.fn(); + + pluginManager.hooks.set('pre_combat', [ + { plugin: 'plugin1', handler: mockHandler1 }, + { plugin: 'plugin2', handler: mockHandler2 } + ]); + + const hookData = { battle: {}, forces: {} }; + await pluginManager.executeHooks('pre_combat', hookData, 'test-correlation'); + + expect(mockHandler1).toHaveBeenCalledWith(hookData, 'test-correlation'); + expect(mockHandler2).toHaveBeenCalledWith(hookData, 'test-correlation'); + }); + + it('should handle hook execution errors gracefully', async () => { + const errorHandler = jest.fn().mockRejectedValue(new Error('Hook failed')); + const successHandler = jest.fn(); + + pluginManager.hooks.set('post_combat', [ + { plugin: 'failing_plugin', handler: errorHandler }, + { plugin: 'working_plugin', handler: successHandler } + ]); + + const hookData = { result: {} }; + await pluginManager.executeHooks('post_combat', hookData, 'test-correlation'); + + expect(errorHandler).toHaveBeenCalled(); + expect(successHandler).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Hook execution failed', + expect.objectContaining({ + hookName: 'post_combat', + plugin: 'failing_plugin' + }) + ); + }); + }); + + describe('registerPlugin', () => { + it('should register plugin dynamically', () => { + const mockPlugin = { + resolveCombat: jest.fn() + }; + + pluginManager.registerPlugin('test_plugin', mockPlugin, ['pre_combat']); + + expect(pluginManager.plugins.has('test_plugin')).toBe(true); + expect(pluginManager.hooks.has('pre_combat')).toBe(true); + expect(pluginManager.hooks.get('pre_combat')).toHaveLength(1); + }); + + it('should validate plugin interface before registration', () => { + const invalidPlugin = { + // Missing required resolveCombat method + someOtherMethod: jest.fn() + }; + + expect(() => { + pluginManager.registerPlugin('invalid_plugin', invalidPlugin); + }).toThrow('Plugin invalid_plugin missing required method: resolveCombat'); + }); + }); +}); + +describe('InstantCombatPlugin', () => { + let plugin; + + beforeEach(() => { + plugin = new InstantCombatPlugin({ damage_variance: 0.1, experience_gain: 1.0 }); + }); + + describe('resolveCombat', () => { + const mockBattle = { + id: 100, + battle_type: 'fleet_vs_fleet' + }; + + const mockForces = { + attacker: { + fleet: { + total_combat_rating: 200, + ships: [ + { design_name: 'Fighter', quantity: 20 }, + { design_name: 'Destroyer', quantity: 5 } + ] + } + }, + defender: { + fleet: { + total_combat_rating: 150, + ships: [ + { design_name: 'Cruiser', quantity: 8 } + ] + } + } + }; + + const mockConfig = {}; + + it('should resolve instant combat with attacker advantage', async () => { + // Mock Math.random to ensure consistent results + const originalRandom = Math.random; + Math.random = jest.fn().mockReturnValue(0.3); // Favors attacker + + const result = await plugin.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('attacker_victory'); + expect(result.casualties.attacker.total_ships).toBeLessThan(result.casualties.defender.total_ships); + expect(result.experience_gained).toBeGreaterThan(0); + expect(result.combat_log).toHaveLength(2); + expect(result.duration).toBeGreaterThan(0); + + Math.random = originalRandom; + }); + + it('should resolve instant combat with defender advantage', async () => { + const originalRandom = Math.random; + Math.random = jest.fn().mockReturnValue(0.8); // Favors defender + + const result = await plugin.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('defender_victory'); + expect(result.casualties.defender.total_ships).toBeLessThan(result.casualties.attacker.total_ships); + + Math.random = originalRandom; + }); + + it('should handle colony defense scenario', async () => { + const colonyForces = { + ...mockForces, + defender: { + colony: { + total_defense_rating: 180, + defense_buildings: [ + { building_name: 'Defense Grid', health_percentage: 100 } + ] + } + } + }; + + const originalRandom = Math.random; + Math.random = jest.fn().mockReturnValue(0.2); + + const result = await plugin.resolveCombat(mockBattle, colonyForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('attacker_victory'); + expect(result.casualties.defender.buildings).toBeDefined(); + expect(result.loot.data_cores).toBeGreaterThan(0); + + Math.random = originalRandom; + }); + }); + + describe('calculateInstantCasualties', () => { + it('should calculate casualties with winner advantage', () => { + const forces = { + attacker: { + fleet: { + ships: [ + { design_name: 'Fighter', quantity: 50 } + ] + } + }, + defender: { + fleet: { + ships: [ + { design_name: 'Cruiser', quantity: 20 } + ] + } + } + }; + + const casualties = plugin.calculateInstantCasualties(forces, true); + + expect(casualties.attacker.total_ships).toBeLessThan(casualties.defender.total_ships); + expect(casualties.attacker.ships['Fighter']).toBeLessThan(15); // Winner loses max 25% + expect(casualties.defender.ships['Cruiser']).toBeGreaterThan(5); // Loser loses min 30% + }); + }); +}); + +describe('TurnBasedCombatPlugin', () => { + let plugin; + + beforeEach(() => { + plugin = new TurnBasedCombatPlugin({ max_rounds: 5 }); + }); + + describe('resolveCombat', () => { + const mockBattle = { + id: 100, + battle_type: 'fleet_vs_fleet' + }; + + const mockForces = { + attacker: { + fleet: { + total_combat_rating: 300, + ships: [{ design_name: 'Battleship', quantity: 10 }] + } + }, + defender: { + fleet: { + total_combat_rating: 200, + ships: [{ design_name: 'Destroyer', quantity: 15 }] + } + } + }; + + const mockConfig = {}; + + it('should resolve turn-based combat over multiple rounds', async () => { + const result = await plugin.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + expect(result.combat_log.length).toBeGreaterThan(2); // Start + multiple rounds + end + expect(result.combat_log[0].event).toBe('combat_start'); + expect(result.combat_log[result.combat_log.length - 1].event).toBe('combat_end'); + expect(result.duration).toBeGreaterThan(30); // Multiple rounds take longer + }); + + it('should end combat when one side is eliminated', async () => { + // Mock very weak defender + const weakForces = { + ...mockForces, + defender: { + fleet: { + total_combat_rating: 10, + ships: [{ design_name: 'Fighter', quantity: 1 }] + } + } + }; + + const result = await plugin.resolveCombat(mockBattle, weakForces, mockConfig, 'test-correlation'); + + expect(result.outcome).toBe('attacker_victory'); + const endLog = result.combat_log.find(log => log.event === 'combat_end'); + expect(endLog.data.defender_survivors).toBe(0); + }); + }); + + describe('initializeCombatState', () => { + it('should properly initialize combat state from forces', () => { + const forces = { + attacker: { + fleet: { + ships: [ + { quantity: 10 }, + { quantity: 5 } + ], + total_combat_rating: 150 + } + }, + defender: { + colony: { + total_defense_rating: 100 + } + } + }; + + const state = plugin.initializeCombatState(forces); + + expect(state.attacker.totalShips).toBe(15); + expect(state.attacker.effectiveStrength).toBe(150); + expect(state.defender.totalShips).toBe(1); // Colony represented as single entity + expect(state.defender.effectiveStrength).toBe(100); + }); + }); + + describe('determineTurnBasedOutcome', () => { + it('should determine attacker victory', () => { + const state = { + attacker: { totalShips: 5 }, + defender: { totalShips: 0 } + }; + + const outcome = plugin.determineTurnBasedOutcome(state); + expect(outcome).toBe('attacker_victory'); + }); + + it('should determine defender victory', () => { + const state = { + attacker: { totalShips: 0 }, + defender: { totalShips: 3 } + }; + + const outcome = plugin.determineTurnBasedOutcome(state); + expect(outcome).toBe('defender_victory'); + }); + + it('should determine draw', () => { + const state = { + attacker: { totalShips: 0 }, + defender: { totalShips: 0 } + }; + + const outcome = plugin.determineTurnBasedOutcome(state); + expect(outcome).toBe('draw'); + }); + }); +}); + +describe('TacticalCombatPlugin', () => { + let plugin; + + beforeEach(() => { + plugin = new TacticalCombatPlugin({}); + }); + + describe('resolveCombat', () => { + it('should provide enhanced rewards compared to turn-based combat', async () => { + const mockBattle = { + id: 100, + battle_type: 'fleet_vs_fleet' + }; + + const mockForces = { + attacker: { + fleet: { + total_combat_rating: 200, + ships: [{ design_name: 'Cruiser', quantity: 10 }] + } + }, + defender: { + fleet: { + total_combat_rating: 150, + ships: [{ design_name: 'Destroyer', quantity: 12 }] + } + } + }; + + const mockConfig = {}; + + const result = await plugin.resolveCombat(mockBattle, mockForces, mockConfig, 'test-correlation'); + + // Tactical combat should give 1.5x experience + expect(result.experience_gained).toBeGreaterThan(0); + // Duration should be 1.2x longer + expect(result.duration).toBeGreaterThan(30); + + // Loot should be enhanced by 1.3x + if (result.loot && Object.keys(result.loot).length > 0) { + expect(result.loot.scrap).toBeGreaterThan(0); + } + }); + }); +}); \ No newline at end of file diff --git a/src/tests/unit/services/combat/CombatService.test.js b/src/tests/unit/services/combat/CombatService.test.js new file mode 100644 index 0000000..b72b376 --- /dev/null +++ b/src/tests/unit/services/combat/CombatService.test.js @@ -0,0 +1,603 @@ +/** + * Combat Service Unit Tests + * Tests for core combat service functionality + */ + +const CombatService = require('../../../../services/combat/CombatService'); +const { CombatPluginManager } = require('../../../../services/combat/CombatPluginManager'); +const db = require('../../../../database/connection'); +const logger = require('../../../../utils/logger'); + +// Mock dependencies +jest.mock('../../../../database/connection'); +jest.mock('../../../../utils/logger'); +jest.mock('../../../../services/combat/CombatPluginManager'); + +describe('CombatService', () => { + let combatService; + let mockGameEventService; + let mockCombatPluginManager; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Mock game event service + mockGameEventService = { + emitCombatInitiated: jest.fn(), + emitCombatCompleted: jest.fn(), + emitCombatStatusUpdate: jest.fn() + }; + + // Mock combat plugin manager + mockCombatPluginManager = { + resolveCombat: jest.fn(), + initialize: jest.fn() + }; + + // Create service instance + combatService = new CombatService(mockGameEventService, mockCombatPluginManager); + + // Mock logger + logger.info = jest.fn(); + logger.error = jest.fn(); + logger.warn = jest.fn(); + logger.debug = jest.fn(); + }); + + describe('initiateCombat', () => { + const mockCombatData = { + attacker_fleet_id: 1, + defender_fleet_id: 2, + location: 'A3-91-X', + combat_type: 'instant' + }; + + const mockAttackerPlayerId = 10; + const correlationId = 'test-correlation-id'; + + beforeEach(() => { + // Mock database operations + db.transaction = jest.fn(); + + // Mock validation methods + combatService.validateCombatInitiation = jest.fn(); + combatService.checkCombatConflicts = jest.fn().mockResolvedValue({ hasConflict: false }); + combatService.getCombatConfiguration = jest.fn().mockResolvedValue({ + id: 1, + combat_type: 'instant', + config_data: { auto_resolve: true, preparation_time: 30 } + }); + }); + + it('should successfully initiate combat between fleets', async () => { + const mockBattle = { + id: 100, + participants: JSON.stringify({ + attacker_fleet_id: 1, + defender_fleet_id: 2, + attacker_player_id: 10 + }), + started_at: new Date(), + estimated_duration: 60 + }; + + // Mock transaction success + db.transaction.mockImplementation(async (callback) => { + const mockTrx = { + 'battles': { + insert: jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue([mockBattle]) + }) + }, + 'fleets': { + whereIn: jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue() + }) + }, + 'combat_queue': { + insert: jest.fn().mockResolvedValue() + } + }; + return callback(mockTrx); + }); + + const result = await combatService.initiateCombat(mockCombatData, mockAttackerPlayerId, correlationId); + + expect(result).toHaveProperty('battleId', 100); + expect(result).toHaveProperty('status'); + expect(combatService.validateCombatInitiation).toHaveBeenCalledWith( + mockCombatData, + mockAttackerPlayerId, + correlationId + ); + expect(mockGameEventService.emitCombatInitiated).toHaveBeenCalledWith(mockBattle, correlationId); + }); + + it('should reject combat if participant already in combat', async () => { + combatService.checkCombatConflicts.mockResolvedValue({ + hasConflict: true, + reason: 'Fleet 1 is already in combat' + }); + + await expect(combatService.initiateCombat(mockCombatData, mockAttackerPlayerId, correlationId)) + .rejects + .toThrow('Combat participant already engaged: Fleet 1 is already in combat'); + }); + + it('should handle validation errors', async () => { + const validationError = new Error('Invalid combat data'); + combatService.validateCombatInitiation.mockRejectedValue(validationError); + + await expect(combatService.initiateCombat(mockCombatData, mockAttackerPlayerId, correlationId)) + .rejects + .toThrow(); + + expect(logger.error).toHaveBeenCalledWith( + 'Combat initiation failed', + expect.objectContaining({ + correlationId, + playerId: mockAttackerPlayerId, + error: validationError.message + }) + ); + }); + + it('should handle database transaction failures', async () => { + const dbError = new Error('Database connection failed'); + db.transaction.mockRejectedValue(dbError); + + await expect(combatService.initiateCombat(mockCombatData, mockAttackerPlayerId, correlationId)) + .rejects + .toThrow(); + + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('processCombat', () => { + const battleId = 100; + const correlationId = 'test-correlation-id'; + + beforeEach(() => { + combatService.getBattleById = jest.fn(); + combatService.getCombatForces = jest.fn(); + combatService.getCombatConfiguration = jest.fn(); + combatService.resolveCombat = jest.fn(); + combatService.applyCombatResults = jest.fn(); + combatService.updateCombatStatistics = jest.fn(); + + db.transaction = jest.fn(); + }); + + it('should successfully process combat', async () => { + const mockBattle = { + id: battleId, + status: 'preparing', + participants: JSON.stringify({ attacker_fleet_id: 1, defender_fleet_id: 2 }), + started_at: new Date() + }; + + const mockForces = { + attacker: { fleet: { id: 1, total_combat_rating: 100 } }, + defender: { fleet: { id: 2, total_combat_rating: 80 } }, + initial: {} + }; + + const mockCombatResult = { + outcome: 'attacker_victory', + casualties: { attacker: { ships: {}, total_ships: 5 }, defender: { ships: {}, total_ships: 12 } }, + experience_gained: 150, + combat_log: [], + duration: 90, + final_forces: {}, + loot: { scrap: 500, energy: 300 } + }; + + combatService.getBattleById.mockResolvedValue(mockBattle); + combatService.getCombatForces.mockResolvedValue(mockForces); + combatService.getCombatConfiguration.mockResolvedValue({ + id: 1, + combat_type: 'instant' + }); + combatService.resolveCombat.mockResolvedValue(mockCombatResult); + + // Mock transaction + db.transaction.mockImplementation(async (callback) => { + const mockTrx = { + 'battles': { + where: jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue() + }) + }, + 'combat_encounters': { + insert: jest.fn().mockReturnValue({ + returning: jest.fn().mockResolvedValue([{ + id: 200, + battle_id: battleId, + outcome: 'attacker_victory' + }]) + }) + }, + 'combat_queue': { + where: jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue() + }) + } + }; + return callback(mockTrx); + }); + + const result = await combatService.processCombat(battleId, correlationId); + + expect(result).toHaveProperty('battleId', battleId); + expect(result).toHaveProperty('outcome', 'attacker_victory'); + expect(combatService.applyCombatResults).toHaveBeenCalled(); + expect(combatService.updateCombatStatistics).toHaveBeenCalled(); + expect(mockGameEventService.emitCombatCompleted).toHaveBeenCalled(); + }); + + it('should reject processing non-existent battle', async () => { + combatService.getBattleById.mockResolvedValue(null); + + await expect(combatService.processCombat(battleId, correlationId)) + .rejects + .toThrow('Battle not found'); + }); + + it('should reject processing completed battle', async () => { + const completedBattle = { + id: battleId, + status: 'completed', + participants: JSON.stringify({ attacker_fleet_id: 1 }) + }; + + combatService.getBattleById.mockResolvedValue(completedBattle); + + await expect(combatService.processCombat(battleId, correlationId)) + .rejects + .toThrow('Battle is not in a processable state'); + }); + }); + + describe('getCombatHistory', () => { + const playerId = 10; + const correlationId = 'test-correlation-id'; + + beforeEach(() => { + // Mock database query builder + const mockQuery = { + select: jest.fn().mockReturnThis(), + join: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis() + }; + + db.mockReturnValue(mockQuery); + }); + + it('should return combat history with pagination', async () => { + const mockCombats = [ + { + id: 1, + outcome: 'attacker_victory', + completed_at: new Date(), + attacker_fleet_name: 'Test Fleet 1' + }, + { + id: 2, + outcome: 'defender_victory', + completed_at: new Date(), + defender_colony_name: 'Test Colony' + } + ]; + + const mockCountResult = [{ total: 25 }]; + + // Mock the main query + db().mockResolvedValueOnce(mockCombats); + // Mock the count query + db().mockResolvedValueOnce(mockCountResult); + + const options = { limit: 10, offset: 0 }; + const result = await combatService.getCombatHistory(playerId, options, correlationId); + + expect(result).toHaveProperty('combats'); + expect(result).toHaveProperty('pagination'); + expect(result.combats).toHaveLength(2); + expect(result.pagination.total).toBe(25); + expect(result.pagination.hasMore).toBe(true); + }); + + it('should filter by outcome when specified', async () => { + const mockQuery = { + select: jest.fn().mockReturnThis(), + join: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis() + }; + + db.mockReturnValue(mockQuery); + mockQuery.mockResolvedValue([]); + + const options = { outcome: 'attacker_victory' }; + await combatService.getCombatHistory(playerId, options, correlationId); + + // Verify that the outcome filter was applied + expect(mockQuery.where).toHaveBeenCalledWith('combat_encounters.outcome', 'attacker_victory'); + }); + }); + + describe('getActiveCombats', () => { + const playerId = 10; + const correlationId = 'test-correlation-id'; + + it('should return active combats for player', async () => { + const mockActiveCombats = [ + { + id: 1, + status: 'active', + battle_type: 'fleet_vs_fleet', + attacker_fleet_name: 'Attack Fleet' + }, + { + id: 2, + status: 'preparing', + battle_type: 'fleet_vs_colony', + defender_colony_name: 'Defended Colony' + } + ]; + + const mockQuery = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis() + }; + + db.mockReturnValue(mockQuery); + mockQuery.mockResolvedValue(mockActiveCombats); + + const result = await combatService.getActiveCombats(playerId, correlationId); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('status', 'active'); + expect(result[1]).toHaveProperty('status', 'preparing'); + }); + + it('should return empty array when no active combats', async () => { + const mockQuery = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis() + }; + + db.mockReturnValue(mockQuery); + mockQuery.mockResolvedValue([]); + + const result = await combatService.getActiveCombats(playerId, correlationId); + + expect(result).toHaveLength(0); + }); + }); + + describe('validateCombatInitiation', () => { + const correlationId = 'test-correlation-id'; + + beforeEach(() => { + // Mock database queries + const mockQuery = { + where: jest.fn().mockReturnThis(), + first: jest.fn() + }; + db.mockReturnValue(mockQuery); + }); + + it('should validate successful fleet vs fleet combat', async () => { + const combatData = { + attacker_fleet_id: 1, + defender_fleet_id: 2, + location: 'A3-91-X' + }; + const attackerPlayerId = 10; + + // Mock attacker fleet + const mockAttackerFleet = { + id: 1, + player_id: 10, + current_location: 'A3-91-X', + fleet_status: 'idle' + }; + + // Mock defender fleet + const mockDefenderFleet = { + id: 2, + player_id: 20, + current_location: 'A3-91-X', + fleet_status: 'idle' + }; + + db().first + .mockResolvedValueOnce(mockAttackerFleet) // First call for attacker fleet + .mockResolvedValueOnce(mockDefenderFleet); // Second call for defender fleet + + await expect(combatService.validateCombatInitiation(combatData, attackerPlayerId, correlationId)) + .resolves + .not.toThrow(); + }); + + it('should reject combat with invalid attacker fleet', async () => { + const combatData = { + attacker_fleet_id: 999, + defender_fleet_id: 2, + location: 'A3-91-X' + }; + const attackerPlayerId = 10; + + db().first.mockResolvedValueOnce(null); // Attacker fleet not found + + await expect(combatService.validateCombatInitiation(combatData, attackerPlayerId, correlationId)) + .rejects + .toThrow('Invalid attacker fleet or fleet not available for combat'); + }); + + it('should reject combat when attacker fleet not at location', async () => { + const combatData = { + attacker_fleet_id: 1, + defender_fleet_id: 2, + location: 'A3-91-X' + }; + const attackerPlayerId = 10; + + const mockAttackerFleet = { + id: 1, + player_id: 10, + current_location: 'B2-50-Y', // Different location + fleet_status: 'idle' + }; + + db().first.mockResolvedValueOnce(mockAttackerFleet); + + await expect(combatService.validateCombatInitiation(combatData, attackerPlayerId, correlationId)) + .rejects + .toThrow('Fleet must be at the specified location to initiate combat'); + }); + + it('should reject combat against own fleet', async () => { + const combatData = { + attacker_fleet_id: 1, + defender_fleet_id: 2, + location: 'A3-91-X' + }; + const attackerPlayerId = 10; + + const mockAttackerFleet = { + id: 1, + player_id: 10, + current_location: 'A3-91-X', + fleet_status: 'idle' + }; + + const mockDefenderFleet = { + id: 2, + player_id: 10, // Same player as attacker + current_location: 'A3-91-X', + fleet_status: 'idle' + }; + + db().first + .mockResolvedValueOnce(mockAttackerFleet) + .mockResolvedValueOnce(mockDefenderFleet); + + await expect(combatService.validateCombatInitiation(combatData, attackerPlayerId, correlationId)) + .rejects + .toThrow('Cannot attack your own fleet'); + }); + }); + + describe('calculateCasualties', () => { + it('should calculate casualties for attacker victory', () => { + const forces = { + attacker: { + fleet: { + ships: [ + { design_name: 'Fighter', quantity: 50 }, + { design_name: 'Destroyer', quantity: 10 } + ] + } + }, + defender: { + fleet: { + ships: [ + { design_name: 'Cruiser', quantity: 20 } + ] + } + } + }; + + const casualties = combatService.calculateCasualties(forces, true, 'test-correlation'); + + expect(casualties).toHaveProperty('attacker'); + expect(casualties).toHaveProperty('defender'); + expect(casualties.attacker.total_ships).toBeGreaterThanOrEqual(0); + expect(casualties.defender.total_ships).toBeGreaterThanOrEqual(0); + + // Attacker should have fewer casualties than defender + expect(casualties.attacker.total_ships).toBeLessThan(casualties.defender.total_ships); + }); + + it('should calculate casualties for defender victory', () => { + const forces = { + attacker: { + fleet: { + ships: [ + { design_name: 'Fighter', quantity: 30 } + ] + } + }, + defender: { + colony: { + defense_buildings: [ + { building_name: 'Defense Grid', health_percentage: 100 } + ] + } + } + }; + + const casualties = combatService.calculateCasualties(forces, false, 'test-correlation'); + + expect(casualties.defender.total_ships).toBeLessThan(casualties.attacker.total_ships); + expect(casualties.defender.buildings).toEqual({}); // Colony defended successfully + }); + }); + + describe('calculateLoot', () => { + it('should calculate loot for attacker victory', () => { + const forces = { + attacker: { fleet: { id: 1 } }, + defender: { fleet: { id: 2 } } + }; + + const loot = combatService.calculateLoot(forces, true, 'test-correlation'); + + expect(loot).toHaveProperty('scrap'); + expect(loot).toHaveProperty('energy'); + expect(loot.scrap).toBeGreaterThan(0); + expect(loot.energy).toBeGreaterThan(0); + }); + + it('should return empty loot for defender victory', () => { + const forces = { + attacker: { fleet: { id: 1 } }, + defender: { fleet: { id: 2 } } + }; + + const loot = combatService.calculateLoot(forces, false, 'test-correlation'); + + expect(loot).toEqual({}); + }); + + it('should include bonus loot for colony raids', () => { + const forces = { + attacker: { fleet: { id: 1 } }, + defender: { colony: { id: 1 } } + }; + + const loot = combatService.calculateLoot(forces, true, 'test-correlation'); + + expect(loot).toHaveProperty('scrap'); + expect(loot).toHaveProperty('energy'); + expect(loot).toHaveProperty('data_cores'); + expect(loot.data_cores).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/unit/services/game-tick.service.test.js b/src/tests/unit/services/game-tick.service.test.js new file mode 100644 index 0000000..333851c --- /dev/null +++ b/src/tests/unit/services/game-tick.service.test.js @@ -0,0 +1,687 @@ +/** + * Game Tick Service Unit Tests + * Comprehensive tests for all game tick functionality including resource production, + * building construction, research progress, and fleet movements. + */ + +const GameTickService = require('../../../services/game-tick.service'); +const db = require('../../../database/connection'); +const redisClient = require('../../../utils/redis'); +const logger = require('../../../utils/logger'); + +// Mock dependencies +jest.mock('../../../database/connection'); +jest.mock('../../../utils/redis'); +jest.mock('../../../utils/logger'); +jest.mock('node-cron'); + +describe('GameTickService', () => { + let gameTickService; + let mockGameEventService; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Mock GameEventService + mockGameEventService = { + emitResourcesUpdated: jest.fn(), + emitBuildingConstructed: jest.fn(), + emitNotification: jest.fn(), + emitErrorEvent: jest.fn(), + emitSystemAnnouncement: jest.fn() + }; + + // Create service instance + gameTickService = new GameTickService(mockGameEventService); + + // Mock Redis operations + redisClient.setex = jest.fn().mockResolvedValue('OK'); + redisClient.get = jest.fn().mockResolvedValue(null); + redisClient.del = jest.fn().mockResolvedValue(1); + + // Mock database transaction + const mockTrx = { + 'players': jest.fn().mockReturnThis(), + 'colonies': jest.fn().mockReturnThis(), + 'colony_buildings': jest.fn().mockReturnThis(), + 'colony_resource_production': jest.fn().mockReturnThis(), + 'player_resources': jest.fn().mockReturnThis(), + 'resource_types': jest.fn().mockReturnThis(), + 'building_types': jest.fn().mockReturnThis(), + 'planet_types': jest.fn().mockReturnThis(), + 'fleets': jest.fn().mockReturnThis(), + 'player_research': jest.fn().mockReturnThis(), + 'technologies': jest.fn().mockReturnThis(), + 'research_facilities': jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + join: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + first: jest.fn(), + orderBy: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + increment: jest.fn().mockReturnThis(), + decrement: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + returning: jest.fn(), + raw: jest.fn() + }; + + db.transaction = jest.fn().mockImplementation(async (callback) => { + return callback(mockTrx); + }); + + // Mock basic database queries + db.mockReturnValue(mockTrx); + }); + + describe('Constructor', () => { + it('should initialize with default values', () => { + const service = new GameTickService(); + + expect(service.isInitialized).toBe(false); + expect(service.currentTick).toBe(0); + expect(service.cronJob).toBe(null); + expect(service.config).toBe(null); + expect(service.isProcessing).toBe(false); + expect(service.failedUserGroups).toBeInstanceOf(Set); + expect(service.tickMetrics).toHaveProperty('totalTicksProcessed', 0); + }); + + it('should accept gameEventService parameter', () => { + const service = new GameTickService(mockGameEventService); + expect(service.gameEventService).toBe(mockGameEventService); + }); + }); + + describe('Configuration Management', () => { + it('should load existing configuration', async () => { + const mockConfig = { + id: 1, + tick_interval_ms: 60000, + user_groups_count: 10, + max_retry_attempts: 5, + bonus_tick_threshold: 3, + retry_delay_ms: 5000, + is_active: true + }; + + db().where().first.mockResolvedValue(mockConfig); + + await gameTickService.loadConfig(); + + expect(gameTickService.config).toEqual(expect.objectContaining(mockConfig)); + }); + + it('should create default configuration if none exists', async () => { + const mockDefaultConfig = { + id: 1, + tick_interval_ms: 60000, + user_groups_count: 10, + max_retry_attempts: 5, + bonus_tick_threshold: 3, + retry_delay_ms: 5000, + is_active: true + }; + + db().where().first.mockResolvedValue(null); + db().insert().returning.mockResolvedValue([mockDefaultConfig]); + + await gameTickService.loadConfig(); + + expect(gameTickService.config).toEqual(expect.objectContaining(mockDefaultConfig)); + expect(db().insert).toHaveBeenCalled(); + }); + }); + + describe('Player Tick Processing', () => { + beforeEach(() => { + gameTickService.config = { + user_groups_count: 10, + max_retry_attempts: 5, + retry_delay_ms: 5000 + }; + }); + + it('should process player tick successfully', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + + const mockPlayer = { + id: playerId, + username: 'testplayer', + last_tick_processed: 0 + }; + + // Mock database responses + db().where().first.mockResolvedValue(mockPlayer); + db().where().update.mockResolvedValue([]); + + // Mock processing methods + gameTickService.processResourceProduction = jest.fn().mockResolvedValue({ + status: 'success', + totalResourcesProduced: { scrap: 100, energy: 50 } + }); + gameTickService.processBuildingConstruction = jest.fn().mockResolvedValue({ + status: 'success', + completedBuildings: [] + }); + gameTickService.processResearch = jest.fn().mockResolvedValue({ + status: 'success', + completedResearch: [] + }); + gameTickService.processFleetMovements = jest.fn().mockResolvedValue({ + status: 'success', + arrivedFleets: [] + }); + + await gameTickService.processPlayerTick(tickNumber, playerId, correlationId); + + // Verify all processing methods were called + expect(gameTickService.processResourceProduction).toHaveBeenCalledWith( + playerId, tickNumber, correlationId, expect.any(Object) + ); + expect(gameTickService.processBuildingConstruction).toHaveBeenCalledWith( + playerId, tickNumber, correlationId, expect.any(Object) + ); + expect(gameTickService.processResearch).toHaveBeenCalledWith( + playerId, tickNumber, correlationId, expect.any(Object) + ); + expect(gameTickService.processFleetMovements).toHaveBeenCalledWith( + playerId, tickNumber, correlationId, expect.any(Object) + ); + + // Verify player was updated + expect(db().where().update).toHaveBeenCalledWith( + expect.objectContaining({ + last_tick_processed: tickNumber + }) + ); + }); + + it('should skip player if already processed', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + + const mockPlayer = { + id: playerId, + username: 'testplayer', + last_tick_processed: 2 // Already processed a later tick + }; + + db().where().first.mockResolvedValue(mockPlayer); + + gameTickService.processResourceProduction = jest.fn(); + + await gameTickService.processPlayerTick(tickNumber, playerId, correlationId); + + // Should not process if already handled + expect(gameTickService.processResourceProduction).not.toHaveBeenCalled(); + }); + + it('should handle player processing errors', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + + db().where().first.mockRejectedValue(new Error('Database error')); + + await expect( + gameTickService.processPlayerTick(tickNumber, playerId, correlationId) + ).rejects.toThrow('Database error'); + + expect(mockGameEventService.emitErrorEvent).toHaveBeenCalledWith( + playerId, + 'tick_processing_failed', + expect.any(String), + expect.any(Object), + correlationId + ); + }); + }); + + describe('Resource Production Processing', () => { + beforeEach(() => { + gameTickService.addResourcesFromProduction = jest.fn().mockResolvedValue(); + gameTickService.processColonyResourceProduction = jest.fn(); + }); + + it('should process resource production for all colonies', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + + const mockColonies = [ + { + id: 1, + name: 'Colony 1', + planet_type_name: 'Terran', + resource_modifiers: { scrap: 1.0, energy: 1.0 } + }, + { + id: 2, + name: 'Colony 2', + planet_type_name: 'Industrial', + resource_modifiers: { scrap: 1.5, energy: 0.8 } + } + ]; + + db().select().join().where.mockResolvedValue(mockColonies); + + // Mock colony processing results + gameTickService.processColonyResourceProduction + .mockResolvedValueOnce({ + status: 'success', + resourcesProduced: { scrap: 50, energy: 25 } + }) + .mockResolvedValueOnce({ + status: 'success', + resourcesProduced: { scrap: 75, energy: 20 } + }); + + const result = await gameTickService.processResourceProduction( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.coloniesProcessed).toBe(2); + expect(result.totalResourcesProduced).toEqual({ + scrap: 125, + energy: 45 + }); + + expect(gameTickService.addResourcesFromProduction).toHaveBeenCalledWith( + playerId, + { scrap: 125, energy: 45 }, + correlationId, + mockTrx + ); + + expect(mockGameEventService.emitResourcesUpdated).toHaveBeenCalledWith( + playerId, + { scrap: 125, energy: 45 }, + 'production_tick', + correlationId + ); + }); + + it('should handle empty colonies gracefully', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + + db().select().join().where.mockResolvedValue([]); + + const result = await gameTickService.processResourceProduction( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.message).toBe('No colonies to process'); + expect(result.resourcesProduced).toEqual({}); + }); + }); + + describe('Building Construction Processing', () => { + it('should complete buildings that are ready', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + const currentTime = new Date(); + + const mockBuildings = [ + { + id: 1, + colony_id: 10, + building_type_id: 1, + level: 2, + colony_name: 'Test Colony', + building_name: 'Power Plant', + construction_completes: new Date(currentTime.getTime() - 1000) // Completed 1 second ago + } + ]; + + db().select().join().where.mockResolvedValue(mockBuildings); + db().where().update.mockResolvedValue([]); + + gameTickService.updateColonyProductionRates = jest.fn().mockResolvedValue(); + + const result = await gameTickService.processBuildingConstruction( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.completedBuildings).toHaveLength(1); + expect(result.completedBuildings[0]).toEqual( + expect.objectContaining({ + buildingId: 1, + buildingName: 'Power Plant', + colonyId: 10, + colonyName: 'Test Colony', + level: 2 + }) + ); + + expect(mockGameEventService.emitBuildingConstructed).toHaveBeenCalledWith( + playerId, + 10, + expect.objectContaining({ id: 1, level: 2 }), + correlationId + ); + + expect(mockGameEventService.emitNotification).toHaveBeenCalledWith( + playerId, + expect.objectContaining({ + type: 'building_completed', + title: 'Construction Complete' + }), + correlationId + ); + }); + + it('should handle no buildings ready for completion', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + + db().select().join().where.mockResolvedValue([]); + + const result = await gameTickService.processBuildingConstruction( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.message).toBe('No buildings ready for completion'); + expect(result.completedBuildings).toHaveLength(0); + }); + }); + + describe('Research Processing', () => { + beforeEach(() => { + gameTickService.calculateResearchBonus = jest.fn().mockResolvedValue(0.1); + }); + + it('should complete research that is finished', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + const currentTime = new Date(); + + const mockResearch = [ + { + id: 1, + technology_id: 10, + technology_name: 'Advanced Mining', + research_time: 60, // 60 minutes + started_at: new Date(currentTime.getTime() - 70 * 60 * 1000), // Started 70 minutes ago + effects: { mining_efficiency: 1.2 } + } + ]; + + db().select().join().where.mockResolvedValue(mockResearch); + db().where().update.mockResolvedValue([]); + + const result = await gameTickService.processResearch( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.completedResearch).toHaveLength(1); + expect(result.completedResearch[0]).toEqual( + expect.objectContaining({ + technologyName: 'Advanced Mining', + effects: { mining_efficiency: 1.2 } + }) + ); + + expect(mockGameEventService.emitNotification).toHaveBeenCalledWith( + playerId, + expect.objectContaining({ + type: 'research_completed', + title: 'Research Complete' + }), + correlationId + ); + }); + + it('should update progress for ongoing research', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + const currentTime = new Date(); + + const mockResearch = [ + { + id: 1, + technology_id: 10, + technology_name: 'Quantum Computing', + research_time: 120, // 120 minutes + started_at: new Date(currentTime.getTime() - 60 * 60 * 1000), // Started 60 minutes ago + effects: {} + } + ]; + + db().select().join().where.mockResolvedValue(mockResearch); + db().where().update.mockResolvedValue([]); + + const result = await gameTickService.processResearch( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.completedResearch).toHaveLength(0); + + // Should update progress (approximately 50% with bonus) + expect(db().where().update).toHaveBeenCalledWith( + expect.objectContaining({ + progress: expect.any(Number) + }) + ); + }); + }); + + describe('Fleet Movement Processing', () => { + it('should move fleets that have arrived', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + const currentTime = new Date(); + + const mockFleets = [ + { + id: 1, + name: 'Exploration Fleet', + destination: 'B2-45-C', + arrival_time: new Date(currentTime.getTime() - 1000) // Arrived 1 second ago + } + ]; + + db().where.mockResolvedValue(mockFleets); + db().where().update.mockResolvedValue([]); + + const result = await gameTickService.processFleetMovements( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.arrivedFleets).toHaveLength(1); + expect(result.arrivedFleets[0]).toEqual( + expect.objectContaining({ + fleetName: 'Exploration Fleet', + destination: 'B2-45-C' + }) + ); + + expect(mockGameEventService.emitNotification).toHaveBeenCalledWith( + playerId, + expect.objectContaining({ + type: 'fleet_arrived', + title: 'Fleet Arrived' + }), + correlationId + ); + }); + + it('should handle no fleets arriving', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + + db().where.mockResolvedValue([]); + + const result = await gameTickService.processFleetMovements( + playerId, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.message).toBe('No fleets arriving'); + expect(result.arrivedFleets).toHaveLength(0); + }); + }); + + describe('Colony Resource Production Calculations', () => { + it('should calculate production with building and planet modifiers', async () => { + const colony = { + id: 1, + name: 'Test Colony', + resource_modifiers: { scrap: 1.5, energy: 0.8 } + }; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + const mockTrx = {}; + + const mockBuildings = [ + { + id: 1, + level: 3, + building_name: 'Salvage Yard', + base_production: { scrap: 10 }, + production_multiplier: 1.2 + }, + { + id: 2, + level: 2, + building_name: 'Power Plant', + base_production: { energy: 8 }, + production_multiplier: 1.2 + } + ]; + + db().select().join().where.mockResolvedValue(mockBuildings); + + gameTickService.updateColonyResourceTracking = jest.fn().mockResolvedValue(); + + const result = await gameTickService.processColonyResourceProduction( + colony, tickNumber, correlationId, mockTrx + ); + + expect(result.status).toBe('success'); + expect(result.buildingsProcessed).toBe(2); + + // Verify production calculations + // Salvage Yard: 10 * 1.2^2 * 1.5 = 21.6 -> 21 + // Power Plant: 8 * 1.2^1 * 0.8 = 7.68 -> 7 + expect(result.resourcesProduced.scrap).toBeGreaterThan(0); + expect(result.resourcesProduced.energy).toBeGreaterThan(0); + }); + }); + + describe('Error Handling and Recovery', () => { + it('should handle database transaction failures', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + + db.transaction.mockRejectedValue(new Error('Transaction failed')); + + await expect( + gameTickService.processPlayerTick(tickNumber, playerId, correlationId) + ).rejects.toThrow('Transaction failed'); + + expect(logger.error).toHaveBeenCalledWith( + 'Player tick processing failed', + expect.objectContaining({ + correlationId, + playerId, + tickNumber, + error: 'Transaction failed' + }) + ); + }); + + it('should release Redis locks even on failure', async () => { + const playerId = 123; + const tickNumber = 1; + const correlationId = 'test-correlation-id'; + + redisClient.setex.mockResolvedValue('OK'); + db.transaction.mockRejectedValue(new Error('Database error')); + + await expect( + gameTickService.processPlayerTick(tickNumber, playerId, correlationId) + ).rejects.toThrow('Database error'); + + expect(redisClient.del).toHaveBeenCalled(); + }); + }); + + describe('Performance Metrics', () => { + it('should track processing metrics', () => { + expect(gameTickService.tickMetrics).toHaveProperty('totalTicksProcessed'); + expect(gameTickService.tickMetrics).toHaveProperty('totalPlayersProcessed'); + expect(gameTickService.tickMetrics).toHaveProperty('averageTickDuration'); + expect(gameTickService.tickMetrics).toHaveProperty('consecutiveFailures'); + }); + + it('should provide comprehensive status information', () => { + const status = gameTickService.getStatus(); + + expect(status).toHaveProperty('initialized'); + expect(status).toHaveProperty('currentTick'); + expect(status).toHaveProperty('running'); + expect(status).toHaveProperty('processing'); + expect(status).toHaveProperty('config'); + expect(status).toHaveProperty('metrics'); + expect(status).toHaveProperty('failedUserGroups'); + }); + }); + + describe('Bonus Tick Compensation', () => { + it('should apply bonus resources for failed ticks', async () => { + const tickNumber = 1; + const userGroup = 0; + const correlationId = 'test-correlation-id'; + + const mockPlayers = [ + { id: 1, username: 'player1' }, + { id: 2, username: 'player2' } + ]; + + db().where().update().mockResolvedValue([]); + db().where().select.mockResolvedValue(mockPlayers); + db().join().where().increment().update.mockResolvedValue([]); + + await gameTickService.applyBonusTick(tickNumber, userGroup, correlationId); + + expect(mockGameEventService.emitNotification).toHaveBeenCalledTimes(2); + expect(mockGameEventService.emitNotification).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + type: 'bonus_compensation', + title: 'System Compensation' + }), + correlationId + ); + }); + }); +}); \ No newline at end of file diff --git a/src/validators/colony.validators.js b/src/validators/colony.validators.js new file mode 100644 index 0000000..c70e7da --- /dev/null +++ b/src/validators/colony.validators.js @@ -0,0 +1,101 @@ +/** + * Colony Validation Schemas + * Joi validation schemas for colony-related API endpoints + */ + +const Joi = require('joi'); + +// Colony creation validation +const createColonySchema = Joi.object({ + name: Joi.string() + .min(3) + .max(50) + .pattern(/^[a-zA-Z0-9\s\-_]+$/) + .required() + .messages({ + 'string.min': 'Colony name must be at least 3 characters long', + 'string.max': 'Colony name cannot exceed 50 characters', + 'string.pattern.base': 'Colony name can only contain letters, numbers, spaces, hyphens, and underscores', + 'any.required': 'Colony name is required' + }), + + coordinates: Joi.string() + .pattern(/^[A-Z]\d+-\d+-[A-Z]$/) + .required() + .messages({ + 'string.pattern.base': 'Coordinates must be in the format: A3-91-X (Letter+Number-Number-Letter)', + 'any.required': 'Coordinates are required' + }), + + planet_type_id: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Planet type ID must be a number', + 'number.integer': 'Planet type ID must be an integer', + 'number.min': 'Planet type ID must be at least 1', + 'any.required': 'Planet type ID is required' + }) +}); + +// Building construction validation +const constructBuildingSchema = Joi.object({ + building_type_id: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Building type ID must be a number', + 'number.integer': 'Building type ID must be an integer', + 'number.min': 'Building type ID must be at least 1', + 'any.required': 'Building type ID is required' + }) +}); + +// Colony ID parameter validation +const colonyIdParamSchema = Joi.object({ + colonyId: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Colony ID must be a number', + 'number.integer': 'Colony ID must be an integer', + 'number.min': 'Colony ID must be at least 1', + 'any.required': 'Colony ID is required' + }) +}); + +// Building upgrade validation (for future use) +const upgradeBuildingSchema = Joi.object({ + building_id: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Building ID must be a number', + 'number.integer': 'Building ID must be an integer', + 'number.min': 'Building ID must be at least 1', + 'any.required': 'Building ID is required' + }), + + target_level: Joi.number() + .integer() + .min(2) + .max(20) + .optional() + .messages({ + 'number.base': 'Target level must be a number', + 'number.integer': 'Target level must be an integer', + 'number.min': 'Target level must be at least 2', + 'number.max': 'Target level cannot exceed 20' + }) +}); + +module.exports = { + createColonySchema, + constructBuildingSchema, + colonyIdParamSchema, + upgradeBuildingSchema +}; \ No newline at end of file diff --git a/src/validators/combat.validators.js b/src/validators/combat.validators.js new file mode 100644 index 0000000..cf2bdcf --- /dev/null +++ b/src/validators/combat.validators.js @@ -0,0 +1,324 @@ +/** + * Combat Validation Schemas + * Joi validation schemas for combat-related endpoints + */ + +const Joi = require('joi'); + +// Combat initiation validation schema +const initiateCombatSchema = Joi.object({ + attacker_fleet_id: Joi.number().integer().positive().required() + .messages({ + 'number.base': 'Attacker fleet ID must be a number', + 'number.integer': 'Attacker fleet ID must be an integer', + 'number.positive': 'Attacker fleet ID must be positive', + 'any.required': 'Attacker fleet ID is required' + }), + + defender_fleet_id: Joi.number().integer().positive().allow(null) + .messages({ + 'number.base': 'Defender fleet ID must be a number', + 'number.integer': 'Defender fleet ID must be an integer', + 'number.positive': 'Defender fleet ID must be positive' + }), + + defender_colony_id: Joi.number().integer().positive().allow(null) + .messages({ + 'number.base': 'Defender colony ID must be a number', + 'number.integer': 'Defender colony ID must be an integer', + 'number.positive': 'Defender colony ID must be positive' + }), + + location: Joi.string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/).required() + .messages({ + 'string.pattern.base': 'Location must be in format A3-91-X', + 'any.required': 'Location is required' + }), + + combat_type: Joi.string().valid('instant', 'turn_based', 'tactical', 'real_time').default('instant') + .messages({ + 'any.only': 'Combat type must be one of: instant, turn_based, tactical, real_time' + }), + + tactical_settings: Joi.object({ + formation: Joi.string().valid('standard', 'defensive', 'aggressive', 'flanking', 'escort').optional(), + priority_targets: Joi.array().items(Joi.string()).optional(), + engagement_range: Joi.string().valid('close', 'medium', 'long').optional(), + retreat_threshold: Joi.number().min(0).max(100).optional() + }).optional() +}).custom((value, helpers) => { + // Ensure exactly one defender is specified + const hasFleetDefender = value.defender_fleet_id !== null && value.defender_fleet_id !== undefined; + const hasColonyDefender = value.defender_colony_id !== null && value.defender_colony_id !== undefined; + + if (hasFleetDefender && hasColonyDefender) { + return helpers.error('custom.bothDefenders'); + } + + if (!hasFleetDefender && !hasColonyDefender) { + return helpers.error('custom.noDefender'); + } + + return value; +}, 'defender validation').messages({ + 'custom.bothDefenders': 'Cannot specify both defender fleet and defender colony', + 'custom.noDefender': 'Must specify either defender fleet or defender colony' +}); + +// Fleet position update validation schema +const updateFleetPositionSchema = Joi.object({ + position_x: Joi.number().min(-1000).max(1000).default(0) + .messages({ + 'number.base': 'Position X must be a number', + 'number.min': 'Position X must be at least -1000', + 'number.max': 'Position X must be at most 1000' + }), + + position_y: Joi.number().min(-1000).max(1000).default(0) + .messages({ + 'number.base': 'Position Y must be a number', + 'number.min': 'Position Y must be at least -1000', + 'number.max': 'Position Y must be at most 1000' + }), + + position_z: Joi.number().min(-1000).max(1000).default(0) + .messages({ + 'number.base': 'Position Z must be a number', + 'number.min': 'Position Z must be at least -1000', + 'number.max': 'Position Z must be at most 1000' + }), + + formation: Joi.string().valid('standard', 'defensive', 'aggressive', 'flanking', 'escort').default('standard') + .messages({ + 'any.only': 'Formation must be one of: standard, defensive, aggressive, flanking, escort' + }), + + tactical_settings: Joi.object({ + auto_engage: Joi.boolean().default(true), + engagement_range: Joi.string().valid('close', 'medium', 'long').default('medium'), + target_priority: Joi.string().valid('closest', 'weakest', 'strongest', 'random').default('closest'), + retreat_threshold: Joi.number().min(0).max(100).default(25), + formation_spacing: Joi.number().min(0.1).max(10.0).default(1.0) + }).default({}) +}); + +// Combat history query parameters validation schema +const combatHistoryQuerySchema = Joi.object({ + limit: Joi.number().integer().min(1).max(100).default(20) + .messages({ + 'number.base': 'Limit must be a number', + 'number.integer': 'Limit must be an integer', + 'number.min': 'Limit must be at least 1', + 'number.max': 'Limit cannot exceed 100' + }), + + offset: Joi.number().integer().min(0).default(0) + .messages({ + 'number.base': 'Offset must be a number', + 'number.integer': 'Offset must be an integer', + 'number.min': 'Offset must be at least 0' + }), + + outcome: Joi.string().valid('attacker_victory', 'defender_victory', 'draw').optional() + .messages({ + 'any.only': 'Outcome must be one of: attacker_victory, defender_victory, draw' + }), + + battle_type: Joi.string().valid('fleet_vs_fleet', 'fleet_vs_colony', 'siege').optional() + .messages({ + 'any.only': 'Battle type must be one of: fleet_vs_fleet, fleet_vs_colony, siege' + }), + + start_date: Joi.date().iso().optional() + .messages({ + 'date.format': 'Start date must be in ISO format' + }), + + end_date: Joi.date().iso().optional() + .messages({ + 'date.format': 'End date must be in ISO format' + }) +}).custom((value, helpers) => { + // Ensure end_date is after start_date if both are provided + if (value.start_date && value.end_date && value.end_date <= value.start_date) { + return helpers.error('custom.invalidDateRange'); + } + return value; +}, 'date validation').messages({ + 'custom.invalidDateRange': 'End date must be after start date' +}); + +// Combat queue query parameters validation schema +const combatQueueQuerySchema = Joi.object({ + status: Joi.string().valid('pending', 'processing', 'completed', 'failed').optional() + .messages({ + 'any.only': 'Status must be one of: pending, processing, completed, failed' + }), + + limit: Joi.number().integer().min(1).max(100).default(50) + .messages({ + 'number.base': 'Limit must be a number', + 'number.integer': 'Limit must be an integer', + 'number.min': 'Limit must be at least 1', + 'number.max': 'Limit cannot exceed 100' + }), + + priority_min: Joi.number().integer().min(1).max(1000).optional() + .messages({ + 'number.base': 'Priority minimum must be a number', + 'number.integer': 'Priority minimum must be an integer', + 'number.min': 'Priority minimum must be at least 1', + 'number.max': 'Priority minimum cannot exceed 1000' + }), + + priority_max: Joi.number().integer().min(1).max(1000).optional() + .messages({ + 'number.base': 'Priority maximum must be a number', + 'number.integer': 'Priority maximum must be an integer', + 'number.min': 'Priority maximum must be at least 1', + 'number.max': 'Priority maximum cannot exceed 1000' + }) +}).custom((value, helpers) => { + // Ensure priority_max is greater than priority_min if both are provided + if (value.priority_min && value.priority_max && value.priority_max <= value.priority_min) { + return helpers.error('custom.invalidPriorityRange'); + } + return value; +}, 'priority validation').messages({ + 'custom.invalidPriorityRange': 'Priority maximum must be greater than priority minimum' +}); + +// Parameter validation schemas +const battleIdParamSchema = Joi.object({ + battleId: Joi.number().integer().positive().required() + .messages({ + 'number.base': 'Battle ID must be a number', + 'number.integer': 'Battle ID must be an integer', + 'number.positive': 'Battle ID must be positive', + 'any.required': 'Battle ID is required' + }) +}); + +const fleetIdParamSchema = Joi.object({ + fleetId: Joi.number().integer().positive().required() + .messages({ + 'number.base': 'Fleet ID must be a number', + 'number.integer': 'Fleet ID must be an integer', + 'number.positive': 'Fleet ID must be positive', + 'any.required': 'Fleet ID is required' + }) +}); + +const encounterIdParamSchema = Joi.object({ + encounterId: Joi.number().integer().positive().required() + .messages({ + 'number.base': 'Encounter ID must be a number', + 'number.integer': 'Encounter ID must be an integer', + 'number.positive': 'Encounter ID must be positive', + 'any.required': 'Encounter ID is required' + }) +}); + +// Combat configuration validation schema (admin only) +const combatConfigurationSchema = Joi.object({ + config_name: Joi.string().min(3).max(100).required() + .messages({ + 'string.min': 'Configuration name must be at least 3 characters', + 'string.max': 'Configuration name cannot exceed 100 characters', + 'any.required': 'Configuration name is required' + }), + + combat_type: Joi.string().valid('instant', 'turn_based', 'tactical', 'real_time').required() + .messages({ + 'any.only': 'Combat type must be one of: instant, turn_based, tactical, real_time', + 'any.required': 'Combat type is required' + }), + + config_data: Joi.object({ + auto_resolve: Joi.boolean().default(true), + preparation_time: Joi.number().integer().min(0).max(300).default(30), + max_rounds: Joi.number().integer().min(1).max(100).default(20), + round_duration: Joi.number().integer().min(1).max(60).default(5), + damage_variance: Joi.number().min(0).max(1).default(0.1), + experience_gain: Joi.number().min(0).max(10).default(1.0), + casualty_rate_min: Joi.number().min(0).max(1).default(0.1), + casualty_rate_max: Joi.number().min(0).max(1).default(0.8), + loot_multiplier: Joi.number().min(0).max(10).default(1.0), + spectator_limit: Joi.number().integer().min(0).max(1000).default(100), + priority: Joi.number().integer().min(1).max(1000).default(100) + }).required() + .custom((value, helpers) => { + // Ensure casualty_rate_max >= casualty_rate_min + if (value.casualty_rate_max < value.casualty_rate_min) { + return helpers.error('custom.invalidCasualtyRange'); + } + return value; + }, 'casualty rate validation') + .messages({ + 'custom.invalidCasualtyRange': 'Maximum casualty rate must be greater than or equal to minimum casualty rate' + }), + + description: Joi.string().max(500).optional() + .messages({ + 'string.max': 'Description cannot exceed 500 characters' + }), + + is_active: Joi.boolean().default(true) +}); + +// Export validation functions +const validateInitiateCombat = (data) => { + return initiateCombatSchema.validate(data, { abortEarly: false }); +}; + +const validateUpdateFleetPosition = (data) => { + return updateFleetPositionSchema.validate(data, { abortEarly: false }); +}; + +const validateCombatHistoryQuery = (data) => { + return combatHistoryQuerySchema.validate(data, { abortEarly: false }); +}; + +const validateCombatQueueQuery = (data) => { + return combatQueueQuerySchema.validate(data, { abortEarly: false }); +}; + +const validateBattleIdParam = (data) => { + return battleIdParamSchema.validate(data, { abortEarly: false }); +}; + +const validateFleetIdParam = (data) => { + return fleetIdParamSchema.validate(data, { abortEarly: false }); +}; + +const validateEncounterIdParam = (data) => { + return encounterIdParamSchema.validate(data, { abortEarly: false }); +}; + +const validateCombatConfiguration = (data) => { + return combatConfigurationSchema.validate(data, { abortEarly: false }); +}; + +module.exports = { + // Validation functions + validateInitiateCombat, + validateUpdateFleetPosition, + validateCombatHistoryQuery, + validateCombatQueueQuery, + validateBattleIdParam, + validateFleetIdParam, + validateEncounterIdParam, + validateCombatConfiguration, + + // Raw schemas for middleware use + schemas: { + initiateCombat: initiateCombatSchema, + updateFleetPosition: updateFleetPositionSchema, + combatHistoryQuery: combatHistoryQuerySchema, + combatQueueQuery: combatQueueQuerySchema, + battleIdParam: battleIdParamSchema, + fleetIdParam: fleetIdParamSchema, + encounterIdParam: encounterIdParamSchema, + combatConfiguration: combatConfigurationSchema + } +}; \ No newline at end of file diff --git a/src/validators/resource.validators.js b/src/validators/resource.validators.js new file mode 100644 index 0000000..76f19ac --- /dev/null +++ b/src/validators/resource.validators.js @@ -0,0 +1,122 @@ +/** + * Resource Validation Schemas + * Joi validation schemas for resource-related API endpoints + */ + +const Joi = require('joi'); + +// Resource transfer validation +const transferResourcesSchema = Joi.object({ + fromColonyId: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Source colony ID must be a number', + 'number.integer': 'Source colony ID must be an integer', + 'number.min': 'Source colony ID must be at least 1', + 'any.required': 'Source colony ID is required' + }), + + toColonyId: Joi.number() + .integer() + .min(1) + .required() + .messages({ + 'number.base': 'Destination colony ID must be a number', + 'number.integer': 'Destination colony ID must be an integer', + 'number.min': 'Destination colony ID must be at least 1', + 'any.required': 'Destination colony ID is required' + }), + + resources: Joi.object() + .pattern( + Joi.string().valid('scrap', 'energy', 'data_cores', 'rare_elements'), + Joi.number().integer().min(1).max(1000000) + ) + .min(1) + .required() + .custom((value, helpers) => { + if (Object.keys(value).length === 0) { + return helpers.error('any.required'); + } + return value; + }) + .messages({ + 'object.min': 'At least one resource must be specified', + 'any.required': 'Resources object is required and must contain at least one resource' + }) +}); + +// Add resources validation (for development/testing) +const addResourcesSchema = Joi.object({ + resources: Joi.object() + .pattern( + Joi.string().valid('scrap', 'energy', 'data_cores', 'rare_elements'), + Joi.number().integer().min(1).max(1000000) + ) + .min(1) + .required() + .custom((value, helpers) => { + if (Object.keys(value).length === 0) { + return helpers.error('any.required'); + } + return value; + }) + .messages({ + 'object.min': 'At least one resource must be specified', + 'any.required': 'Resources object is required and must contain at least one resource' + }) +}); + +// Resource amount validation helper +const resourceAmountSchema = Joi.number() + .integer() + .min(0) + .max(Number.MAX_SAFE_INTEGER) + .messages({ + 'number.base': 'Resource amount must be a number', + 'number.integer': 'Resource amount must be an integer', + 'number.min': 'Resource amount cannot be negative', + 'number.max': 'Resource amount is too large' + }); + +// Resource type validation +const resourceTypeSchema = Joi.string() + .valid('scrap', 'energy', 'data_cores', 'rare_elements') + .messages({ + 'any.only': 'Invalid resource type. Valid types are: scrap, energy, data_cores, rare_elements' + }); + +// Query parameters for resource endpoints +const resourceQuerySchema = Joi.object({ + includeProduction: Joi.boolean() + .optional() + .default(false) + .messages({ + 'boolean.base': 'includeProduction must be a boolean value' + }), + + includeColonyBreakdown: Joi.boolean() + .optional() + .default(false) + .messages({ + 'boolean.base': 'includeColonyBreakdown must be a boolean value' + }), + + format: Joi.string() + .valid('detailed', 'summary') + .optional() + .default('summary') + .messages({ + 'any.only': 'Format must be either "detailed" or "summary"' + }) +}); + +module.exports = { + transferResourcesSchema, + addResourcesSchema, + resourceAmountSchema, + resourceTypeSchema, + resourceQuerySchema +}; \ No newline at end of file