This repository has been archived on 2026-05-04. You can view files and clone it, but cannot push or open issues or pull requests.
Galaxy-Strike-Online/GameServer/server.js
2026-03-11 00:32:45 -03:00

4205 lines
190 KiB
JavaScript

/**
* 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 };