208 lines
13 KiB
JavaScript
208 lines
13 KiB
JavaScript
/**
|
||
* 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: each level's effect names the next level up
|
||
const levelsOk = [2, 3, 4].every((lvl) => {
|
||
const n = nodes[`tether_l${lvl}`];
|
||
return n && n.effects?.tether?.level === lvl;
|
||
});
|
||
check('exploration: tether_l2/l3/l4 each raise the tether to their level', levelsOk);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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);
|
||
|
||
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 — credits + minerals (the next deck feature)
|
||
// ----------------------------------------------------------------------
|
||
check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string');
|
||
check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string');
|
||
check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds));
|
||
check('builds: no builds yet (empty map)', Object.keys(builds.builds ?? {}).filter((k) => !k.startsWith('_')).length === 0);
|
||
|
||
const bt = builds._template ?? {};
|
||
for (const k of ['label', 'description', 'category', 'cost', 'requires', 'repeatable', 'effects', 'theme']) {
|
||
check(`builds._template documents "${k}"`, k in bt);
|
||
}
|
||
check('builds._template.category is a known kind', ['ship', 'planet', 'station', 'general'].includes(bt.category));
|
||
check('builds._template.cost pays in credits + minerals', typeof bt.cost?.credits === 'number' && 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 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
||
|
||
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
||
process.exit(failures === 0 ? 0 : 1);
|