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-10 13:06:33 -03:00

3138 lines
136 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');
// ── 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();
// 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);
// 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 = [];
let cHp = Math.max(1, (challenger.ship?.health || 1000) + (challenger.level || 1) * 50);
let dHp = Math.max(1, (defender.ship?.health || 1000) + (defender.level || 1) * 50);
const cAtk = Math.max(1, (challenger.ship?.attack || 100) + (challenger.level || 1) * 10);
const dAtk = Math.max(1, (defender.ship?.attack || 100) + (defender.level || 1) * 10);
let round = 0;
while (cHp > 0 && dHp > 0 && round < 20) {
round++;
const cDmg = Math.floor(cAtk * (0.85 + Math.random() * 0.3));
const dDmg = Math.floor(dAtk * (0.85 + Math.random() * 0.3));
dHp -= cDmg;
cHp -= dDmg;
rounds.push({ round, challengerDmg: cDmg, defenderDmg: dDmg, challengerHp: Math.max(0, cHp), defenderHp: Math.max(0, dHp) });
}
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() });
});
// 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
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,
});
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', () => {
console.log('[GAME SERVER] Sending skill definitions to:', socket.id);
try {
const skills = skillSystem.getAllSkills();
socket.emit('skills_data', skills);
} catch (error) {
console.error('[GAME SERVER] Error sending skills:', error);
socket.emit('skills_data', []);
}
});
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
const skillLevel = crafting.skill || 1;
const reduction = 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
const timeSec = Math.max(5, Math.round(baseTimeSec * (1 - reduction)));
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();
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 });
} 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: 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) {
const def = BUILDING_DEFS[defId];
if (!def) return 60;
return Math.floor(def.timeBase * Math.pow(1.8, currentLevel));
}
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);
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) }));
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) * 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 });
}
});
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) * 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 });
} 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 leaves a dungeon ──────────────────
socket.on('exit_dungeon', (data) => {
console.log('[GAME SERVER] exit_dungeon for:', socket.id);
try {
const { instanceId, userId } = data || {};
if (instanceId) dungeonSystem.abandonInstance(instanceId);
socket.emit('dungeon_exited', { success: true });
} 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
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});
// 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});
} 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.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;
await allianceSystem.leaveAlliance(client.playerData);
await savePlayerData(client.playerData.userId, client.playerData);
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 tree = require('./data/gso/alliance/research_tree.json');
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();
socket.emit('alliance_research_result', { success:true, completed: techId, allCompleted: alliance.research.completed });
} catch(err) { socket.emit('alliance_research_result', {success:false, error:err.message}); }
});
// ════════════════════════════════════════════════════════════════════════
// 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 VALID_CATEGORIES = ['level', 'credits', 'questsCompleted', 'dungeonsCleared', 'totalKills'];
if (!VALID_CATEGORIES.includes(category)) {
return socket.emit('leaderboard_data', { success: false, error: 'Invalid category' });
}
// Query top 20 players for the requested stat
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, updatedAt: 1 }
)
.sort({ [sortField]: -1 })
.limit(20)
.lean();
const myUserId = connectedClients.get(socket.id)?.userId;
const entries = players.map((p, i) => ({
rank: i + 1,
username: p.username || p.stats?.username || 'Unknown Commander',
value: p.stats?.[category] || 0,
level: p.stats?.level || 1,
isMe: p.userId === myUserId || p._id?.toString() === myUserId,
}));
socket.emit('leaderboard_data', { success: true, category, entries });
} catch (err) {
console.error('[LEADERBOARD] get_leaderboard error:', err);
socket.emit('leaderboard_data', { 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 });
}
});
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 visibleSectors = galaxySystem.getVisibleSectors(client.playerData);
socket.emit('galaxy_map_data', {
success: true,
sectors: visibleSectors,
homeSector: client.playerData.homeSector,
gridW: galaxySystem.GRID_W,
gridH: galaxySystem.GRID_H,
});
} 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 sector = galaxySystem.exploreSector(sectorId, client.playerData);
await savePlayerData(client.playerData.userId, client.playerData);
// XP reward for first explore
const xpGain = 50 + sector.threat * 10;
client.playerData.stats.experience = (client.playerData.stats.experience || 0) + xpGain;
socket.emit('sector_explored', { success: true, sector, xpGain });
// Push updated visible sectors
const visibleSectors = galaxySystem.getVisibleSectors(client.playerData);
socket.emit('galaxy_map_data', {
success: true,
sectors: visibleSectors,
homeSector: client.playerData.homeSector,
gridW: galaxySystem.GRID_W,
gridH: galaxySystem.GRID_H,
});
} 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(),
};
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 });
}
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 });
}
});
socket.on('disconnect', async () => {
console.log('[GAME SERVER] Client disconnected:', socket.id);
const clientData = connectedClients.get(socket.id);
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) ────────────
setInterval(async () => {
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);
if (Object.keys(produced).length > 0) {
io.to(socketId).emit('resource_update', {
resources: clientData.playerData.resources,
produced,
capped,
rates: resourceSystem.getProductionRates(clientData.playerData),
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 };