orbit/dev/research-builds.test.mjs

314 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* Research data-layer test (dev tool, run with Node — no browser):
*
* node dev/research-builds.test.mjs
*
* Pins the contract behind the research console (the deck's RESEARCH
* button → js/ui/ResearchWindow.js) and the build system that follows it:
* - the config files are registered in data/manifest.json;
* - research.json carries the rules (time-based, one project at a time),
* the category registry, the video feed and the fx knobs;
* - each category's tech tree (data/research/<id>.json) is a well-formed
* DAG with typed nodes — checked both as RAW JSON and through the real
* code (ResearchModel's loadCategory/issues/layoutTree/isAvailable);
* - the player-progress rules (ResearchState) behave: starting techs
* pre-unlocked, availability, one at a time, save/restore round-trip;
* - builds.json + actionbar.json keep their contracts.
*/
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 dataDir = join(root, 'data');
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
const manifest = read('manifest.json');
const research = read('research.json');
const exploration = read('research/exploration.json');
const mining = read('research/mining.json');
const builds = read('builds.json');
const actionbar = read('actionbar.json');
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const hex = /^#[0-9a-fA-F]{6}$/;
// ----------------------------------------------------------------------
// 1. Manifest registration (section name = file basename)
// ----------------------------------------------------------------------
for (const f of ['research.json', 'research/exploration.json', 'research/mining.json', 'builds.json', 'actionbar.json']) {
check(`manifest registers ${f}`, manifest.files.includes(f));
}
// ----------------------------------------------------------------------
// 2. research.json — the rules + the registry
// ----------------------------------------------------------------------
check('research: master switch present', typeof research.enabled === 'boolean');
check('research: time-based (timeUnit named)', typeof research.timeUnit === 'string' && research.timeUnit.length > 0);
check('research: one project at a time (maxConcurrent === 1)', research.maxConcurrent === 1);
check('research: categories is a non-empty array', Array.isArray(research.categories) && research.categories.length > 0);
check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? '')));
check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory));
// every STATIC category in the registry has its own tree file in the
// manifest. A category flagged `dynamic: true` (currently: `system`)
// has NO file — its tree is built at runtime (js/research/SystemCategory.js,
// one tech pair per solar system); dev/system-category.test.mjs pins that
// side of the contract.
const missing = research.categories.filter((c) => !c.dynamic && !manifest.files.includes(`research/${c.id}.json`));
check('research: every static registered category has a tree file', missing.length === 0);
check('research: the SYSTEM category is registered (per-solar-system techs)', research.categories.some((c) => c.id === 'system'));
check('research: the SYSTEM category is flagged dynamic (built at runtime)', (() => { const c = research.categories.find((c) => c.id === 'system'); return c?.dynamic === true; })());
// the video feed (a 2:3 portrait loop)
check('research: video file configured', typeof research.video?.file === 'string' && research.video.file.length > 0);
check('research: video file exists on disk', fs.existsSync(join(root, research.video?.file ?? '')));
check('research: video aspect is [w, h] > 0', Array.isArray(research.video?.aspect) && research.video.aspect.every((n) => typeof n === 'number' && n > 0));
check('research: fx knobs present', !!research.fx?.glitch && !!research.fx?.sweep);
// ----------------------------------------------------------------------
// 3. The exploration tree — RAW JSON contract
// ----------------------------------------------------------------------
const nodes = exploration.nodes ?? {};
check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Object.keys(nodes).length > 0);
check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id]));
check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1'));
const ICON_NAMES = ['tether', 'tether2', 'tether3', 'mining', 'mining2', 'storage', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
let nodeFieldsOk = true;
let requiresOk = true;
let unlocksOk = true;
let iconOk = true;
for (const [id, n] of Object.entries(nodes)) {
if (!n || typeof n !== 'object') { nodeFieldsOk = false; continue; }
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) nodeFieldsOk = false;
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && nodes[r])) requiresOk = false;
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) unlocksOk = false;
if (!ICON_NAMES.includes(n.icon)) iconOk = false;
}
check('exploration: every node has label/description/icon/duration', nodeFieldsOk);
check('exploration: every `requires` names a node in the same tree', requiresOk);
check('exploration: every node documents unlocks {builds, research}', unlocksOk);
check('exploration: icons use the procedural glyph set', iconOk);
// no cycles (iterative DFS, three-color)
const color = new Map();
let cyclic = false;
for (const id of Object.keys(nodes)) {
if (color.has(id)) continue;
const stack = [[id, (nodes[id].requires ?? []).filter((r) => nodes[r])]];
color.set(id, 1);
while (stack.length) {
const [cur, kids] = stack[stack.length - 1];
if (kids.length) {
const next = kids[0];
stack[stack.length - 1][1] = kids.slice(1);
if (color.get(next) === 1) { cyclic = true; break; }
if (!color.has(next)) {
color.set(next, 1);
stack.push([next, (nodes[next].requires ?? []).filter((r) => nodes[r])]);
}
} else {
color.set(cur, 2);
stack.pop();
}
}
if (cyclic) break;
}
check('exploration: the tree is a DAG (no cycles)', !cyclic);
check('exploration: has at least one root (a node with no requires)', Object.values(nodes).some((n) => !(n.requires ?? []).length));
// tether level chain (3 levels total): L2 and L3 are BLUEPRINTS (research
// unlocks their build, the build applies the raise — data/builds.json →
// tether-l2 / tether-l3), so neither carries an immediate tether effect.
const blueprints = [2, 3].every((lvl) => !nodes[`tether_l${lvl}`]?.effects?.tether);
check('exploration: tether_l2 + tether_l3 carry no immediate tether effect (their builds apply it)', blueprints);
// ----------------------------------------------------------------------
// 3b. The mining tree — RAW JSON contract (data/research/mining.json)
// ----------------------------------------------------------------------
const mnodes = mining.nodes ?? {};
check('mining: nodes is a non-empty map (3 techs)', Object.keys(mnodes).length === 3);
check('mining: nothing is starting (all three must be researched)', (mining.starting ?? []).length === 0);
let mfieldsOk = true;
for (const [id, n] of Object.entries(mnodes)) {
if (!n || typeof n !== 'object') { mfieldsOk = false; continue; }
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) mfieldsOk = false;
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && mnodes[r])) mfieldsOk = false;
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) mfieldsOk = false;
}
check('mining: every node has label/description/icon/duration/requires/unlocks', mfieldsOk);
check('mining: labels match the brief', mnodes.improved_arm?.label === 'Improved Mining Arm' && mnodes.advanced_arm?.label === 'Advanced Mining Arm' && mnodes.improved_storage?.label === 'Improved Mining Storage');
check('mining: all three take 60 s (research.timeUnit)', Object.values(mnodes).every((n) => n.duration === 60));
check('mining: improved_arm is the Tier-1 root', !!mnodes.improved_arm && (mnodes.improved_arm.requires ?? []).length === 0);
check('mining: both Tier-2 projects require the Tier-1 root', (mnodes.advanced_arm?.requires ?? []).includes('improved_arm') && (mnodes.improved_storage?.requires ?? []).includes('improved_arm'));
check('mining: Tier 1 declares its two children in unlocks.research', JSON.stringify(mnodes.improved_arm?.unlocks?.research ?? []) === JSON.stringify(['advanced_arm', 'improved_storage']));
check('mining: improved_arm declares its build (mining-arm-improved)', JSON.stringify(mnodes.improved_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-improved']));
check('mining: advanced_arm declares its build (mining-arm-advanced)', JSON.stringify(mnodes.advanced_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-advanced']));
check('mining: improved_storage declares its build (mining-storage-improved)', JSON.stringify(mnodes.improved_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-improved']));
check('mining: all three are blueprints (no effects — the effect lives on the build)', Object.values(mnodes).every((n) => Object.keys(n.effects ?? {}).length === 0));
// ----------------------------------------------------------------------
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, mining, builds, actionbar });
const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
check('model: categories() resolves the registry', categories().some((c) => c.id === 'exploration'));
const tree = loadCategory('exploration');
check('model: loadCategory returns the exploration tree', !!tree && tree.nodes.tether_l1 && tree.order.length === Object.keys(nodes).length);
check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).length === 0);
// the UNLOCKS space — research declares what it opens (builds + follow-on
// research); the build side is the authoritative gate; the test locks the
// two declarations together.
const { unlocksOf, buildDefs, unlockIssues, buildIssues } = await import('../js/research/ResearchModel.js');
check('model: unlocksOf normalizes {builds, research}', JSON.stringify(unlocksOf(tree, 'tether_l2')) === JSON.stringify({ builds: ['tether-l2'], research: ['tether_l3'] }));
check('model: unlocksOf(tether_l3) declares the level-3 build', JSON.stringify(unlocksOf(tree, 'tether_l3')) === JSON.stringify({ builds: ['tether-l3'], research: [] }));
check('model: unlocksOf normalizes a node with no builds', JSON.stringify(unlocksOf(tree, 'tether_l1')) === JSON.stringify({ builds: [], research: ['tether_l2'] }));
check('model: unlockIssues(tree) is clean (mirrors + build wiring agree)', unlockIssues(tree).length === 0);
check('model: buildIssues() is clean (every build require resolves)', buildIssues().length === 0);
const layout = layoutTree(tree);
check('model: layout rows = 3 (l1 → l2 → l3)', layout.rows === 3);
check('model: layout levels — l1 root, l2 tier 1, l3 tier 2',
layout.level.tether_l1 === 0 && layout.level.tether_l2 === 1 && layout.level.tether_l3 === 2);
check('model: layout is deterministic (same tree → same columns)', JSON.stringify(layout.col) === JSON.stringify(layoutTree(tree).col));
// a fresh run: starting techs pre-unlocked, the rest follow the rules
const state = new ResearchState();
for (const id of tree.starting) state.unlock(tree.id, id);
check('state: a fresh run already owns Tether Level 1', state.isUnlocked('exploration', 'tether_l1'));
check('state: Tether Level 2 is the FIRST thing to research', isAvailable(tree, state, 'tether_l2'));
check('state: Tether Level 3 waits on L2 (locked)', !isAvailable(tree, state, 'tether_l3'));
check('state: missingRequires names the parents (L3 ← L2)', JSON.stringify(missingRequires(tree, state, 'tether_l3')) === JSON.stringify(['tether_l2']));
// one project at a time — the in-flight slot
check('state: start() claims the slot', state.start('exploration', 'tether_l2', 60_000, 0) === true);
check('state: a second start() is refused while one runs', state.start('exploration', 'tether_l3', 60_000, 0) === false);
check('state: progress runs 0→1 over the duration', state.progress(0).fraction === 0 && state.progress(60_000).fraction === 1);
check('state: tick() reports completion at the deadline', state.tick(59_999).length === 0 && state.tick(60_000).length === 1);
check('state: L2 is researched after completion', state.isUnlocked('exploration', 'tether_l2'));
check('state: L3 opens up next', isAvailable(tree, state, 'tether_l3'));
// save/restore round-trip — the in-flight project keeps its remaining time
const state2 = new ResearchState();
for (const id of tree.starting) state2.unlock(tree.id, id);
state2.start('exploration', 'tether_l2', 60_000, 1_000);
const saved = state2.toJSON(10_000); // saved 9s into a 60s project
const restored = new ResearchState();
for (const k of saved.unlocked) restored.unlock(...k.split('::'));
restored.restoreActive(saved.active, 500_000); // reloaded an hour later
check('state: save carries the remaining time (60s 9s = 51s)', saved.active?.remainingMs === 51_000);
check('state: restore keeps the project in flight', restored.getActive() !== null && restored.getActive().id === 'tether_l2');
check('state: restored project finishes 51s after the load', restored.progress(500_000).fraction < 0.2 && restored.progress(551_000).fraction >= 1);
check('state: an old save without research restores as a fresh start', (() => { const s = new ResearchState(); s.restoreActive(null, 0); return s.getActive() === null; })());
// ----------------------------------------------------------------------
// 4b. The mining tree — the REAL code path (ResearchModel + ResearchState)
// ----------------------------------------------------------------------
const mtree = loadCategory('mining');
check('model: categories() registers Mining', categories().some((c) => c.id === 'mining'));
check('model: loadCategory returns the mining tree', !!mtree && !!mtree.nodes.improved_arm && mtree.order.length === 3);
check('model: mining: issues(tree) is clean', Array.isArray(issues(mtree)) && issues(mtree).length === 0);
check('model: mining: unlockIssues is clean (mirror + build wiring agree)', unlockIssues(mtree).length === 0);
const mlayout = layoutTree(mtree);
check('model: mining layout — 2 rows (Tier 1 → two Tier-2 children)', mlayout.rows === 2 && mlayout.level.improved_arm === 0 && mlayout.level.advanced_arm === 1 && mlayout.level.improved_storage === 1);
const mstate = new ResearchState();
check('mining: Tier 1 is the first thing to research (available on a fresh run)', isAvailable(mtree, mstate, 'improved_arm'));
check('mining: both Tier-2 projects wait on Tier 1 (locked)', !isAvailable(mtree, mstate, 'advanced_arm') && !isAvailable(mtree, mstate, 'improved_storage'));
check('mining: missingRequires names the parent (Tier 2 ← Tier 1)', JSON.stringify(missingRequires(mtree, mstate, 'advanced_arm')) === JSON.stringify(['improved_arm']));
check('mining: start() claims the slot', mstate.start('mining', 'improved_arm', 60_000, 0) === true);
check('mining: a second start() is refused while one runs', mstate.start('mining', 'advanced_arm', 60_000, 0) === false);
check('mining: tick() reports completion at the deadline', mstate.tick(59_999).length === 0 && mstate.tick(60_000).length === 1);
check('mining: Tier 1 is researched after completion', mstate.isUnlocked('mining', 'improved_arm'));
check('mining: both Tier-2 projects open up after Tier 1', isAvailable(mtree, mstate, 'advanced_arm') && isAvailable(mtree, mstate, 'improved_storage'));
// ----------------------------------------------------------------------
// 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js)
// ----------------------------------------------------------------------
check('builds: enabled switch present', typeof builds.enabled === 'boolean');
check('builds: one build at a time (maxConcurrent === 1)', builds.maxConcurrent === 1);
check('builds: categories is a non-empty array', Array.isArray(builds.categories) && builds.categories.length > 0);
check('builds: every category has id/label/accent', builds.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? '')));
check('builds: defaultCategory names a real category', (builds.categories ?? []).some((c) => c.id === builds.defaultCategory));
check('builds: video file configured', typeof builds.video?.file === 'string' && builds.video.file.length > 0);
check('builds: video file exists on disk', fs.existsSync(join(root, builds.video?.file ?? '')));
check('builds: video aspect is [w, h] > 0', Array.isArray(builds.video?.aspect) && builds.video.aspect.every((n) => typeof n === 'number' && n > 0));
check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string');
check('builds: credits resource defined (future seam)', typeof builds.resources?.credits?.label === 'string');
check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds));
// the tether-l2 build — the first research→build unlock, wired both ways
const tl2 = builds.builds['tether-l2'] ?? {};
check('builds: tether-l2 entry exists (the level-2 tether build)', !!tl2);
check('builds: tether-l2 is a planet build', JSON.stringify(tl2.targets ?? []) === JSON.stringify(['planet']));
check('builds: tether-l2 is gated on exploration/tether_l2 (authoritative side)', JSON.stringify(tl2.requires ?? []) === JSON.stringify(['exploration/tether_l2']));
check('builds: tether-l2 needs a level-1 tether on the planet', tl2.planetRequires?.tetherLevel === 1);
check('builds: tether-l2 costs 200 minerals, 20 s, one-off', tl2.repeatable === false && tl2.cost?.minerals === 200 && tl2.duration === 20);
check('builds: tether_l2 declares the build in its unlocks (declaration side)', (nodes.tether_l2?.unlocks?.builds ?? []).includes('tether-l2'));
check('builds: tether-l2 effect raises a level-2 tether on the target', tl2.effects?.tether?.level === 2 && tl2.effects?.tether?.anchor === 'target');
// the tether-l3 build — same pattern, one level up: the research is the
// blueprint, the build installs the level-3 ring (300 minerals).
const tl3 = builds.builds['tether-l3'] ?? {};
check('builds: tether-l3 entry exists (the level-3 tether build)', !!tl3);
check('builds: tether-l3 is a planet build', JSON.stringify(tl3.targets ?? []) === JSON.stringify(['planet']));
check('builds: tether-l3 is gated on exploration/tether_l3 (authoritative side)', JSON.stringify(tl3.requires ?? []) === JSON.stringify(['exploration/tether_l3']));
check('builds: tether-l3 needs a level-2 tether on the planet', tl3.planetRequires?.tetherLevel === 2);
check('builds: tether-l3 costs 300 minerals, 30 s, one-off', tl3.repeatable === false && tl3.cost?.minerals === 300 && tl3.duration === 30);
check('builds: tether_l3 declares the build in its unlocks (declaration side)', (nodes.tether_l3?.unlocks?.builds ?? []).includes('tether-l3'));
check('builds: tether-l3 effect raises a level-3 tether on the target', tl3.effects?.tether?.level === 3 && tl3.effects?.tether?.anchor === 'target');
// the tether-l1 build — the home world's starting install
const tl1 = builds.builds['tether-l1'] ?? {};
check('builds: tether-l1 entry exists (the starting install)', !!tl1);
check('builds: tether-l1 is already installed on home (starting)', JSON.stringify(tl1.starting ?? []) === JSON.stringify(['home']));
check('builds: tether-l1 is a free, instant one-off', tl1.repeatable === false && !(tl1.cost?.minerals > 0) && tl1.duration === 0);
const bt = builds._template ?? {};
for (const k of ['label', 'description', 'category', 'targets', 'cost', 'requires', 'repeatable', 'effects', 'theme']) {
check(`builds._template documents "${k}"`, k in bt);
}
check('builds._template.category is a console tab', ['planet', 'cargo'].some((c) => String(bt.category).includes(c)));
check('builds._template.cost pays in minerals', typeof bt.cost?.minerals === 'number');
check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'boolean');
check('builds._template.requires is an array', Array.isArray(bt.requires));
// ----------------------------------------------------------------------
// 6. Command deck — six evenly spaced slots, right order
// (Quests sits on the flight deck to the right of Ship — the
// mission dossier; the surface deck carries the same slot.)
// ----------------------------------------------------------------------
const slots = actionbar.buttons ?? [];
check('actionbar: exactly six slots', slots.length === 6);
check('actionbar: slot ids in order (Research, Scan, Ship, Quests, Map, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'quests', 'map', 'menu']));
check('actionbar: labels (Research / Scan / Ship / Quests / Map / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Quests', 'Map', 'Menu']));
check('actionbar: the QUESTS slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'quests'; })());
check('actionbar: the QUESTS slot carries a label + accent', (() => { const q = slots.find((s) => s.id === 'quests'); return q && typeof q.label === 'string' && hex.test(q.accent ?? ''); })());
check('actionbar: the MAP slot sits right of the QUESTS slot', (() => { const iQ = slots.findIndex((s) => s.id === 'quests'); return iQ >= 0 && slots[iQ + 1]?.id === 'map'; })());
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
check('actionbar: no reserved (empty) slots — every slot is a button', slots.every((s) => s.id !== null && typeof s.label === 'string'));
check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');
check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
check('actionbar: sane geometry (height 40200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
// the surface deck (SurfaceScene) — Shop for Research, Build, Take Off
const sSlots = actionbar.surface?.buttons ?? [];
check('actionbar: surface deck has six slots', sSlots.length === 6);
check('actionbar: surface slot ids in order (Shop, Build, Ship, Quests, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', 'quests', 'takeoff', 'menu']));
check('actionbar: the QUESTS slot is on the surface deck (right of SHIP)', (() => { const iShip = sSlots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && sSlots[iShip + 1]?.id === 'quests'; })());
check('actionbar: the BUILD slot is on the surface deck', sSlots.some((s) => s.id === 'build'));
check('actionbar: the BUILD slot carries a label + accent', (() => { const b = sSlots.find((s) => s.id === 'build'); return b && typeof b.label === 'string' && hex.test(b.accent ?? ''); })());
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);