314 lines
24 KiB
JavaScript
314 lines
24 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 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);
|