/** * 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/.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 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', '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 is a BUILD (research unlocks // it, the build applies it — data/builds.json → tether-l2), so it carries // no immediate effect; L3 still applies its tether raise on research // completion. const l2NoEffect = !nodes.tether_l2 || !nodes.tether_l2.effects?.tether; const levelsOk = [3].every((lvl) => { const n = nodes[`tether_l${lvl}`]; return n && n.effects?.tether?.level === lvl; }); check('exploration: tether_l3 raises the tether to level 3', 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, 'signal_amp')) === 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 = 3 (l1 → l2/anchor/signal → l3)', layout.rows === 3); check('model: layout levels — l1 root, l2/signal tier 1, l3/anchors tier 2', 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); 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, Map, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', 'map', null, 'menu'])); check('actionbar: labels (Research / Scan / Ship / Map / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', 'Map', null, 'Menu'])); check('actionbar: the MAP slot sits right of the SHIP slot', (() => { const iShip = slots.findIndex((s) => s.id === 'ship'); return iShip >= 0 && slots[iShip + 1]?.id === 'map'; })()); 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); // 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);