199 lines
6.8 KiB
JavaScript
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;
|