/** * 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} skill id → points spent */ this.ranks = new Map(); /** @type {Array} 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|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, 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 first RANKED passive of an effect kind (ledger id → its record) — * for the effect values a SUM cannot state: a mending clock is ONE interval * between ticks (effect.intervalMs), not one interval per skill, and the * mend's color is the skill's own dressing. Null when nothing is ranked. * @param {string} kind the effect kind (e.g. 'hullRegen') * @returns {object|null} the model's def, or null */ firstPassiveOf(kind) { for (const [id, points] of this.ranks) { if (points <= 0) continue; const def = this.defOf(id); if (!def || !def.passive || def.classId !== this.classId) continue; if (def.effect?.kind !== kind) continue; return def; } return null; } /** The hull-mending share Nanobots grants (0.05 at 5/5 with the shipped * 0.01 per point) — the fraction of MAX hull regrown per tick. */ hullRegen() { return this.effectTotal('hullRegen', 'regenPercentPerRank'); } // ------------------------------------------------------------- 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;