554 lines
23 KiB
JavaScript
554 lines
23 KiB
JavaScript
/**
|
|
* SKILL STATE — the run's skill LEDGER (pure; the roster is
|
|
* data/skills.json via js/skills/SkillModel.js).
|
|
*
|
|
* What the run remembers about its skills:
|
|
*
|
|
* ranks skill id → points actually spent (1 … maxPoints). A skill at
|
|
* rank 0 exists in the tree but is INERT; rank ≥1 is live.
|
|
* seats the combat bar's bindings — one entry per seat (index 0 = seat
|
|
* 1, the key `1` presses), holding a skill id or null. Seeded from
|
|
* the data's default `seat` values on a fresh ledger, then owned by
|
|
* the player (the Skill Tree console assigns: pick a skill, pick a
|
|
* seat). Only CASTABLE skills hold a seat — a passive is always on.
|
|
*
|
|
* THE BANK IS NOT DUPLICATED HERE. Points live in js/player/ClassState.js
|
|
* (the class lock-in banks them); this ledger spends from it and never
|
|
* mints its own, so "bank + spent" can never disagree with the class layer.
|
|
* A REFUND runs the other way through that same door (`awardSkillPoints`), and it
|
|
* is scoped by the SESSION below — a committed rank is otherwise forever.
|
|
*
|
|
* THE CONSOLE SESSION (the Skill Tree console's RESET rule): opening the console
|
|
* files a snapshot of the ranks (`beginSession`); every point spent after that is
|
|
* PENDING, and RESET hands a skill's pending points back to the bank — only
|
|
* those, never a rank that was already committed. Closing the console commits what
|
|
* is still pending (`commitSession`), which is why closing it is the player's
|
|
* "sign here". A cascade goes with it: giving a parent its points back would strand
|
|
* a child bought under it, so that child (whose own points must be pending too)
|
|
* resets in the same press — or the whole reset is REFUSED and the child is
|
|
* named. The session is transient and deliberately NOT saved: a load mid-window
|
|
* simply leaves nothing pending to unwind.
|
|
*
|
|
* UNLOCKS ARE EDGES IN THE DATA: `requires` names parent skills, optionally
|
|
* with the points they need (`{ skill, points }`; a bare id means "one
|
|
* point"). A parent at rank 0 satisfies nothing — spend-through-unlock.
|
|
*
|
|
* Save-safe like every other ledger (js/save/SaveData.js: record.playerSkills):
|
|
* toJSON() is plain data, fromJSON() validates against the CURRENT roster (an
|
|
* unknown id is dropped rather than handed out, ranks clamp to maxPoints, a
|
|
* seat on a passive or an unknown skill clears), and a fresh run starts empty.
|
|
*/
|
|
import { seatCount, skillDef, skillList } from './SkillModel.js';
|
|
|
|
/** The outcome record every entry point reports. */
|
|
function result(extra = {}) {
|
|
return { ok: false, reason: null, ...extra };
|
|
}
|
|
|
|
export class SkillState {
|
|
/**
|
|
* @param {object|null} classState the run's js/player/ClassState.js ledger
|
|
* (the point bank + the class on file). Null = nothing to spend.
|
|
*/
|
|
constructor(classState = null) {
|
|
this.classState = classState ?? null;
|
|
/** @type {Map<string, number>} skill id → points spent */
|
|
this.ranks = new Map();
|
|
/** @type {Array<string|null>} seat index → skill id (null = an empty seat) */
|
|
this.seats = new Array(Math.max(1, seatCount())).fill(null);
|
|
/** The data's default bindings have been seeded (never re-seeded later). */
|
|
this.defaultsApplied = false;
|
|
/** @type {Map<string,number>|null} the ranks on file when the Skill Tree
|
|
* console opened — null when no session is running (everything on file is
|
|
* committed). Transient by design: it never rides the save. */
|
|
this.sessionBase = null;
|
|
}
|
|
|
|
/** The class on file ('' when the run has none) — the tree's owner. */
|
|
get classId() {
|
|
return this.classState?.id ?? '';
|
|
}
|
|
|
|
/** Does the run have a class (and therefore any skills at all)? */
|
|
get hasClass() {
|
|
return !!this.classId;
|
|
}
|
|
|
|
/** The live bank (js/player/ClassState.js) — read, never copied. */
|
|
get points() {
|
|
return Math.max(0, Math.floor(Number(this.classState?.skillPoints) || 0));
|
|
}
|
|
|
|
/** The record for a skill id, scoped to the run's class when it has one. */
|
|
defOf(id) {
|
|
return this.classId ? skillDef(id, this.classId) ?? skillDef(id) : skillDef(id);
|
|
}
|
|
|
|
/** Points spent into a skill (0 = learned nothing yet — the skill is inert). */
|
|
rank(id) {
|
|
return Math.max(0, Math.floor(Number(this.ranks.get(String(id ?? '').toLowerCase())) || 0));
|
|
}
|
|
|
|
/** Rank ≥1: the skill is live (a passive works, an active can be cast). */
|
|
hasRank(id) {
|
|
return this.rank(id) > 0;
|
|
}
|
|
|
|
/** Every rank of a skill spent. */
|
|
isMaxed(id) {
|
|
const def = this.defOf(id);
|
|
return !!def && this.rank(id) >= def.maxPoints;
|
|
}
|
|
|
|
/** Are all of a skill's `requires` edges satisfied by points actually spent? */
|
|
unlocked(def) {
|
|
if (!def) return false;
|
|
return (def.requires ?? []).every((r) => this.rank(r.skill) >= (r.points || 1));
|
|
}
|
|
|
|
/**
|
|
* The first requirement NOT satisfied — the console's LOCKED note names it.
|
|
* @returns {{skill: string, points: number, label: string}|null}
|
|
*/
|
|
lockedBy(def) {
|
|
if (!def) return null;
|
|
for (const r of def.requires ?? []) {
|
|
if (this.rank(r.skill) < (r.points || 1)) {
|
|
const parent = this.defOf(r.skill);
|
|
return { skill: r.skill, points: r.points || 1, label: parent?.label ?? r.skill };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Can another point go into this skill right now?
|
|
* @returns {{ok: boolean, reason: (string|null), def: object|null}}
|
|
* reason 'noClass' — nothing is filed, there is no tree to spend into;
|
|
* 'unknown' — no such skill in this class's tree;
|
|
* 'locked' — a `requires` parent is short (see lockedBy);
|
|
* 'maxed' — every point the skill holds is spent;
|
|
* 'noPoints' — the bank is empty.
|
|
*/
|
|
canRank(id) {
|
|
if (!this.hasClass) return result({ reason: 'noClass', def: null });
|
|
const def = this.defOf(id);
|
|
if (!def || def.classId !== this.classId) return result({ reason: 'unknown', def: null });
|
|
if (!this.unlocked(def)) return result({ reason: 'locked', def, locked: this.lockedBy(def) });
|
|
if (this.rank(id) >= def.maxPoints) return result({ reason: 'maxed', def });
|
|
if (this.points < 1) return result({ reason: 'noPoints', def });
|
|
return { ok: true, reason: null, def };
|
|
}
|
|
|
|
/**
|
|
* Spend ONE point into a skill (the console's RANK UP action — the ledger
|
|
* has no concept of buying several at once, so a click is always one rank).
|
|
* @returns {{ok: boolean, reason?: string, id: string|null, rank: number,
|
|
* max: number, points: number, seat: (number|null)}}
|
|
*/
|
|
rankUp(id) {
|
|
const gate = this.canRank(id);
|
|
const def = gate.def ?? this.defOf(id);
|
|
if (!gate.ok) {
|
|
return { ...result({ reason: gate.reason, id: def?.id ?? null }), rank: this.rank(id), max: def?.maxPoints ?? 0, points: this.points, seat: null };
|
|
}
|
|
// The bank is the class layer's; ask IT to pay (it refuses without a class
|
|
// or when short, which is the same answer we just gave).
|
|
if (!this.classState.spendSkillPoints(1)) {
|
|
return { ...result({ reason: 'noPoints', id: def.id }), rank: this.rank(id), max: def.maxPoints, points: this.points, seat: null };
|
|
}
|
|
const next = Math.min(def.maxPoints, this.rank(def.id) + 1);
|
|
this.ranks.set(def.id, next);
|
|
// The FIRST point into an active skill gives it somewhere to live: the
|
|
// data's default seat when that is free, else the first free seat — and if
|
|
// the bar is full, no seat at all (the row still reads UNSHIFTED).
|
|
let seat = this.seatOf(def.id);
|
|
if (next === 1 && def.castable && seat === null) seat = this.autoBind(def.id);
|
|
return { ok: true, reason: null, id: def.id, rank: next, max: def.maxPoints, points: this.points, seat };
|
|
}
|
|
|
|
// ------------------------------------------------------ the console session
|
|
|
|
/** Is a Skill Tree console session open (i.e. is anything resettable)? */
|
|
get inSession() {
|
|
return this.sessionBase instanceof Map;
|
|
}
|
|
|
|
/**
|
|
* Open a session: file the ranks as they stand, so every point spent from now
|
|
* until `commitSession` is PENDING and RESET-able. Idempotent — re-opening (the
|
|
* console steps aside for a seat hand-off and comes straight back) keeps the
|
|
* ORIGINAL snapshot rather than silently committing the pending points.
|
|
*/
|
|
beginSession() {
|
|
if (!this.sessionBase) this.sessionBase = new Map(this.ranks);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Close a session: everything pending becomes committed. The console calls this
|
|
* on its way out (✕, ESC, or the ship console taking the screen back), which is
|
|
* the rule "closing the window commits the points".
|
|
* @returns {{ok: boolean, committed: number}} how many points were signed off
|
|
*/
|
|
commitSession() {
|
|
const committed = this.pendingTotal();
|
|
this.sessionBase = null;
|
|
return { ok: true, committed };
|
|
}
|
|
|
|
/** The rank a skill had when the session opened (its current rank with no session). */
|
|
baseRank(id) {
|
|
const key = String(id ?? '').toLowerCase();
|
|
if (!this.sessionBase) return this.rank(key);
|
|
return Math.max(0, Math.floor(Number(this.sessionBase.get(key)) || 0));
|
|
}
|
|
|
|
/** Points THIS SKILL gained during the open session (0 = nothing to reset). */
|
|
pendingOf(id) {
|
|
return Math.max(0, this.rank(id) - this.baseRank(id));
|
|
}
|
|
|
|
/** Every pending point on the ledger (the console's "N points uncommitted"). */
|
|
pendingTotal() {
|
|
if (!this.sessionBase) return 0;
|
|
let n = 0;
|
|
for (const [id, rank] of this.ranks) n += Math.max(0, rank - (this.sessionBase.get(id) ?? 0));
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* THE RESET PLAN — what handing this skill's session points back would do,
|
|
* computed WITHOUT touching the ledger so a refused reset never half-happens.
|
|
*
|
|
* A child bought under the target goes with it (its unlock would fall away), and
|
|
* so does that child's own child, transitively. Cascading to a child that still
|
|
* holds COMMITTED points is refused rather than quietly unspent from under the
|
|
* player — a session reset can only unwind what this session created.
|
|
*
|
|
* @returns {{ok: boolean, reason: (string|null), id: string|null, points: number,
|
|
* cascade: Array<{id: string, label: string, from: number, to: number}>,
|
|
* target: number, rank: number, blocked: object|null}}
|
|
*/
|
|
resetPlan(id) {
|
|
const def = this.defOf(id);
|
|
const noSession = { ok: false, reason: 'noSession', id: def?.id ?? null, points: 0, cascade: [], target: 0, rank: this.rank(id), blocked: null };
|
|
if (!this.inSession) return noSession;
|
|
const key = def?.id ?? String(id ?? '').toLowerCase();
|
|
const target = this.baseRank(key);
|
|
if (this.rank(key) <= target) {
|
|
return { ok: false, reason: 'nothing', id: key, points: 0, cascade: [], target, rank: this.rank(key), blocked: null };
|
|
}
|
|
const refuse = (blocker) => ({
|
|
ok: false, reason: 'blocked', id: key, points: 0, cascade: [], target, rank: this.rank(key), blocked: blocker,
|
|
});
|
|
// Simulate on a copy: drop the target to its session-base, then repeatedly
|
|
// drop anything that can no longer be unlocked — every drop goes only as far
|
|
// as that skill's OWN committed rank.
|
|
const next = new Map(this.ranks);
|
|
const write = (map, id, to) => (to <= 0 ? map.delete(id) : map.set(id, to));
|
|
const dropped = [{ id: key, label: def?.label ?? key, from: this.rank(key), to: target }];
|
|
const touched = new Set([key]);
|
|
write(next, key, target);
|
|
for (let guard = 0; guard < this.ranks.size + 2; guard++) {
|
|
let broke = null;
|
|
for (const [sid, rank] of next) {
|
|
if (rank <= 0 || touched.has(sid)) continue;
|
|
const sdef = this.defOf(sid);
|
|
if (!sdef || this.rankSatisfiedBy(sdef, next)) continue;
|
|
broke = { id: sid, def: sdef };
|
|
break;
|
|
}
|
|
if (!broke) break; // the plan stands on its own
|
|
const floor = this.baseRank(broke.id);
|
|
const cur = next.get(broke.id) ?? 0;
|
|
const trial = new Map(next);
|
|
write(trial, broke.id, floor);
|
|
const sdef = this.defOf(broke.id);
|
|
// A drop to floor 0 removes the skill — and a rank-0 skill has no prerequisite
|
|
// to break, so only a drop that LEAVES it ranked has to still satisfy its own `requires`.
|
|
if (floor >= cur || (floor > 0 && !this.rankSatisfiedBy(sdef, trial))) {
|
|
// Fixing this would mean reaching BELOW what the player already committed
|
|
// in an earlier session — not this session's call, so nothing moves.
|
|
return refuse({ skill: broke.id, label: sdef?.label ?? broke.id });
|
|
}
|
|
dropped.push({ id: broke.id, label: sdef?.label ?? broke.id, from: cur, to: floor });
|
|
touched.add(broke.id);
|
|
next.clear();
|
|
for (const [id, rank] of trial) next.set(id, rank);
|
|
}
|
|
const points = dropped.reduce((n, d) => n + Math.max(0, d.from - d.to), 0);
|
|
return { ok: true, reason: null, id: key, points, cascade: dropped.slice(1), target, rank: this.rank(key), blocked: null };
|
|
}
|
|
|
|
/** Would `requires` all be satisfied by the ranks in `map` (id → rank)? */
|
|
rankSatisfiedBy(def, map) {
|
|
return (def.requires ?? []).every((r) => Math.floor(Number(map.get(r.skill)) || 0) >= (r.points || 1));
|
|
}
|
|
|
|
/**
|
|
* RESET: hand this skill's SESSION points back to the bank (see `resetPlan`
|
|
* for the cascade + the refusals). The bank is js/player/ClassState.js again —
|
|
* `awardSkillPoints` is the same door a quest reward would use.
|
|
* @returns {{ok: boolean, reason: (string|null), id: string|null, refunded: number,
|
|
* cascade: Array<object>, blocked: object|null, points: number}}
|
|
*/
|
|
resetSession(id) {
|
|
const plan = this.resetPlan(id);
|
|
if (!plan.ok) return { ...plan, refunded: 0, points: this.points };
|
|
// Apply the plan exactly as it was proved out above.
|
|
const writes = [{ id: plan.id, to: plan.target }, ...plan.cascade.map((c) => ({ id: c.id, to: c.to }))];
|
|
for (const w of writes) {
|
|
if (w.to <= 0) this.ranks.delete(w.id);
|
|
else this.ranks.set(w.id, Math.min(w.to, this.rank(w.id)));
|
|
}
|
|
// The bank pays itself back (js/player/ClassState.js → awardSkillPoints). No
|
|
// class ledger means nothing could have been ranked anyway — the ranks above
|
|
// are already down and the bank simply says nothing.
|
|
this.classState?.awardSkillPoints?.(plan.points);
|
|
return {
|
|
ok: true,
|
|
reason: null,
|
|
id: plan.id,
|
|
refunded: plan.points,
|
|
cascade: plan.cascade,
|
|
blocked: null,
|
|
points: this.points,
|
|
rank: this.rank(plan.id),
|
|
max: this.defOf(plan.id)?.maxPoints ?? 0,
|
|
};
|
|
}
|
|
|
|
// --------------------------------------------------------------- seats
|
|
|
|
/** How many seats the bar has (data/combat.json → bar.slot.count). */
|
|
get seatCount() {
|
|
return this.seats.length;
|
|
}
|
|
|
|
/** The skill id bound to a seat (null when it is empty / out of range). */
|
|
skillAtSeat(index) {
|
|
const i = Math.floor(Number(index));
|
|
if (!Number.isInteger(i) || i < 0 || i >= this.seats.length) return null;
|
|
return this.seats[i];
|
|
}
|
|
|
|
/** The 1-based seat number a skill is bound to (null when unbound). */
|
|
seatOf(id) {
|
|
const key = String(id ?? '').toLowerCase();
|
|
const i = this.seats.findIndex((s) => s === key);
|
|
return i < 0 ? null : i + 1;
|
|
}
|
|
|
|
/**
|
|
* Bind a skill to a seat (the console's assign click). Any CASTABLE skill of
|
|
* the run's class is seatable, ranked or not (an unranked one sits inert on
|
|
* the bar); a passive never is. Re-binding moves it —
|
|
* a seat already holding another skill is taken over, never duplicated.
|
|
* @returns {{ok: boolean, reason?: string, id: string|null, seat: number|null}}
|
|
*/
|
|
bindSeat(id, index) {
|
|
const def = this.defOf(id);
|
|
if (!def) return result({ reason: 'unknown', id: null, seat: null });
|
|
if (def.passive) return result({ reason: 'passive', id: def.id, seat: null });
|
|
// A rank-0 skill MAY hold a seat (that is how the data's default layout
|
|
// arrives too): the seat draws inert and pressing it in combat says RANK IT
|
|
// UP rather than silently eating the keypress.
|
|
const i = Math.floor(Number(index));
|
|
if (!Number.isInteger(i) || i < 1 || i > this.seats.length) return result({ reason: 'range', id: def.id, seat: null });
|
|
// One skill never holds two seats: whatever seats it sat in are emptied
|
|
// first, then the new seat takes it over (displacing its old occupant).
|
|
this.seats = this.seats.map((s) => (s === def.id ? null : s));
|
|
this.seats[i - 1] = def.id;
|
|
return { ok: true, reason: null, id: def.id, seat: i };
|
|
}
|
|
|
|
/** Empty a seat (the way to make room for another skill). */
|
|
clearSeat(index) {
|
|
const i = Math.floor(Number(index));
|
|
if (!Number.isInteger(i) || i < 1 || i > this.seats.length) return result({ reason: 'range' });
|
|
const was = this.seats[i - 1];
|
|
this.seats[i - 1] = null;
|
|
return { ok: true, reason: null, id: was, seat: i };
|
|
}
|
|
|
|
/** Drop a skill from every seat it holds (un-equipping without losing rank). */
|
|
unbind(id) {
|
|
const key = String(id ?? '').toLowerCase();
|
|
let n = 0;
|
|
this.seats = this.seats.map((s) => (s === key ? (n++, null) : s));
|
|
return { ok: n > 0, id: key, cleared: n };
|
|
}
|
|
|
|
/** The first empty seat takes the skill (null when the bar is full). */
|
|
autoBind(id) {
|
|
const key = String(id ?? '').toLowerCase();
|
|
if (!this.seats.includes(null)) return null;
|
|
// Prefer the data's own default seat, then plain left-to-right.
|
|
const def = this.defOf(key);
|
|
const want = def?.seatDefault ? def.seatDefault - 1 : -1;
|
|
const i = want >= 0 && want < this.seats.length && this.seats[want] === null ? want : this.seats.indexOf(null);
|
|
if (i < 0) return null;
|
|
this.seats[i] = key;
|
|
return i + 1;
|
|
}
|
|
|
|
/**
|
|
* Seed a fresh ledger's seats from the data's `seat` values (idempotent —
|
|
* once applied, the player's layout is theirs). Passive skills are skipped:
|
|
* they are never seatable.
|
|
*/
|
|
applyDefaultSeats() {
|
|
if (this.defaultsApplied) return this;
|
|
this.defaultsApplied = true;
|
|
for (const def of skillList(this.classId)) {
|
|
if (!def.castable || !def.seatDefault) continue;
|
|
const i = def.seatDefault - 1;
|
|
if (i >= 0 && i < this.seats.length && this.seats[i] === null) this.seats[i] = def.id;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/** The bar's row: one entry per seat — { index, number, id, def, rank }. */
|
|
seatRow() {
|
|
return this.seats.map((id, i) => {
|
|
const def = id ? this.defOf(id) : null;
|
|
return {
|
|
index: i,
|
|
number: i + 1,
|
|
id: def?.id ?? null,
|
|
def,
|
|
rank: def ? this.rank(def.id) : 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ passives
|
|
|
|
/**
|
|
* Σ per-rank effect numbers across every ranked skill of a kind — the
|
|
* shape 90% of passives take ("+5% hull per point"). A castable skill's
|
|
* effect is NOT counted: passives are always on, actives fire on cast.
|
|
*/
|
|
effectTotal(kind, key) {
|
|
let total = 0;
|
|
for (const [id, points] of this.ranks) {
|
|
const def = this.defOf(id);
|
|
if (!def || !def.passive || def.classId !== this.classId) continue;
|
|
if (def.effect?.kind !== kind) continue;
|
|
const per = Number(def.effect[key]);
|
|
if (Number.isFinite(per)) total += per * points;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/** The hull-strength bonus Reinforced Plating grants (0.25 at 5/5). */
|
|
hullBonus() {
|
|
return this.effectTotal('hullPool', 'hullBonusPerRank');
|
|
}
|
|
|
|
// ------------------------------------------------------------- the view
|
|
|
|
/**
|
|
* The console's rows for the run's class, in data (display) order — each
|
|
* with the state stamp + copy keys the window paints, so no screen
|
|
* re-implements the rules and dev/skills.test.mjs can assert them.
|
|
*/
|
|
rows() {
|
|
return skillList(this.classId).map((def) => {
|
|
const rank = this.rank(def.id);
|
|
const maxed = rank >= def.maxPoints;
|
|
const locked = !this.unlocked(def);
|
|
let state = 'upgradable';
|
|
if (locked) state = 'locked';
|
|
else if (maxed) state = 'maxed';
|
|
else if (this.points < 1) state = 'noPoints';
|
|
else if (rank === 0) state = 'available';
|
|
return {
|
|
def,
|
|
// The plain key the consoles read rows off (the def stays alongside it).
|
|
id: def.id,
|
|
rank,
|
|
max: def.maxPoints,
|
|
state,
|
|
locked: this.lockedBy(def),
|
|
seat: this.seatOf(def.id),
|
|
spendable: state === 'available' || state === 'upgradable',
|
|
// The session half of the story (the console's RESET button lights off
|
|
// exactly this, so no view re-implements the rule).
|
|
pending: this.pendingOf(def.id),
|
|
resettable: this.pendingOf(def.id) > 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** A snapshot for the consoles + the dev console. */
|
|
stats() {
|
|
return {
|
|
classId: this.classId || null,
|
|
points: this.points,
|
|
spent: [...this.ranks.values()].reduce((a, b) => a + b, 0),
|
|
ranks: Object.fromEntries(this.ranks),
|
|
seats: [...this.seats],
|
|
pending: this.pendingTotal(),
|
|
inSession: this.inSession,
|
|
};
|
|
}
|
|
|
|
/** The save's shape (plain data — the roster lives in data/, not here). */
|
|
toJSON() {
|
|
return {
|
|
ranks: Object.fromEntries(this.ranks),
|
|
seats: [...this.seats],
|
|
defaultsApplied: this.defaultsApplied === true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply a saved record in place. Every rank is validated against the
|
|
* CURRENT roster and clamped to its maxPoints; a seat naming an unknown or
|
|
* passive skill clears (a hand-edited save can never smuggle a passive onto
|
|
* the bar). A save predating the skill layer has no field → an empty ledger.
|
|
* @param {object|null} state record.playerSkills (tolerant of junk)
|
|
*/
|
|
restore(state) {
|
|
this.ranks = new Map();
|
|
this.seats = new Array(Math.max(1, seatCount())).fill(null);
|
|
this.defaultsApplied = false;
|
|
// A loaded ledger has no session to unwind (the snapshot is transient), so the
|
|
// console simply has nothing pending until it is opened again.
|
|
this.sessionBase = null;
|
|
if (!state || typeof state !== 'object') return this;
|
|
const rawRanks = state.ranks && typeof state.ranks === 'object' ? state.ranks : {};
|
|
for (const [id, v] of Object.entries(rawRanks)) {
|
|
const def = skillDef(id); // global lookup: a rank is never lost to a class-scope mismatch
|
|
if (!def) continue;
|
|
const n = Math.floor(Number(v));
|
|
if (!Number.isFinite(n) || n < 1) continue;
|
|
this.ranks.set(def.id, Math.min(def.maxPoints, n));
|
|
}
|
|
this.defaultsApplied = state.defaultsApplied === true;
|
|
const seats = Array.isArray(state.seats) ? state.seats : [];
|
|
seats.slice(0, this.seats.length).forEach((id, i) => {
|
|
const def = typeof id === 'string' ? skillDef(id) : null;
|
|
if (!def || def.passive) return;
|
|
if (this.seats[i] === def.id) return; // one skill per seat — a duplicate is dropped
|
|
if (this.seats.includes(def.id)) return;
|
|
this.seats[i] = def.id;
|
|
});
|
|
return this;
|
|
}
|
|
|
|
/** A fresh ledger (a new run — nothing ranked, the seats empty). */
|
|
reset() {
|
|
return this.restore(null);
|
|
}
|
|
|
|
/** The save's record → a ledger (validated against data/skills.json). */
|
|
static fromJSON(state, classState = null) {
|
|
return new SkillState(classState).restore(state);
|
|
}
|
|
}
|
|
|
|
export default SkillState;
|