/** * 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 `?? `, 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);