/** * Galaxy Strike Online — SkillSystem (GDD §10) * Server-side skill definitions, player tracking, XP, allocation, and bonus computation. * * Skill effect pipeline: * getSkillBonuses(userId) → aggregates all levelled skill bonuses for a player. * applySkillsToCombat(userId, combatStats) → mutates combatStats in-place. * applyCraftingBonuses(userId, baseCraftTime) → returns reduced craft time. */ class SkillSystem { constructor(contentLoader) { this.loader = contentLoader; this.playerSkills = new Map(); // userId → { skillPoints, skills, unlockedSkills } } // ── Content helpers ──────────────────────────────────────────────────────── getAllSkills() { return this.loader.getAllSkills(); } getSkill(id) { return this.loader.getSkill(id); } getSkillsByCategory(cat) { return this.loader.getSkillsByCategory(cat); } // ── Player data ──────────────────────────────────────────────────────────── initializePlayerData(userId) { if (this.playerSkills.has(userId)) return this.playerSkills.get(userId); const defaultUnlocked = this.loader.getAllSkills() .filter(s => s.defaultUnlocked) .map(s => s.id); const skills = {}; for (const id of defaultUnlocked) skills[id] = { level: 1, experience: 0 }; const data = { skillPoints: 0, skills, unlockedSkills: new Set(defaultUnlocked) }; this.playerSkills.set(userId, data); return data; } getPlayerSkills(userId) { return this.initializePlayerData(userId); } // Restore from persisted DB object (called on player load) restorePlayerData(userId, saved) { if (!saved) return this.initializePlayerData(userId); const data = { skillPoints: saved.skillPoints || 0, skills: saved.skills || {}, unlockedSkills: new Set(saved.unlockedSkills || Object.keys(saved.skills || {})), }; this.playerSkills.set(userId, data); return data; } // ── XP & levelling ──────────────────────────────────────────────────────── /** * Award experience to a specific skill. * Returns { skillId, newLevel, leveled, xpToNextLevel }. */ addExperience(userId, skillId, amount) { const playerData = this.initializePlayerData(userId); const skillDef = this.getSkill(skillId); if (!skillDef) throw new Error(`Unknown skill: ${skillId}`); if (!playerData.unlockedSkills.has(skillId)) throw new Error(`Skill ${skillId} not unlocked`); if (!playerData.skills[skillId]) playerData.skills[skillId] = { level: 1, experience: 0 }; const entry = playerData.skills[skillId]; const xpPerLvl = skillDef.experiencePerLevel || 1000; const maxLevel = skillDef.maxLevel || 100; entry.experience += amount; let leveled = false; while (entry.level < maxLevel && entry.experience >= xpPerLvl) { entry.experience -= xpPerLvl; entry.level++; leveled = true; } if (entry.level >= maxLevel) entry.experience = 0; return { skillId, newLevel: entry.level, experience: entry.experience, xpToNextLevel: entry.level >= maxLevel ? 0 : xpPerLvl - entry.experience, leveled, }; } // ── Skill points ────────────────────────────────────────────────────────── awardSkillPoints(userId, amount) { const playerData = this.initializePlayerData(userId); playerData.skillPoints = (playerData.skillPoints || 0) + amount; return { skillPoints: playerData.skillPoints }; } /** * Spend 1 skill point to increase a skill's level by 1. */ allocateSkillPoint(userId, skillId) { const playerData = this.initializePlayerData(userId); const skillDef = this.getSkill(skillId); if (!skillDef) throw new Error(`Unknown skill: ${skillId}`); if (!playerData.unlockedSkills.has(skillId)) throw new Error(`Skill ${skillId} not unlocked`); if ((playerData.skillPoints || 0) < 1) throw new Error('No skill points available'); const maxLevel = skillDef.maxLevel || 100; if (!playerData.skills[skillId]) playerData.skills[skillId] = { level: 0, experience: 0 }; if (playerData.skills[skillId].level >= maxLevel) throw new Error(`Skill ${skillId} already at max level`); playerData.skills[skillId].level++; playerData.skillPoints--; return { skillId, newLevel: playerData.skills[skillId].level, skillPoints: playerData.skillPoints, }; } // ── Unlock ──────────────────────────────────────────────────────────────── unlockSkill(userId, skillId) { const playerData = this.initializePlayerData(userId); const skillDef = this.getSkill(skillId); if (!skillDef) throw new Error(`Unknown skill: ${skillId}`); if (playerData.unlockedSkills.has(skillId)) throw new Error(`Skill ${skillId} already unlocked`); const req = skillDef.unlockRequires; if (req) { const prereq = playerData.skills[req.skill]; if (!prereq || prereq.level < req.level) { throw new Error(`Requires ${req.skill} at level ${req.level}`); } } playerData.unlockedSkills.add(skillId); playerData.skills[skillId] = { level: 1, experience: 0 }; return { skillId, unlocked: true }; } // ── Bonus aggregation ───────────────────────────────────────────────────── /** * Returns an object of aggregated stat bonuses for a player across * all levelled unlocked skills. Values are per-level; total = level × bonus. */ getSkillBonuses(userId) { const playerData = this.initializePlayerData(userId); const bonuses = { // Combat damage: 0, accuracy: 0, criticalChance: 0, defense: 0, speed: 0, // Crafting craftingBonus: 0, craftingSpeed: 0, armorStats: 0, durabilityBonus: 0, shipBuildSpeed: 0, hullStrengthBonus: 0, shipRepairSpeed: 0, fleetCapacity: 0, circuitYield: 0, techComponentQuality: 0, researchSpeed: 0, // Science xpGainBonus: 0, alienTechBonus: 0, energyEfficiency: 0, quantumBonus: 0, bioEngineeringBonus: 0, }; for (const [skillId, entry] of Object.entries(playerData.skills)) { if (!playerData.unlockedSkills.has(skillId)) continue; const def = this.getSkill(skillId); if (!def || !def.bonuses) continue; const level = entry.level || 1; for (const [key, value] of Object.entries(def.bonuses)) { if (bonuses[key] !== undefined) bonuses[key] += value * level; } } return bonuses; } /** * Apply a player's skill bonuses to a combat stats object. * combatStats shape: { attack, defense, critChance, speed, accuracy } * Mutates in-place and returns modified object. */ applySkillsToCombat(userId, combatStats) { const b = this.getSkillBonuses(userId); combatStats.attack = Math.floor((combatStats.attack || 0) * (1 + b.damage / 100)); combatStats.defense = Math.floor((combatStats.defense || 0) * (1 + b.defense / 100)); combatStats.critChance= Math.min(0.75, (combatStats.critChance || 0.05) + b.criticalChance / 100); combatStats.accuracy = Math.min(1.0, (combatStats.accuracy || 0.9) + b.accuracy / 100); combatStats.speed = Math.floor((combatStats.speed || 10) * (1 + b.speed / 100)); return combatStats; } /** * Apply crafting bonuses to a base craft time in seconds. * Returns reduced craft time (minimum 1s). */ applyCraftingBonuses(userId, baseCraftTime) { const b = this.getSkillBonuses(userId); const speedBonus = Math.min(0.8, (b.craftingBonus + b.craftingSpeed) / 100); return Math.max(1, Math.floor(baseCraftTime * (1 - speedBonus))); } /** * Apply XP gain bonus from science skills. * Returns adjusted XP amount. */ applyXpBonus(userId, baseXp) { const b = this.getSkillBonuses(userId); return Math.floor(baseXp * (1 + b.xpGainBonus / 100)); } // ── Statistics ──────────────────────────────────────────────────────────── getSkillStatistics(userId) { const playerData = this.initializePlayerData(userId); const allSkills = this.getAllSkills(); const unlocked = playerData.unlockedSkills.size; let totalLevels = 0; let maxedSkills = 0; for (const [skillId, entry] of Object.entries(playerData.skills)) { const def = this.getSkill(skillId); if (!def) continue; totalLevels += entry.level || 0; if ((entry.level || 0) >= (def.maxLevel || 100)) maxedSkills++; } return { totalSkills: allSkills.length, unlockedSkills: unlocked, totalLevels, maxedSkills, skillPoints: playerData.skillPoints || 0, }; } } module.exports = SkillSystem;