/** * XP MANAGER — the combat level + experience bank. Pure rules, no * Phaser, no scene (the same contract as js/combat/Combat.js — it runs * in bare Node, dev/xp.test.mjs exercises exactly this). * * The curve lives in data/progression.json (the config owns the numbers, * this owns the behavior): * * xpForLevel(n) = base × n^power (n = the level ADVANCED TO, 1..maxLevel) * * with the shipped values (maxLevel 80, base 50, power 2) that makes * level 1 cost 50 XP — five kills of the base hostile (its 10 XP, * data/combat.json → enemy.xp) — the barometer. The quadratic means each * level's cost is n²: the relative step SHRINKS as you climb (4×, 2.25×, * 1.78× … 1.03×), so early levels fly by and the late ones never become * a wall. Two independent dials: `power` is the grind INTENSITY (lower = * gentler back half), `base` scales the whole curve. * * Enemies carry their own XP (per-type `xp` in data/combat.json → * enemy / enemyTypes) — the curve is fixed in XP, the roster sets * XP-per-threat, so pacing tunes without touching the other. * * EVENTS (the extension seam — the skill layer subscribes to the same * ones later; listeners can be added/removed from any owner): * * 'grant' every grant: { amount, source, level, xp, levelsGained } * 'levelup' per level crossed (one BIG grant crossing several fires * one event EACH, so an "unlocks exactly at level N" rule * can't miss): { from, to, levelsGained, source } * 'max' once, when the cap is first reached: { level, source } * * A listener that throws is caught + warned — one bad subscriber can't * wedge the kill path. * * CAP: the level holds at maxLevel and the XP is BANKED (xp keeps * counting — harmless; the post-cap currency will be the skill layer, * not XP). * * Save-safe: toJSON() → { level, xp } (the curve is data, so the record * stays tiny — the "save = seed + player state" doctrine). restore() * NEVER fires events — a load is not a level-up. */ import { config } from '../config/Config.js'; export class XpManager { constructor() { this.maxLevel = Math.max(1, Math.floor(Number(config.get('progression.maxLevel', 80)) || 80)); this.base = Math.max(0, Number(config.get('progression.base', 50)) || 0); this.power = Math.max(0.05, Number(config.get('progression.power', 2)) || 2); this.level = 0; this.xp = 0; // progress toward the next level (BANKED at the cap) this._maxAnnounced = false; this._listeners = new Map(); // event name → Set } /** Level 80 (data) and holding — the XP bank keeps counting. */ get isMax() { return this.level >= this.maxLevel; } /** * The XP needed to ADVANCE TO level n (n = 1..maxLevel — the level-0 * player pays xpForLevel(1) for level 1). Pure math; n outside the * range just computes (the CAP is the level, not the formula). * @param {number} n the target level * @returns {number} the XP cost (0 for n < 1) */ xpForLevel(n) { n = Math.floor(Number(n) || 0); if (n < 1) return 0; return Math.round(this.base * Math.pow(n, this.power)); } /** The XP from the CURRENT level to the next (null at the cap). */ xpForNext() { if (this.isMax) return null; return this.xpForLevel(this.level + 1); } /** * Subscribe to a manager event ('grant' | 'levelup' | 'max'). * @param {string} event * @param {(payload: object) => void} fn * @returns {() => void} the unsubscribe (idempotent) */ on(event, fn) { if (typeof fn !== 'function') return () => {}; const key = String(event || ''); if (!this._listeners.has(key)) this._listeners.set(key, new Set()); this._listeners.get(key).add(fn); return () => this.off(event, fn); } /** Remove a subscriber (safe to call twice / for a stranger). */ off(event, fn) { this._listeners.get(String(event || ''))?.delete(fn); } /** Fire an event to its subscribers — a throw is caught + warned. */ _emit(event, payload) { const subs = this._listeners.get(event); if (!subs || subs.size === 0) return; for (const fn of [...subs]) { try { fn(payload); } catch (err) { if (typeof console !== 'undefined') console.warn(`[orbit] xp: "${event}" listener threw`, err); } } } /** * Bank XP. Crosses as many levels as the grant buys (one event each — * see the class doc), caps at maxLevel (the bank keeps counting), and * fires 'grant' last, with the settled state. * @param {number} amount the XP earned (a hostile's type `xp`, a sidequest's * payout, a cargo sale's profit — whatever the caller banks) * @param {string} [source] the ledger's source tag ('kill'; 'sidequest' — * the board's claim, the contract's XP payout; 'trade' — the profit on a * cargo sale, js/ui/shop/ShopView.js — all flow through the same pipe) * @returns {{gained:number, level:number, levelsGained:number, leveledUp:boolean}} */ grant(amount, source = 'kill') { const n = Math.max(0, Math.floor(Number(amount) || 0)); if (n === 0) { return { gained: 0, level: this.level, levelsGained: 0, leveledUp: false }; } this.xp += n; let levelsGained = 0; while (this.level < this.maxLevel) { const need = this.xpForLevel(this.level + 1); if (this.xp < need) break; this.xp -= need; this.level += 1; levelsGained += 1; this._emit('levelup', { from: this.level - 1, to: this.level, levelsGained, source, }); if (this.level >= this.maxLevel && !this._maxAnnounced) { this._maxAnnounced = true; this._emit('max', { level: this.level, source }); } } this._emit('grant', { amount: n, source, level: this.level, xp: Math.max(0, Math.round(this.xp)), levelsGained, }); return { gained: n, level: this.level, levelsGained, leveledUp: levelsGained > 0 }; } /** * Apply a saved { level, xp } (or reset to the fresh ledger when the * state is absent). NO events — a load is not a level-up. * @param {object|null} state { level, xp } (tolerant of junk) * @returns {XpManager} this */ restore(state) { const level = state && typeof state === 'object' ? Math.max(0, Math.min(this.maxLevel, Math.floor(Number(state.level) || 0))) : 0; const xp = state && typeof state === 'object' ? Math.max(0, Math.floor(Number(state.xp) || 0)) : 0; this.level = level; this.xp = xp; this._maxAnnounced = level >= this.maxLevel; return this; } /** The fresh run's ledger (a new game — not a level-up either). */ reset() { this.level = 0; this.xp = 0; this._maxAnnounced = false; return this; } /** The save's shape (tiny — the curve lives in data, not the record). */ toJSON() { return { level: this.level, xp: Math.max(0, Math.round(this.xp)) }; } /** A fresh manager from a save's { level, xp } (no events). */ static fromJSON(state) { return new XpManager().restore(state); } /** * A snapshot for the UI (the combat bar's XP row) / the dev console. * `progress` = 0..1 toward the next level (1 at the cap — the bank is * full by definition). */ stats() { const next = this.xpForNext(); return { level: this.level, xp: Math.max(0, Math.round(this.xp)), xpForNext: next, maxLevel: this.maxLevel, isMax: this.isMax, progress: next ? Math.max(0, Math.min(1, this.xp / next)) : 1, }; } }