475 lines
38 KiB
JavaScript
475 lines
38 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 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);
|