210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
/**
|
||
* 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<fn>
|
||
}
|
||
|
||
/** 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`)
|
||
* @param {string} [source] the ledger's source tag ('kill' for now;
|
||
* quests/dev probes flow through the same pipe later)
|
||
* @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,
|
||
};
|
||
}
|
||
}
|