238 lines
10 KiB
JavaScript
238 lines
10 KiB
JavaScript
/**
|
||
* 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;
|