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