orbit/js/skills/SkillState.js

579 lines
24 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 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;