orbit/dev/skills.test.mjs

462 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Skill layer test (dev tool, run with Node — no browser):
*
* node dev/skills.test.mjs (or: node dev/run-all.mjs skills)
*
* Covers the skill layer's PURE half — everything except the console and
* the combat runtime (dev/skills-combat.test.mjs drives those numbers):
*
* 1. THE DATA CONTRACT — data/skills.json is a real config file (listed in
* data/manifest.json), its type taxonomy / seats table / per-class trees
* are well formed, the icon sheet exists on disk with the frames the tree
* references, and the two authored Vanguard skills carry EXACTLY the
* spec'd numbers; those numbers live in the JSON only — no js/ file
* restates them (the "code owns behavior, JSON owns parameters" rule).
* 2. THE MODEL — js/skills/SkillModel.js normalizes whatever the data says
* into records with fallbacks (junk dropped, never propagated) and
* answers the type/seats/tree/lookup reads the UI + combat share.
* 3. THE LEDGER — js/skills/SkillState.js: rank 0 is inert, one point per
* click up to maxPoints, the bank and the class gate are honoured, the
* `requires` edges (id form and {skill, points} form) unlock through
* points actually spent, passives report modifiers and never a seat, and
* seat binding/rebinding/clearing behaves within the seat count.
* 4. THE SAVE — record.playerSkills captures and restores through the real
* js/save/SaveData.js; a save with no field loads as an empty ledger; a
* hand-edited record degrades instead of handing out unknown skills.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
const dataDir = join(root, 'data');
const readData = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const hex = /^#[0-9a-fA-F]{6}$/;
/** Every .js file under js/ (the "no code restates the data" sweep). */
function jsSources() {
const out = [];
const walk = (dir) => {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'vendor') continue;
walk(p);
} else if (ent.name.endsWith('.js')) out.push(p);
}
};
walk(join(root, 'js'));
return out;
}
// =====================================================================
// 1. THE DATA CONTRACT
// =====================================================================
const manifest = readData('manifest.json');
const skills = readData('skills.json');
const classes = readData('classes.json');
const combat = readData('combat.json');
check('manifest registers skills.json', manifest.files.includes('skills.json'));
check('skills: master switch present', typeof skills.enabled === 'boolean');
// ---- the icon sheet -------------------------------------------------
const sheet = skills.icons ?? {};
check('icons: texture is assets/images/skills.png', sheet.texture === 'assets/images/skills.png');
check('icons: the sheet file exists on disk', fs.existsSync(join(root, 'assets/images/skills.png')));
check('icons: 128×128 frames', sheet.frameWidth === 128 && sheet.frameHeight === 128);
// ---- the type taxonomy ---------------------------------------------
// `_`-prefixed keys are documentation, not data (the project's rule).
const real = (o) => Object.fromEntries(Object.entries(o ?? {}).filter(([k]) => !k.startsWith('_')));
const types = real(skills.types);
const typeKeys = Object.keys(types);
check('types: the taxonomy is non-empty', typeKeys.length > 0);
check(
'types: every entry has label/accent/passive',
typeKeys.every((k) => typeof types[k].label === 'string' && hex.test(types[k].accent ?? '') && typeof types[k].passive === 'boolean'),
);
check('types: Offense exists and is castable', types.Offense && types.Offense.passive === false);
check('types: Passive exists and is NOT castable', types.Passive && types.Passive.passive === true);
// ---- the seats table -------------------------------------------------
check('seats: fallback count matches the combat bar (8)', skills.seats?.count === combat.bar?.slot?.count);
// ---- the trees ------------------------------------------------------
const roster = classes.classes.map((c) => c.id);
const trees = real(skills.classes);
check(`classes: the trees cover the whole class roster (${Object.keys(trees).join(', ')})`, JSON.stringify(Object.keys(trees)) === JSON.stringify(roster));
check('classes: every tree key is a real class id', Object.keys(trees).every((id) => roster.includes(id)));
const vanguard = trees.vanguard?.skills ?? [];
const byId = Object.fromEntries(vanguard.map((s) => [s.id, s]));
check('vanguard: the tree holds the two authored skills', vanguard.length === 2);
check('vanguard: ids are ramming_charge + reinforced_plating', JSON.stringify(Object.keys(byId)) === JSON.stringify(['ramming_charge', 'reinforced_plating']));
check(
'every skill in every tree is well formed (id/label/type/frame/maxPoints/description/effect)',
Object.values(trees).every((t) =>
(t.skills ?? []).every(
(s) =>
typeof s.id === 'string' &&
/^[a-z0-9_]+$/.test(s.id) &&
typeof s.label === 'string' &&
typeof types[s.type] === 'object' &&
Number.isInteger(s.frame) &&
s.frame >= 0 &&
Number.isInteger(s.maxPoints) &&
s.maxPoints >= 1 &&
s.energy >= 0 &&
s.cooldown >= 0 &&
typeof s.description === 'string' &&
s.description.length > 0 &&
typeof s.effect?.kind === 'string',
),
),
);
check(
'every skill frame fits the sheet (32 frames of 128 in a 1024×512 sheet)',
Object.values(trees).every((t) => (t.skills ?? []).every((s) => s.frame < 32)),
);
// Requires edges must name skills that exist IN THE SAME TREE (a dangling
// edge would lock a branch nothing can ever open).
check(
'requires edges name skills in the same tree',
Object.values(trees).every((t) => {
const ids = new Set((t.skills ?? []).map((s) => s.id));
return (t.skills ?? []).every((s) => (s.requires ?? []).every((r) => ids.has(typeof r === 'string' ? r : r?.skill)));
}),
);
// ---- the two authored skills, spec for spec ------------------------
const ram = byId.ramming_charge ?? {};
const plate = byId.reinforced_plating ?? {};
check('Ramming Charge: label "Ramming Charge"', ram.label === 'Ramming Charge');
check('Ramming Charge: type Offense', ram.type === 'Offense');
check('Ramming Charge: 60 energy', ram.energy === 60);
check('Ramming Charge: 6 second recharge', ram.cooldown === 6);
check('Ramming Charge: 5 assignable points', ram.maxPoints === 5);
check('Ramming Charge: icon frame 0', ram.frame === 0);
check('Ramming Charge: a root (available the moment the class is picked)', Array.isArray(ram.requires) && ram.requires.length === 0);
check('Ramming Charge: effect is a dashBlast', ram.effect?.kind === 'dashBlast');
check('Ramming Charge: dashes at 3x speed', ram.effect?.speedMul === 3);
check('Ramming Charge: the charge lasts 1 second', ram.effect?.durationMs === 1000);
check('Ramming Charge: hits for 4x weapon damage', ram.effect?.damageMul === 4);
check('Ramming Charge: +2x per additional skill point', ram.effect?.damageMulPerRank === 2);
check(
'Ramming Charge: the effect copy names the speed, the duration and the damage',
/3x speed/.test(ram.description ?? '') && /1 second/.test(ram.description ?? '') && /4x regular damage/.test(ram.description ?? '') && /2x additional damage per additional skill point/.test(ram.description ?? ''),
);
check('Reinforced Plating: label "Reinforced Plating"', plate.label === 'Reinforced Plating');
check('Reinforced Plating: type Passive', plate.type === 'Passive');
check('Reinforced Plating: 0 energy', plate.energy === 0);
check('Reinforced Plating: 0 recharge', plate.cooldown === 0);
check('Reinforced Plating: 5 assignable points', plate.maxPoints === 5);
check('Reinforced Plating: icon frame 1', plate.frame === 1);
check('Reinforced Plating: a root', Array.isArray(plate.requires) && plate.requires.length === 0);
check('Reinforced Plating: effect is a hullPool', plate.effect?.kind === 'hullPool');
check('Reinforced Plating: +5% hull per skill point', plate.effect?.hullBonusPerRank === 0.05);
check('Reinforced Plating: no bar seat — it is passive', plate.seat === null);
check(
'Reinforced Plating: the effect copy names both increases',
/5%/.test(plate.description ?? '') && /additional 5% increase per additional skill point/.test(plate.description ?? ''),
);
// ---- the console's copy --------------------------------------------
const win = skills.window ?? {};
for (const k of ['title', 'meta', 'treeTitle', 'pointsLine', 'rankLine', 'lockedNote', 'emptyNote']) {
check(`window: ${k} is authored`, typeof win[k] === 'string' && win[k].length > 0);
}
check('window: every state stamp is authored', ['locked', 'available', 'upgradable', 'noPoints', 'maxed'].every((k) => typeof win.states?.[k] === 'string' && win.states[k].length > 0));
// ---- the "JSON owns the numbers" sweep -----------------------------
// These key/value pairs are BALANCE. They belong to data/skills.json and
// must not be restated in a js/ file (a fallback is `?? <default>`, never
// `energyCost: 60`). If a handler needs one, it reads it off the record.
const BALANCE = [
['damageMul', 4],
['damageMulPerRank', 2],
['hullBonusPerRank', 0.05],
['speedMul', 3],
['durationMs', 1000],
['blastRadiusPx', 170],
['overshootPx', 90],
];
const offenders = [];
for (const p of jsSources()) {
const src = fs.readFileSync(p, 'utf8');
for (const [key, value] of BALANCE) {
const re = new RegExp(`${key}\\s*[:=]\\s*[^\\n]*\\b${String(value).replace('.', '\\.')}\\b`);
if (re.test(src)) offenders.push(`${p.replace(root + '/', '')} restates ${key} = ${value}`);
}
}
check(`no js/ file restates a skill balance number${offenders.length ? ' — ' + offenders.join('; ') : ''}`, offenders.length === 0);
// =====================================================================
// THE MODEL + LEDGER run on the REAL config (data/*.json → the singleton),
// exactly as the game boots it.
// =====================================================================
const { config } = await import(file('config/Config.js'));
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = readData(f);
}
config.init(configData);
const model = await import(file('skills/SkillModel.js'));
const { SkillState } = await import(file('skills/SkillState.js'));
const { ClassState } = await import(file('player/ClassState.js'));
/** A run with Vanguard filed and `points` unspent in the bank. */
function vanguardRun(points = 5) {
const cls = new ClassState();
cls.choose('vanguard');
cls.skillPoints = points; // the grant is 5; a test may starve or fatten it
const skills = new SkillState(cls);
skills.applyDefaultSeats();
return { cls, skills };
}
// =====================================================================
// 2. THE MODEL — normalization + the reads every screen shares
// =====================================================================
{
check('model: skillsEnabled() is true with the shipped data', model.skillsEnabled() === true);
const sheet = model.iconSheet();
const rawSheet = config.section('skills.icons', {});
check(
'model: iconSheet() is the data\'s sheet (path + frame size read from skills.json)',
sheet.texture === rawSheet.texture && sheet.frameWidth === rawSheet.frameWidth && sheet.frameHeight === rawSheet.frameHeight,
);
const offense = model.typeDef('Offense');
const passive = model.typeDef('Passive');
check(`model: Offense reads as castable with the data's own label (${offense.label})`, offense.passive === false && offense.label === String(config.get('skills.types.Offense.label')).toUpperCase());
check('model: Passive reads as always-on', passive.passive === true);
check('model: a type lookup ignores case but returns the canonical id', model.typeDef('offense').id === 'Offense' && model.typeDef('OFFENSE').accentCss === config.get('skills.types.Offense.accent'));
const bogus = model.typeDef('NoSuchType');
check('model: an unknown type is INERT (no invented colour, castable)', bogus.id === '' && bogus.accentCss === '' && bogus.accent === 0 && bogus.passive === false);
check('model: seatCount() follows the combat bar (8)', model.seatCount() === config.get('combat.bar.slot.count'));
const list = model.skillList('vanguard');
check('model: the Vanguard tree lists both skills in data order', list.map((s) => s.id).join() === 'ramming_charge,reinforced_plating');
check('model: an unknown class has no tree', model.skillList('nobody').length === 0 && model.hasTree('wraith') === false);
const ram = model.skillDef('ramming_charge', 'vanguard');
check('model: the record carries the spec numbers', ram.energy === 60 && ram.cooldown === 6 && ram.maxPoints === 5 && ram.frame === 0);
check('model: type is resolved to a record + castable flag', ram.type.id === 'Offense' && ram.castable === true && ram.passive === false);
check('model: requires normalizes to [{skill, points}] (a root is empty)', Array.isArray(ram.requires) && ram.requires.length === 0);
check('model: the active skill keeps its default seat', ram.seatDefault === 1);
const plate = model.skillDef('reinforced_plating');
check('model: a passive is never castable and never holds a seat', plate.castable === false && plate.passive === true && plate.seatDefault === null);
check('model: effect numbers ride the record untouched', plate.effect.kind === 'hullPool' && plate.effect.hullBonusPerRank === 0.05 && ram.effect.damageMul === 4 && ram.effect.damageMulPerRank === 2);
// Junk handling: a skill without a usable id is dropped, never propagated.
const raw = config.get('skills.classes.vanguard.skills', []);
raw.push({ label: 'Half Authored' });
model.invalidateSkillCache();
check('model: a skill with no id is dropped (never a dead row)', model.skillList('vanguard').length === 2 && model.skillDef('undefined') === null);
raw.push({ id: 'edge_test', label: 'Edge Test', type: 'Offense', requires: ['ramming_charge', { skill: 'nope', points: 3 }, { skill: 'reinforced_plating', points: 2 }] });
model.invalidateSkillCache();
const edge = model.skillDef('edge_test');
check(
'model: requires accept ids and {skill, points}; a dangling edge drops, the rest stand',
JSON.stringify(edge.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 1 }, { skill: 'reinforced_plating', points: 2 }]),
);
raw.length = 2;
model.invalidateSkillCache();
check('model: windowSpec() fills every copy knob with a fallback', (() => {
const spec = model.windowSpec();
return typeof spec.title === 'string' && spec.states.maxed.length > 0 && spec.rankLine.includes('{rank}') && !!spec.sfx.spend;
})());
check('model: fillTemplate fills known keys and leaves unknown ones alone', model.fillTemplate('{a}/{b}', { a: 1 }) === '1/{b}');
}
// =====================================================================
// 3. THE LEDGER — ranks, the bank, unlock edges and bar seats
// =====================================================================
{
const { cls, skills } = vanguardRun(5);
check('ledger: a fresh Vanguard knows nothing but banks 5', skills.rank('ramming_charge') === 0 && cls.skillPoints === 5);
check('ledger: rank 0 is inert', skills.hasRank('ramming_charge') === false);
check('ledger: roots are unlocked with nothing spent', skills.unlocked(model.skillDef('ramming_charge')) && skills.unlocked(model.skillDef('reinforced_plating')));
const r1 = skills.rankUp('ramming_charge');
check('ledger: one click spends one point and lands rank 1/5', r1.ok && r1.rank === 1 && cls.skillPoints === 4);
check('ledger: rank 1 is live', skills.hasRank('ramming_charge') === true);
const seats = vanguardRun(0).skills;
for (let i = 0; i < 5; i++) seats.rankUp('ramming_charge');
// (no points after the first: rankUp must refuse, not go negative)
const starved = vanguardRun(1);
check('ledger: the last point in the bank still buys a rank', starved.skills.rankUp('ramming_charge').ok === true && starved.cls.skillPoints === 0);
const broke = starved.skills.rankUp('ramming_charge');
check('ledger: an empty bank refuses with noPoints', broke.ok === false && broke.reason === 'noPoints' && starved.cls.skillPoints === 0 && starved.skills.rank('ramming_charge') === 1);
const rich = vanguardRun(99);
for (let i = 0; i < 8; i++) rich.skills.rankUp('ramming_charge');
check('ledger: five points reach 5/5 and the sixth refuses with maxed', rich.skills.rank('ramming_charge') === 5 && rich.skills.isMaxed('ramming_charge'));
const over = rich.skills.rankUp('ramming_charge');
check('ledger: rank never passes maxPoints (bank stays whole)', over.ok === false && over.reason === 'maxed' && rich.cls.skillPoints === 99 - 5);
// Passive scaling: +5% per point, read as a modifier, not a cast.
const hull = vanguardRun(99);
check('ledger: rank 0 passive grants nothing', hull.skills.hullBonus() === 0);
hull.skills.rankUp('reinforced_plating');
check('ledger: 1/5 Reinforced Plating is +5% hull', Math.abs(hull.skills.hullBonus() - 0.05) < 1e-9);
for (let i = 0; i < 4; i++) hull.skills.rankUp('reinforced_plating');
check('ledger: 5/5 Reinforced Plating is +25% hull', Math.abs(hull.skills.hullBonus() - 0.25) < 1e-9);
// No class → nothing to spend into.
const bare = new SkillState(new ClassState());
check('ledger: no class on file means no tree to spend in', bare.rankUp('ramming_charge').reason === 'noClass');
// A skill that exists but belongs to ANOTHER class's tree is not spendable
// here (the same id could legally exist in two trees).
const wraithTree = config.get('skills.classes.wraith.skills', []);
wraithTree.push({ id: 'wraith_only', label: 'Wraith Only', type: 'Offense', maxPoints: 2 });
model.invalidateSkillCache();
check('ledger: a skill owned by another class is refused (unknown here)', rich.skills.rankUp('wraith_only').reason === 'unknown');
wraithTree.length = 0;
model.invalidateSkillCache();
// ---- seats --------------------------------------------------------
const s2 = vanguardRun(5);
check('seats: the data default binds Ramming Charge to seat 1', s2.skills.seatOf('ramming_charge') === 1 && s2.skills.skillAtSeat(0) === 'ramming_charge');
check('seats: a passive can never be seated', s2.skills.bindSeat('reinforced_plating', 2).reason === 'passive' && s2.skills.seatOf('reinforced_plating') === null);
// A RANK-0 active skill may still hold a seat (that is how the data's default
// layout arrives) — it draws inert and combat refuses the press with a reason.
const pre = s2.skills.bindSeat('ramming_charge', 3);
check('seats: an unranked ACTIVE skill can be pre-bound (it sits inert)', pre.ok === true && pre.seat === 3);
const freshCls = new ClassState();
freshCls.choose('vanguard');
const fresh = new SkillState(freshCls);
check('seats: an unseeded ledger holds nothing until the data lays it out', fresh.seatOf('ramming_charge') === null);
fresh.applyDefaultSeats();
fresh.bindSeat('ramming_charge', 6);
check('seats: applying defaults twice never overwrites the player\'s layout', fresh.applyDefaultSeats().seatOf('ramming_charge') === 6);
const rebinding = fresh.bindSeat('reinforced_plating', 2);
check('seats: a passive is refused with reason "passive", never seated', rebinding.ok === false && rebinding.reason === 'passive' && fresh.seatOf('reinforced_plating') === null);
const moved = s2.skills.bindSeat('ramming_charge', 5);
check('seats: re-binding moves the skill and empties nothing behind it twice', moved.ok && moved.seat === 5 && s2.skills.seatOf('ramming_charge') === 5 && s2.skills.skillAtSeat(0) === null);
check('seats: an out-of-range seat is refused', s2.skills.bindSeat('ramming_charge', 0).reason === 'range' && s2.skills.bindSeat('ramming_charge', 99).reason === 'range');
check('seats: clearing frees the seat', s2.skills.clearSeat(5).ok && s2.skills.skillAtSeat(4) === null);
check('seats: unbinding drops every copy', s2.skills.bindSeat('ramming_charge', 2).ok && s2.skills.unbind('ramming_charge').cleared === 1 && s2.skills.seatOf('ramming_charge') === null);
const bar = vanguardRun(99);
bar.skills.clearSeat(1);
bar.skills.rankUp('ramming_charge');
check('seats: a freshly ranked active skill takes the data seat when it is free', bar.skills.seatOf('ramming_charge') === 1);
// ---- unlock edges (spent-through, not mere existence) --------------
const gated = vanguardRun(99);
const rawSkills = config.get('skills.classes.vanguard.skills', []);
rawSkills.push({ id: 'follow_on', label: 'Follow On', type: 'Offense', energy: 10, cooldown: 1, maxPoints: 3, requires: [{ skill: 'ramming_charge', points: 3 }] });
model.invalidateSkillCache();
check('edges: a branch needing 3 points is locked at 0 spent', gated.skills.canRank('follow_on').reason === 'locked' && gated.skills.lockedBy(model.skillDef('follow_on'))?.label === 'Ramming Charge');
for (let i = 0; i < 2; i++) gated.skills.rankUp('ramming_charge');
check('edges: two points is still short of a 3-point requirement', gated.skills.canRank('follow_on').reason === 'locked');
gated.skills.rankUp('ramming_charge');
check('edges: the third point opens the branch', gated.skills.canRank('follow_on').ok === true);
rawSkills.length = 2;
model.invalidateSkillCache();
// ---- the console's rows -------------------------------------------
const view = vanguardRun(1);
view.skills.rankUp('ramming_charge'); // spend the single point there
const rows = view.skills.rows();
check('rows: one row per skill, in data order', rows.map((r) => r.def.id).join() === 'ramming_charge,reinforced_plating');
check('rows: a maxed-out bank reads noPoints on the spendable root', rows[1].state === 'noPoints' && rows[1].spendable === false);
check('rows: a rank-0 root with points banked reads available', vanguardRun(9).skills.rows()[0].state === 'available' && vanguardRun(9).skills.rows()[0].spendable === true);
const mid = vanguardRun(9);
mid.skills.rankUp('ramming_charge');
check('rows: rank 1 of 5 with points left reads upgradable', mid.skills.rows()[0].state === 'upgradable' && mid.skills.rows()[0].rank === 1);
const maxView = vanguardRun(99);
for (let i = 0; i < 5; i++) maxView.skills.rankUp('ramming_charge');
check('rows: 5/5 reads maxed', maxView.skills.rows()[0].state === 'maxed' && maxView.skills.rows()[0].rank === 5);
check('stats(): reports the bank, the spend and the bar', (() => {
const st = maxView.skills.stats();
return st.classId === 'vanguard' && st.ranks.ramming_charge === 5 && st.seats.length === model.seatCount();
})());
}
// =====================================================================
// 4. THE SAVE — record.playerSkills through the real js/save/SaveData.js
// =====================================================================
{
const { captureState, prepareLoad, resetRunState } = await import(file('save/SaveData.js'));
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
const galaxy = Galaxy.create('SKILLTEST', { systemCount: 8 });
const mkRegistry = () => new Map();
// A run with both skills ranked and a hand-bound seat…
const live = vanguardRun(99);
for (let i = 0; i < 3; i++) live.skills.rankUp('ramming_charge');
for (let i = 0; i < 2; i++) live.skills.rankUp('reinforced_plating');
live.skills.bindSeat('ramming_charge', 4);
// A minimal but REAL scene shape (the same stand-in dev/classes.test.mjs
// uses): captureState reads the galaxy, the ledgers and the clock.
const scene = {
registry: mkRegistry(),
galaxy,
systemRecord: { name: galaxy.byId.get(galaxy.currentSystemId).name },
ship: { x: 10, y: 20, rotation: 0, minerals: 0, credits: 0, stats: {} },
discovery: null,
classState: live.cls,
skillState: live.skills,
playTimeMs: 1000,
};
const registry = mkRegistry();
registry.set('playerSkills', live.skills);
const rec = captureState(scene);
check('save: record.playerSkills carries the ranks + seats', (() => {
prepareLoad(registry, rec);
const back = registry.get('playerSkills');
return !!back && back.rank('ramming_charge') === 3 && back.rank('reinforced_plating') === 2 && back.seatOf('ramming_charge') === 4;
})());
// The bank itself rides playerClass — the two halves reload together.
prepareLoad(registry, rec);
const clsBack = registry.get('playerClass');
const skillsBack = registry.get('playerSkills');
check('save: the bank stays on the class ledger (one source of points)', clsBack.skillPoints === live.cls.skillPoints && skillsBack.points === live.cls.skillPoints);
// An old save (no field) is an empty ledger, not a crash.
const legacy = { ...rec };
delete legacy.playerSkills;
const regOld = mkRegistry();
prepareLoad(regOld, legacy);
check('save: a save with no playerSkills field stages nothing (old saves load)', regOld.get('playerSkills') === null);
// A hand-edited record degrades instead of handing out what does not exist.
const bogus = { ...rec, playerSkills: { ranks: { 'not-a-skill': 9, ramming_charge: 42 }, seats: ['reinforced_plating', 'ghost', null, 'ramming_charge'] } };
const regBogus = mkRegistry();
prepareLoad(regBogus, bogus);
const hardened = regBogus.get('playerSkills');
check('save: an unknown skill id drops its rank', hardened.rank('ramming_charge') === 5 && hardened.rank('not-a-skill') === 0);
check('save: a seat wearing a passive or an unknown skill clears', hardened.skillAtSeat(0) === null && hardened.skillAtSeat(1) === null && hardened.seatOf('ramming_charge') === 4);
// New Game clears it.
const regNew = mkRegistry();
prepareLoad(regNew, rec);
resetRunState(regNew);
check('resetRunState clears the skill ledger', regNew.get('playerSkills') === null);
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nskills: all checks passed');
process.exit(failures ? 1 : 0);