orbit/js/player/ClassState.js

199 lines
6.8 KiB
JavaScript

/**
* CLASS STATE — the run's class LEDGER (pure; the roster is
* data/classes.json via js/player/ClassModel.js).
*
* What the run remembers about its class:
*
* id the class on file (null = the player has not picked yet)
* perks the perk ids the initial bonus granted (the combat code's
* gates: dualCannon → the twin-barrel volley,
* targetingComputer → the leading solve)
* skillPoints the BANKED skill points (the class grant put them there;
* the tree that spends them is the next layer)
* granted the initial-bonus witness — once the bonus has been paid,
* it is never paid twice
*
* THE GRANT IS IDEMPOTENT: `choose()` pays a class's initial bonus exactly
* once. A second call for the SAME class re-affirms the class and reports
* `alreadyGranted` without moving a single number (no double skill points);
* a call for a DIFFERENT class is refused — swapping classes is a later
* layer with a price on it, so the ledger holds the line here rather than
* in the UI (data/classes.json → window.alreadyChosenToast is the voice).
*
* Save-safe like every other ledger (js/save/SaveData.js: record.playerClass):
* toJSON() is plain data, fromJSON() validates against the CURRENT roster
* (a hand-edited or stale id degrades to "no class on file" instead of
* handing out unknown perks), and a fresh run starts empty.
*/
import { classDef, classList } from './ClassModel.js';
export class ClassState {
constructor() {
/** @type {string|null} the class on file */
this.id = null;
/** @type {Set<string>} the perk ids the initial bonus granted */
this.perks = new Set();
/** @type {number} skill points banked, unspent */
this.skillPoints = 0;
/** @type {boolean} the initial bonus has been paid (the idempotency witness) */
this.granted = false;
}
/** Does the run have a class yet (level ≥ classAt with nothing on file = not chosen)? */
get hasClass() {
return !!this.id;
}
/** The class's normalized definition (null when nothing is on file). */
get def() {
return this.id ? classDef(this.id) : null;
}
/** The class's display label ('' when no class) — the ship console's plate. */
get label() {
return this.def?.label ?? '';
}
/** The insignia frame off assets/images/classes.png (null when no class). */
get frame() {
return this.def?.frame ?? null;
}
/** Does the run own this perk (the combat code's gate)? */
hasPerk(id) {
return !!id && this.perks.has(String(id));
}
/**
* Lock in a class and pay its INITIAL bonus.
*
* @param {string} id the class picked
* @returns {{ok: boolean, reason?: string, alreadyGranted?: boolean,
* id: string|null, skillPoints: number, perks: string[]}}
* ok=false + reason 'unknown' — no such class in the roster;
* ok=false + reason 'taken' — a DIFFERENT class is already on file
* (changing class is a later layer);
* ok=true + alreadyGranted — the same class again: nothing was paid.
*/
choose(id) {
const key = typeof id === 'string' ? id.trim().toLowerCase() : '';
const def = key ? classDef(key) : null;
if (!def) return this._result('unknown');
if (this.id && this.id !== key) return this._result('taken');
this.id = key;
if (!this.granted) {
for (const perk of def.perks) this.perks.add(perk.id);
this.skillPoints += def.skillPoints;
this.granted = true;
return this._result(null, false);
}
return this._result(null, true);
}
/**
* Bank more skill points (the seam the next layer uses — a quest reward,
* a level-up bonus). Always non-negative.
* @returns {number} the new bank
*/
awardSkillPoints(n) {
const v = Math.max(0, Math.floor(Number(n) || 0));
this.skillPoints += v;
return this.skillPoints;
}
/**
* Spend from the bank (the future skill tree's door — refused when the
* bank is short or there is no class on file).
* @returns {boolean} true when the bank paid for it
*/
spendSkillPoints(n) {
const v = Math.max(0, Math.floor(Number(n) || 0));
if (!this.hasClass || v > this.skillPoints) return false;
this.skillPoints -= v;
return true;
}
/** The result record every entry point reports. */
_result(reason, alreadyGranted = false) {
return {
ok: !reason,
reason: reason ?? null,
alreadyGranted,
id: this.id,
skillPoints: this.skillPoints,
perks: [...this.perks],
};
}
/** A snapshot for the consoles (the ship window's class plate, the dev console). */
stats() {
const def = this.def;
return {
id: this.id,
name: this.label || null,
label: this.label || null,
accent: def?.accentCss ?? null,
frame: this.frame,
tagline: def?.tagline ?? '',
perks: [...this.perks],
skillPoints: this.skillPoints,
granted: this.granted,
};
}
/** The save's shape (plain data — the roster lives in data/, not here). */
toJSON() {
return {
id: this.id ?? null,
perks: [...this.perks],
skillPoints: this.skillPoints,
granted: this.granted === true,
};
}
/**
* Apply a saved record in place. The class id and every perk must exist in
* the CURRENT roster — an unknown id drops the whole class (and with it
* any perks), so a stale record can never leave a half-owned build.
* @param {object|null} state record.playerClass (tolerant of junk)
*/
restore(state) {
this.id = null;
this.perks = new Set();
this.skillPoints = 0;
this.granted = false;
if (!state || typeof state !== 'object') return this;
const def = classDef(state.id);
if (!def) return this;
const owned = new Set(def.perks.map((p) => p.id));
this.id = def.id;
this.perks = new Set(
(Array.isArray(state.perks) ? state.perks : []).filter((p) => typeof p === 'string' && owned.has(p)),
);
// A class on file but no witness means the bonus never paid (a record
// from before the grant, or a hand-edited save): keep the class, mark
// the bonus unpaid so a later lock-in can still settle it.
this.granted = state.granted === true;
if (!Number.isFinite(Number(state.skillPoints))) return this;
this.skillPoints = Math.max(0, Math.floor(Number(state.skillPoints)));
return this;
}
/** A fresh ledger (a new run — nothing on file, the bank empty). */
reset() {
return this.restore(null);
}
/** The save's record → a ledger (validated against data/classes.json). */
static fromJSON(state) {
return new ClassState().restore(state);
}
/** Every class id the roster offers (the validator's universe). */
static knownIds() {
return classList().map((c) => c.id);
}
}
export default ClassState;