orbit/dev/research-builds.test.mjs

475 lines
38 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 cargo = read('research/cargo.json');
const quests = read('quests.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', 'research/cargo.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));
check('research: tab order is System, Mining, Cargo, Exploration (Cargo between Mining and Exploration)', JSON.stringify(research.categories.map((c) => c.id)) === JSON.stringify(['system', 'mining', 'cargo', 'exploration']));
// 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 (5 techs)', Object.keys(mnodes).length === 5);
check('mining: nothing is starting (all five 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 are the short button copy (the tree node has no room for "Mining" — the category tab says it)', mnodes.improved_arm?.label === 'Improved Arm' && mnodes.advanced_arm?.label === 'Advanced Arm' && mnodes.premium_arm?.label === 'Premium Arm' && mnodes.improved_storage?.label === 'Improved Storage' && mnodes.advanced_storage?.label === 'Advanced Storage');
check('mining: all five 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: premium_arm is Tier 3 under advanced_arm', (mnodes.premium_arm?.requires ?? []).length === 1 && (mnodes.premium_arm?.requires ?? []).includes('advanced_arm'));
check('mining: advanced_storage is Tier 3 under improved_storage', (mnodes.advanced_storage?.requires ?? []).length === 1 && (mnodes.advanced_storage?.requires ?? []).includes('improved_storage'));
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: advanced_arm declares its child (premium_arm) in unlocks.research', JSON.stringify(mnodes.advanced_arm?.unlocks?.research ?? []) === JSON.stringify(['premium_arm']));
check('mining: improved_storage declares its child (advanced_storage) in unlocks.research', JSON.stringify(mnodes.improved_storage?.unlocks?.research ?? []) === JSON.stringify(['advanced_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: premium_arm declares its build (mining-arm-premium)', JSON.stringify(mnodes.premium_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-premium']));
check('mining: improved_storage declares its build (mining-storage-improved)', JSON.stringify(mnodes.improved_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-improved']));
check('mining: advanced_storage declares its build (mining-storage-advanced)', JSON.stringify(mnodes.advanced_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-advanced']));
check('mining: all five are blueprints (no effects — the effect lives on the build)', Object.values(mnodes).every((n) => Object.keys(n.effects ?? {}).length === 0));
// The STORY-QUEST gate (node.quest — data/quests.json): both Tier-3 techs
// wait on the 'Special Delivery' quest's reward being CLAIMED (the scene
// wires the live predicate via attachQuestGates — the behavior is pinned
// below in 4b and the quest's well-formedness in dev/quests.test.mjs).
check('mining: premium_arm + advanced_storage gate on the special_delivery quest',
mnodes.premium_arm?.quest === 'special_delivery' && mnodes.advanced_storage?.quest === 'special_delivery');
check('mining: the Tier-1 + Tier-2 techs carry no quest gate',
!('quest' in (mnodes.improved_arm ?? {})) && !('quest' in (mnodes.advanced_arm ?? {})) && !('quest' in (mnodes.improved_storage ?? {})));
// ----------------------------------------------------------------------
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, mining, cargo, builds, actionbar, quests });
const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
const { questDef } = await import('../js/quests/QuestState.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: the quest gate names a DEFINED quest (a typo would be a gate that never lifts)',
['premium_arm', 'advanced_storage'].every((id) => mnodes[id]?.quest === 'special_delivery') && questDef('special_delivery') !== null);
check('model: the gated quest is the THIRD quest in the run (Special Delivery, main story)',
(() => { const q = questDef('special_delivery'); return q?.category === 'main' && (quests.quests ?? []).indexOf(q) === 2; })());
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 === 5);
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 — 3 rows (Tier 1 → two Tier-2 → two Tier-3)', mlayout.rows === 3 && mlayout.level.improved_arm === 0 && mlayout.level.advanced_arm === 1 && mlayout.level.improved_storage === 1 && mlayout.level.premium_arm === 2 && mlayout.level.advanced_storage === 2);
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: both Tier-3 projects wait on their Tier-2 parent (locked)', !isAvailable(mtree, mstate, 'premium_arm') && !isAvailable(mtree, mstate, 'advanced_storage'));
check('mining: missingRequires names the parent (Tier 2 ← Tier 1)', JSON.stringify(missingRequires(mtree, mstate, 'advanced_arm')) === JSON.stringify(['improved_arm']));
check('mining: missingRequires names the Tier-3 parent (premium_arm ← advanced_arm)', JSON.stringify(missingRequires(mtree, mstate, 'premium_arm')) === JSON.stringify(['advanced_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'));
check('mining: Tier-3 stays locked until its own Tier-2 parent is researched', !isAvailable(mtree, mstate, 'premium_arm') && !isAvailable(mtree, mstate, 'advanced_storage'));
mstate.unlock('mining', 'advanced_arm');
mstate.unlock('mining', 'improved_storage');
check('mining: premium_arm opens once advanced_arm is researched', isAvailable(mtree, mstate, 'premium_arm'));
check('mining: advanced_storage opens once improved_storage is researched', isAvailable(mtree, mstate, 'advanced_storage'));
// The STORY-QUEST gate — the REAL wiring (attachQuestGates; the scene's
// _treeFor does this with its live quest check): with 'Special Delivery'
// NOT CLAIMED the Tier-3 techs stay locked even with every parent
// researched; CLAIMED, they open. The ungated techs are untouched.
const { attachQuestGates } = await import('../js/research/ResearchModel.js');
{
const gateNo = attachQuestGates(loadCategory('mining'), (qid) => qid !== 'special_delivery');
check('quest-gate: NOT CLAIMED — premium_arm + advanced_storage stay locked (all parents researched)',
!isAvailable(gateNo, mstate, 'premium_arm') && !isAvailable(gateNo, mstate, 'advanced_storage'));
check('quest-gate: NOT CLAIMED — the Tier-2 techs are untouched by the wiring (fresh state, only Tier-1 researched)',
(() => {
const st = new ResearchState();
st.unlock('mining', 'improved_arm');
return isAvailable(gateNo, st, 'advanced_arm') && isAvailable(gateNo, st, 'improved_storage') &&
!isAvailable(gateNo, st, 'premium_arm') && !isAvailable(gateNo, st, 'advanced_storage');
})());
check('quest-gate: NOT CLAIMED — lockNote reads the quest title (the console LOCKED line)',
gateNo.lockNote(mstate, 'premium_arm') === 'SPECIAL DELIVERY REQUIRED' &&
gateNo.lockNote(mstate, 'advanced_storage') === 'SPECIAL DELIVERY REQUIRED' &&
gateNo.lockNote(mstate, 'advanced_arm') === null);
const gateYes = attachQuestGates(loadCategory('mining'), (qid) => qid === 'special_delivery');
check('quest-gate: CLAIMED — premium_arm + advanced_storage open (all parents researched)',
isAvailable(gateYes, mstate, 'premium_arm') && isAvailable(gateYes, mstate, 'advanced_storage'));
check('quest-gate: a tree with no gated nodes is returned untouched (no hooks added)',
(() => { const t = loadCategory('exploration'); const r = attachQuestGates(t, () => false); return r === t && typeof t.available !== 'function'; })());
check('quest-gate: wiring composes over a pre-existing available hook (the SYSTEM seam)',
(() => {
const t = loadCategory('exploration');
t.available = (s, id) => id !== 'tether_l2'; // a pre-existing gate
attachQuestGates(t, () => false);
return t.available({}, 'tether_l2') === false && t.available({}, 'tether_l3') === true;
})());
}
// ----------------------------------------------------------------------
// 4c. The cargo tree — data/research/cargo.json (the Cargo tab, between
// Mining and Exploration): 8 Bays → 12 Bays → 16 Bays, each a
// blueprint for its ship-hold build (data/builds.json → cargo-bay-*).
// ----------------------------------------------------------------------
const cnodes = cargo.nodes ?? {};
check('cargo: nodes is a non-empty map (3 techs — the 8 → 12 → 16 chain)', Object.keys(cnodes).length === 3);
check('cargo: nothing is starting (all three must be researched)', (cargo.starting ?? []).length === 0);
let cfieldsOk = true;
for (const [id, n] of Object.entries(cnodes)) {
if (!n || typeof n !== 'object') { cfieldsOk = false; continue; }
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) cfieldsOk = false;
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && cnodes[r])) cfieldsOk = false;
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) cfieldsOk = false;
}
check('cargo: every node has label/description/icon/duration/requires/unlocks', cfieldsOk);
check('cargo: labels read Cargo - 8 / 12 / 16 Bays', cnodes.cargo_bay_8?.label === 'Cargo - 8 Bays' && cnodes.cargo_bay_12?.label === 'Cargo - 12 Bays' && cnodes.cargo_bay_16?.label === 'Cargo - 16 Bays');
check('cargo: cargo_bay_8 is the root (no requires — the chain opens here)', (cnodes.cargo_bay_8?.requires ?? []).length === 0);
check('cargo: cargo_bay_12 waits on cargo_bay_8', JSON.stringify(cnodes.cargo_bay_12?.requires ?? []) === JSON.stringify(['cargo_bay_8']));
check('cargo: cargo_bay_16 waits on cargo_bay_12', JSON.stringify(cnodes.cargo_bay_16?.requires ?? []) === JSON.stringify(['cargo_bay_12']));
check('cargo: 8 Bays declares its build (cargo-bay-8) + child (cargo_bay_12)', JSON.stringify(cnodes.cargo_bay_8?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-8'], research: ['cargo_bay_12'] }));
check('cargo: 12 Bays declares its build (cargo-bay-12) + child (cargo_bay_16)', JSON.stringify(cnodes.cargo_bay_12?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-12'], research: ['cargo_bay_16'] }));
check('cargo: 16 Bays declares its build (cargo-bay-16) and no child', JSON.stringify(cnodes.cargo_bay_16?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-16'], research: [] }));
check('cargo: cargo_bay_8 gates on the special_delivery quest (reward CLAIMED first)', cnodes.cargo_bay_8?.quest === 'special_delivery');
check('cargo: cargo_bay_12 gates on the mining-storage-improved build (BUILT first)', cnodes.cargo_bay_12?.build === 'mining-storage-improved');
check('cargo: cargo_bay_16 gates on the mining-storage-advanced build (BUILT first)', cnodes.cargo_bay_16?.build === 'mining-storage-advanced');
check('cargo: the gated builds are REAL builds (a typo would be a gate that never lifts)', !!builds.builds['mining-storage-improved'] && !!builds.builds['mining-storage-advanced']);
check('cargo: the gated quest is DEFINED (a typo would be a gate that never lifts)', questDef('special_delivery') !== null);
check('cargo: all three are blueprints (no effects — the build carries the hold)', Object.values(cnodes).every((n) => Object.keys(n.effects ?? {}).length === 0));
const ctree = loadCategory('cargo');
check('model: loadCategory returns the cargo tree', !!ctree && !!ctree.nodes.cargo_bay_8 && ctree.order.length === 3);
check('model: cargo: issues(tree) is clean', Array.isArray(issues(ctree)) && issues(ctree).length === 0);
check('model: cargo: unlockIssues is clean (mirror + build wiring agree)', unlockIssues(ctree).length === 0);
check('model: cargo layout — a 3-row chain (8 → 12 → 16)', (() => { const l = layoutTree(ctree); return l.rows === 3 && l.level.cargo_bay_8 === 0 && l.level.cargo_bay_12 === 1 && l.level.cargo_bay_16 === 2; })());
const cstate = new ResearchState();
check('cargo (pure DAG): 8 Bays is the root (available on a fresh run)', isAvailable(ctree, cstate, 'cargo_bay_8'));
check('cargo (pure DAG): 12 + 16 Bays wait on their parent (locked)', !isAvailable(ctree, cstate, 'cargo_bay_12') && !isAvailable(ctree, cstate, 'cargo_bay_16'));
check('cargo: missingRequires names the parent (12 ← 8)', JSON.stringify(missingRequires(ctree, cstate, 'cargo_bay_12')) === JSON.stringify(['cargo_bay_8']));
cstate.unlock('cargo', 'cargo_bay_8');
check('cargo (pure DAG): 12 Bays opens once 8 Bays is researched', isAvailable(ctree, cstate, 'cargo_bay_12'));
check('cargo (pure DAG): 16 Bays stays locked until its own parent is researched', !isAvailable(ctree, cstate, 'cargo_bay_16'));
cstate.unlock('cargo', 'cargo_bay_12');
check('cargo (pure DAG): 16 Bays opens once 12 Bays is researched', isAvailable(ctree, cstate, 'cargo_bay_16'));
// The live gates — the scene's wiring (attachBuildGates + attachQuestGates,
// the same seam as the mining Tier-3 quest gate): fresh run = quest NOT
// CLAIMED, nothing BUILT → every cargo tech locked; each gate lifts as its
// condition lands (claim → 8 Bays; Improved Storage built → 12 Bays;
// Advanced Storage built → 16 Bays).
const { attachBuildGates } = await import('../js/research/ResearchModel.js');
{
const gates = (built, claimed) => attachQuestGates(
attachBuildGates(loadCategory('cargo'), (bid) => built.includes(bid)),
(qid) => (claimed ? qid === 'special_delivery' : false),
);
const gstate = new ResearchState();
check('cargo-gates: fresh run — 8 Bays waits on the quest (SPECIAL DELIVERY REQUIRED)',
!isAvailable(gates([], false), gstate, 'cargo_bay_8') && gates([], false).lockNote(gstate, 'cargo_bay_8') === 'SPECIAL DELIVERY REQUIRED');
check('cargo-gates: fresh run — 12 + 16 Bays locked (parent + build gates)',
!isAvailable(gates([], false), gstate, 'cargo_bay_12') && !isAvailable(gates([], false), gstate, 'cargo_bay_16'));
check('cargo-gates: quest CLAIMED — 8 Bays opens (the first cargo research)',
isAvailable(gates([], true), gstate, 'cargo_bay_8'));
gstate.unlock('cargo', 'cargo_bay_8');
check('cargo-gates: Improved Storage NOT built — 12 Bays stays locked (the build gate)',
!isAvailable(gates([], true), gstate, 'cargo_bay_12') && gates([], true).lockNote(gstate, 'cargo_bay_12') === 'IMPROVED MINING STORAGE BUILD REQUIRED');
check('cargo-gates: Improved Storage NOT built — 16 Bays stays locked too',
!isAvailable(gates([], true), gstate, 'cargo_bay_16'));
const g2 = new ResearchState();
g2.unlock('cargo', 'cargo_bay_8');
check('cargo-gates: Improved Storage BUILT — 12 Bays opens (8 Bays researched + the build done)',
isAvailable(gates(['mining-storage-improved'], true), g2, 'cargo_bay_12'));
g2.unlock('cargo', 'cargo_bay_12');
check('cargo-gates: Advanced Storage NOT built — 16 Bays stays locked (the build gate)',
!isAvailable(gates(['mining-storage-improved'], true), g2, 'cargo_bay_16') &&
gates(['mining-storage-improved'], true).lockNote(g2, 'cargo_bay_16') === 'ADVANCED MINING STORAGE BUILD REQUIRED');
check('cargo-gates: both storages BUILT — 16 Bays opens (12 Bays researched + the build done)',
isAvailable(gates(['mining-storage-improved', 'mining-storage-advanced'], true), g2, 'cargo_bay_16'));
check('cargo-gates: lockNote reports the right gate per node (quest on 8 Bays, the builds on 12 + 16)',
(() => {
const none = gates([], false);
const st = new ResearchState();
return none.lockNote(st, 'cargo_bay_8') === 'SPECIAL DELIVERY REQUIRED' &&
none.lockNote(st, 'cargo_bay_12') === 'IMPROVED MINING STORAGE BUILD REQUIRED' &&
none.lockNote(st, 'cargo_bay_16') === 'ADVANCED MINING STORAGE BUILD REQUIRED';
})());
}
// The BUILDS side — the authoritative gate for each hold expansion (the
// research node's unlocks.builds is the declaration; the build's requires
// is what beginBuild/BuildModel enforce).
check('builds: cargo-bay-8 is gated on cargo/cargo_bay_8 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-8']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_8']));
check('builds: cargo-bay-12 is gated on cargo/cargo_bay_12 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-12']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_12']));
check('builds: cargo-bay-16 is gated on cargo/cargo_bay_16 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-16']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_16']));
// ----------------------------------------------------------------------
// 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, Explore for
// Ship (the full-width service drawer), 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, Explore, Quests, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'explore', 'quests', 'takeoff', 'menu']));
check('actionbar: the QUESTS slot is on the surface deck (right of EXPLORE)', (() => { const iExp = sSlots.findIndex((s) => s.id === 'explore'); return iExp >= 0 && sSlots[iExp + 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);