717 lines
45 KiB
JavaScript
717 lines
45 KiB
JavaScript
/**
|
||
* 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);
|