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