3138 lines
136 KiB
JavaScript
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 };
|