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