/** * Game Server - Based on Client LocalServer Infrastructure * Handles real-time multiplayer game instances and gameplay */ const express = require('express'); const http = require('http'); const errorReporter = require('./utils/ErrorReporter'); const socketIo = require('socket.io'); const cors = require('cors'); const helmet = require('helmet'); const compression = require('compression'); const mongoose = require('mongoose'); const fs = require('fs'); const path = require('path'); require('dotenv').config(); const logger = require('./utils/logger'); const connectDB = require('./config/database'); const PlayerData = require('./models/PlayerData'); // Import server systems for player initialization const QuestSystem = require('./systems/QuestSystem'); const SkillSystem = require('./systems/SkillSystem'); const DungeonSystem = require('./systems/DungeonSystem'); const CraftingSystem = require('./systems/CraftingSystem'); const IdleSystem = require('./systems/IdleSystem'); const ItemSystem = require('./systems/ItemSystem'); const ContentLoader = require('./systems/ContentLoader'); const FleetSystem = require('./systems/FleetSystem'); const { ResourceSystem, RESOURCE_CONFIG, RESOURCE_TYPES } = require('./systems/ResourceSystem'); const MissionSystem = require('./systems/MissionSystem'); const { AllianceSystem } = require('./systems/AllianceSystem'); const { MarketSystem } = require('./systems/MarketSystem'); const SocialSystem = require('./systems/SocialSystem'); const { ReputationSystem } = require('./systems/ReputationSystem'); const GalaxyEventSystem = require('./systems/GalaxyEventSystem'); const { SeasonSystem } = require('./systems/SeasonSystem'); const GalaxySystem = require('./systems/GalaxySystem'); const { ResearchSystem } = require('./systems/ResearchSystem'); const RaidSystem = require('./systems/RaidSystem'); const AllianceWarSystem = require('./systems/AllianceWarSystem'); const AnalyticsSystem = require('./systems/AnalyticsSystem'); // ── Load all JSON game content before systems are initialised ── const contentLoader = new ContentLoader(); contentLoader._loadSkills(); contentLoader._loadEnemies(); contentLoader._loadDungeons(); contentLoader._loadItems(); contentLoader._loadRecipes(); contentLoader._loadQuests(); contentLoader._loaded = true; console.log(`[SERVER] Content loaded — ${contentLoader.skills.size} skills, ${contentLoader.items.size} items, ${contentLoader.recipes.size} recipes, ${contentLoader.quests.size} quests, ${contentLoader.dungeons.size} dungeons, ${contentLoader.enemies.size} enemies`); // Initialize server systems — each receives contentLoader as its data source const questSystem = new QuestSystem(contentLoader); const skillSystem = new SkillSystem(contentLoader); const dungeonSystem = new DungeonSystem(contentLoader); const craftingSystem = new CraftingSystem(contentLoader); const idleSystem = new IdleSystem(); const itemSystem = new ItemSystem(contentLoader); const fleetSystem = new FleetSystem(contentLoader); const resourceSystem = new ResourceSystem(); const missionSystem = new MissionSystem(); const allianceSystem = new AllianceSystem(); const marketSystem = new MarketSystem(); const socialSystem = new SocialSystem(); const reputationSystem = new ReputationSystem(); const galaxyEventSystem = new GalaxyEventSystem(); const seasonSystem = new SeasonSystem(); const galaxySystem = new GalaxySystem(); const researchSystem = new ResearchSystem(); const raidSystem = new RaidSystem(contentLoader); const allianceWarSystem = new AllianceWarSystem(); const analyticsSystem = new AnalyticsSystem(); analyticsSystem.start(); // Set server URL for ItemSystem const SERVER_URL = process.env.SERVER_URL || 'https://dev.gameserver.galaxystrike.online'; itemSystem.setServerUrl(SERVER_URL); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: [ "http://localhost:3000", "http://127.0.0.1:3000", "file://", "https://dev.galaxystrike.online", "https://galaxystrike.online" ], methods: ["GET", "POST"], credentials: true } }); // Pass io instance to systems that need it dungeonSystem.setIO(io); raidSystem.setIO(io); allianceWarSystem.setIO(io); allianceWarSystem.resumeTimers().catch(err => console.error('[WAR] Timer resume error:', err)); // Game state const gameInstances = new Map(); const connectedClients = new Map(); let pvpChallenges = new Map(); // GDD §9.4 — active PvP challenge invites // ── PvP battle simulation helper ───────────────────────────────────────────── function _simulatePvpBattle(challenger, defender) { const rounds = []; // Base stats let cHp = Math.max(1, (challenger.ship?.health || 1000) + (challenger.stats?.level || 1) * 50); let dHp = Math.max(1, (defender.ship?.health || 1000) + (defender.stats?.level || 1) * 50); // Build combat stat objects and apply skill + research bonuses const cResearch = challenger.research?.effects || {}; const dResearch = defender.research?.effects || {}; const cStats = { attack: Math.max(1, (challenger.ship?.attack || 100) + (challenger.stats?.level || 1) * 10), defense: Math.max(0, challenger.ship?.defense || 5), critChance: 0.05, accuracy: 0.90, speed: challenger.ship?.speed || 10, }; const dStats = { attack: Math.max(1, (defender.ship?.attack || 100) + (defender.stats?.level || 1) * 10), defense: Math.max(0, defender.ship?.defense || 5), critChance: 0.05, accuracy: 0.90, speed: defender.ship?.speed || 10, }; // Apply research weapon/armor/hull bonuses cStats.attack = Math.floor(cStats.attack * (1 + (cResearch.weaponDamage || 0) / 100)); dStats.attack = Math.floor(dStats.attack * (1 + (dResearch.weaponDamage || 0) / 100)); cStats.defense = Math.floor(cStats.defense * (1 + ((cResearch.armorBonus || 0) + (cResearch.hullBonus || 0)) / 100)); dStats.defense = Math.floor(dStats.defense * (1 + ((dResearch.armorBonus || 0) + (dResearch.hullBonus || 0)) / 100)); cHp = Math.floor(cHp * (1 + (cResearch.hullBonus || 0) / 100)); dHp = Math.floor(dHp * (1 + (dResearch.hullBonus || 0) / 100)); // Apply skill effects if skillSystem is available if (skillSystem && challenger.userId) skillSystem.applySkillsToCombat(challenger.userId, cStats); if (skillSystem && defender.userId) skillSystem.applySkillsToCombat(defender.userId, dStats); // Apply reputation effects (Pirate Syndicate attack bonus) const cRepEffects = reputationSystem.getActiveEffects(challenger); const dRepEffects = reputationSystem.getActiveEffects(defender); cStats.attack = Math.floor(cStats.attack * (1 + cRepEffects.pvpAttackBonus)); dStats.attack = Math.floor(dStats.attack * (1 + dRepEffects.pvpAttackBonus)); // Speed advantage: faster ship gets +5% damage const speedAdv = cStats.speed > dStats.speed ? 1.05 : (dStats.speed > cStats.speed ? 0.95 : 1.0); let round = 0; while (cHp > 0 && dHp > 0 && round < 20) { round++; // Miss chance based on accuracy (1 - accuracy = miss rate) const cMiss = Math.random() > cStats.accuracy; const dMiss = Math.random() > dStats.accuracy; // Damage roll with crit const cCrit = !cMiss && Math.random() < cStats.critChance; const dCrit = !dMiss && Math.random() < dStats.critChance; const cDmg = cMiss ? 0 : Math.floor(cStats.attack * speedAdv * (0.85 + Math.random() * 0.3) * (cCrit ? 1.75 : 1)); const dDmg = dMiss ? 0 : Math.floor(dStats.attack * (1 / speedAdv) * (0.85 + Math.random() * 0.3) * (dCrit ? 1.75 : 1)); // Defense mitigation (flat reduction, min 0) const cFinalDmg = Math.max(0, cDmg - dStats.defense); const dFinalDmg = Math.max(0, dDmg - cStats.defense); dHp -= cFinalDmg; cHp -= dFinalDmg; rounds.push({ round, challengerDmg: cFinalDmg, defenderDmg: dFinalDmg, challengerHp: Math.max(0, cHp), defenderHp: Math.max(0, dHp), challengerCrit: cCrit, defenderCrit: dCrit, challengerMiss: cMiss, defenderMiss: dMiss, }); } return { winner: cHp >= dHp ? 'challenger' : 'defender', rounds }; } // ── Ship module slot helper ──────────────────────────────────────────────────── function _getShipSlots(ship) { const base = ['weapon_1', 'weapon_2', 'armor_1', 'engine', 'shield']; // Higher-rarity ships get extra slots const rarity = ship?.rarity || 'common'; if (rarity === 'rare') return [...base, 'special_1']; if (rarity === 'epic') return [...base, 'special_1', 'special_2']; if (rarity === 'legendary') return [...base, 'special_1', 'special_2', 'special_3']; return base; } // Server-side shop item lookup using ItemSystem function findShopItem(itemId) { return itemSystem.findShopItem(itemId); } // Middleware app.use(cors({ origin: [ "http://localhost:3000", "http://127.0.0.1:3000", "file://", "https://dev.galaxystrike.online", "https://galaxystrike.online" ], credentials: true })); app.use(express.json({ limit: '10mb' })); app.use(errorReporter.requestMiddleware()); // Health + metrics endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', ...errorReporter.getMetrics() }); }); // Analytics metrics endpoint (protected by METRICS_KEY env var if set) app.get('/metrics', async (req, res) => { const key = process.env.METRICS_KEY; if (key && req.headers['x-metrics-key'] !== key) { return res.status(401).json({ error: 'Unauthorised' }); } try { const [snapshot, last30] = await Promise.all([ analyticsSystem.getMetrics(), analyticsSystem.getLast30Days(), ]); res.json({ ...snapshot, last30, connectedClients: connectedClients.size }); } catch (err) { res.status(500).json({ error: 'Metrics unavailable' }); } }); // Internal: called by API webhook to credit gems after Stripe payment app.use('/internal/credit-gems', express.json()); app.post('/internal/credit-gems', async (req, res) => { const key = process.env.INTERNAL_API_KEY || 'gso-internal'; if (req.headers['x-internal-key'] !== key) return res.status(401).json({ error: 'Unauthorised' }); const { userId, gems, packageId, amountCents } = req.body; if (!userId || !gems) return res.status(400).json({ error: 'userId and gems required' }); try { // Credit in DB const pd = await PlayerData.findOne({ userId }); if (pd) { pd.stats = pd.stats || {}; pd.stats.gems = (pd.stats.gems || 0) + gems; pd.markModified('stats'); await pd.save(); } // Push live update to connected socket for (const [sid, client] of connectedClients.entries()) { if (client.userId === userId) { io.to(sid).emit('gems_credited', { gems, packageId, newBalance: pd?.stats?.gems }); io.to(sid).emit('economy_data', { credits: pd?.stats?.credits, gems: pd?.stats?.gems }); break; } } // Analytics analyticsSystem.track('gems.purchase', userId, { gems, packageId, amountCents }); analyticsSystem.track('stripe.payment', userId, { amountCents, packageId }); res.json({ success: true }); } catch (err) { console.error('[CREDIT-GEMS]', err); res.status(500).json({ error: err.message }); } }); // Serve static files from the client directory app.use(express.static(path.join(__dirname, '../Client'))); // Serve ships from server-side storage app.use('/images/ships', (req, res, next) => { const requestedPath = req.path; const serverImagePath = path.join(__dirname, 'assets/images/ships', requestedPath); console.log('[IMAGE SERVER] Ship requested:', requestedPath); if (fs.existsSync(serverImagePath)) { res.sendFile(serverImagePath); } else { const placeholderPath = path.join(__dirname, 'assets/images/ui/placeholder.png'); res.sendFile(placeholderPath); } }); // Serve weapons from server-side storage app.use('/images/weapons', (req, res, next) => { const requestedPath = req.path; const serverImagePath = path.join(__dirname, 'assets/images/weapons', requestedPath); console.log('[IMAGE SERVER] Weapon requested:', requestedPath); if (fs.existsSync(serverImagePath)) { res.sendFile(serverImagePath); } else { const placeholderPath = path.join(__dirname, 'assets/images/ui/placeholder.png'); res.sendFile(placeholderPath); } }); // Serve armors from server-side storage app.use('/images/armors', (req, res, next) => { const requestedPath = req.path; const serverImagePath = path.join(__dirname, 'assets/images/armors', requestedPath); console.log('[IMAGE SERVER] Armor requested:', requestedPath); if (fs.existsSync(serverImagePath)) { res.sendFile(serverImagePath); } else { const placeholderPath = path.join(__dirname, 'assets/images/ui/placeholder.png'); res.sendFile(placeholderPath); } }); // Serve other items (materials, consumables, cosmetics) from server-side storage app.use('/images/items', (req, res, next) => { const requestedPath = req.path; const serverImagePath = path.join(__dirname, 'assets/images/items', requestedPath); console.log('[IMAGE SERVER] Item requested:', requestedPath); if (fs.existsSync(serverImagePath)) { res.sendFile(serverImagePath); } else { const placeholderPath = path.join(__dirname, 'assets/images/ui/placeholder.png'); res.sendFile(placeholderPath); } }); // Serve UI elements and icons from server-side storage app.use('/images/ui', (req, res, next) => { const requestedPath = req.path; const serverImagePath = path.join(__dirname, 'assets/images/ui', requestedPath); if (fs.existsSync(serverImagePath)) { res.sendFile(serverImagePath); } else { res.status(404).send('UI asset not found'); } }); app.use(express.urlencoded({ extended: true })); // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'OK', timestamp: new Date().toISOString(), uptime: process.uptime(), mode: 'game-server', activeInstances: gameInstances.size, connectedClients: connectedClients.size }); }); // API version endpoint app.get('/api/ssc/version', (req, res) => { res.status(200).json({ version: '1.0.0', service: 'galaxystrikeonline-game-server', timestamp: new Date().toISOString(), mode: 'multiplayer' }); }); // Shop API endpoints app.get('/api/shop/items', (req, res) => { try { res.status(200).json(itemSystem.buildShopResponse()); } catch (error) { console.error('[GAME SERVER] Error fetching shop items:', error); res.status(500).json({ success: false, error: 'Failed to fetch shop items' }); } }); app.get('/api/shop/items/:category', (req, res) => { try { const { category } = req.params; res.status(200).json({ success: true, category, items: itemSystem.getRandomItemsByCategory(category), timestamp: new Date().toISOString() }); } catch (error) { console.error('[GAME SERVER] Error fetching category items:', error); res.status(500).json({ success: false, error: 'Failed to fetch category items' }); } }); app.get('/api/items/:itemId', (req, res) => { try { const { itemId } = req.params; // Find item across all categories const allItems = itemSystem.getAllItems(); let item = null; for (const [category, items] of Object.entries(allItems)) { item = items.find(i => i.id === itemId); if (item) break; } if (!item) { return res.status(404).json({ success: false, error: 'Item not found' }); } res.status(200).json({ success: true, item: item, timestamp: new Date().toISOString() }); } catch (error) { console.error('[GAME SERVER] Error fetching item details:', error); res.status(500).json({ success: false, error: 'Failed to fetch item details' }); } }); // Game data endpoints (similar to LocalServer) app.post('/api/game/player/:id/save', (req, res) => { const playerId = req.params.id; const playerData = req.body; // Store player data in game instance if (connectedClients.has(playerId)) { connectedClients.get(playerId).playerData = playerData; // Broadcast to other players in same instance const instanceId = connectedClients.get(playerId).instanceId; if (gameInstances.has(instanceId)) { io.to(instanceId).emit('playerDataUpdated', { playerId, timestamp: Date.now() }); } } res.status(200).json({ success: true, message: 'Player data saved to game server' }); }); app.get('/api/game/player/:id', (req, res) => { const playerId = req.params.id; if (connectedClients.has(playerId)) { const playerData = connectedClients.get(playerId).playerData; res.status(200).json({ success: true, player: playerData }); } else { res.status(404).json({ success: false, error: 'Player not connected to game server' }); } }); // Game system routes app.use('/api/crafting', require('./routes/crafting')); app.use('/api/quests', require('./routes/quests')); app.use('/api/skills', require('./routes/skills')); app.use('/api/ships', require('./routes/ships')); app.use('/api/idle', require('./routes/idle')); app.use('/api/dungeons', require('./routes/dungeons')); app.use('/api/base', require('./routes/base')); // Socket.IO handlers (based on LocalServer) io.on('connection', (socket) => { console.log('[GAME SERVER] === NEW CLIENT CONNECTION ==='); console.log('[GAME SERVER] Client connected:', socket.id); // ── Rate limiter: GDD §18.4 — max 20 events/sec per socket ────────── { const RL_MAX = 20, RL_WIN = 1000; let rlCount = 0, rlReset = Date.now() + RL_WIN; const _origOn = socket.on.bind(socket); const SYSTEM_EVENTS = new Set(['connect','disconnect','error','reconnect']); socket.on = function(event, handler) { if (SYSTEM_EVENTS.has(event)) return _origOn(event, handler); return _origOn(event, function(...args) { const now = Date.now(); if (now > rlReset) { rlCount = 0; rlReset = now + RL_WIN; } if (++rlCount > RL_MAX) { console.warn(`[RATE_LIMIT] ${socket.id} dropped '${event}' (${rlCount}/s)`); return; } handler.apply(this, args); }); }; } connectedClients.set(socket.id, { connectedAt: Date.now(), playerData: null, instanceId: null }); console.log('[GAME SERVER] Waiting for authentication from:', socket.id); // Update player count on API server when new player connects updatePlayerCountOnAPI(); // Add timeout for authentication const authTimeout = setTimeout(() => { const clientData = connectedClients.get(socket.id); if (clientData && !clientData.userId) { console.log('[GAME SERVER] Authentication timeout for:', socket.id); socket.emit('authenticated', { success: false, error: 'Authentication timeout' }); socket.disconnect(); } }, 10000); // 10 seconds timeout // Clear timeout when authenticated socket.on('authenticated', () => { clearTimeout(authTimeout); }); // Log all incoming events for debugging socket.onAny((eventName, ...args) => { if (eventName !== 'ping' && eventName !== 'pong') { console.log(`[GAME SERVER] Event received: ${eventName} from ${socket.id}`, args); } }); // Authentication (similar to LocalServer) socket.on('authenticate', async (data) => { console.log('[GAME SERVER] Authenticating client:', socket.id, data); try { // Check database connection first if (mongoose.connection.readyState !== 1) { console.error('[GAME SERVER] Database not connected, authentication failed'); socket.emit('authenticated', { success: false, error: 'Database not available' }); return; } console.log('[GAME SERVER] Database is connected, proceeding with authentication'); // Load player data from database const playerData = await loadPlayerData(data.userId || socket.id, data.username || 'Game Player'); if (playerData) { console.log('[GAME SERVER] Player data loaded successfully:', playerData.username); // Store player data in client connection const clientData = connectedClients.get(socket.id); if (clientData) { clientData.playerData = playerData; clientData.userId = playerData.userId; clientData.username = playerData.username; } else { console.error('[GAME SERVER] No client data found for socket:', socket.id); } // Join server playerData.joinServer(`devgame-server-${PORT}`); // Update last login time + daily login streak gems (GDD §3 v3.2) if (!playerData.stats) playerData.stats = {}; const now = new Date(); const lastLogin = playerData.stats.lastLogin ? new Date(playerData.stats.lastLogin) : null; let streakGems = 0; if (lastLogin) { const hoursSince = (now - lastLogin) / 3600000; if (hoursSince >= 20 && hoursSince <= 48) { // Consecutive day login — increment streak playerData.stats.loginStreak = Math.min(30, (playerData.stats.loginStreak || 0) + 1); } else if (hoursSince > 48) { // Streak broken playerData.stats.loginStreak = 1; } } else { playerData.stats.loginStreak = 1; } // Gem reward: day 1=0, day 3=1, day 7=3, day 14=5, day 30=10 const streak = playerData.stats.loginStreak || 1; if (streak >= 30) streakGems = 10; else if (streak >= 14) streakGems = 5; else if (streak >= 7) streakGems = 3; else if (streak >= 3) streakGems = 1; if (streakGems > 0) { playerData.stats.gems = (playerData.stats.gems || 0) + streakGems; } playerData.stats.lastLogin = now.toISOString(); // Ensure all required fields exist (for existing players) if (playerData.stats.totalExperience === undefined) playerData.stats.totalExperience = playerData.stats.experience || 0; if (playerData.stats.gems === undefined || playerData.stats.gems === 0) playerData.stats.gems = 50; // Restore gems if wiped if (playerData.stats.skillPoints === undefined) playerData.stats.skillPoints = 0; if (playerData.stats.totalKills === undefined) playerData.stats.totalKills = 0; if (playerData.stats.questsCompleted === undefined) playerData.stats.questsCompleted = 0; // Ensure idle system production rates are initialized idleSystem.initializePlayerData(playerData.userId); console.log('[GAME SERVER] Idle system initialized for player:', playerData.username); await savePlayerData(playerData.userId, playerData); // Auto-collect any completed fleet missions (GDD §8.3) try { if (playerData.fleetMissions?.length > 0) { const mResults = missionSystem.collectMissions(playerData, resourceSystem); if (mResults.length > 0) { await savePlayerData(playerData.userId, playerData); mResults.forEach(r => socket.emit('mission_completed', r)); } } } catch(me) { console.error('[AUTH MISSION COLLECT]', me.message); } // Calculate pending offline rewards to show on dashboard const pendingOffline = idleSystem.calculateOfflineRewards(playerData.userId); if (pendingOffline.offlineTime > 0) { playerData.stats.offlineTime = pendingOffline.offlineTime; playerData.stats.offlineCredits = pendingOffline.rewards?.credits || 0; } else { playerData.stats.offlineTime = 0; playerData.stats.offlineCredits = 0; } // In production, validate with API server // Join alliance room for broadcast (chat + research events) if (playerData.allianceId) { socket.join(`alliance_${playerData.allianceId}`); } socket.emit('authenticated', { success: true, user: { id: playerData.userId, username: playerData.username, token: 'game-token-' + Date.now() }, playerData: { ...playerData.toObject(), serverTimestamp: Date.now(), serverTimezone: 'UTC' }, loginStreak: playerData.stats.loginStreak || 1, streakGemsAwarded: streakGems || 0, }); // Analytics: session start + login streak gems analyticsSystem.onLogin(playerData.userId, playerData.username); if (streakGems > 0) analyticsSystem.track('gems.earn', playerData.userId, { amount: streakGems, source: 'login_streak' }); console.log(`[GAME SERVER] ${playerData.username} authenticated with Level ${playerData.stats.level}`); } else { console.error('[GAME SERVER] Failed to load player data'); socket.emit('authenticated', { success: false, error: 'Failed to load player data' }); } } catch (error) { console.error('[GAME SERVER] Authentication error:', error); console.error('[GAME INITIALIZER] Error stack:', error.stack); socket.emit('authenticated', { success: false, error: 'Authentication failed: ' + error.message }); } }); // Game data events (similar to LocalServer) socket.on('saveGameData', async (data) => { console.log('[GAME SERVER] Saving game data for:', socket.id); const clientData = connectedClients.get(socket.id); if (clientData && clientData.userId) { try { // Validate the data before saving if (!data || typeof data !== 'object') { console.warn('[GAME SERVER] Invalid game data received, skipping save'); socket.emit('gameDataSaved', { success: false, error: 'Invalid game data' }); return; } // Update player data with new game data const updatedPlayerData = { ...clientData.playerData, ...data }; clientData.playerData = updatedPlayerData; // Save to database const success = await savePlayerData(clientData.userId, updatedPlayerData); socket.emit('gameDataSaved', { success: success, message: success ? 'Game saved to server!' : 'Failed to save to server' }); console.log(`[GAME SERVER] Saved game data for ${clientData.username}`); } catch (error) { console.error('[GAME SERVER] Error saving game data:', error); socket.emit('gameDataSaved', { success: false, error: 'Failed to save data' }); } } else { console.warn('[GAME SERVER] No client data or user ID found for saveGameData'); socket.emit('gameDataSaved', { success: false, error: 'Player not authenticated' }); } }); socket.on('loadGameData', (data) => { console.log('[GAME SERVER] Loading game data for:', socket.id); const playerData = connectedClients.get(socket.id)?.playerData || {}; socket.emit('gameDataLoaded', { success: true, data: playerData }); }); // Test idle system manually socket.on('testIdleRewards', (data) => { console.log('[GAME SERVER] Testing idle rewards for:', socket.id); const clientData = connectedClients.get(socket.id); if (clientData && clientData.userId) { const onlineRewards = idleSystem.generateOnlineIdleRewards(clientData.userId, 10000); console.log('[GAME SERVER] Test idle rewards:', onlineRewards); socket.emit('testIdleRewards', { rewards: onlineRewards, productionRates: idleSystem.playerProductionRates.get(clientData.userId) }); } else { socket.emit('testIdleRewards', { error: 'Not authenticated' }); } }); // Shop and item system events socket.on('ping', (data) => { console.log('[GAME SERVER] Ping received from:', socket.id, data); socket.emit('pong', { timestamp: Date.now(), received: data.timestamp, serverTime: new Date().toISOString() }); }); socket.on('getShopItems', () => { try { socket.emit('shopItemsReceived', itemSystem.buildShopResponse()); } catch (error) { console.error('[GAME SERVER] getShopItems error:', error); socket.emit('shopItemsReceived', { success: false, error: 'Failed to load shop items' }); } }); socket.on('getShopCategory', (data) => { try { const { category } = data || {}; if (!category) { socket.emit('shopCategoryReceived', { success: false, error: 'Category required' }); return; } socket.emit('shopCategoryReceived', { success: true, category, items: itemSystem.getRandomItemsByCategory(category), timestamp: new Date().toISOString() }); } catch (error) { console.error('[GAME SERVER] getShopCategory error:', error); socket.emit('shopCategoryReceived', { success: false, error: 'Failed to load category items' }); } }); socket.on('getItemDetails', (data) => { const { itemId } = data || {}; if (!itemId) { socket.emit('itemDetailsReceived', { success: false, error: 'Item ID required' }); return; } socket.emit('itemDetailsReceived', itemSystem.buildItemDetailResponse(itemId)); }); // Game-specific events socket.on('joinGameInstance', (data) => { const { instanceId } = data; if (!gameInstances.has(instanceId)) { gameInstances.set(instanceId, { id: instanceId, players: new Set(), createdAt: Date.now() }); } const instance = gameInstances.get(instanceId); instance.players.add(socket.id); connectedClients.get(socket.id).instanceId = instanceId; socket.join(instanceId); socket.emit('joinedGameInstance', { instanceId, playerCount: instance.players.size }); // Notify other players socket.to(instanceId).emit('playerJoinedInstance', { playerId: socket.id, playerCount: instance.players.size }); }); socket.on('leaveGameInstance', (data) => { const clientData = connectedClients.get(socket.id); if (clientData && clientData.instanceId) { const instance = gameInstances.get(clientData.instanceId); if (instance) { instance.players.delete(socket.id); socket.leave(clientData.instanceId); // Clean up empty instances if (instance.players.size === 0) { gameInstances.delete(clientData.instanceId); } // Notify other players socket.to(clientData.instanceId).emit('playerLeftInstance', { playerId: socket.id, playerCount: instance.players.size }); } clientData.instanceId = null; } }); socket.on('gameAction', (data) => { const clientData = connectedClients.get(socket.id); if (clientData && clientData.instanceId) { // Broadcast game action to other players in same instance socket.to(clientData.instanceId).emit('gameAction', { playerId: socket.id, action: data, timestamp: Date.now() }); } }); // Idle rewards events socket.on('claimOfflineRewards', async (data) => { console.log('[GAME SERVER] Claiming offline rewards for:', socket.id); const clientData = connectedClients.get(socket.id); if (!clientData || !clientData.userId) { socket.emit('offlineRewardsClaimed', { success: false, error: 'Not authenticated' }); return; } try { const playerData = await loadPlayerData(clientData.userId, clientData.username || 'Player'); const offlineRewards = idleSystem.calculateOfflineRewards(clientData.userId); if (offlineRewards.offlineTime > 0 && offlineRewards.rewards.credits > 0) { // Apply rewards to player playerData.stats.credits = (playerData.stats.credits || 0) + offlineRewards.rewards.credits; playerData.stats.experience = (playerData.stats.experience || 0) + offlineRewards.rewards.experience; // Update idle system data if (!playerData.idleSystem) playerData.idleSystem = {}; playerData.idleSystem.lastActive = new Date().toISOString(); playerData.idleSystem.totalOfflineTime += offlineRewards.offlineTime; playerData.idleSystem.totalIdleCredits += offlineRewards.rewards.credits; await savePlayerData(clientData.userId, playerData); socket.emit('offlineRewardsClaimed', { success: true, rewards: offlineRewards.rewards, offlineTime: offlineRewards.offlineTime }); console.log(`[GAME SERVER] Offline rewards claimed for ${clientData.username}: ${offlineRewards.rewards.credits} credits`); } else { socket.emit('offlineRewardsClaimed', { success: true, rewards: { credits: 0, experience: 0, energy: 0 }, offlineTime: 0 }); } } catch (error) { console.error('[GAME SERVER] Error claiming offline rewards:', error); socket.emit('offlineRewardsClaimed', { success: false, error: 'Failed to claim rewards' }); } }); // Shop purchase events socket.on('purchaseItem', async (data) => { const clientData = connectedClients.get(socket.id); if (!clientData?.userId) { socket.emit('purchaseCompleted', { success: false, error: 'Not authenticated' }); return; } try { const { itemId, quantity = 1 } = data || {}; if (!itemId) { socket.emit('purchaseCompleted', { success: false, error: 'Item ID required' }); return; } const playerData = await loadPlayerData(clientData.userId, clientData.username || 'Player'); if (!playerData) { socket.emit('purchaseCompleted', { success: false, error: 'Failed to load player data' }); return; } // Server-side validation: item must exist and be in the shop const item = itemSystem.findShopItem(itemId); if (!item) { socket.emit('purchaseCompleted', { success: false, error: 'Item not found in shop' }); return; } // Block re-purchase of already-owned cosmetics and decorations if (item.type === 'cosmetic' && playerData.ownedCosmetics?.includes(item.id)) { socket.emit('purchaseCompleted', { success: false, error: 'You already own this cosmetic' }); return; } if (item.type === 'decoration') { const sb = playerData.starbase || {}; if (item.subtype === 'wallpaper' && sb.ownedWallpapers?.includes(item.id)) { socket.emit('purchaseCompleted', { success: false, error: 'You already own this wallpaper' }); return; } if (item.subtype === 'room_unlock' && item.roomId && sb.unlockedRooms?.includes(item.roomId)) { socket.emit('purchaseCompleted', { success: false, error: 'You already own this room' }); return; } } // Validate affordability const validation = itemSystem.validatePurchase(item, playerData.stats, quantity); if (!validation.valid) { socket.emit('purchaseCompleted', { success: false, error: validation.error }); return; } // Apply purchase (deducts currency + grants item in playerData) const purchaseSummary = itemSystem.applyPurchase(item, playerData, quantity); await savePlayerData(clientData.userId, playerData); clientData.playerData = playerData; socket.emit('purchaseCompleted', { success: true, item: itemSystem.buildItemDetailResponse(item.id).item, quantity, totalCost: purchaseSummary.totalCost, currency: purchaseSummary.currency, newBalance: purchaseSummary.newBalance }); console.log(`[GAME SERVER] Purchase: ${clientData.username} bought ${item.name} x${quantity} for ${purchaseSummary.totalCost} ${purchaseSummary.currency}`); broadcastEconomyUpdate(socket.id); } catch (error) { console.error('[GAME SERVER] purchaseItem error:', error); socket.emit('purchaseCompleted', { success: false, error: 'Purchase failed: ' + error.message }); } }); // ── Starbase customisation ────────────────────────────────────────────── socket.on('get_starbase_data', async () => { const client = connectedClients.get(socket.id); if (!client?.userId) { socket.emit('starbase_data', { success: false }); return; } try { const pd = await loadPlayerData(client.userId, client.username || 'Player'); const sb = pd?.starbase || { wallpaper: null, ownedWallpapers: [], unlockedRooms: [] }; const catalog = itemSystem.getAllItems().filter(i => i.type === 'decoration'); socket.emit('starbase_data', { success: true, starbase: sb, catalog }); } catch (err) { console.error('[GAME SERVER] get_starbase_data error:', err); socket.emit('starbase_data', { success: false, error: err.message }); } }); socket.on('set_wallpaper', async (data) => { const { wallpaperId, roomId } = data || {}; const client = connectedClients.get(socket.id); if (!client?.userId) { socket.emit('wallpaper_set', { success: false, error: 'Not authenticated' }); return; } try { const pd = await loadPlayerData(client.userId, client.username || 'Player'); if (!pd) { socket.emit('wallpaper_set', { success: false, error: 'Player not found' }); return; } if (!pd.starbase) pd.starbase = { wallpaper: null, ownedWallpapers: [], unlockedRooms: [] }; if (wallpaperId === null) { if (roomId) { if (!pd.starbase.roomWallpapers) pd.starbase.roomWallpapers = {}; delete pd.starbase.roomWallpapers[roomId]; } else { pd.starbase.wallpaper = null; } } else { if (!pd.starbase.ownedWallpapers?.includes(wallpaperId)) { socket.emit('wallpaper_set', { success: false, error: 'Wallpaper not owned' }); return; } if (roomId) { if (!pd.starbase.roomWallpapers) pd.starbase.roomWallpapers = {}; pd.starbase.roomWallpapers[roomId] = wallpaperId; } else { pd.starbase.wallpaper = wallpaperId; } } await savePlayerData(client.userId, pd); client.playerData = pd; socket.emit('wallpaper_set', { success: true, starbase: pd.starbase }); } catch (err) { console.error('[GAME SERVER] set_wallpaper error:', err); socket.emit('wallpaper_set', { success: false, error: err.message }); } }); socket.on('get_skills', () => { try { const client = connectedClients.get(socket.id); const playerData = client?.playerData; const allSkills = skillSystem.getAllSkills(); // Merge per-player skill levels and skill points const skillPoints = playerData?.stats?.skillPoints || 0; const playerSkills = playerData?.skills || {}; const skills = allSkills.map(sk => ({ ...sk, level: playerSkills[sk.id]?.level || 0, locked: sk.prerequisites ? sk.prerequisites.some(req => (playerSkills[req]?.level || 0) < 1) : false, })); socket.emit('skills_data', { success: true, skills, skillPoints }); } catch (error) { console.error('[GAME SERVER] Error sending skills:', error); socket.emit('skills_data', { success: false, skills: [], skillPoints: 0 }); } }); socket.on('allocate_skill_point', async ({ skillId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return socket.emit('skill_allocated', { success: false, error: 'Not authenticated' }); const pd = client.playerData; const skillPoints = pd.stats?.skillPoints || 0; if (skillPoints < 1) return socket.emit('skill_allocated', { success: false, error: 'No skill points available' }); const allSkills = skillSystem.getAllSkills(); const skillDef = allSkills.find(s => s.id === skillId); if (!skillDef) return socket.emit('skill_allocated', { success: false, error: 'Unknown skill' }); pd.skills = pd.skills || {}; pd.skills[skillId] = pd.skills[skillId] || { level: 0 }; const curLevel = pd.skills[skillId].level; const maxLevel = skillDef.maxLevel || 5; if (curLevel >= maxLevel) return socket.emit('skill_allocated', { success: false, error: 'Skill already maxed' }); // Check prerequisites if (skillDef.prerequisites) { for (const req of skillDef.prerequisites) { if ((pd.skills[req]?.level || 0) < 1) { return socket.emit('skill_allocated', { success: false, error: `Requires ${req} first` }); } } } pd.skills[skillId].level = curLevel + 1; pd.stats.skillPoints = skillPoints - 1; await savePlayerData(pd.userId, pd); socket.emit('skill_allocated', { success: true, skillId, newLevel: pd.skills[skillId].level, skillPoints: pd.stats.skillPoints }); } catch (err) { socket.emit('skill_allocated', { success: false, error: err.message }); } }); socket.on('get_recipes', () => { console.log('[GAME SERVER] Sending crafting recipes to:', socket.id); try { const recipes = craftingSystem.getAllRecipes(); socket.emit('recipes_data', recipes); } catch (error) { console.error('[GAME SERVER] Error sending recipes:', error); socket.emit('recipes_data', []); } }); // ── CRAFTING: craft_item (GDD §11) — timed queue ──────────────────────── // Materials are consumed immediately; item arrives after craft_time_seconds. // Skill level reduces time: time = base * (1 - 0.01 * craftingLevel) capped at 50% reduction. socket.on('craft_item', async ({ recipeId } = {}) => { if (!socket.userId) return socket.emit('craft_result', { success: false, error: 'Not authenticated' }); if (!recipeId) return socket.emit('craft_result', { success: false, error: 'No recipe specified' }); try { const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return socket.emit('craft_result', { success: false, error: 'Player not found' }); const recipe = craftingSystem.getRecipe(recipeId); if (!recipe) return socket.emit('craft_result', { success: false, error: 'Recipe not found' }); // Check max queue size (5 simultaneous crafts) const crafting = playerData.crafting || {}; const queue = Array.isArray(crafting.queue) ? crafting.queue : []; if (queue.length >= 5) return socket.emit('craft_result', { success: false, error: 'Crafting queue full (max 5)' }); // Server-side material check const check = craftingSystem.checkMaterials(recipeId, playerData.inventory || { items: [] }); if (!check.canCraft) return socket.emit('craft_result', { success: false, error: 'Missing materials', missing: check.missing }); // Consume materials now const inputs = recipe.recipe?.inputs || recipe.inputs || {}; const inv = playerData.inventory || { items: [] }; for (const [itemId, qty] of Object.entries(inputs)) { craftingSystem._removeItems(inv, itemId, qty); } playerData.inventory = inv; // Calculate craft time with skill reduction (SkillSystem + legacy crafting.skill) const skillLevel = crafting.skill || 1; const legacyReduction = Math.min(0.5, skillLevel * 0.01); // 1% per level, cap 50% const baseTimeSec = recipe.recipe?.craft_time_seconds || recipe.recipe?.alloy_time_seconds || recipe.recipe?.smelt_time_seconds || recipe.recipe?.process_time_seconds || recipe.recipe?.cook_time_seconds || recipe.recipe?.harvest_time_seconds || 30; // default 30s // Apply SkillSystem crafting bonuses on top of legacy reduction const afterLegacy = Math.max(5, Math.round(baseTimeSec * (1 - legacyReduction))); const timeSec = skillSystem ? skillSystem.applyCraftingBonuses(socket.userId, afterLegacy) : afterLegacy; const completesAt = Date.now() + timeSec * 1000; // Queue the craft const craftJobId = `craft_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; const outputs = recipe.recipe?.output || recipe.output || {}; queue.push({ jobId: craftJobId, recipeId, outputs, xpGain: recipe.craft?.xp || recipe.xp || 10, startedAt: Date.now(), completesAt, timeSec, }); // Update skill XP on start (small preview XP — full XP on collect) crafting.queue = queue; crafting.skill = crafting.skill || 1; crafting.experience = crafting.experience || 0; playerData.crafting = crafting; playerData.markModified('inventory'); playerData.markModified('crafting'); await playerData.save(); socket.emit('craft_result', { success: true, queued: true, jobId: craftJobId, recipeId, timeSec, completesAt, queueLength: queue.length, }); socket.emit('inventory_update', { items: playerData.inventory?.items || [], maxSize: playerData.inventory?.maxSize || 50, }); socket.emit('craft_queue_update', { queue }); console.log(`[CRAFTING] ${socket.username} queued ${recipeId} — completes in ${timeSec}s`); } catch (err) { console.error('[CRAFTING] craft_item error:', err); socket.emit('craft_result', { success: false, error: 'Server error' }); } }); // ── CRAFTING: collect_craft — collect a completed craft job ───────────── socket.on('collect_craft', async ({ jobId } = {}) => { if (!socket.userId || !jobId) return; try { const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return; const crafting = playerData.crafting || {}; const queue = Array.isArray(crafting.queue) ? crafting.queue : []; const idx = queue.findIndex(j => j.jobId === jobId); if (idx < 0) return socket.emit('collect_craft_result', { success: false, error: 'Job not found' }); const job = queue[idx]; if (Date.now() < job.completesAt) return socket.emit('collect_craft_result', { success: false, error: 'Not ready yet', remainingMs: job.completesAt - Date.now() }); // Add outputs to inventory const inv = playerData.inventory || { items: [] }; const outputItems = []; for (const [itemId, qty] of Object.entries(job.outputs || {})) { const item = { id: itemId, itemId, quantity: qty, obtainedAt: Date.now(), source: 'crafting' }; (inv.items || inv).push(item); outputItems.push(item); } playerData.inventory = inv; // Award XP and skill up crafting.experience = (crafting.experience || 0) + job.xpGain; const newSkill = Math.min(50, Math.floor(crafting.experience / 200) + 1); crafting.skill = newSkill; crafting.totalCrafted = (crafting.totalCrafted || 0) + 1; queue.splice(idx, 1); crafting.queue = queue; playerData.crafting = crafting; playerData.markModified('inventory'); playerData.markModified('crafting'); await playerData.save(); // Push craft_complete notification const craftUserId = connectedClients.get(socket.id)?.userId; if (craftUserId) { socialSystem.pushNotification(craftUserId, { type: 'craft_complete', title: '🔨 Crafting Complete!', body: `Your item is ready to collect from the crafting queue.`, }).catch(() => {}); } socket.emit('collect_craft_result', { success: true, jobId, output: outputItems, xpGained: job.xpGain, craftingLevel: newSkill, craftingXp: crafting.experience, }); socket.emit('inventory_update', { items: inv.items || [], maxSize: playerData.inventory?.maxSize || 50 }); socket.emit('craft_queue_update', { queue }); analyticsSystem.track('craft.complete', connectedClients.get(socket.id)?.userId, { recipeId: job.recipeId }); } catch (err) { socket.emit('collect_craft_result', { success: false, error: 'Server error' }); } }); // ── CRAFTING: get_craft_queue — fetch current queue state ─────────────── socket.on('get_craft_queue', async () => { if (!socket.userId) return; try { const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return; const queue = Array.isArray(playerData.crafting?.queue) ? playerData.crafting.queue : []; socket.emit('craft_queue_update', { queue, skill: playerData.crafting?.skill || 1, xp: playerData.crafting?.experience || 0 }); } catch (err) {} }); // ── CRAFTING: speedup_craft — spend gems to instant-finish a job (GDD §11 v3.3) ── socket.on('speedup_craft', async ({ jobId, gemCost = 3 } = {}) => { if (!socket.userId || !jobId) return; try { const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return socket.emit('speedup_craft_result', { success: false, error: 'Player not found' }); const crafting = playerData.crafting || {}; const queue = Array.isArray(crafting.queue) ? crafting.queue : []; const idx = queue.findIndex(j => j.jobId === jobId); if (idx < 0) return socket.emit('speedup_craft_result', { success: false, error: 'Job not found' }); if (Date.now() >= queue[idx].completesAt) return socket.emit('speedup_craft_result', { success: false, error: 'Already complete — collect it!' }); // Deduct gems const currentGems = playerData.stats?.gems || 0; if (currentGems < gemCost) return socket.emit('speedup_craft_result', { success: false, error: `Need ${gemCost} gems, have ${currentGems}` }); playerData.stats.gems = currentGems - gemCost; // Instant-complete the job queue[idx].completesAt = Date.now() - 1; crafting.queue = queue; playerData.crafting = crafting; playerData.markModified('crafting'); await playerData.save(); socket.emit('speedup_craft_result', { success: true, jobId, gems: playerData.stats.gems }); socket.emit('craft_queue_update', { queue, skill: crafting.skill || 1, xp: crafting.experience || 0 }); socket.emit('economy_data', { credits: playerData.stats.credits, gems: playerData.stats.gems }); } catch (err) { socket.emit('speedup_craft_result', { success: false, error: 'Server error' }); } }); // ── GEM PURCHASE (GDD §Phase2 v3.3 Gem Store) ─────────────────────────── const GEM_CATALOGUE = { inv_slot_5: { name: '+5 Inventory Slots', cost: 5, apply: (pd) => { pd.inventory = pd.inventory || {}; pd.inventory.maxSize = (pd.inventory.maxSize || 50) + 5; } }, craft_queue_1: { name: '+1 Craft Queue Slot', cost: 8, apply: (pd) => { pd.crafting = pd.crafting || {}; pd.crafting.maxQueue = (pd.crafting.maxQueue || 5) + 1; } }, fleet_slot_6: { name: '+1 Fleet Slot', cost: 12, apply: (pd) => { pd.stats = pd.stats || {}; pd.stats.bonusFleetSlots = (pd.stats.bonusFleetSlots || 0) + 1; } }, xp_boost_24h: { name: '24h XP Boost', cost: 6, apply: (pd) => { pd.stats = pd.stats || {}; pd.stats.xpBoostExpiry = Date.now() + 86400000; pd.stats.xpBoostMult = 1.5; } }, res_boost_24h: { name: '24h Resource Boost', cost: 6, apply: (pd) => { pd.stats = pd.stats || {}; pd.stats.resBoostExpiry = Date.now() + 86400000; pd.stats.resBoostMult = 1.25; } }, nameplate_gold: { name: 'Gold Nameplate', cost: 3, cosmetic: true }, nameplate_neon: { name: 'Neon Nameplate', cost: 3, cosmetic: true }, ship_trail_blue:{ name: 'Blue Thruster Trail', cost: 4, cosmetic: true }, ship_trail_fire:{ name: 'Fire Thruster Trail', cost: 4, cosmetic: true }, }; socket.on('gem_purchase', async ({ itemId } = {}) => { if (!socket.userId || !itemId) return; try { const item = GEM_CATALOGUE[itemId]; if (!item) return socket.emit('gem_purchase_result', { success: false, error: 'Unknown item' }); const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return socket.emit('gem_purchase_result', { success: false, error: 'Player not found' }); const gems = playerData.stats?.gems || 0; if (gems < item.cost) return socket.emit('gem_purchase_result', { success: false, error: `Need ${item.cost} 💎 (have ${gems})` }); // Check not already owned (for permanent upgrades) const owned = (playerData.inventory?.items || []).some(i => i.id === itemId); const isOneTime = !item.cosmetic && !['xp_boost_24h','res_boost_24h'].includes(itemId); if (isOneTime && owned) return socket.emit('gem_purchase_result', { success: false, error: 'Already owned' }); // Deduct gems and apply effect playerData.stats.gems = gems - item.cost; if (item.apply) item.apply(playerData); // Add to inventory as record playerData.inventory = playerData.inventory || {}; playerData.inventory.items = playerData.inventory.items || []; const newItem = { id: itemId, name: item.name, type: 'gem_item', acquiredAt: Date.now() }; if (item.cosmetic || ['xp_boost_24h','res_boost_24h'].includes(itemId)) { playerData.inventory.items.push(newItem); } else if (!owned) { playerData.inventory.items.push(newItem); } playerData.markModified('inventory'); playerData.markModified('stats'); playerData.markModified('crafting'); await playerData.save(); socket.emit('gem_purchase_result', { success: true, itemId, itemName: item.name, gems: playerData.stats.gems, newItem }); socket.emit('economy_data', { credits: playerData.stats.credits, gems: playerData.stats.gems }); } catch(err) { socket.emit('gem_purchase_result', { success: false, error: 'Server error' }); } }); // ── CRAFTING: check_craft (can player craft a recipe?) ────────────────── socket.on('check_craft', async ({ recipeId } = {}) => { if (!socket.userId || !recipeId) return; try { const playerData = await PlayerData.findOne({ userId: socket.userId }); if (!playerData) return; const recipe = craftingSystem.getRecipe(recipeId); const result = craftingSystem.checkMaterials(recipeId, playerData.inventory || { items: [] }); const skillLevel = playerData.crafting?.skill || 1; const reduction = Math.min(0.5, skillLevel * 0.01); const baseTime = recipe?.recipe?.craft_time_seconds || recipe?.recipe?.alloy_time_seconds || recipe?.recipe?.smelt_time_seconds || 30; const timeSec = Math.max(5, Math.round(baseTime * (1 - reduction))); socket.emit('craft_check_result', { recipeId, ...result, timeSec, skillLevel }); } catch (err) { console.error('[CRAFTING] check_craft error:', err); } }); socket.on('get_dungeons', () => { console.log('[GAME SERVER] Sending dungeons data to:', socket.id); const dungeons = dungeonSystem.getDungeonsGroupedByDifficulty(); socket.emit('dungeons_data', dungeons); }); socket.on('get_enemy_templates', () => { // Client expects { [id]: enemyTemplate } — convert array to keyed object const arr = dungeonSystem.getEnemyTemplates(); const templates = {}; for (const e of arr) templates[e.id] = e; socket.emit('enemy_templates_data', templates); }); // Economy System Packet Handlers socket.on('get_economy_data', () => { console.log('[GAME SERVER] Sending economy data to:', socket.id); const clientData = connectedClients.get(socket.id); if (clientData && clientData.playerData) { const economyData = { credits: clientData.playerData.stats.credits || 0, gems: clientData.playerData.stats.gems || 0 }; socket.emit('economy_data', economyData); } }); // Function to broadcast economy updates to specific client function broadcastEconomyUpdate(socketId) { const clientData = connectedClients.get(socketId); if (clientData && clientData.playerData) { const economyData = { credits: clientData.playerData.stats.credits || 0, gems: clientData.playerData.stats.gems || 0 }; io.to(socketId).emit('economy_data', economyData); console.log('[GAME SERVER] Broadcasted economy update to:', socketId, economyData); } } // ════════════════════════════════════════════════════════════════════════ // BUILDINGS / BASE HANDLERS // ════════════════════════════════════════════════════════════════════════ // Building definitions (in-memory; extend to JSON data later) const BUILDING_DEFS = { // GDD §6.2 — buildings cost metal/gas/crystal in addition to credits command_center: { name:'Command Center', maxLevel:20, baseCost:{credits:0, metal:100 }, timeBase:60, effects:{buildSlots:1}, icon:'fa-satellite-dish', description:'Gates all other building levels.' }, mining_facility: { name:'Mining Facility', maxLevel:20, baseCost:{credits:0, metal:300 }, timeBase:90, effects:{metalPerHr:100}, icon:'fa-industry', description:'+15% metal/hr per level.' }, gas_extractor: { name:'Gas Extractor', maxLevel:20, baseCost:{credits:0, metal:200, gas:100 }, timeBase:90, effects:{gasPerHr:80}, icon:'fa-cloud', description:'+15% gas/hr per level.' }, power_reactor: { name:'Power Reactor', maxLevel:20, baseCost:{credits:0, metal:400, crystal:50 }, timeBase:120, effects:{energyPerHr:200}, icon:'fa-bolt', description:'+200 energy/hr per level.' }, shipyard: { name:'Shipyard', maxLevel:15, baseCost:{credits:500, metal:1000, gas:300 }, timeBase:180, effects:{buildSpeed:10}, icon:'fa-anchor', description:'Build ships; each level +10% speed.' }, research_lab: { name:'Research Lab', maxLevel:20, baseCost:{credits:300, metal:600, crystal:100 }, timeBase:150, effects:{researchSpeed:8}, icon:'fa-flask', description:'+8% research speed per level.' }, defense_platform: { name:'Defense Platform', maxLevel:10, baseCost:{credits:0, metal:500, crystal:150 }, timeBase:120, effects:{baseDPS:50}, icon:'fa-shield-alt', description:'+50 auto-defense DPS per level.' }, storage_depot: { name:'Storage Depot', maxLevel:15, baseCost:{credits:0, metal:200 }, timeBase:60, effects:{storageBonus:2000}, icon:'fa-warehouse', description:'+2000 storage cap per level.' }, sensor_array: { name:'Sensor Array', maxLevel:10, baseCost:{credits:200, metal:300, crystal:50 }, timeBase:90, effects:{sensorRange:1}, icon:'fa-broadcast-tower',description:'Reveals sectors; level 7+ enables interdiction.' }, hangar_bay: { name:'Hangar Bay', maxLevel:10, baseCost:{credits:500, metal:1500, gas:400 }, timeBase:200, effects:{fleetSlots:1}, icon:'fa-shuttle-space', description:'Level 2/4/7/10 unlock extra fleet slots.' }, trade_port: { name:'Trade Port', maxLevel:10, baseCost:{credits:2500}, timeBase:180, effects:{tradeIncome:5}, icon:'fa-ship', description:'+5% market income per level.' }, shield_generator: { name:'Shield Generator', maxLevel:10, baseCost:{credits:4000}, timeBase:240, effects:{shieldDuration:8}, icon:'fa-circle-notch', description:'Deployable base shield +8hrs per level.' }, crystal_refinery: { name:'Crystal Refinery', maxLevel:20, baseCost:{credits:900}, timeBase:100, effects:{crystalPerHr:60}, icon:'fa-gem', description:'+12% crystal/hr per level.' }, }; function getBuildingCost(defId, currentLevel) { const def = BUILDING_DEFS[defId]; if (!def) return null; const mult = Math.pow(1.6, currentLevel); // exponential cost scaling return { credits: Math.floor((def.baseCost.credits || 0) * mult) }; } function getBuildingBuildTime(defId, currentLevel, playerData) { const def = BUILDING_DEFS[defId]; if (!def) return 60; const baseTime = Math.floor(def.timeBase * Math.pow(1.8, currentLevel)); // Apply research buildTimeReduction (GDD §6.2 formula) const reduction = Math.min(0.75, (playerData?.research?.effects?.buildTimeReduction || 0) / 100); return Math.max(5, Math.floor(baseTime * (1 - reduction))); } socket.on('get_base_data', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return socket.emit('base_data', { success: false, error: 'Not authenticated' }); const pd = client.playerData; // Init buildings if missing if (!pd.buildings) pd.buildings = { command_center: { level: 1, buildQueue: null } }; // Check for completed builds const now = Date.now(); let changed = false; for (const [id, bld] of Object.entries(pd.buildings)) { if (bld.buildQueue && now >= bld.buildQueue.completesAt) { bld.level = (bld.level || 1) + 1; bld.buildQueue = null; changed = true; socket.emit('building_upgraded', { success: true, buildingId: id, newLevel: bld.level }); } } if (changed) await savePlayerData(pd.userId, pd); const buildings = Object.entries(pd.buildings).map(([id, bld]) => { const def = BUILDING_DEFS[id] || {}; const nextCost = getBuildingCost(id, bld.level || 1); const nextTime = getBuildingBuildTime(id, bld.level || 1, pd); return { id, name: def.name || id, level: bld.level || 1, maxLevel: def.maxLevel || 10, icon: def.icon || 'fa-building', description: def.description || '', buildQueue: bld.buildQueue || null, nextCost, nextTime, effects: def.effects || {} }; }); // Available to build (not yet built) const available = Object.entries(BUILDING_DEFS) .filter(([id]) => !pd.buildings[id]) .map(([id, def]) => ({ id, name: def.name, icon: def.icon, description: def.description, maxLevel: def.maxLevel, effects: def.effects, cost: getBuildingCost(id, 0), buildTime: getBuildingBuildTime(id, 0, pd) })); socket.emit('base_data', { success: true, buildings, available }); } catch (err) { console.error('[BASE] get_base_data error:', err); socket.emit('base_data', { success: false, error: err.message }); } }); socket.on('construct_building', async ({ buildingId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; if (!pd.buildings) pd.buildings = {}; if (pd.buildings[buildingId]) return socket.emit('building_constructed', { success: false, error: 'Already built' }); const def = BUILDING_DEFS[buildingId]; if (!def) return socket.emit('building_constructed', { success: false, error: 'Unknown building' }); const cost = getBuildingCost(buildingId, 0); // Deduct credits if ((pd.stats.credits || 0) < (cost.credits||0)) return socket.emit('building_constructed', { success: false, error: `Need ${cost.credits} credits` }); pd.stats.credits -= (cost.credits||0); // Deduct resources (GDD §6.2) resourceSystem.initResources(pd); const resCost = {}; if (cost.metal) resCost.metal = cost.metal; if (cost.gas) resCost.gas = cost.gas; if (cost.crystal) resCost.crystal = cost.crystal; if (Object.keys(resCost).length > 0) { try { resourceSystem.spend(pd, resCost); } catch(e) { pd.stats.credits += (cost.credits||0); return socket.emit('building_constructed',{success:false,error:e.message}); } } const buildTime = getBuildingBuildTime(buildingId, 0, pd) * 1000; pd.buildings[buildingId] = { level: 0, buildQueue: { startedAt: Date.now(), completesAt: Date.now() + buildTime } }; await savePlayerData(pd.userId, pd); socket.emit('building_constructed', { success: true, buildingId, completesAt: Date.now() + buildTime }); socket.emit('economy_data', { credits: pd.stats.credits, gems: pd.stats.gems }); socket.emit('resource_update',{resources:pd.resources,rates:resourceSystem.getProductionRates(pd),caps:resourceSystem.getStorageCaps(pd)}); } catch (err) { socket.emit('building_constructed', { success: false, error: err.message }); } }); // collect_building — client polls; resolves completed build queue, emits animation cue socket.on('collect_building', async ({ buildingId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const bld = pd.buildings?.[buildingId]; if (!bld) return socket.emit('building_collect_result', { success: false, error: 'Building not found' }); const now = Date.now(); if (!bld.buildQueue) return socket.emit('building_collect_result', { success: false, error: 'No build in progress' }); if (now < bld.buildQueue.completesAt) { return socket.emit('building_collect_result', { success: false, error: 'Not complete yet', remainingMs: bld.buildQueue.completesAt - now, }); } const prevLevel = bld.level || 0; bld.level = prevLevel + 1; bld.buildQueue = null; await savePlayerData(pd.userId, pd); // Emit construction_complete for client animation socket.emit('construction_complete', { buildingId, newLevel: bld.level, previousLevel: prevLevel, icon: BUILDING_DEFS[buildingId]?.icon || 'fa-building', }); socket.emit('building_collect_result', { success: true, buildingId, newLevel: bld.level }); socket.emit('resource_update', { resources: pd.resources, rates: resourceSystem.getProductionRates(pd), caps: resourceSystem.getStorageCaps(pd), }); } catch (err) { socket.emit('building_collect_result', { success: false, error: err.message }); } }); socket.on('upgrade_building', async ({ buildingId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const bld = pd.buildings?.[buildingId]; if (!bld) return socket.emit('building_upgraded', { success: false, error: 'Building not found' }); if (bld.buildQueue) return socket.emit('building_upgraded', { success: false, error: 'Already building' }); const def = BUILDING_DEFS[buildingId]; if (bld.level >= (def?.maxLevel || 10)) return socket.emit('building_upgraded', { success: false, error: 'Already max level' }); const cost = getBuildingCost(buildingId, bld.level); if ((pd.stats.credits || 0) < (cost.credits||0)) return socket.emit('building_upgraded', { success: false, error: `Need ${cost.credits} credits` }); pd.stats.credits -= (cost.credits||0); resourceSystem.initResources(pd); const resCost = {}; if (cost.metal) resCost.metal = cost.metal; if (cost.gas) resCost.gas = cost.gas; if (cost.crystal) resCost.crystal = cost.crystal; if (Object.keys(resCost).length > 0) { try { resourceSystem.spend(pd, resCost); } catch(e) { pd.stats.credits += (cost.credits||0); return socket.emit('building_upgraded',{success:false,error:e.message}); } } const buildTime = getBuildingBuildTime(buildingId, bld.level, pd) * 1000; bld.buildQueue = { startedAt: Date.now(), completesAt: Date.now() + buildTime }; await savePlayerData(pd.userId, pd); socket.emit('building_upgraded', { success: true, buildingId, completesAt: Date.now() + buildTime }); socket.emit('economy_data', { credits: pd.stats.credits, gems: pd.stats.gems }); socket.emit('resource_update',{resources:pd.resources,rates:resourceSystem.getProductionRates(pd),caps:resourceSystem.getStorageCaps(pd)}); } catch (err) { socket.emit('building_upgraded', { success: false, error: err.message }); } }); socket.on('start_dungeon', (data) => { console.log('[GAME SERVER] Starting dungeon for:', socket.id, data); try { const { dungeonId, userId } = data; // Check if dungeon is one-time only and already completed const dungeon = dungeonSystem.getDungeon(dungeonId); if (dungeon && dungeon.oneTimeOnly) { const completedDungeons = dungeonSystem.getPlayerCompletedDungeons(userId); if (completedDungeons.includes(dungeonId)) { socket.emit('dungeon_started', { success: false, error: 'This dungeon can only be completed once per character.' }); return; } } const instance = dungeonSystem.createInstance(dungeonId, userId, []); socket.emit('dungeon_started', { instance }); } catch (error) { console.error('[GAME SERVER] Error starting dungeon:', error); socket.emit('dungeon_started', { success: false, error: error.message }); } }); socket.on('process_encounter', (data) => { console.log('[GAME SERVER] Processing encounter for:', socket.id, data); try { const { instanceId, userId } = data; const result = dungeonSystem.startEncounter(instanceId, userId); // Auto-complete combat for enemies with 0 attack if (result.encounter.enemies && result.encounter.enemies.length > 0) { const allZeroAttack = result.encounter.enemies.every(enemy => enemy.attack === 0); if (allZeroAttack) { console.log('[GAME SERVER] Auto-combat: All enemies have 0 attack, completing encounter'); const completionResult = dungeonSystem.completeEncounter(instanceId, userId, { victory: true }); socket.emit('encounter_completed', { success: true, rewards: completionResult.rewards, nextEncounter: completionResult.nextEncounter, encounterIndex: completionResult.encounterIndex }); return; } } socket.emit('encounter_data', { encounter: result.encounter, encounterIndex: result.encounterIndex, instance: result.instance }); } catch (error) { console.error('[GAME SERVER] Error processing encounter:', error); socket.emit('encounter_data', { success: false, error: error.message }); } }); socket.on('complete_dungeon', async (data) => { console.log('[GAME SERVER] Completing dungeon for:', socket.id, data); try { const { instanceId, userId } = data; const client = connectedClients.get(socket.id); const result = dungeonSystem.completeDungeon(instanceId); // Grant item rewards + gem bonuses to player inventory if (result.success && client?.userId) { try { const playerData = await loadPlayerData(client.userId, client.username || 'Player'); if (playerData) { // Grant item rewards if (result.rewards?.length) { for (const reward of result.rewards) { const item = itemSystem.getItem(reward.itemId); if (!item) continue; itemSystem.applyPurchase(item, playerData, reward.quantity || 1); } } // Gem rewards by dungeon difficulty (GDD §11 v3.2) // extreme=1, legendary=3, boss kills add 1 bonus gem const gemsByDifficulty = { tutorial:0, easy:0, medium:0, hard:0, extreme:1, legendary:3 }; const dungeon = dungeonSystem.getDungeon ? dungeonSystem.getDungeon(instanceId) : null; const difficulty = dungeon?.difficulty || result.difficulty || 'medium'; const gemReward = gemsByDifficulty[difficulty] || 0; if (gemReward > 0) { playerData.stats.gems = (playerData.stats.gems || 0) + gemReward; result.gemsAwarded = gemReward; } await savePlayerData(client.userId, playerData); client.playerData = playerData; if (gemReward > 0) { socket.emit('economy_data', { credits: playerData.stats.credits, gems: playerData.stats.gems }); } } } catch (grantErr) { console.error('[GAME SERVER] Failed to grant dungeon rewards:', grantErr.message); } } // Log to combat log (GDD §9.5) if (client?.playerData) { socialSystem.addCombatLogEntry(client.playerData.userId, { type: 'dungeon', outcome: 'win', enemy: instanceId, xpGained: result.xpReward || 0, timestamp: new Date() }).catch(()=>{}); } socket.emit('dungeon_completed', { rewards: result }); analyticsSystem.track('dungeon.complete', connectedClients.get(socket.id)?.userId, { dungeonId: data?.dungeonId }); // Push notification for dungeon completion if (client?.playerData) { socialSystem.pushNotification(client.playerData.userId, { type: 'dungeon_complete', title: '🏰 Dungeon Complete!', body: `You cleared ${result.dungeonName || 'the dungeon'} and earned ${result.creditsReward || 0} credits`, meta: { dungeonId: data?.dungeonId }, }).catch(() => {}); } } catch (error) { console.error('[GAME SERVER] Error completing dungeon:', error); socket.emit('dungeon_completed', { success: false, error: error.message }); } }); socket.on('get_quests', (data) => { console.log('[GAME SERVER] Getting quests for:', socket.id); try { const userId = connectedClients.get(socket.id)?.userId || 'anonymous'; const playerQD = questSystem.getPlayerQuests(userId); // Build quests with per-player progress merged in const buildList = (category) => questSystem.getQuestsByCategory(category).map(q => { const activeState = playerQD.active[q.id]; return { ...q, status: playerQD.completed.includes(q.id) ? 'completed' : activeState ? 'active' : (q.prerequisites?.playerLevelMin || 1) <= 1 ? 'available' : 'locked', objectives: q.objectives.map(obj => ({ ...obj, progress: activeState?.objectives?.[obj.id]?.progress ?? 0, complete: activeState?.objectives?.[obj.id]?.complete ?? false, })), }; }); socket.emit('quests_data', { success: true, mainQuests: buildList('main_story'), dailyQuests: buildList('daily'), weeklyQuests: buildList('weekly'), monthlyQuests:buildList('monthly'), playerState: playerQD, }); } catch (error) { console.error('[GAME SERVER] Error getting quests:', error); socket.emit('quests_data', { success: false, error: error.message }); } }); // ── quest_completed: a system or action triggers a quest completion ──── // Note: io.on doesn't exist; use a proper socket-level handler instead. // Clients can emit 'complete_quest' to finish a quest; the server validates & pushes result. socket.on('complete_quest', async (data) => { const { questId } = data || {}; const client = connectedClients.get(socket.id); if (!client?.userId) { socket.emit('quest_completed', { success: false, error: 'Not authenticated' }); return; } try { const quest = questSystem.getQuest(questId); if (!quest) { socket.emit('quest_completed', { success: false, error: 'Quest not found' }); return; } const result = questSystem.completeQuest(client.userId, questId, quest.rewards); // Resolve reward amounts into flat numbers for the client const rewardCredits = quest.rewards ? quest.rewards.filter(r => r.type === 'coin').reduce((s, r) => s + (r.amount || 0), 0) : 0; const rewardMoney = quest.rewards ? quest.rewards.filter(r => r.type === 'money').reduce((s, r) => s + (r.amount || 0), 0) : 0; // Credit the player + grant item rewards if (client.playerData) { if (rewardCredits > 0) { client.playerData.stats.credits = (client.playerData.stats.credits || 0) + rewardCredits; } client.playerData.stats.questsCompleted = (client.playerData.stats.questsCompleted || 0) + 1; // Grant item rewards (decorations, consumables, etc.) const itemRewards = quest.rewards?.filter(r => r.type === 'item') || []; for (const reward of itemRewards) { const item = itemSystem.getItem(reward.itemId); if (item) itemSystem.applyPurchase(item, client.playerData, reward.quantity || 1); } await savePlayerData(client.playerData.userId, client.playerData); } socket.emit('quest_completed', { success: true, questId, questName: quest.name, rewards: { credits: rewardCredits, money: rewardMoney, items: quest.rewards?.filter(r => r.type === 'item') || [] }, }); // Push updated economy to client socket.emit('economy_data', { credits: client.playerData?.stats?.credits || 0, gems: client.playerData?.stats?.gems || 0, }); } catch (err) { console.error('[GAME SERVER] complete_quest error:', err); socket.emit('quest_completed', { success: false, error: err.message }); } }); socket.on('next_room', (data) => { console.log('[GAME SERVER] Moving to next room for:', socket.id, data); try { const { instanceId, userId } = data; const result = dungeonSystem.moveToNextRoom(instanceId, userId); socket.emit('next_room_data', result); } catch (error) { console.error('[GAME SERVER] Error moving to next room:', error); socket.emit('next_room_data', { success: false, error: error.message }); } }); socket.on('get_dungeon_status', (data) => { console.log('[GAME SERVER] Getting dungeon status for:', socket.id, data); try { const { userId } = data; const instance = dungeonSystem.getPlayerInstance(userId); if (instance) { socket.emit('dungeon_status', { hasActiveDungeon: true, currentInstance: instance }); } else { socket.emit('dungeon_status', { hasActiveDungeon: false, currentInstance: null }); } } catch (error) { console.error('[GAME SERVER] Error getting dungeon status:', error); socket.emit('dungeon_status', { success: false, error: error.message }); } }); // ── joinServer: client emits after connecting ────────────────────────── socket.on('joinServer', (data) => { const { serverId, userId, username } = data || {}; console.log(`[GAME SERVER] joinServer from ${username} (${userId}) for server ${serverId}`); // Join a socket.io room named after the serverId so we can broadcast to it if (serverId) socket.join(serverId); const client = connectedClients.get(socket.id); if (client) { client.serverId = serverId; } // Notify room that a player joined socket.to(serverId).emit('playerJoined', { userId, username }); }); // ── getPlayerList ─────────────────────────────────────────────────────── socket.on('getPlayerList', (data) => { const { serverId } = data || {}; const players = []; for (const [sid, cd] of connectedClients) { if (cd.serverId === serverId && cd.username) { players.push({ userId: cd.userId, username: cd.username }); } } socket.emit('playerList', { players }); }); // ── chatMessage: relay to everyone in the server room ────────────────── socket.on('chatMessage', (data) => { const client = connectedClients.get(socket.id); const serverId = client?.serverId; const payload = { userId: client?.userId || data.userId, username: client?.username || data.username || 'Unknown', message: data.message || '', timestamp: Date.now(), }; if (serverId) { io.to(serverId).emit('chatMessage', payload); } else { socket.emit('chatMessage', payload); // echo back to sender only } }); // ── updatePlayerStats: client-reported stat delta (server validates) ──── socket.on('updatePlayerStats', async (data) => { const client = connectedClients.get(socket.id); if (!client?.playerData) { socket.emit('player_stat_update', { success: false, error: 'Not authenticated' }); return; } try { const pd = client.playerData; // Only trust non-economy fields from client; economy is server-authoritative const allowed = ['playTime']; for (const key of allowed) { if (data.playerStats?.[key] !== undefined) { pd.stats[key] = data.playerStats[key]; } } await savePlayerData(pd.userId, pd); socket.emit('player_stat_update', { success: true, stats: pd.stats }); } catch (err) { console.error('[GAME SERVER] updatePlayerStats error:', err); socket.emit('player_stat_update', { success: false, error: err.message }); } }); // ── exit_dungeon: player voluntarily retreats from a dungeon ───────────── socket.on('exit_dungeon', async (data) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return socket.emit('exit_dungeon_result', { success: false, error: 'Not authenticated' }); const { instanceId } = data || {}; // Get partial rewards before abandoning (50% of accrued credits/xp) const instance = dungeonSystem.instances?.get(instanceId); const partialRewards = []; let creditsEarned = 0; let xpEarned = 0; if (instance) { const def = dungeonSystem.getDungeon(instance.dungeonId); const roomsCleared = instance.currentRoom || 0; const totalRooms = instance.totalRooms || 1; const progress = roomsCleared / totalRooms; creditsEarned = Math.floor((def?.rewards?.creditsMin || 0) * progress * 0.5); xpEarned = Math.floor((def?.rewards?.experienceMin || 0) * progress * 0.5); if (creditsEarned > 0) { client.playerData.stats = client.playerData.stats || {}; client.playerData.stats.credits = (client.playerData.stats.credits || 0) + creditsEarned; partialRewards.push({ type: 'credits', amount: creditsEarned }); } if (xpEarned > 0) { const lvlResult = client.playerData.addExperience ? client.playerData.addExperience(xpEarned) : { leveled: false }; partialRewards.push({ type: 'xp', amount: xpEarned }); if (lvlResult?.leveled) { socket.emit('level_up', { level: client.playerData.stats.level }); } } } if (instanceId) dungeonSystem.abandonInstance(instanceId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('exit_dungeon_result', { success: true, rewards: partialRewards, creditsEarned, xpEarned }); } catch (err) { console.error('[GAME SERVER] exit_dungeon error:', err); socket.emit('dungeon_exited', { success: false, error: err.message }); } }); // ── get_room_types: dungeon room type definitions ─────────────────────── socket.on('get_room_types', () => { socket.emit('room_types_data', dungeonSystem.roomTypes || {}); }); // ════════════════════════════════════════════════════════════════════════ // SEASON SYSTEM (GDD §20.3) // ════════════════════════════════════════════════════════════════════════ socket.on('get_season', () => { const data = seasonSystem.getCurrentSeason(); if (data.active) { const client = connectedClients.get(socket.id); if (client?.playerData) data.myScore = seasonSystem.getSeasonScore(client.playerData); } socket.emit('season_data', data); }); // ════════════════════════════════════════════════════════════════════════ // GALAXY EVENTS (GDD §20.2) // ════════════════════════════════════════════════════════════════════════ socket.on('get_galaxy_event', () => { socket.emit('galaxy_event_data', galaxyEventSystem.getEventData()); }); socket.on('claim_event_reward', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = galaxyEventSystem.claimEventReward(client.playerData); if (result.success) await savePlayerData(client.playerData.userId, client.playerData); socket.emit('event_reward_result', result); } catch(err) { socket.emit('event_reward_result',{success:false,error:err.message}); } }); // ════════════════════════════════════════════════════════════════════════ // REPUTATION HANDLERS (GDD §15.3) // ════════════════════════════════════════════════════════════════════════ socket.on('get_reputation', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; reputationSystem.initReputation(client.playerData); socket.emit('reputation_data', { success:true, reputations: reputationSystem.getReputationData(client.playerData) }); } catch(err) { socket.emit('reputation_data',{success:false,error:err.message}); } }); // ════════════════════════════════════════════════════════════════════════ // SOCIAL: FRIENDS + COMBAT LOG (GDD §17.2, §9.5) // ════════════════════════════════════════════════════════════════════════ socket.on('get_friends', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const data = await socialSystem.getFriendsList(client.playerData.userId, client.playerData.username, connectedClients); socket.emit('friends_data', { success:true, ...data }); } catch(err) { socket.emit('friends_data',{success:false,error:err.message}); } }); socket.on('add_friend', async ({ username }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = await socialSystem.sendFriendRequest(client.playerData.userId, client.playerData.username, username, connectedClients); socket.emit('friend_request_sent', { success:true, targetName: result.targetName }); // Notify target if online + push to notification feed socialSystem.pushNotification(result.targetId, { type: 'friend_request', title: '👤 Friend Request', body: `${client.playerData.username} wants to be your friend`, meta: { fromId: client.playerData.userId, fromName: client.playerData.username }, }).catch(() => {}); for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === result.targetId) io.to(sid).emit('friend_request', { fromId: client.playerData.userId, fromName: client.playerData.username }); } } catch(err) { socket.emit('friend_request_sent',{success:false,error:err.message}); } }); socket.on('accept_friend', async ({ fromId, fromName }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; await socialSystem.acceptFriendRequest(client.playerData.userId, client.playerData.username, fromId, fromName); socket.emit('friend_accepted', { success:true, friendId: fromId, friendName: fromName }); for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === fromId) io.to(sid).emit('friend_accepted', { friendId: client.playerData.userId, friendName: client.playerData.username }); } } catch(err) { socket.emit('friend_accepted',{success:false,error:err.message}); } }); socket.on('remove_friend', async ({ friendId }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; await socialSystem.removeFriend(client.playerData.userId, friendId); socket.emit('friend_removed', { success:true }); } catch(err) { socket.emit('friend_removed',{success:false,error:err.message}); } }); socket.on('send_gift', async ({ targetId, amount }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = await socialSystem.sendGift(client.playerData, targetId, amount); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('gift_sent', { success:true, amount }); socket.emit('economy_data',{credits:client.playerData.stats.credits,gems:client.playerData.stats.gems}); // Push notification feed entry for recipient socialSystem.pushNotification(targetId, { type: 'gift_received', title: '🎁 Gift Received!', body: `${client.playerData.username} sent you ${amount.toLocaleString()} credits`, meta: { fromId: client.playerData.userId, fromName: client.playerData.username, amount }, }).catch(() => {}); // Give credits to recipient if online for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === targetId) { cd.playerData.stats.credits = (cd.playerData.stats.credits||0) + amount; io.to(sid).emit('gift_received', { fromName: client.playerData.username, amount }); io.to(sid).emit('economy_data',{credits:cd.playerData.stats.credits,gems:cd.playerData.stats.gems}); await savePlayerData(cd.userId, cd.playerData); } } } catch(err) { socket.emit('gift_sent',{success:false,error:err.message}); } }); socket.on('get_combat_log', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const log = await socialSystem.getCombatLog(client.playerData.userId); socket.emit('combat_log_data', { success:true, log }); } catch(err) { socket.emit('combat_log_data',{success:false,error:err.message}); } }); // ════════════════════════════════════════════════════════════════════════ // PLAYER MARKET HANDLERS (GDD §14) // ════════════════════════════════════════════════════════════════════════ socket.on('get_market', async ({ itemId, category } = {}) => { try { const listings = await marketSystem.getListings({ itemId, category }); const tradeRes = marketSystem.getTradeableResources(); socket.emit('market_data', { success:true, listings, tradeableResources: tradeRes }); } catch(err) { socket.emit('market_data',{success:false,error:err.message}); } }); socket.on('get_my_listings', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const listings = await marketSystem.getMyListings(client.playerData.userId); socket.emit('my_listings', { success:true, listings }); } catch(err) { socket.emit('my_listings',{success:false,error:err.message}); } }); socket.on('list_resource', async (data={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; resourceSystem.initResources(client.playerData); const result = await marketSystem.listResource(client.playerData, data); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('list_result', { success:true, listing:result.listing, listingFee:result.listingFee }); socket.emit('resource_update',{resources:client.playerData.resources,rates:resourceSystem.getProductionRates(client.playerData),caps:resourceSystem.getStorageCaps(client.playerData)}); } catch(err) { socket.emit('list_result',{success:false,error:err.message}); } }); socket.on('list_item', async (data={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = await marketSystem.listItem(client.playerData, data); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('list_result', { success:true, listing:result.listing, listingFee:result.listingFee }); } catch(err) { socket.emit('list_result',{success:false,error:err.message}); } }); socket.on('buy_listing', async ({ listingId }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = await marketSystem.buyListing(client.playerData, listingId); // Credit seller — online: immediate push; offline: persist to DB so they receive on next login let sellerCredited = false; for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === result.listing.sellerId) { cd.playerData.stats.credits = (cd.playerData.stats.credits||0) + result.proceeds; io.to(sid).emit('economy_data',{credits:cd.playerData.stats.credits,gems:cd.playerData.stats.gems}); io.to(sid).emit('market_sale', { listingId, itemName: result.listing.itemName, amount: result.proceeds }); await savePlayerData(cd.userId, cd.playerData); sellerCredited = true; } } // Offline seller: load their data from DB, credit, and resave if (!sellerCredited) { try { const PlayerData = require('./models/PlayerData'); const sellerDoc = await PlayerData.findOne({ userId: result.listing.sellerId }); if (sellerDoc) { sellerDoc.stats.credits = (sellerDoc.stats.credits || 0) + result.proceeds; await sellerDoc.save(); } } catch(e) { console.warn('[market] offline seller credit error:', e.message); } } await savePlayerData(client.playerData.userId, client.playerData); socket.emit('buy_result',{success:true,listingId,proceeds:result.proceeds,itemName:result.listing.itemName}); socket.emit('economy_data',{credits:client.playerData.stats.credits,gems:client.playerData.stats.gems}); analyticsSystem.track('market.sale', client.userId, { listingId, proceeds: result.proceeds }); } catch(err) { socket.emit('buy_result',{success:false,error:err.message}); } }); // Price history for market sparklines (GDD §14 v3.2) socket.on('get_price_history', async ({ itemId, days = 14 } = {}) => { try { if (!itemId) return socket.emit('price_history', { error: 'itemId required' }); const history = await marketSystem.getPriceHistory(itemId, Math.min(days, 30)); socket.emit('price_history', history); } catch(err) { socket.emit('price_history', { error: err.message }); } }); socket.on('cancel_listing', async ({ listingId }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; await marketSystem.cancelListing(client.playerData, listingId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('cancel_listing_result',{success:true}); } catch(err) { socket.emit('cancel_listing_result',{success:false,error:err.message}); } }); // ════════════════════════════════════════════════════════════════════════ // ALLIANCE HANDLERS (GDD §12) // ════════════════════════════════════════════════════════════════════════ socket.on('get_alliance', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const allianceData = pd.allianceId ? await allianceSystem.getAllianceData(pd.allianceId) : null; socket.emit('alliance_data', { success:true, alliance:allianceData, playerRank: pd.allianceRank, playerAllianceId: pd.allianceId }); } catch(err) { socket.emit('alliance_data',{success:false,error:err.message}); } }); socket.on('search_alliances', async ({ query }={}) => { try { const results = await allianceSystem.searchAlliances(query); socket.emit('alliance_search_results', { success:true, results }); } catch(err) { socket.emit('alliance_search_results',{success:false,error:err.message}); } }); socket.on('create_alliance', async ({ name, tag, description }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const alliance = await allianceSystem.createAlliance(client.playerData, { name, tag, description }); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('alliance_created', { success:true, alliance }); socket.emit('economy_data', { credits: client.playerData.stats.credits, gems: client.playerData.stats.gems }); } catch(err) { socket.emit('alliance_created',{success:false,error:err.message}); } }); socket.on('join_alliance', async ({ allianceId }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const alliance = await allianceSystem.joinAlliance(client.playerData, allianceId); await savePlayerData(client.playerData.userId, client.playerData); socket.join(`alliance_${client.playerData.allianceId}`); socket.emit('alliance_joined', { success:true, alliance }); } catch(err) { socket.emit('alliance_joined',{success:false,error:err.message}); } }); socket.on('leave_alliance', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const oldAllianceId = client.playerData.allianceId; await allianceSystem.leaveAlliance(client.playerData); await savePlayerData(client.playerData.userId, client.playerData); if (oldAllianceId) socket.leave(`alliance_${oldAllianceId}`); socket.emit('alliance_left', { success:true }); } catch(err) { socket.emit('alliance_left',{success:false,error:err.message}); } }); socket.on('alliance_deposit', async (data={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const warehouse = await allianceSystem.depositWarehouse(client.playerData, data); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('alliance_warehouse_update', { success:true, warehouse }); socket.emit('resource_update',{resources:client.playerData.resources,rates:resourceSystem.getProductionRates(client.playerData),caps:resourceSystem.getStorageCaps(client.playerData)}); } catch(err) { socket.emit('alliance_warehouse_update',{success:false,error:err.message}); } }); socket.on('alliance_withdraw', async (data={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const warehouse = await allianceSystem.withdrawWarehouse(client.playerData, data); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('alliance_warehouse_update', { success:true, warehouse }); } catch(err) { socket.emit('alliance_warehouse_update',{success:false,error:err.message}); } }); // Alliance Research Tree (GDD §12.3 — v3.2) socket.on('get_alliance_research', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData?.allianceId) return socket.emit('alliance_research_data', { error: 'Not in an alliance' }); const Alliance = require('./systems/AllianceSystem').Alliance || mongoose.model('Alliance'); const alliance = await Alliance.findOne({ allianceId: client.playerData.allianceId }); if (!alliance) return socket.emit('alliance_research_data', { error: 'Alliance not found' }); const rawTree = require('./data/gso/alliance/research_tree.json'); // Flatten tiers array into { techId: techDef } map for client compatibility const tree = {}; for (const tier of (rawTree.tiers || [])) { for (const tech of (tier.techs || [])) { tree[tech.id] = { ...tech, tier: tier.tier, requires: tech.prereq || [], description: tech.desc, effects: tech.effect || {}, }; } } socket.emit('alliance_research_data', { tree, completed: alliance.research?.completed || [], inProgress: alliance.research?.inProgress || null, }); } catch(err) { socket.emit('alliance_research_data', { error: err.message }); } }); socket.on('start_alliance_research', async ({ techId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; if (!client.playerData.allianceId) return socket.emit('alliance_research_result', { success:false, error:'Not in an alliance' }); if (!['founder','officer'].includes(client.playerData.allianceRank)) return socket.emit('alliance_research_result', { success:false, error:'Only founders and officers can start research' }); const Alliance = mongoose.model('Alliance'); const alliance = await Alliance.findOne({ allianceId: client.playerData.allianceId }); if (!alliance) return socket.emit('alliance_research_result', { success:false, error:'Alliance not found' }); const tree = require('./data/gso/alliance/research_tree.json'); const tech = tree.tiers.flatMap(t => t.techs).find(t => t.id === techId); if (!tech) return socket.emit('alliance_research_result', { success:false, error:'Tech not found' }); const completed = alliance.research?.completed || []; // Check prerequisites for (const prereq of (tech.prereq || [])) { if (!completed.includes(prereq)) return socket.emit('alliance_research_result', { success:false, error:`Requires ${prereq} first` }); } if (completed.includes(techId)) return socket.emit('alliance_research_result', { success:false, error:'Already researched' }); if (alliance.research?.inProgress) return socket.emit('alliance_research_result', { success:false, error:'Research already in progress' }); // Deduct cost from warehouse const cost = tech.cost || {}; const wh = alliance.warehouse; for (const [res, amt] of Object.entries(cost)) { if (res === 'credits') { if ((wh.credits||0) < amt) return socket.emit('alliance_research_result', {success:false, error:`Need ${amt} warehouse credits`}); wh.credits -= amt; } else { if ((wh[res]||0) < amt) return socket.emit('alliance_research_result', {success:false, error:`Need ${amt} warehouse ${res}`}); wh[res] -= amt; } } const researchTimeSec = 3600; // 1 hour per tech (Phase 3 may vary) alliance.research = { ...(alliance.research||{}), completed, inProgress: { techId, startedAt: Date.now(), completesAt: Date.now() + researchTimeSec * 1000 }, }; alliance.markModified('research'); alliance.markModified('warehouse'); await alliance.save(); socket.emit('alliance_research_result', { success: true, techId, completesAt: alliance.research.inProgress.completesAt, warehouse: alliance.warehouse, }); } catch(err) { socket.emit('alliance_research_result', {success:false, error:err.message}); } }); socket.on('collect_alliance_research', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData?.allianceId) return; const Alliance = mongoose.model('Alliance'); const alliance = await Alliance.findOne({ allianceId: client.playerData.allianceId }); if (!alliance?.research?.inProgress) return socket.emit('alliance_research_result', {success:false, error:'No research in progress'}); if (Date.now() < alliance.research.inProgress.completesAt) return socket.emit('alliance_research_result', {success:false, error:'Not complete yet', remainingMs: alliance.research.inProgress.completesAt - Date.now()}); const techId = alliance.research.inProgress.techId; alliance.research.completed = [...(alliance.research.completed||[]), techId]; alliance.research.inProgress = null; alliance.markModified('research'); await alliance.save(); // Emit to all alliance members online const allianceRoom = `alliance_${client.playerData.allianceId}`; io.to(allianceRoom).emit('alliance_research_collected', { success:true, completed: alliance.research.completed }); } catch(err) { socket.emit('alliance_research_result', {success:false, error:err.message}); } }); // ── ALLIANCE CHAT (GDD §Phase2 v3.3) ───────────────────────────────── socket.on('get_alliance_chat', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData?.allianceId) return; const Alliance = mongoose.model('Alliance'); const alliance = await Alliance.findOne({ allianceId: client.playerData.allianceId }); if (!alliance) return; // Keep only last 7 days of messages const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; const messages = (alliance.chat || []).filter(m => m.timestamp > cutoff).slice(-80); socket.emit('alliance_chat_history', { messages }); } catch(err) {} }); socket.on('alliance_chat_send', async ({ message } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData?.allianceId) return socket.emit('alliance_chat_msg', { error: 'Not in an alliance' }); if (!message || typeof message !== 'string') return; const msg = message.trim().slice(0, 200); if (!msg) return; const Alliance = mongoose.model('Alliance'); const alliance = await Alliance.findOne({ allianceId: client.playerData.allianceId }); if (!alliance) return; const payload = { username: client.playerData.username || 'Commander', message: msg, timestamp: Date.now(), }; // Persist — keep last 200 messages, prune older than 7 days if (!Array.isArray(alliance.chat)) alliance.chat = []; const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; alliance.chat = alliance.chat.filter(m => m.timestamp > cutoff); alliance.chat.push(payload); if (alliance.chat.length > 200) alliance.chat = alliance.chat.slice(-200); alliance.markModified('chat'); await alliance.save(); // Broadcast to all alliance members in the room const allianceRoom = `alliance_${client.playerData.allianceId}`; io.to(allianceRoom).emit('alliance_chat_msg', payload); } catch(err) { console.error('[ALLIANCE CHAT]', err); } }); // ════════════════════════════════════════════════════════════════════════ // FLEET MISSION HANDLERS (GDD §8.3) // ════════════════════════════════════════════════════════════════════════ socket.on('get_missions', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; // Auto-complete any finished missions const results = missionSystem.collectMissions(pd, resourceSystem); if (results.length > 0) { await savePlayerData(pd.userId, pd); for (const r of results) socket.emit('mission_completed', r); } socket.emit('missions_data', missionSystem.getMissionsForPlayer(pd)); } catch(err) { console.error('[MISSIONS]', err); } }); socket.on('start_mission', async ({ missionType, fleetShipIds } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const mission = missionSystem.startMission(client.playerData, { missionType, fleetShipIds }); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('mission_started', { success: true, mission }); socket.emit('missions_data', missionSystem.getMissionsForPlayer(client.playerData)); } catch(err) { socket.emit('mission_started', { success: false, error: err.message }); } }); socket.on('collect_missions', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const results = missionSystem.collectMissions(pd, resourceSystem); await savePlayerData(pd.userId, pd); socket.emit('missions_collected', { results, missions: missionSystem.getMissionsForPlayer(pd) }); if (results.some(r => r.rewards?.metal || r.rewards?.gas)) { socket.emit('resource_update', { resources:pd.resources, rates:resourceSystem.getProductionRates(pd), caps:resourceSystem.getStorageCaps(pd) }); } } catch(err) { console.error('[COLLECT_MISSIONS]', err); } }); socket.on('get_faction_missions', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; // Auto-collect completed faction missions first const results = missionSystem.collectFactionMissions(pd, resourceSystem, reputationSystem); if (results.length > 0) { await savePlayerData(pd.userId, pd); results.forEach(r => socket.emit('mission_completed', r)); } socket.emit('faction_missions_data', { success: true, available: missionSystem.getAvailableFactionMissions(pd), active: (pd.fleetMissions||[]).filter(m => m.type==='faction'), }); } catch(err) { socket.emit('faction_missions_data',{success:false,error:err.message}); } }); socket.on('start_faction_mission', async ({ missionId }={}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const job = missionSystem.startFactionMission(client.playerData, missionId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('faction_mission_started', { success:true, job }); } catch(err) { socket.emit('faction_mission_started',{success:false,error:err.message}); } }); socket.on('recall_mission', async ({ missionId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; missionSystem.recallMission(client.playerData, missionId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('missions_data', missionSystem.getMissionsForPlayer(client.playerData)); } catch(err) { socket.emit('recall_mission_result', { success:false, error:err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // RESOURCE HANDLERS (GDD §5) // ════════════════════════════════════════════════════════════════════════ socket.on('get_resources', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; resourceSystem.initResources(client.playerData); // Run a tick to credit any offline production resourceSystem.tick(client.playerData); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('resource_update', { resources: client.playerData.resources, rates: resourceSystem.getProductionRates(client.playerData), caps: resourceSystem.getStorageCaps(client.playerData), config: resourceSystem.getConfig(), }); } catch (err) { console.error('[RESOURCES] get_resources error:', err); } }); // ════════════════════════════════════════════════════════════════════════ // SHIP CONSTRUCTION QUEUE (GDD §7.4) // ════════════════════════════════════════════════════════════════════════ const SHIP_BLUEPRINTS = { starter_cruiser: { name:'Starter Cruiser', icon:'🚀', rarity:'common', hull:100, attack:10, defense:5, speed:15, buildTime:30, metalCost:200, gasCost:50, crystalCost:0, level:1 }, light_fighter: { name:'Light Fighter', icon:'✈', rarity:'uncommon', hull:80, attack:18, defense:3, speed:25, buildTime:60, metalCost:400, gasCost:150, crystalCost:50, level:3 }, heavy_bomber: { name:'Heavy Bomber', icon:'💣', rarity:'uncommon', hull:160, attack:25, defense:8, speed:10, buildTime:120, metalCost:800, gasCost:200, crystalCost:100, level:5 }, destroyer: { name:'Destroyer', icon:'⚔', rarity:'rare', hull:200, attack:30, defense:15, speed:18, buildTime:300, metalCost:1500, gasCost:500, crystalCost:300, level:8 }, heavy_cruiser: { name:'Heavy Cruiser', icon:'🛸', rarity:'rare', hull:350, attack:40, defense:25, speed:12, buildTime:600, metalCost:3000, gasCost:1000, crystalCost:800, level:12 }, battleship: { name:'Battleship', icon:'🌟', rarity:'epic', hull:600, attack:60, defense:45, speed:8, buildTime:1800, metalCost:8000, gasCost:3000, crystalCost:2000,level:20 }, }; function getShipyardLevel(playerData) { return playerData.buildings?.shipyard?.level || 0; } function getShipQueueMax(playerData) { const sy = getShipyardLevel(playerData); return sy >= 1 ? Math.min(3, 1 + Math.floor(sy / 3)) : 0; } function checkShipQueue(playerData) { const q = playerData.shipQueue || []; const now = Date.now(); let changed = false; const completed = []; const remaining = []; for (const job of q) { if (job.completesAt <= now) { // Add ship to inventory const bp = SHIP_BLUEPRINTS[job.shipId]; if (bp) { if (!playerData.inventory) playerData.inventory = []; playerData.inventory.push({ id: job.shipId + '_' + Date.now(), type: 'ship', shipId: job.shipId, name: bp.name, icon: bp.icon, rarity: bp.rarity, stats: { hull: bp.hull, maxHull: bp.hull, attack: bp.attack, defense: bp.defense, speed: bp.speed, currentHull: bp.hull } }); completed.push({ shipId: job.shipId, name: bp.name }); changed = true; } } else { remaining.push(job); } } playerData.shipQueue = remaining; return { changed, completed }; } socket.on('get_shipyard', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; checkShipQueue(pd); const ships = (pd.inventory || []).filter(i => i.type === 'ship'); socket.emit('shipyard_data', { success: true, blueprints: SHIP_BLUEPRINTS, queue: pd.shipQueue || [], ships, shipyardLevel: getShipyardLevel(pd), queueMax: getShipQueueMax(pd), activeShipId: pd.stats?.activeShipId, resources: pd.resources, }); } catch(err) { console.error('[SHIPYARD]', err); } }); socket.on('build_ship', async ({ shipId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const bp = SHIP_BLUEPRINTS[shipId]; if (!bp) return socket.emit('build_ship_result', { success:false, error:'Unknown blueprint' }); const syLevel = getShipyardLevel(pd); if (syLevel < 1) return socket.emit('build_ship_result', { success:false, error:'Build a Shipyard first (Base → Buildings)' }); if ((pd.stats?.level || 1) < bp.level) return socket.emit('build_ship_result', { success:false, error:`Requires Commander Level ${bp.level}` }); const q = pd.shipQueue || []; const qMax = getShipQueueMax(pd); if (q.length >= qMax) return socket.emit('build_ship_result', { success:false, error:`Queue full (${qMax} slots). Upgrade Shipyard for more.` }); // Deduct resources resourceSystem.initResources(pd); try { resourceSystem.spend(pd, { metal: bp.metalCost, gas: bp.gasCost, crystal: bp.crystalCost }); } catch(e) { return socket.emit('build_ship_result', { success:false, error: e.message }); } const now = Date.now(); const job = { shipId, name:bp.name, icon:bp.icon, startedAt:now, completesAt: now + bp.buildTime*1000 }; pd.shipQueue = [...q, job]; await savePlayerData(pd.userId, pd); socket.emit('build_ship_result', { success:true, job, resources:pd.resources }); socket.emit('resource_update', { resources:pd.resources, rates:resourceSystem.getProductionRates(pd), caps:resourceSystem.getStorageCaps(pd) }); } catch(err) { console.error('[BUILD_SHIP]', err); socket.emit('build_ship_result',{success:false,error:'Server error'}); } }); socket.on('cancel_ship_build', async ({ shipId, startedAt } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; const idx = (pd.shipQueue||[]).findIndex(j => j.shipId===shipId && j.startedAt===startedAt); if (idx < 0) return socket.emit('cancel_ship_result',{success:false,error:'Job not found'}); const job = pd.shipQueue[idx]; const bp = SHIP_BLUEPRINTS[job.shipId]; // Refund 75% if (bp) resourceSystem.add(pd, { metal:Math.floor(bp.metalCost*.75), gas:Math.floor(bp.gasCost*.75), crystal:Math.floor(bp.crystalCost*.75) }); pd.shipQueue.splice(idx,1); await savePlayerData(pd.userId, pd); socket.emit('cancel_ship_result',{success:true}); socket.emit('resource_update',{resources:pd.resources,rates:resourceSystem.getProductionRates(pd),caps:resourceSystem.getStorageCaps(pd)}); } catch(err) { console.error('[CANCEL_SHIP]',err); } }); // ════════════════════════════════════════════════════════════════════════ // LEADERBOARD HANDLER // ════════════════════════════════════════════════════════════════════════ socket.on('get_leaderboard', async ({ category = 'level' } = {}) => { try { const STANDARD_CATEGORIES = ['level', 'credits', 'questsCompleted', 'dungeonsCleared', 'totalKills']; const myUserId = connectedClients.get(socket.id)?.userId; // ── Alliance leaderboard ───────────────────────────────────────── if (category === 'alliance') { const allPlayers = await PlayerData.find({ allianceId: { $exists: true, $ne: null } }, { allianceId: 1, username: 1, stats: 1 }).lean(); const allianceMap = {}; for (const p of allPlayers) { const aid = p.allianceId?.toString(); if (!aid) continue; if (!allianceMap[aid]) allianceMap[aid] = { allianceId: aid, memberCount: 0, totalScore: 0 }; allianceMap[aid].memberCount++; allianceMap[aid].totalScore += (p.stats?.level || 1) * 100 + (p.stats?.dungeonsCleared || 0) * 50; } const entries = Object.values(allianceMap).sort((a, b) => b.totalScore - a.totalScore).slice(0, 20).map((e, i) => ({ rank: i + 1, ...e })); return socket.emit('leaderboard_data', { success: true, category, entries }); } // ── PvP Rating leaderboard ─────────────────────────────────────── if (category === 'pvp_rating') { const players = await PlayerData.find({ 'pvp.rating': { $exists: true, $gt: 0 } }, { username: 1, pvp: 1, stats: 1 }) .sort({ 'pvp.rating': -1 }).limit(50).lean(); let myRank = null; if (myUserId) { const myPos = await PlayerData.countDocuments({ 'pvp.rating': { $gt: (await PlayerData.findOne({ userId: myUserId }, { 'pvp.rating': 1 }).lean())?.pvp?.rating || 0 } }); myRank = myPos + 1; } const entries = players.map((p, i) => ({ rank: i + 1, username: p.username || 'Unknown', rating: p.pvp?.rating || 1000, tier: p.pvp?.tier || 'Bronze', isMe: p.userId === myUserId || p._id?.toString() === myUserId, })); return socket.emit('leaderboard_data', { success: true, category, entries, myRank }); } // ── Standard leaderboards ──────────────────────────────────────── if (!STANDARD_CATEGORIES.includes(category)) { return socket.emit('leaderboard_data', { success: false, error: 'Invalid category' }); } const sortField = `stats.${category}`; const players = await PlayerData.find( { [`stats.${category}`]: { $exists: true, $gt: 0 } }, { 'stats.username': 1, username: 1, [`stats.${category}`]: 1, 'stats.level': 1, 'stats.allianceTag': 1, allianceId: 1 } ).sort({ [sortField]: -1 }).limit(50).lean(); // Find requesting player's rank if not in top 50 let myRank = null; if (myUserId) { const myData = await PlayerData.findOne({ userId: myUserId }, { [`stats.${category}`]: 1 }).lean(); const myVal = myData?.stats?.[category] || 0; const above = await PlayerData.countDocuments({ [`stats.${category}`]: { $gt: myVal } }); myRank = above + 1; } const entries = players.map((p, i) => ({ rank: i + 1, username: p.username || 'Unknown Commander', value: p.stats?.[category] || 0, level: p.stats?.level || 1, allianceTag: p.stats?.allianceTag || null, isMe: p.userId === myUserId || p._id?.toString() === myUserId, })); socket.emit('leaderboard_data', { success: true, category, entries, myRank }); } catch (err) { console.error('[LEADERBOARD] get_leaderboard error:', err); socket.emit('leaderboard_data', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // FRIENDS — DECLINE / BLOCK (GDD §17.2) // ════════════════════════════════════════════════════════════════════════ socket.on('decline_friend_request', async ({ fromId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; pd.friendRequests = (pd.friendRequests || []).filter(id => id !== fromId); await savePlayerData(pd.userId, pd); socket.emit('friend_request_declined', { success: true, fromId }); // Notify sender if online for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === fromId) { io.to(sid).emit('friend_request_declined', { byUsername: pd.username }); } } } catch (err) { socket.emit('friend_request_declined', { success: false, error: err.message }); } }); socket.on('block_player', async ({ targetId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; pd.blockedPlayers = pd.blockedPlayers || []; if (!pd.blockedPlayers.includes(targetId)) pd.blockedPlayers.push(targetId); // Auto-remove from friends pd.friends = (pd.friends || []).filter(id => id !== targetId); pd.friendRequests = (pd.friendRequests || []).filter(id => id !== targetId); await savePlayerData(pd.userId, pd); socket.emit('player_blocked', { success: true, targetId }); } catch (err) { socket.emit('player_blocked', { success: false, error: err.message }); } }); socket.on('unblock_player', async ({ targetId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; pd.blockedPlayers = (pd.blockedPlayers || []).filter(id => id !== targetId); await savePlayerData(pd.userId, pd); socket.emit('player_unblocked', { success: true, targetId }); } catch (err) { socket.emit('player_unblocked', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // DIRECT MESSAGES (GDD §17.2 Phase 2) // ════════════════════════════════════════════════════════════════════════ socket.on('send_dm', async ({ toUserId, message } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; if (!message?.trim()) return socket.emit('dm_sent', { success: false, error: 'Empty message' }); if (message.length > 500) return socket.emit('dm_sent', { success: false, error: 'Message too long' }); const msg = { id: `dm_${Date.now()}_${Math.random().toString(36).slice(2,7)}`, fromId: pd.userId, fromName: pd.username, toId: toUserId, message: message.trim(), sentAt: Date.now(), read: false }; // Store in sender's DM history pd.directMessages = pd.directMessages || {}; pd.directMessages[toUserId] = pd.directMessages[toUserId] || []; pd.directMessages[toUserId].push(msg); if (pd.directMessages[toUserId].length > 200) pd.directMessages[toUserId].shift(); // Store in recipient's DM history (if online) or persist via DB let delivered = false; for (const [sid, cd] of connectedClients.entries()) { if (cd.userId === toUserId) { cd.playerData.directMessages = cd.playerData.directMessages || {}; cd.playerData.directMessages[pd.userId] = cd.playerData.directMessages[pd.userId] || []; cd.playerData.directMessages[pd.userId].push(msg); if (cd.playerData.directMessages[pd.userId].length > 200) cd.playerData.directMessages[pd.userId].shift(); io.to(sid).emit('receive_dm', { message: msg }); await savePlayerData(cd.userId, cd.playerData); delivered = true; } } if (!delivered) { // Recipient offline — save to their stored data const recipientData = await loadPlayerData(toUserId).catch(() => null); if (recipientData) { recipientData.directMessages = recipientData.directMessages || {}; recipientData.directMessages[pd.userId] = recipientData.directMessages[pd.userId] || []; recipientData.directMessages[pd.userId].push(msg); if (recipientData.directMessages[pd.userId].length > 200) recipientData.directMessages[pd.userId].shift(); await savePlayerData(toUserId, recipientData); } } await savePlayerData(pd.userId, pd); socket.emit('dm_sent', { success: true, message: msg }); } catch (err) { socket.emit('dm_sent', { success: false, error: err.message }); } }); socket.on('get_dm_history', async ({ withUserId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const msgs = (client.playerData.directMessages?.[withUserId] || []).slice(-50); socket.emit('dm_history', { success: true, withUserId, messages: msgs }); } catch (err) { socket.emit('dm_history', { success: false, error: err.message }); } }); socket.on('mark_dm_read', async ({ withUserId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const msgs = client.playerData.directMessages?.[withUserId] || []; msgs.forEach(m => { if (m.fromId === withUserId) m.read = true; }); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('dm_marked_read', { success: true, withUserId }); } catch (err) { socket.emit('dm_marked_read', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // GALAXY EVENT — JOIN (GDD §20.2) // ════════════════════════════════════════════════════════════════════════ socket.on('join_galaxy_event', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = galaxyEventSystem.participate(client.playerData.userId); if (result) { socket.emit('galaxy_event_joined', { success: true, event: galaxyEventSystem.getEventStatus() }); } else { socket.emit('galaxy_event_joined', { success: false, error: 'No active event' }); } } catch (err) { socket.emit('galaxy_event_joined', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // FLEET HANDLERS // ════════════════════════════════════════════════════════════════════════ socket.on('get_fleet_data', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return socket.emit('fleet_data', { success: false, error: 'Not authenticated' }); // Auto-give starter ship if inventory has no ships const ships = (client.playerData.inventory || []).filter(i => i.type === 'ship'); if (ships.length === 0) { const starterShip = { id: `ship_starter_${Date.now()}`, name: 'Starter Cruiser', type: 'ship', rarity: 'common', texture: 'assets/gso/textures/ships/starter_cruiser_common.png', stats: { attack: 10, defense: 5, speed: 10, hull: 100, maxHull: 100, currentHull: 100 }, level: 1, equipped: false, }; client.playerData.inventory = client.playerData.inventory || []; client.playerData.inventory.push(starterShip); client.playerData.stats.activeShipId = starterShip.id; await savePlayerData(client.playerData.userId, client.playerData); } const fleetData = fleetSystem.getFleetData(client.playerData); const templates = fleetSystem.getAllShipTemplates(); socket.emit('fleet_data', { success: true, ...fleetData, templates }); } catch (err) { console.error('[FLEET] get_fleet_data error:', err); socket.emit('fleet_data', { success: false, error: err.message }); } }); // Save fleet formation order (v3.3 drag-drop) socket.on('save_fleet_formation', async ({ formation } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; if (!Array.isArray(formation)) return; // Validate all IDs are actual ships const ships = (client.playerData.inventory || []).filter(i => i.type === 'ship').map(s => s.id); const valid = formation.filter(id => ships.includes(id)); client.playerData.stats = client.playerData.stats || {}; client.playerData.stats.fleetFormation = valid; await savePlayerData(client.playerData.userId, client.playerData); socket.emit('fleet_formation_saved', { success: true, formation: valid }); } catch(err) { socket.emit('fleet_formation_saved', { success: false, error: err.message }); } }); socket.on('set_active_ship', async ({ shipId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const stats = fleetSystem.setActiveShip(client.playerData, shipId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('active_ship_set', { success: true, shipId, stats }); } catch (err) { socket.emit('active_ship_set', { success: false, error: err.message }); } }); socket.on('repair_ship', async ({ shipId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const credits = client.playerData.stats?.credits || 0; const result = fleetSystem.repairShip(client.playerData, shipId, credits); client.playerData.stats.credits -= result.repairCost; await savePlayerData(client.playerData.userId, client.playerData); socket.emit('ship_repaired', { success: true, shipId, repairCost: result.repairCost }); socket.emit('economy_data', { credits: client.playerData.stats.credits, gems: client.playerData.stats.gems }); } catch (err) { socket.emit('ship_repaired', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // GALAXY MAP HANDLERS // ════════════════════════════════════════════════════════════════════════ socket.on('get_galaxy_map', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; // Seed home sector if first time if (!client.playerData.homeSector) { client.playerData.homeSector = '15_10'; client.playerData.exploredSectors = ['15_10']; await savePlayerData(client.playerData.userId, client.playerData); } const pd = client.playerData; const sensorBldLevel = pd.buildings?.sensor_array?.level || 0; const sensorResearch = pd.research?.effects?.sensorRange || 0; const visRadius = 1 + Math.floor(sensorBldLevel / 2) + sensorResearch; const visibleSectors = galaxySystem.getVisibleSectors(pd, visRadius); socket.emit('galaxy_map_data', { success: true, sectors: visibleSectors, homeSector: pd.homeSector, gridW: galaxySystem.GRID_W, gridH: galaxySystem.GRID_H, visRadius, }); } catch (err) { console.error('[GALAXY] get_galaxy_map error:', err); socket.emit('galaxy_map_data', { success: false, error: err.message }); } }); socket.on('explore_sector', async ({ sectorId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const pd = client.playerData; // Determine sensor visibility radius from sensor_array building level + research const sensorBldLevel = pd.buildings?.sensor_array?.level || 0; const sensorResearch = pd.research?.effects?.sensorRange || 0; const visRadius = 1 + Math.floor(sensorBldLevel / 2) + sensorResearch; const sector = galaxySystem.exploreSector(sectorId, pd, visRadius); await savePlayerData(pd.userId, pd); // XP reward for first explore (threat-scaled), boosted by science skills const baseXp = 50 + sector.threat * 10; const xpGain = skillSystem ? skillSystem.applyXpBonus(pd.userId, baseXp) : baseXp; pd.stats.experience = (pd.stats.experience || 0) + xpGain; socket.emit('sector_explored', { success: true, sector, xpGain }); const visibleSectors = galaxySystem.getVisibleSectors(pd, visRadius); socket.emit('galaxy_map_data', { success: true, sectors: visibleSectors, homeSector: pd.homeSector, gridW: galaxySystem.GRID_W, gridH: galaxySystem.GRID_H, visRadius, }); } catch (err) { socket.emit('sector_explored', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // RESEARCH HANDLERS // ════════════════════════════════════════════════════════════════════════ socket.on('get_research_data', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; // Check for completed research first const justCompleted = researchSystem.checkCompletion(client.playerData); if (justCompleted) { await savePlayerData(client.playerData.userId, client.playerData); socket.emit('research_completed', { tech: justCompleted }); } const available = researchSystem.getAvailableResearch(client.playerData); socket.emit('research_data', { success: true, research: available, inProgress: client.playerData.research?.inProgress || null, completed: client.playerData.research?.completed || [], effects: client.playerData.research?.effects || {}, }); } catch (err) { console.error('[RESEARCH] get_research_data error:', err); socket.emit('research_data', { success: false, error: err.message }); } }); socket.on('start_research', async ({ techId }) => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const result = researchSystem.startResearch(client.playerData, techId); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('research_started', { success: true, tech: result.tech, completesAt: result.completesAt }); socket.emit('economy_data', { credits: client.playerData.stats.credits, gems: client.playerData.stats.gems }); } catch (err) { socket.emit('research_started', { success: false, error: err.message }); } }); socket.on('cancel_research', async () => { try { const client = connectedClients.get(socket.id); if (!client?.playerData) return; const tech = researchSystem.cancelResearch(client.playerData); await savePlayerData(client.playerData.userId, client.playerData); socket.emit('research_cancelled', { success: true, tech }); socket.emit('economy_data', { credits: client.playerData.stats.credits, gems: client.playerData.stats.gems }); } catch (err) { socket.emit('research_cancelled', { success: false, error: err.message }); } }); // ════════════════════════════════════════════════════════════════════════ // SHIP MODULE SYSTEM (GDD §7.3) // ════════════════════════════════════════════════════════════════════════ // Get equipped modules for active ship socket.on('get_ship_modules', async () => { if (!socket.userId) return; try { const pd = await PlayerData.findOne({ userId: socket.userId }); if (!pd) return; const ship = pd.ship || {}; socket.emit('ship_modules_data', { shipId: ship.id || ship.shipId || null, modules: ship.modules || {}, availableSlots: _getShipSlots(ship), }); } catch (err) { console.error('[MODULES] get_ship_modules error:', err); } }); // Equip a module item into a slot socket.on('equip_module', async ({ itemId, slot } = {}) => { if (!socket.userId) return socket.emit('equip_result', { success: false, error: 'Not authenticated' }); if (!itemId || !slot) return socket.emit('equip_result', { success: false, error: 'Missing itemId or slot' }); try { const pd = await PlayerData.findOne({ userId: socket.userId }); if (!pd) return socket.emit('equip_result', { success: false, error: 'Player not found' }); // Find item in inventory const invItems = pd.inventory?.items || []; const itemIdx = invItems.findIndex(it => it && (it.id === itemId || it.itemId === itemId)); if (itemIdx === -1) return socket.emit('equip_result', { success: false, error: 'Item not in inventory' }); const item = invItems[itemIdx]; const ship = pd.ship || {}; if (!ship.modules) ship.modules = {}; // Unequip whatever is currently in that slot back to inventory const currentlyEquipped = ship.modules[slot]; if (currentlyEquipped) { invItems.push({ ...currentlyEquipped, source: 'unequipped', obtainedAt: Date.now() }); } // Remove item from inventory and equip invItems.splice(itemIdx, 1); ship.modules[slot] = { ...item, equippedAt: Date.now() }; pd.ship = ship; pd.markModified('ship'); pd.markModified('inventory'); await pd.save(); socket.emit('equip_result', { success: true, slot, equipped: ship.modules[slot], unequipped: currentlyEquipped || null, }); socket.emit('ship_modules_data', { shipId: ship.id || ship.shipId || null, modules: ship.modules, availableSlots: _getShipSlots(ship), }); socket.emit('inventory_update', { items: invItems, maxSize: pd.inventory?.maxSize || 50 }); console.log(`[MODULES] ${socket.username} equipped ${itemId} in slot ${slot}`); } catch (err) { console.error('[MODULES] equip_module error:', err); socket.emit('equip_result', { success: false, error: 'Server error' }); } }); // Unequip a module from a slot back to inventory socket.on('unequip_module', async ({ slot } = {}) => { if (!socket.userId || !slot) return; try { const pd = await PlayerData.findOne({ userId: socket.userId }); if (!pd) return; const ship = pd.ship || {}; if (!ship.modules?.[slot]) return socket.emit('equip_result', { success: false, error: 'Nothing equipped in that slot' }); const item = ship.modules[slot]; delete ship.modules[slot]; const invItems = pd.inventory?.items || []; invItems.push({ ...item, source: 'unequipped', obtainedAt: Date.now() }); pd.ship = ship; pd.markModified('ship'); pd.markModified('inventory'); await pd.save(); socket.emit('equip_result', { success: true, slot, unequipped: item }); socket.emit('ship_modules_data', { shipId: ship.id || null, modules: ship.modules, availableSlots: _getShipSlots(ship) }); socket.emit('inventory_update', { items: invItems, maxSize: pd.inventory?.maxSize || 50 }); } catch (err) { console.error('[MODULES] unequip_module error:', err); } }); // ════════════════════════════════════════════════════════════════════════ // PVP CHALLENGE SYSTEM (GDD §9.4) // ════════════════════════════════════════════════════════════════════════ // Challenge another online player socket.on('pvp_challenge', async ({ targetUsername } = {}) => { if (!socket.userId || !targetUsername) return; if (targetUsername === socket.username) return socket.emit('pvp_result', { success: false, error: 'Cannot challenge yourself' }); // Find target socket const targetEntry = [...connectedClients.entries()].find(([, c]) => c.username === targetUsername); if (!targetEntry) return socket.emit('pvp_result', { success: false, error: 'Player not online' }); const [targetSocketId, targetClient] = targetEntry; const challengeId = `pvp_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; // Store pending challenge if (!pvpChallenges) pvpChallenges = new Map(); pvpChallenges.set(challengeId, { challengerId: socket.userId, challengerName: socket.username, targetId: targetClient.userId, targetName: targetUsername, createdAt: Date.now(), status: 'pending', }); // Notify challenger socket.emit('pvp_challenge_sent', { challengeId, targetUsername, message: `Challenge sent to ${targetUsername}!` }); // Notify target io.to(targetSocketId).emit('pvp_challenge_received', { challengeId, challengerName: socket.username, message: `${socket.username} challenges you to PvP combat!`, }); // Auto-expire after 30 seconds setTimeout(() => { if (pvpChallenges && pvpChallenges.get(challengeId)?.status === 'pending') { pvpChallenges.delete(challengeId); socket.emit('pvp_challenge_expired', { challengeId }); io.to(targetSocketId).emit('pvp_challenge_expired', { challengeId }); } }, 30000); }); // Accept a PvP challenge socket.on('pvp_accept', async ({ challengeId } = {}) => { if (!socket.userId || !challengeId) return; if (!pvpChallenges?.has(challengeId)) return socket.emit('pvp_result', { success: false, error: 'Challenge not found or expired' }); const challenge = pvpChallenges.get(challengeId); if (challenge.targetId !== socket.userId) return socket.emit('pvp_result', { success: false, error: 'Not your challenge' }); if (challenge.status !== 'pending') return socket.emit('pvp_result', { success: false, error: 'Challenge already resolved' }); challenge.status = 'active'; try { // Load both players const [challenger, defender] = await Promise.all([ PlayerData.findOne({ userId: challenge.challengerId }), PlayerData.findOne({ userId: challenge.targetId }), ]); if (!challenger || !defender) return socket.emit('pvp_result', { success: false, error: 'Player data not found' }); // Simulate PvP battle const result = _simulatePvpBattle(challenger, defender); // Apply outcomes const winnerId = result.winner === 'challenger' ? challenge.challengerId : challenge.targetId; const loserId = result.winner === 'challenger' ? challenge.targetId : challenge.challengerId; const winnerPd = result.winner === 'challenger' ? challenger : defender; const loserPd = result.winner === 'challenger' ? defender : challenger; // Rewards const creditsWon = Math.floor(100 + (loserPd.level || 1) * 20); const xpWon = Math.floor(50 + (loserPd.level || 1) * 10); winnerPd.credits = (winnerPd.credits || 0) + creditsWon; winnerPd.experience = (winnerPd.experience || 0) + xpWon; // Record in combat log const battleLog = { type: 'pvp', opponent: result.winner === 'challenger' ? challenge.targetName : challenge.challengerName, result: 'victory', creditsWon, xpWon, rounds: result.rounds, timestamp: Date.now(), }; // Record PvP match history (last 50 entries) on both players const now = Date.now(); const winRecord = { opponent: loserPd.username, result: 'win', creditsWon, xpWon, ranked: false, timestamp: now }; const lossRecord = { opponent: winnerPd.username, result: 'loss', creditsWon: 0, xpWon: 0, ranked: false, timestamp: now }; if (!winnerPd.pvp) winnerPd.pvp = {}; if (!loserPd.pvp) loserPd.pvp = {}; if (!winnerPd.pvp.history) winnerPd.pvp.history = []; if (!loserPd.pvp.history) loserPd.pvp.history = []; winnerPd.pvp.history.unshift(winRecord); loserPd.pvp.history.unshift(lossRecord); if (winnerPd.pvp.history.length > 50) winnerPd.pvp.history.length = 50; if (loserPd.pvp.history.length > 50) loserPd.pvp.history.length = 50; winnerPd.markModified('pvp'); loserPd.markModified('pvp'); await Promise.all([winnerPd.save(), loserPd.save()]); pvpChallenges.delete(challengeId); // Find challenger socket const challengerEntry = [...connectedClients.entries()].find(([, c]) => c.userId === challenge.challengerId); const battleSummary = { success: true, challengeId, winner: result.winner === 'challenger' ? challenge.challengerName : challenge.targetName, loser: result.winner === 'challenger' ? challenge.targetName : challenge.challengerName, rounds: result.rounds, creditsWon, xpWon, }; socket.emit('pvp_result', { ...battleSummary, youWon: challenge.targetId === winnerId }); if (challengerEntry) { io.to(challengerEntry[0]).emit('pvp_result', { ...battleSummary, youWon: challenge.challengerId === winnerId }); } analyticsSystem.track('pvp.battle', challenge.challengerId, { winnerId }); // Push notification feed entries const pvpNotifWin = { type: 'pvp_win', title: '⚔️ PvP Victory!', body: `You defeated ${battleSummary.loser} (+${creditsWon} credits)`, meta: { creditsWon, xpWon } }; const pvpNotifLoss = { type: 'pvp_loss', title: '⚔️ PvP Defeat', body: `You were defeated by ${battleSummary.winner}`, meta: {} }; socialSystem.pushNotification(winnerId, pvpNotifWin).catch(() => {}); socialSystem.pushNotification(loserId, pvpNotifLoss).catch(() => {}); console.log(`[PVP] ${challenge.challengerName} vs ${challenge.targetName} — winner: ${battleSummary.winner}`); } catch (err) { console.error('[PVP] pvp_accept error:', err); pvpChallenges.delete(challengeId); socket.emit('pvp_result', { success: false, error: 'Battle error' }); } }); // Decline a PvP challenge socket.on('pvp_decline', ({ challengeId } = {}) => { if (!challengeId || !pvpChallenges?.has(challengeId)) return; const challenge = pvpChallenges.get(challengeId); pvpChallenges.delete(challengeId); const challengerEntry = [...connectedClients.entries()].find(([, c]) => c.userId === challenge.challengerId); if (challengerEntry) { io.to(challengerEntry[0]).emit('pvp_declined', { challengeId, targetName: challenge.targetName }); } socket.emit('pvp_result', { success: false, declined: true }); }); // ════════════════════════════════════════════════════════════════════════ // SEASON LEADERBOARD (GDD §20.3) // ════════════════════════════════════════════════════════════════════════ socket.on('get_season_leaderboard', async ({ limit = 50 } = {}) => { try { const season = seasonSystem.getCurrentSeason(); if (!season.active) return socket.emit('season_leaderboard_data', { entries: [], season: null }); // Get top players by credits+level score const players = await PlayerData.find({}) .select('username level credits experience reputation fleetMissions') .limit(200) .lean(); const scored = players .map(p => ({ username: p.username || 'Unknown', level: p.level || 1, score: seasonSystem.getSeasonScore(p), credits: p.credits || 0, })) .sort((a, b) => b.score - a.score) .slice(0, limit) .map((p, i) => ({ rank: i + 1, ...p })); socket.emit('season_leaderboard_data', { entries: scored, season: season.name, endsAt: season.endsAt, }); } catch (err) { console.error('[SEASON LEADERBOARD] error:', err); socket.emit('season_leaderboard_data', { entries: [], season: null }); } }); // ════════════════════════════════════════════════════════════════════════ // PVP RANKED MATCHMAKING & RANKINGS (GDD Phase 3) // ════════════════════════════════════════════════════════════════════════ // ELO helper — K=32 standard function _calcElo(ratingA, ratingB, aWon) { const expected = 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); const score = aWon ? 1 : 0; return Math.round(ratingA + 32 * (score - expected)); } function _pvpTier(rating) { if (rating >= 1800) return 'diamond'; if (rating >= 1500) return 'platinum'; if (rating >= 1200) return 'gold'; if (rating >= 1000) return 'silver'; return 'bronze'; } // Ranked PvP challenge — same battle sim as casual but updates ELO socket.on('pvp_ranked_challenge', async ({ targetUsername } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.authenticated) return socket.emit('pvp_ranked_result', { success: false, error: 'Not authenticated' }); if (targetUsername === socket.username) return socket.emit('pvp_ranked_result', { success: false, error: 'Cannot challenge yourself' }); const targetEntry = [...connectedClients.entries()].find(([, c]) => c.username === targetUsername); if (!targetEntry) return socket.emit('pvp_ranked_result', { success: false, error: 'Player not online' }); const [targetSocketId] = targetEntry; const challengeId = `ranked_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; pvpChallenges.set(challengeId, { challengerId: client.userId, challengerName: socket.username, targetId: targetEntry[1].userId, targetName: targetUsername, targetSocketId, status: 'pending', ranked: true, }); // 30s expiry setTimeout(() => { const ch = pvpChallenges.get(challengeId); if (ch?.status === 'pending') { pvpChallenges.delete(challengeId); socket.emit('pvp_ranked_expired', { challengeId }); io.to(targetSocketId).emit('pvp_ranked_expired', { challengeId }); } }, 30000); socket.emit('pvp_ranked_challenge_sent', { challengeId, targetUsername }); io.to(targetSocketId).emit('pvp_ranked_challenge_received', { challengeId, challengerName: socket.username, ranked: true, message: `${socket.username} challenges you to a RANKED battle!` }); } catch (err) { socket.emit('pvp_ranked_result', { success: false, error: 'Challenge error' }); } }); socket.on('pvp_ranked_accept', async ({ challengeId } = {}) => { try { if (!pvpChallenges?.has(challengeId)) return socket.emit('pvp_ranked_result', { success: false, error: 'Challenge not found' }); const challenge = pvpChallenges.get(challengeId); if (!challenge.ranked) return socket.emit('pvp_ranked_result', { success: false, error: 'Not a ranked challenge' }); if (challenge.targetId !== connectedClients.get(socket.id)?.userId) return; if (challenge.status !== 'pending') return; challenge.status = 'accepted'; pvpChallenges.set(challengeId, challenge); const [challenger, defender] = await Promise.all([ PlayerData.findOne({ userId: challenge.challengerId }), PlayerData.findOne({ userId: challenge.targetId }), ]); if (!challenger || !defender) return socket.emit('pvp_ranked_result', { success: false, error: 'Player data not found' }); // Ensure pvp field exists if (!challenger.pvp) challenger.pvp = { rating: 1000, wins: 0, losses: 0, winStreak: 0, bestStreak: 0, seasonRating: 1000, tier: 'bronze' }; if (!defender.pvp) defender.pvp = { rating: 1000, wins: 0, losses: 0, winStreak: 0, bestStreak: 0, seasonRating: 1000, tier: 'bronze' }; const result = _simulatePvpBattle(challenger, defender); const winnerId = result.winner === 'challenger' ? challenge.challengerId : challenge.targetId; const chalWon = result.winner === 'challenger'; // Update ELO const newChalRating = _calcElo(challenger.pvp.rating, defender.pvp.rating, chalWon); const newDefRating = _calcElo(defender.pvp.rating, challenger.pvp.rating, !chalWon); challenger.pvp.rating = newChalRating; challenger.pvp.seasonRating = newChalRating; challenger.pvp.tier = _pvpTier(newChalRating); defender.pvp.rating = newDefRating; defender.pvp.seasonRating = newDefRating; defender.pvp.tier = _pvpTier(newDefRating); if (chalWon) { challenger.pvp.wins++; challenger.pvp.winStreak++; defender.pvp.losses++; defender.pvp.winStreak = 0; if (challenger.pvp.winStreak > (challenger.pvp.bestStreak || 0)) challenger.pvp.bestStreak = challenger.pvp.winStreak; } else { defender.pvp.wins++; defender.pvp.winStreak++; challenger.pvp.losses++; challenger.pvp.winStreak = 0; if (defender.pvp.winStreak > (defender.pvp.bestStreak || 0)) defender.pvp.bestStreak = defender.pvp.winStreak; } challenger.pvp.lastRankedAt = new Date(); defender.pvp.lastRankedAt = new Date(); // Credit/XP rewards const creditsWon = 200 + Math.max(challenger.stats?.level || 1, defender.stats?.level || 1) * 25; const xpWon = 100 + Math.max(challenger.stats?.level || 1, defender.stats?.level || 1) * 15; if (chalWon) { challenger.stats.credits = (challenger.stats.credits || 0) + creditsWon; } else { defender.stats.credits = (defender.stats.credits || 0) + creditsWon; } await challenger.addExperience(chalWon ? xpWon : Math.floor(xpWon * 0.3)); await defender.addExperience(!chalWon ? xpWon : Math.floor(xpWon * 0.3)); // Record ranked match history (last 50) const nowR = Date.now(); const chalHistRec = { opponent: challenge.targetName, result: chalWon ? 'win' : 'loss', ranked: true, ratingChange: newChalRating - (challenger.pvp.rating || 1000), timestamp: nowR }; const defHistRec = { opponent: challenge.challengerName, result: chalWon ? 'loss' : 'win', ranked: true, ratingChange: newDefRating - (defender.pvp.rating || 1000), timestamp: nowR }; if (!challenger.pvp.history) challenger.pvp.history = []; if (!defender.pvp.history) defender.pvp.history = []; challenger.pvp.history.unshift(chalHistRec); defender.pvp.history.unshift(defHistRec); if (challenger.pvp.history.length > 50) challenger.pvp.history.length = 50; if (defender.pvp.history.length > 50) defender.pvp.history.length = 50; challenger.markModified('pvp'); challenger.markModified('stats'); defender.markModified('pvp'); defender.markModified('stats'); await Promise.all([challenger.save(), defender.save()]); pvpChallenges.delete(challengeId); const summary = { challengeId, winnerId, attackerName: challenge.challengerName, defenderName: challenge.targetName, rounds: result.rounds, log: result.log, ratingChanges: { [challenge.challengerName]: { old: challenger.pvp.rating - (chalWon ? newChalRating - challenger.pvp.rating : 0), new: newChalRating, tier: challenger.pvp.tier }, [challenge.targetName]: { old: defender.pvp.rating - (!chalWon ? newDefRating - defender.pvp.rating : 0), new: newDefRating, tier: defender.pvp.tier }, } }; socket.emit('pvp_ranked_result', { ...summary, youWon: challenge.targetId === winnerId }); const challengerEntry = [...connectedClients.entries()].find(([, c]) => c.userId === challenge.challengerId); if (challengerEntry) io.to(challengerEntry[0]).emit('pvp_ranked_result', { ...summary, youWon: challenge.challengerId === winnerId }); } catch (err) { console.error('[PVP RANKED]', err); socket.emit('pvp_ranked_result', { success: false, error: 'Ranked battle error' }); } }); // ── PvP History (GDD §9.4) ─────────────────────────────────────────────── socket.on('get_pvp_history', async () => { if (!socket.userId) return socket.emit('pvp_history', { success: false, error: 'Not authenticated' }); try { const pd = await PlayerData.findOne({ userId: socket.userId }).lean(); const history = pd?.pvp?.history || []; socket.emit('pvp_history', { success: true, history, stats: { wins: pd?.pvp?.wins || 0, losses: pd?.pvp?.losses || 0, winStreak: pd?.pvp?.winStreak || 0, bestStreak: pd?.pvp?.bestStreak || 0, rating: pd?.pvp?.rating || 1000, tier: pd?.pvp?.tier || 'bronze', }, }); } catch (err) { console.error('[PVP HISTORY]', err); socket.emit('pvp_history', { success: false, error: 'Failed to load PvP history' }); } }); // ── Notification Feed (GDD §17) ─────────────────────────────────────────── // In-memory feed per connected socket; persisted subset via SocialSystem socket.on('get_notification_feed', async () => { if (!socket.userId) return socket.emit('notification_feed', { success: false, error: 'Not authenticated' }); try { const social = await socialSystem.getOrCreate(socket.userId, connectedClients.get(socket.id)?.username || ''); const feed = (social.notificationFeed || []).slice(0, 50); socket.emit('notification_feed', { success: true, feed }); } catch (err) { console.error('[NOTIF FEED]', err); socket.emit('notification_feed', { success: false, feed: [] }); } }); socket.on('mark_notifications_read', async () => { if (!socket.userId) return; try { await socialSystem.markNotificationsRead(socket.userId); socket.emit('notifications_marked_read', { success: true }); } catch (err) { /* non-critical */ } }); socket.on('pvp_ranked_decline', ({ challengeId } = {}) => { if (!challengeId || !pvpChallenges?.has(challengeId)) return; const ch = pvpChallenges.get(challengeId); pvpChallenges.delete(challengeId); const cEntry = [...connectedClients.entries()].find(([, c]) => c.userId === ch.challengerId); if (cEntry) io.to(cEntry[0]).emit('pvp_ranked_declined', { challengeId, targetName: ch.targetName }); socket.emit('pvp_ranked_result', { success: false, declined: true }); }); socket.on('get_pvp_rankings', async ({ limit = 50 } = {}) => { try { const players = await PlayerData.find({ 'pvp.rating': { $exists: true } }) .select('username pvp stats.level') .limit(500).lean(); const ranked = players .filter(p => p.pvp?.rating) .map((p, i) => ({ rank: i + 1, username: p.username, rating: p.pvp.rating, tier: p.pvp.tier || _pvpTier(p.pvp.rating), wins: p.pvp.wins || 0, losses: p.pvp.losses || 0, level: p.stats?.level || 1, })) .sort((a, b) => b.rating - a.rating) .slice(0, limit) .map((p, i) => ({ ...p, rank: i + 1 })); socket.emit('pvp_rankings_data', { rankings: ranked, total: ranked.length }); } catch (err) { socket.emit('pvp_rankings_data', { rankings: [], total: 0 }); } }); // ════════════════════════════════════════════════════════════════════════ // RAID SYSTEM (GDD Phase 3 — Weekly Guild Raids + Monthly World Boss) // ════════════════════════════════════════════════════════════════════════ socket.on('get_raid_info', async () => { try { const client = connectedClients.get(socket.id); const raids = raidSystem.getAllRaids(); const active = client?.userId ? raidSystem.getPlayerRaid(client.userId) : null; socket.emit('raid_info', { raids: raids.map(r => ({ id: r.id, name: r.name, type: r.type, description: r.description, minPlayers: r.minPlayers, maxPlayers: r.maxPlayers, minLevel: r.minLevel, energyCost: r.energyCost, phases: r.phases, ui: r.ui, weeklyReset: raidSystem.getWeeklyResetTime().toISOString(), monthlyReset: raidSystem.getMonthlyResetTime().toISOString(), })), activeRaid: active ? { raidId: active.raidId, name: active.name, phase: active.phase, bossHp: active.bossHp, party: active.party.map(p => p.username) } : null, }); } catch (err) { socket.emit('raid_info', { raids: [], activeRaid: null }); } }); socket.on('join_raid_queue', async ({ raidId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.authenticated) return socket.emit('raid_queue_result', { success: false, error: 'Not authenticated' }); const playerData = await PlayerData.findOne({ userId: client.userId }); if (!playerData) return socket.emit('raid_queue_result', { success: false, error: 'Player data not found' }); const result = raidSystem.queueForRaid(raidId, client.userId, socket.username, playerData.allianceId, playerData); if (!result.success) return socket.emit('raid_queue_result', result); if (result.launched) { // Deduct energy cost for all party members const def = raidSystem.getRaid(raidId); for (const member of result.party) { const pd = await PlayerData.findOne({ userId: member.userId }); if (pd) { pd.resources = pd.resources || {}; pd.resources.energyCells = Math.max(0, (pd.resources.energyCells || 0) - def.energyCost); pd.markModified('resources'); await pd.save(); } } } socket.emit('raid_queue_result', { success: true, ...result }); } catch (err) { console.error('[RAID QUEUE]', err); socket.emit('raid_queue_result', { success: false, error: 'Queue error' }); } }); socket.on('raid_action', async ({ raidId, action = 'attack' } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.authenticated) return socket.emit('raid_action_result', { success: false, error: 'Not authenticated' }); const result = raidSystem.processRaidAction(raidId, client.userId, action); if (!result.success) return socket.emit('raid_action_result', result); if (result.bossDefeated) { // Distribute rewards to all party members const raid = raidSystem.getRaidInstance(raidId); if (raid) { for (const member of raid.party) { const pd = await PlayerData.findOne({ userId: member.userId }); if (!pd) continue; const rewards = result.rewards; const credits = rewards.creditsMin + Math.floor(Math.random() * (rewards.creditsMax - rewards.creditsMin)); pd.stats = pd.stats || {}; pd.stats.credits = (pd.stats.credits || 0) + credits; pd.stats.gems = (pd.stats.gems || 0) + (rewards.gems || 0); pd.raids = pd.raids || {}; pd.raids.totalRaids = (pd.raids.totalRaids || 0) + 1; if (raidSystem.getRaid(raidId)?.type === 'weekly') pd.raids.weeklyDone = true; if (raidSystem.getRaid(raidId)?.type === 'monthly') pd.raids.monthlyBossDone = true; pd.markModified('stats'); pd.markModified('raids'); const xp = rewards.xpMin + Math.floor(Math.random() * (rewards.xpMax - rewards.xpMin)); await pd.addExperience(xp); await pd.save(); const memberSockets = [...connectedClients.entries()].filter(([, c]) => c.userId === member.userId); memberSockets.forEach(([sid]) => { io.to(sid).emit('raid_complete', { raidId, loot: result.loot, credits, gems: rewards.gems, xp }); }); } } } socket.emit('raid_action_result', result); } catch (err) { console.error('[RAID ACTION]', err); socket.emit('raid_action_result', { success: false, error: 'Raid action error' }); } }); // ════════════════════════════════════════════════════════════════════════ // ALLIANCE WARS (GDD Phase 3) // ════════════════════════════════════════════════════════════════════════ socket.on('declare_alliance_war', async ({ targetAllianceId } = {}) => { try { const client = connectedClients.get(socket.id); if (!client?.authenticated) return socket.emit('alliance_war_result', { success: false, error: 'Not authenticated' }); const playerData = await PlayerData.findOne({ userId: client.userId }); if (!playerData?.allianceId) return socket.emit('alliance_war_result', { success: false, error: 'Not in an alliance' }); if (playerData.allianceRank !== 'leader' && playerData.allianceRank !== 'officer') { return socket.emit('alliance_war_result', { success: false, error: 'Only leaders and officers can declare war' }); } const { AllianceModel } = require('./systems/AllianceSystem'); const [attacker, defender] = await Promise.all([ AllianceModel.findById(playerData.allianceId), AllianceModel.findById(targetAllianceId), ]); if (!attacker || !defender) return socket.emit('alliance_war_result', { success: false, error: 'Alliance not found' }); const war = await allianceWarSystem.declareWar(attacker, defender); socket.emit('alliance_war_result', { success: true, war }); io.emit('alliance_war_declared', { attackerTag: war.attackerTag, attackerName: war.attackerName, defenderTag: war.defenderTag, defenderName: war.defenderName, startTime: war.startedAt, endsAt: war.endsAt, }); } catch (err) { console.error('[ALLIANCE WAR DECLARE]', err); socket.emit('alliance_war_result', { success: false, error: err.message || 'War declaration error' }); } }); socket.on('get_alliance_wars', async () => { try { const wars = await allianceWarSystem.getActiveWars(); socket.emit('alliance_wars_data', { wars }); } catch (err) { socket.emit('alliance_wars_data', { wars: [] }); } }); socket.on('get_my_alliance_wars', async () => { try { const client = connectedClients.get(socket.id); const playerData = await PlayerData.findOne({ userId: client?.userId }); if (!playerData?.allianceId) return socket.emit('my_alliance_wars_data', { wars: [] }); const wars = await allianceWarSystem.getWarsByAlliance(playerData.allianceId); socket.emit('my_alliance_wars_data', { wars }); } catch (err) { socket.emit('my_alliance_wars_data', { wars: [] }); } }); // Ranked PvP doubles as a war battle if both players are in warring alliances // This is handled automatically inside pvp_ranked_accept via war score submission socket.on('disconnect', async () => { console.log('[GAME SERVER] Client disconnected:', socket.id); const clientData = connectedClients.get(socket.id); if (clientData?.userId) analyticsSystem.onLogout(clientData.userId); if (clientData) { // Save player data before disconnect if (clientData.playerData && clientData.userId) { try { // Final resource tick to capture any partial production resourceSystem.initResources(clientData.playerData); resourceSystem.tick(clientData.playerData); clientData.playerData.leaveServer(); await savePlayerData(clientData.userId, clientData.playerData); console.log(`[GAME SERVER] Saved data for ${clientData.username} on disconnect`); } catch (error) { console.error('[GAME SERVER] Error saving data on disconnect:', error); } } // Handle game instance cleanup if (clientData.instanceId) { const instance = gameInstances.get(clientData.instanceId); if (instance) { instance.players.delete(socket.id); // Clean up empty instances if (instance.players.size === 0) { gameInstances.delete(clientData.instanceId); } // Notify other players socket.to(clientData.instanceId).emit('playerLeftInstance', { playerId: socket.id, playerCount: instance.players.size }); } } } connectedClients.delete(socket.id); // Update player count on API server updatePlayerCountOnAPI(); }); }); // Load player data from database async function loadPlayerData(userId, username) { try { // Check if database is connected if (mongoose.connection.readyState !== 1) { console.error('[GAME SERVER] Database not connected, cannot load player data'); return null; } let playerData = await PlayerData.findOne({ userId }); if (!playerData) { // Create new player data with initialized systems console.log(`[GAME SERVER] Creating new player data for ${username} with system initialization`); // Initialize player data in all systems const questData = questSystem.initializePlayerData(userId); const skillData = skillSystem.initializePlayerData(userId); const craftingData = craftingSystem.initializePlayerData(userId); // For dungeons, initialize with available dungeons const availableDungeons = dungeonSystem.getAllDungeons(); const dungeonData = { availableDungeons: availableDungeons.map(d => ({ id: d.id, name: d.name, difficulty: d.difficulty, minLevel: d.minLevel || 1, maxPlayers: d.maxPlayers || 4, description: d.description })), completedDungeons: [], currentInstance: null, dungeonProgress: {} }; playerData = new PlayerData({ userId, username, resources: { metal: 500, gas: 200, crystal: 100, energyCells: 300, darkMatter: 0, lastTick: Date.now() }, stats: { level: 1, experience: 0, totalExperience: 0, credits: 1000, gems: 50, // Give some starting gems skillPoints: 0, totalKills: 0, dungeonsCleared: 0, questsCompleted: 0, playTime: 0, lastLogin: new Date() }, // Initialize with system data quests: { active: Array.from(questData.activeQuests.values()), completed: Array.from(questData.completedQuests.keys()) }, skills: skillData, dungeonSystem: dungeonData, crafting: craftingData }); await playerData.save(); console.log(`[GAME SERVER] Created new player data for ${username} with ${questData.activeQuests.size} active quests`); } else { // Auto-migration for existing players - check for missing data console.log(`[GAME SERVER] Loading existing player data for ${username} (Level ${playerData.stats.level})`); let migrated = false; let migrationLog = []; // Always categorize quests to ensure proper format console.log(`[GAME SERVER] Categorizing quests for ${username}`); console.log(`[GAME SERVER] Current playerData.quests before categorization:`, playerData.quests); const questData = questSystem.getPlayerData(userId); console.log(`[GAME SERVER] QuestSystem data:`, { activeQuestsCount: questData.activeQuests.size, completedQuestsCount: questData.completedQuests.size, activeQuests: Array.from(questData.activeQuests.values()) }); // Categorize quests by type for client const activeQuests = Array.from(questData.activeQuests.values()); const mainQuests = activeQuests.filter(quest => quest.type === 'main'); const dailyQuests = activeQuests.filter(quest => quest.type === 'daily'); const weeklyQuests = activeQuests.filter(quest => quest.type === 'weekly'); const tutorialQuests = activeQuests.filter(quest => quest.type === 'tutorial'); playerData.quests = { main: mainQuests, daily: dailyQuests, weekly: weeklyQuests, tutorial: tutorialQuests, active: activeQuests, completed: Array.from(questData.completedQuests.keys()) }; console.log(`[GAME SERVER] Quest categorization complete:`, { main: mainQuests.length, daily: dailyQuests.length, weekly: weeklyQuests.length, tutorial: tutorialQuests.length, total: activeQuests.length }); console.log(`[GAME SERVER] Final playerData.quests after categorization:`, playerData.quests); // IMPORTANT: Save the updated quest data to database try { await savePlayerData(userId, playerData); console.log(`[GAME SERVER] Saved categorized quest data for ${username}`); } catch (error) { console.error(`[GAME SERVER] Failed to save quest data for ${username}:`, error); } // Migrate skills if missing or empty if (!playerData.skills || Object.keys(playerData.skills).length === 0) { console.log(`[GAME SERVER] Migrating skills for ${username}`); const skillData = skillSystem.initializePlayerData(userId); playerData.skills = skillData; migrated = true; migrationLog.push('Added skill data'); } // Migrate dungeons if missing or empty if (!playerData.dungeonSystem || !playerData.dungeonSystem.availableDungeons || (playerData.dungeonSystem.availableDungeons && playerData.dungeonSystem.availableDungeons.length === 0)) { console.log(`[GAME SERVER] Migrating dungeons for ${username}`); const availableDungeons = dungeonSystem.getAllDungeons(); playerData.dungeonSystem = { availableDungeons: availableDungeons.map(d => ({ id: d.id, name: d.name, difficulty: d.difficulty, minLevel: d.minLevel || 1, maxPlayers: d.maxPlayers || 4, description: d.description })), completedDungeons: [], currentInstance: null, dungeonProgress: {} }; migrated = true; migrationLog.push(`Added ${availableDungeons.length} available dungeons`); } // Migrate crafting if missing or empty (this field might not exist at all) if (!playerData.crafting || Object.keys(playerData.crafting).length === 0) { console.log(`[GAME SERVER] Migrating crafting for ${username}`); const craftingData = craftingSystem.initializePlayerData(userId); playerData.crafting = craftingData; migrated = true; migrationLog.push('Added crafting data'); } // Initialize idle system data const idleData = idleSystem.initializePlayerData(userId); if (!playerData.idleSystem) { playerData.idleSystem = { lastActive: new Date().toISOString(), totalOfflineTime: 0, totalIdleCredits: 0 }; migrated = true; migrationLog.push('Added idle system data'); } // Save if migration occurred if (migrated) { await playerData.save(); console.log(`[GAME SERVER] Migration completed for ${username}: ${migrationLog.join(', ')}`); } // Update existing player login info playerData.username = username; playerData.lastLogin = new Date(); await playerData.save(); } return playerData; } catch (error) { console.error('[GAME SERVER] Error loading player data:', error); return null; } } // Save player data to database async function savePlayerData(userId, playerData) { try { // Check if database is connected if (mongoose.connection.readyState !== 1) { console.error('[GAME SERVER] Database not connected, cannot save player data'); return false; } await PlayerData.findOneAndUpdate( { userId }, playerData, { upsert: true, new: true } ); console.log(`[GAME SERVER] Saved player data for ${playerData.username}`); return true; } catch (error) { console.error('[GAME SERVER] Error saving player data:', error); return false; } } // Update player count on API server async function updatePlayerCountOnAPI() { try { const apiServerUrl = 'http://localhost:3001'; const currentPlayers = connectedClients.size; console.log(`[GAME SERVER] Updating player count: ${currentPlayers} players`); const response = await fetch(`${apiServerUrl}/api/servers/update-status/devgame-server-3002`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPlayers: currentPlayers, status: currentPlayers > 0 ? 'active' : 'waiting' }) }); if (response.ok) { const result = await response.json(); console.log(`[GAME SERVER] Player count updated:`, result); } else { console.error(`[GAME SERVER] Failed to update player count: ${response.status}`); } } catch (error) { console.error('[GAME SERVER] Error updating player count:', error); } } // Start server const PORT = process.env.PORT || 3002; async function startServer() { try { console.log('[GAME SERVER] Attempting to connect to database...'); // Connect to database await connectDB(); console.log('[GAME SERVER] Database connection established'); server.listen(PORT, async () => { console.log(`[GAME SERVER] Game server running on port ${PORT}`); console.log(`[GAME SERVER] Socket.IO ready for multiplayer connections`); // Register with API server await registerWithAPIServer(); // Start periodic player count updates setInterval(updatePlayerCountOnAPI, 30000); // Update every 30 seconds // Start online idle rewards generation (every 10 seconds) setInterval(async () => { console.log('[GAME SERVER] Idle reward timer triggered - checking', connectedClients.size, 'connected clients'); for (const [socketId, clientData] of connectedClients.entries()) { if (clientData.userId && clientData.playerData) { try { console.log('[GAME SERVER] Processing idle rewards for client:', socketId, 'user:', clientData.username); // Update playTime for active players const sessionTime = clientData.playerData.updatePlayTime(); console.log(`[GAME SERVER] Updated playTime for ${clientData.username}: +${sessionTime}ms, Total: ${clientData.playerData.stats.playTime}ms`); // Send playTime update to client io.to(socketId).emit('playTimeUpdated', { playTime: clientData.playerData.stats.playTime, sessionTime: sessionTime }); const onlineRewards = idleSystem.generateOnlineIdleRewards(clientData.userId, 10000); // 10 seconds console.log('[GAME SERVER] Generated online rewards for', clientData.username, ':', onlineRewards); if (onlineRewards.credits > 0) { // Update player data with online rewards clientData.playerData.stats.credits = (clientData.playerData.stats.credits || 0) + onlineRewards.credits; clientData.playerData.stats.experience = (clientData.playerData.stats.experience || 0) + onlineRewards.experience; console.log('[GAME SERVER] Applied idle rewards - Credits:', onlineRewards.credits, 'New balance:', clientData.playerData.stats.credits); // Send update to client io.to(socketId).emit('onlineIdleRewards', { credits: onlineRewards.credits, experience: onlineRewards.experience, newBalance: clientData.playerData.stats.credits, playTime: clientData.playerData.stats.playTime }); console.log('[GAME SERVER] Sent onlineIdleRewards to client:', socketId); } else { console.log('[GAME SERVER] No idle rewards generated for', clientData.username); } } catch (error) { console.error(`[GAME SERVER] Error generating online idle rewards for ${socketId}:`, error); } } } }, 10000); // Every 10 seconds // ── Market Tick: expire old listings every 15 min (GDD §18.2) ──────────── setInterval(async () => { try { const n = await marketSystem.expireListings(); if(n>0) console.log(`[MARKET TICK] Expired ${n} listings`); } catch(e) { console.error('[MARKET TICK]', e.message); } }, 15 * 60 * 1000); // ── Auto-collect missions on login (handled in get_missions) ───────────── // ── Economy Tick: resource production every 60s (GDD §18.2) ──────────── let _lastSeasonActive = seasonSystem.isActive(); setInterval(async () => { const seasonBonuses = seasonSystem.getSeasonBonuses(); const eventStatus = galaxyEventSystem.getEventStatus(); // Check if season just ended this tick — distribute rewards once const nowSeasonActive = seasonSystem.isActive(); if (_lastSeasonActive && !nowSeasonActive) { const allPD = [...connectedClients.values()].filter(c => c.playerData).map(c => c.playerData); seasonSystem.distributeSeasonRewards(allPD); for (const clientData of connectedClients.values()) { if (clientData.userId && clientData.playerData) { await savePlayerData(clientData.userId, clientData.playerData).catch(() => {}); } } } _lastSeasonActive = nowSeasonActive; for (const [socketId, clientData] of connectedClients.entries()) { if (!clientData.userId || !clientData.playerData) continue; try { resourceSystem.initResources(clientData.playerData); const { produced, capped } = resourceSystem.tick(clientData.playerData, seasonBonuses, eventStatus); if (Object.keys(produced).length > 0) { io.to(socketId).emit('resource_update', { resources: clientData.playerData.resources, produced, capped, rates: resourceSystem.getProductionRates(clientData.playerData, seasonBonuses, eventStatus), caps: resourceSystem.getStorageCaps(clientData.playerData), }); } } catch (err) { console.error('[ECONOMY TICK] Error:', err.message); } } }, 60000); // GDD §18.2: 60-second economy tick }); } catch (error) { console.error('[GAME SERVER] Failed to start server:', error); process.exit(1); } } // Register this GameServer with the API server async function registerWithAPIServer() { try { // Force use of local API server for development const apiServerUrl = 'http://localhost:3001'; const gameServerUrl = 'https://dev.gameserver.galaxystrike.online'; const serverData = { serverId: `devgame-server-${PORT}`, name: 'Dev Game Server', type: 'public', // Must be 'public' or 'private' according to model region: 'Europe', maxPlayers: 100, // Hardcoded to allow 100 players currentPlayers: 0, gameServerUrl: gameServerUrl, owner: { userId: 'developer', username: 'Developer' } }; console.log(`[GAME SERVER] Registering with API server at ${apiServerUrl}`); console.log(`[GAME SERVER] Server data:`, serverData); const response = await fetch(`${apiServerUrl}/api/servers/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(serverData) }); if (response.ok) { const result = await response.json(); console.log(`[GAME SERVER] Successfully registered with API server:`, result); } else { const errorText = await response.text(); console.error(`[GAME SERVER] Failed to register with API server: ${response.status}`); console.error(`[GAME SERVER] Error response:`, errorText); } } catch (error) { console.error('[GAME SERVER] Error registering with API server:', error); } } startServer(); module.exports = { app, server, io, gameInstances, connectedClients };