orbit/dev/research-builds.test.mjs

251 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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 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', '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 category in the registry has its own tree file in the manifest
const missing = research.categories.filter((c) => !manifest.files.includes(`research/${c.id}.json`));
check('research: every registered category has a tree file', missing.length === 0);
// 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', 'anchor', 'signal', 'diamond']; // 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: L2 is a BUILD (research unlocks it, the build
// applies it — data/builds.json → tether-l2), so it carries no immediate
// effect; L3/L4 still apply their tether raise on research completion.
const l2NoEffect = !nodes.tether_l2 || !nodes.tether_l2.effects?.tether;
const levelsOk = [3, 4].every((lvl) => {
const n = nodes[`tether_l${lvl}`];
return n && n.effects?.tether?.level === lvl;
});
check('exploration: tether_l3/l4 each raise the tether to their level', levelsOk);
check('exploration: tether_l2 carries no immediate tether effect (the build applies it)', l2NoEffect);
// ----------------------------------------------------------------------
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, 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', 'tether_anchors'] }));
check('model: unlocksOf is empty for a node without unlocks', JSON.stringify(unlocksOf(tree, 'tether_l4')) === JSON.stringify({ builds: [], research: [] }));
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 = 4 (l1 → l2/anchor/signal → l3 → l4)', layout.rows === 4);
check('model: layout levels — l1 root, l2/signal tier 1, l3/anchors tier 2, l4 tier 3',
layout.level.tether_l1 === 0 && layout.level.tether_l2 === 1 && layout.level.signal_amp === 1 &&
layout.level.tether_l3 === 2 && layout.level.tether_anchors === 2 && layout.level.tether_l4 === 3);
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: Signal Amplification is also available (requires only L1)', isAvailable(tree, state, 'signal_amp'));
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', 'signal_amp', 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 + Tether Anchoring open up next', isAvailable(tree, state, 'tether_l3') && isAvailable(tree, state, 'tether_anchors'));
// 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; })());
// ----------------------------------------------------------------------
// 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-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
// ----------------------------------------------------------------------
const slots = actionbar.buttons ?? [];
check('actionbar: exactly six slots', slots.length === 6);
check('actionbar: slot ids in order (Research, Scan, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', null, null, 'menu']));
check('actionbar: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu']));
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
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, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', null, 'takeoff', 'menu']));
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);