orbit/dev/skills.test.mjs

717 lines
45 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 four 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 four authored skills', vanguard.length === 4);
check('vanguard: ids are ramming_charge + inferno + reinforced_plating + nanobots', JSON.stringify(Object.keys(byId)) === JSON.stringify(['ramming_charge', 'inferno', 'reinforced_plating', 'nanobots']));
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 four authored skills, spec for spec ------------------------
const ram = byId.ramming_charge ?? {};
const inferno = byId.inferno ?? {};
const plate = byId.reinforced_plating ?? {};
const nano = byId.nanobots ?? {};
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 ?? ''),
);
check('Inferno: label "Inferno"', inferno.label === 'Inferno');
check('Inferno: type Offense', inferno.type === 'Offense');
check('Inferno: 50 energy', inferno.energy === 50);
check('Inferno: 7 second recharge', inferno.cooldown === 7);
check('Inferno: 5 assignable points', inferno.maxPoints === 5);
check('Inferno: icon frame 2', inferno.frame === 2);
check('Inferno: the bar seat (default seat 2)', inferno.seat === 2);
check('Inferno: needs 3 points in Ramming Charge', JSON.stringify(inferno.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 3 }]));
check('Inferno: effect is a flameArc', inferno.effect?.kind === 'flameArc');
check('Inferno: arcs the nearest 2 (the 4th point adds the third)', inferno.effect?.targetBase === 2 && inferno.effect?.extraTargetAtRank === 4 && inferno.effect?.extraTargets === 1);
check('Inferno: a 0.5s burn ticks at 1x weapon damage', inferno.effect?.burnBaseMs === 500 && inferno.effect?.burnTickMs === 500 && inferno.effect?.burnDamageMul === 1);
check('Inferno: the 4th burn second per point, +0.1x tick per point', inferno.effect?.burnPerRankMs === 500 && inferno.effect?.burnDamagePerRank === 0.1);
check(
'Inferno: the effect copy names the targets, the burn and its growth',
/nearest 2/.test(inferno.description ?? '') && /4th point/.test(inferno.description ?? '') && /0\.5 seconds/.test(inferno.description ?? '') && /1x regular damage/.test(inferno.description ?? '') && /0\.1 extra damage/.test(inferno.description ?? ''),
);
check('Nanobots: label "Nanobots"', nano.label === 'Nanobots');
check('Nanobots: type Passive', nano.type === 'Passive');
check('Nanobots: 0 energy', nano.energy === 0);
check('Nanobots: 0 recharge', nano.cooldown === 0);
check('Nanobots: 5 assignable points', nano.maxPoints === 5);
check('Nanobots: icon frame 3', nano.frame === 3);
check('Nanobots: no bar seat — it is passive', nano.seat === null);
check('Nanobots: needs 3 points in Reinforced Plating', JSON.stringify(nano.requires) === JSON.stringify([{ skill: 'reinforced_plating', points: 3 }]));
check('Nanobots: effect is a hullRegen', nano.effect?.kind === 'hullRegen');
check('Nanobots: 1% of max hull per point, mended every 2s', nano.effect?.regenPercentPerRank === 0.01 && nano.effect?.intervalMs === 2000);
check(
'Nanobots: the effect copy names both the share and the cadence',
/1% of maximum hull/.test(nano.description ?? '') && /every 2 seconds/.test(nano.description ?? '') && /additional 1% of maximum hull per additional skill point/.test(nano.description ?? ''),
);
// ---- the console's copy --------------------------------------------
const win = skills.window ?? {};
for (const k of ['title', 'meta', 'treeTitle', 'pointsLine', 'rankLine', 'lockedNote', 'emptyNote',
'detailPrompt', 'detailPromptSub', 'addPointLabel', 'addPointNoPoints', 'resetLabel',
'assignLabel', 'unbindLabel', 'sessionNote', 'commitToast', 'pointsChip', 'pendingNote']) {
check(`window: ${k} is authored`, typeof win[k] === 'string' && win[k].length > 0);
}
// The session's ANSWERS (the door says why it did nothing, the ledger says what it kept).
check('window: the reset toasts are authored with their template keys',
/\{skill\}/.test(win.resetToast ?? '') && /\{child\}/.test(win.cascadeToast ?? '') && /\{n\}/.test(win.commitToast ?? ''));
check('window: every RESET refusal is authored', ['nothing', 'noSession', 'blocked'].every((k) => typeof win.resetRefusals?.[k] === 'string' && win.resetRefusals[k].length > 0));
// The restyled console reads its GEOMETRY from the data too: three regions, square
// tiles, the join colours, a scroller. Every value here is a knob the console honours
// verbatim (windowSpec clamps it into range, nothing more).
{
const t = win.tree ?? {};
const chex = /^#[0-9a-f]{6}$/;
check('window.tree: cols/regions are sane numbers', Number.isInteger(t.cols) && t.cols >= 3 && t.regions.splitFraction > 0.5 && t.regions.splitFraction < 0.9 && t.regions.plateFraction > 0.1 && t.regions.plateFraction < 0.5);
check('window.tree: tiles are SQUARE with a data-bounded size', Number.isInteger(t.tile.minPx) && t.tile.maxPx > t.tile.minPx && Number.isInteger(t.gap.colPx) && Number.isInteger(t.gap.rowPx));
check('window.tree: the join carries one colour per state', ['spentColor', 'openColor', 'lockedColor'].every((k) => chex.test(t.edge?.[k] ?? '')) && t.edge.notchPx < t.gap.colPx);
check('window.tree: every tile frame state has a colour', ['idleEdge', 'availableEdge', 'spentEdge', 'maxedEdge', 'lockedEdge', 'selectedEdge', 'faceColor'].every((k) => chex.test(t.frame?.[k] ?? '')));
check('window.tree: the rank badge ("n/m") is authored', chex.test(t.badge?.color ?? '') && chex.test(t.badge?.spentColor ?? ''));
check('window.tree: empty cells are plated as sockets', t.socket?.enabled === true && chex.test(t.socket.edgeColor ?? '') && t.socket.tickPx >= 0);
check('window.tree: the scroller is authored', t.scrollbar.wPx >= 4 && Number.isInteger(t.scrollbar.wheelPxPerNotch) && chex.test(t.scrollbar.thumbColor ?? ''));
// The grid must be square in the data's own units: column and row gaps are both px.
check('window.tree: hintLine explains the scroll', typeof t.hintLine === 'string' && /scroll/i.test(t.hintLine));
}
// Skills carry their authored cell, or the console falls back to graph depth — so a
// tree whose cells ARE authored must say so in every entry or none.
{
const cells = (skills.classes.vanguard.skills ?? []).map((s) => s.node ?? null);
check('data: an authored grid is a grid (every skill has {col,row}, or nobody does)',
cells.every(Boolean) === true && cells.every((n) => Number.isInteger(n.col) && Number.isInteger(n.row)));
}
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],
// Inferno + Nanobots (data/skills.json → inferno.effect / nanobots.effect).
['targetBase', 2],
['extraTargetAtRank', 4],
['burnBaseMs', 500],
['burnPerRankMs', 500],
['burnTickMs', 500],
['burnDamagePerRank', 0.1],
['splashRadiusPx', 64],
['regenPercentPerRank', 0.01],
['intervalMs', 2000],
];
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 all four skills in data order', list.map((s) => s.id).join() === 'ramming_charge,inferno,reinforced_plating,nanobots');
check('model: an unknown class has no tree', model.skillList('nobody').length === 0 && model.hasTree('nobody') === false);
// The WRAITH tree (data/skills.json → classes.wraith): Boleadoras (the
// snare) + Blink (the escape), both Utility roots, both castable.
const wraithList = model.skillList('wraith');
check('model: the Wraith tree lists both skills in data order', wraithList.map((s) => s.id).join() === 'boleadoras,blink' && model.hasTree('wraith') === true);
const bolas = model.skillDef('boleadoras', 'wraith');
check('model: Boleadoras carries the spec numbers (60 energy, 6s, 5 points, the snaring-orbs insignia)', bolas.energy === 60 && bolas.cooldown === 6 && bolas.maxPoints === 5 && bolas.frame === 8 && bolas.seatDefault === 1);
check('model: Boleadoras is a castable Utility root', bolas.type.id === 'Utility' && bolas.castable === true && bolas.passive === false && Array.isArray(bolas.requires) && bolas.requires.length === 0);
check('model: the snare numbers ride the record untouched', bolas.effect.kind === 'boleadoras' && bolas.effect.targetCount === 3 && bolas.effect.disableBaseMs === 500 && bolas.effect.disablePerRankMs === 500 && bolas.effect.captureRadiusPx === 56 && bolas.effect.speed === 700);
const hop = model.skillDef('blink', 'wraith');
check('model: Blink carries the spec numbers (70 energy, 15s, 5 points, the watching-eye insignia)', hop.energy === 70 && hop.cooldown === 15 && hop.maxPoints === 5 && hop.frame === 9 && hop.seatDefault === 2);
check('model: the escape numbers ride the record untouched', hop.effect.kind === 'blink' && hop.effect.decoyFrame === 1 && hop.effect.decoyBaseMs === 400 && hop.effect.decoyPerRankMs === 400 && hop.effect.minDistancePx === 180);
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);
const inferno = model.skillDef('inferno');
check('model: Inferno carries the spec numbers (50 energy, 7s, 5 points, the flame insignia)', inferno.energy === 50 && inferno.cooldown === 7 && inferno.maxPoints === 5 && inferno.frame === 2);
check('model: Inferno is a castable Offense on a default seat', inferno.type.id === 'Offense' && inferno.castable === true && inferno.passive === false && inferno.seatDefault === 2);
check('model: Inferno requires 3 points in Ramming Charge', JSON.stringify(inferno.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 3 }]));
check('model: the fire numbers ride the record untouched', inferno.effect.kind === 'flameArc' && inferno.effect.targetBase === 2 && inferno.effect.burnBaseMs === 500 && inferno.effect.burnTickMs === 500 && inferno.effect.burnDamageMul === 1);
const nano = model.skillDef('nanobots');
check('model: Nanobots carries the spec numbers (0 energy, 0s, 5 points, the microswarm insignia)', nano.energy === 0 && nano.cooldown === 0 && nano.maxPoints === 5 && nano.frame === 3);
check('model: Nanobots is a passive with no seat', nano.castable === false && nano.passive === true && nano.seatDefault === null);
check('model: Nanobots requires 3 points in Reinforced Plating', JSON.stringify(nano.requires) === JSON.stringify([{ skill: 'reinforced_plating', points: 3 }]));
check('model: the mending numbers ride the record untouched', nano.effect.kind === 'hullRegen' && nano.effect.regenPercentPerRank === 0.01 && nano.effect.intervalMs === 2000);
// 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 === 4 && 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 = 4;
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). The Wraith's own
// Boleadoras is the cleanest witness: it is on file, but it is not the
// Vanguard's to spend into.
check('ledger: a skill owned by another class is refused (unknown here)', rich.skills.rankUp('boleadoras').reason === 'unknown');
// Same rule on a skill that is on NO tree at all (a hand-edit, caught by
// the ledger, not by the model).
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 foreign skill id is refused (unknown here)', rich.skills.rankUp('wraith_only').reason === 'unknown');
wraithTree.pop();
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: the data default binds Inferno to seat 2 (a rank-0 active may sit inert)', s2.skills.seatOf('inferno') === 2 && s2.skills.skillAtSeat(1) === 'inferno');
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);
// ---- the new pair's edges + passive read --------------------------
const gated2 = vanguardRun(99);
check('edges: Inferno is locked until 3 points sit in Ramming Charge', gated2.skills.canRank('inferno').reason === 'locked' && gated2.skills.lockedBy(model.skillDef('inferno'))?.skill === 'ramming_charge');
gated2.skills.rankUp('ramming_charge');
gated2.skills.rankUp('ramming_charge');
check('edges: two points in Ramming Charge is still short of Inferno', gated2.skills.canRank('inferno').reason === 'locked');
gated2.skills.rankUp('ramming_charge');
check('edges: the third point opens Inferno', gated2.skills.canRank('inferno').ok === true && gated2.skills.canRank('nanobots').reason === 'locked');
for (let i = 0; i < 3; i++) gated2.skills.rankUp('reinforced_plating');
check('edges: 3 points in Reinforced Plating opens Nanobots', gated2.skills.canRank('nanobots').ok === true);
// Passive scaling: +1% hull per point, read as a modifier, not a cast.
const mend = vanguardRun(99);
check('ledger: rank 0 Nanobots grant nothing', mend.skills.hullRegen() === 0);
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('nanobots');
check('ledger: 1/5 Nanobots mends 1% of max hull per tick', Math.abs(mend.skills.hullRegen() - 0.01) < 1e-9);
for (let i = 0; i < 4; i++) mend.skills.rankUp('nanobots');
check('ledger: 5/5 Nanobots mend 5% of max hull per tick', Math.abs(mend.skills.hullRegen() - 0.05) < 1e-9);
// ---- 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 = 4;
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,inferno,reinforced_plating,nanobots');
check('rows: a maxed-out bank reads noPoints on the spendable root', rows[2].state === 'noPoints' && rows[2].spendable === false);
check('rows: the 3-point branches read locked while their parents sit short', rows[1].state === 'locked' && rows[3].state === 'locked' && 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);
}
// =====================================================================
// 6. THE LAYOUT — js/skills/SkillModel.js → treeLayout() is the grid the
// console draws: authored cells win, unplaced skills fall to graph depth,
// collisions slide right instead of stacking, and the joins come from
// `requires` (both forms). Pure function, so the geometry is testable
// without a canvas.
// =====================================================================
{
const laid = model.classTreeLayout('vanguard');
const cell = Object.fromEntries(laid.nodes.map((n) => [n.id, `${n.col},${n.row}`]));
check('layout: every skill of the tree gets exactly one node', laid.nodes.length === model.skillList('vanguard').length);
check(`layout: authored cells are honoured (${JSON.stringify(cell)})`,
cell.ramming_charge === '1,0' && cell.inferno === '1,1' && cell.reinforced_plating === '3,0' && cell.nanobots === '3,1');
check(`layout: bounds/cols/rows follow the placed cells (cols ${laid.cols}, rows ${laid.rows})`,
laid.cols >= 4 && laid.rows === 2 && laid.bounds.minCol <= 1 && laid.bounds.maxCol >= 3 && laid.bounds.maxRow >= 1);
// A tree with NO authored cells is laid out by the graph: a chain walks down one
// row per dependency depth, which is what "tree" means when nobody plans it.
const raw = config.get('skills.classes.vanguard.skills', []);
raw.push(
{ id: 'lay_root', label: 'Lay Root', type: 'Offense' },
{ id: 'lay_mid', label: 'Lay Mid', type: 'Offense', requires: ['lay_root'] },
{ id: 'lay_leaf', label: 'Lay Leaf', type: 'Offense', requires: [{ skill: 'lay_mid', points: 2 }] },
{ id: 'lay_junk', label: 'Lay Junk', type: 'Offense', node: 'not-a-cell' },
);
model.invalidateSkillCache();
const auto = model.classTreeLayout('vanguard');
const at = Object.fromEntries(auto.nodes.map((n) => [n.id, n]));
check('layout: an unplaced chain walks DOWN by dependency depth',
at.lay_root.row === 0 && at.lay_mid.row === 1 && at.lay_leaf.row === 2);
check(`layout: the join carries the required rank (${JSON.stringify(auto.edges.map((e) => `${e.from}>${e.to}:${e.points}`))})`,
auto.edges.some((e) => e.from === 'lay_mid' && e.to === 'lay_leaf' && e.points === 2));
check('layout: junk in `node` is no placement (the skill joins the auto-layout, never a tile piled at 0,0)',
at.lay_junk.authored === false && Number.isInteger(at.lay_junk.col) && at.lay_junk.col >= 0);
// Two skills authored into ONE cell: the second slides right rather than stacking.
raw[5].node = { col: raw[0].node.col, row: raw[0].node.row }; // lay_mid on top of Ramming Charge
model.invalidateSkillCache();
const clash = model.classTreeLayout('vanguard');
const keys = clash.nodes.map((n) => `${n.col}:${n.row}`);
check('layout: an authored collision slides right (never two tiles in one cell)', new Set(keys).size === keys.length);
// Edges only ever point at a skill of the same tree.
raw.length = 4;
model.invalidateSkillCache();
const clean = model.classTreeLayout('vanguard');
check('layout: no edge survives to a skill that is not in the tree',
clean.edges.every((e) => clean.nodes.some((n) => n.id === e.from) && clean.nodes.some((n) => n.id === e.to)));
}
// =====================================================================
// 7. THE SESSION — points spent while the console is OPEN are held until it
// closes. Reset gives the session back; closing commits everything. The
// refund can never strand a point that was ALREADY committed, so the
// cascade refuses rather than de-powering the ship under the player.
// =====================================================================
{
const raw = config.get('skills.classes.vanguard.skills', []);
raw.push({ id: 'ses_child', label: 'Session Child', type: 'Offense', requires: [{ skill: 'ramming_charge', points: 1 }] });
model.invalidateSkillCache();
const { cls, skills } = vanguardRun(6);
check('session: nothing is pending before the console opens', skills.stats().inSession === false && skills.pendingTotal() === 0);
skills.beginSession();
check('session: opening files a session and baselines the ledger', skills.stats().inSession === true && skills.baseRank('ramming_charge') === 0);
skills.beginSession();
check('session: re-opening does not move the baseline (the session is one open stretch)', skills.baseRank('ramming_charge') === 0);
skills.rankUp('ramming_charge');
skills.rankUp('ramming_charge');
check(`session: spending is live AND counted against the session (${skills.pendingOf('ramming_charge')}/${skills.rank('ramming_charge')})`,
skills.rank('ramming_charge') === 2 && skills.pendingOf('ramming_charge') === 2 && skills.pendingTotal() === 2);
check('session: the bank paid as it always does', cls.skillPoints === 4);
const row = skills.rows().find((r) => r.id === 'ramming_charge');
check(`session: the rows carry the session read for the badge/chip (pending ${row?.pending}, resettable ${row?.resettable})`,
row?.pending === 2 && row?.resettable === true);
const back = skills.resetSession('ramming_charge');
check(`session: RESET gives the session's points straight back (${JSON.stringify(back)})`,
back.ok === true && skills.rank('ramming_charge') === 0 && cls.skillPoints === 6 && skills.pendingTotal() === 0);
check('session: RESET leaves the session open (undo, not an exit)', skills.stats().inSession === true);
skills.rankUp('ramming_charge');
skills.rankUp('ses_child'); // needs ramming_charge ≥ 1 — unlocked by the spend above
const done = skills.commitSession();
check(`session: closing commits what is held (${JSON.stringify(done)})`,
done.committed === 2 && skills.stats().inSession === false && skills.pendingTotal() === 0);
check('session: a committed rank is still spent (the bank stayed paid)', cls.skillPoints === 4 && skills.rank('ramming_charge') === 1);
const tooLate = skills.resetSession('ramming_charge');
check(`session: RESET after the close says no — committed points are not its business (${tooLate.reason})`,
tooLate.ok === false && tooLate.reason === 'noSession' && skills.rank('ramming_charge') === 1 && cls.skillPoints === 4);
// A closed session has nothing to unwind once re-opened either.
skills.beginSession();
const nothing = skills.resetSession('ramming_charge');
check(`session: with nothing pending RESET declines (${nothing.reason})`,
nothing.ok === false && nothing.reason === 'nothing' && cls.skillPoints === 4);
// THE CASCADE GUARD. Here the child's point is COMMITTED (its session base is 1)
// while the parent's rank only exists because of this session: unwinding the parent
// would strand a point already paid for — so nothing moves.
skills.sessionBase.set('ramming_charge', 0); // parent: committed 0, rank 1 → pending
const strand = skills.resetSession('ramming_charge');
check(`session: a reset that would strand a committed point refuses instead (${JSON.stringify(strand)})`,
strand.ok === false && strand.reason === 'blocked' && strand.blocked?.skill === 'ses_child'
&& skills.rank('ses_child') === 1 && skills.rank('ramming_charge') === 1 && cls.skillPoints === 4);
skills.sessionBase.set('ramming_charge', 1); // restore the honest baseline
skills.commitSession();
// A branch bought ENTIRELY inside this session refunds all the way down.
const { cls: cls2, skills: s2 } = vanguardRun(8);
s2.beginSession();
s2.rankUp('ramming_charge');
s2.rankUp('ses_child');
const branch = s2.resetSession('ramming_charge');
check(`session: a branch bought entirely in this session refunds down the tree (${JSON.stringify(branch)})`,
branch.ok === true && s2.rank('ses_child') === 0 && s2.rank('ramming_charge') === 0 && cls2.skillPoints === 8);
// Restore wipes an open session: a load can never refund points across a reload.
const { skills: s3 } = vanguardRun(3);
s3.beginSession();
s3.rankUp('ramming_charge');
s3.restore({ ranks: { ramming_charge: 2 }, seats: {} });
check('session: restore() drops the open session (no refund across a load)', s3.stats().inSession === false && s3.pendingTotal() === 0);
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nskills: all checks passed');
process.exit(failures ? 1 : 0);