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