orbit/js/combat/XpManager.js

210 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};
}
}