/** * Research & build data-layer test (dev tool, run with Node — no browser): * * node dev/research-builds.test.mjs * * The player's progression loop has two halves — research (time-based, * one project at a time) and building (credits + minerals) — and the * command deck that will host them. The RULES are not implemented yet; * this test pins the data contract the future code will lean on: * - the three config files are registered in data/manifest.json; * - the rule knobs exist (timeUnit, maxConcurrent = 1, resources); * - projects/builds are typed, empty maps (no content yet); * - the `_template` entries document every required field; * - the deck has exactly six slots in the right order. */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dataDir = join(__dirname, '../data'); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); const manifest = read('manifest.json'); const research = read('research.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++; }; // ---------------------------------------------------------------------- // 1. Manifest registration // ---------------------------------------------------------------------- for (const f of ['research.json', 'builds.json', 'actionbar.json']) { check(`manifest registers ${f}`, manifest.files.includes(f)); } // ---------------------------------------------------------------------- // 2. Research — time-based, one at a time // ---------------------------------------------------------------------- check('research: player researches one thing at a time (maxConcurrent === 1)', research.maxConcurrent === 1); check('research: timeUnit is a named unit', typeof research.timeUnit === 'string' && research.timeUnit.length > 0); check('research: projects is a map', !!research.projects && typeof research.projects === 'object' && !Array.isArray(research.projects)); check('research: no projects yet (empty map)', Object.keys(research.projects ?? {}).filter((k) => !k.startsWith('_')).length === 0); const rt = research._template ?? {}; for (const k of ['label', 'description', 'duration', 'requires', 'unlocks', 'effects', 'theme']) { check(`research._template documents "${k}"`, k in rt); } check('research._template.duration is a positive number', typeof rt.duration === 'number' && rt.duration > 0); check('research._template.requires is an array', Array.isArray(rt.requires)); check('research._template.unlocks names builds[] + research[]', Array.isArray(rt.unlocks?.builds) && Array.isArray(rt.unlocks?.research)); // ---------------------------------------------------------------------- // 3. Builds — credits + minerals // ---------------------------------------------------------------------- check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string'); check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string'); check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds)); check('builds: no builds yet (empty map)', Object.keys(builds.builds ?? {}).filter((k) => !k.startsWith('_')).length === 0); const bt = builds._template ?? {}; for (const k of ['label', 'description', 'category', 'cost', 'requires', 'repeatable', 'effects', 'theme']) { check(`builds._template documents "${k}"`, k in bt); } check('builds._template.category is a known kind', ['ship', 'planet', 'station', 'general'].includes(bt.category)); check('builds._template.cost pays in credits + minerals', typeof bt.cost?.credits === 'number' && 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)); // ---------------------------------------------------------------------- // 4. Command deck — six evenly spaced slots, right order // ---------------------------------------------------------------------- const slots = actionbar.buttons ?? []; check('actionbar: exactly six slots', slots.length === 6); check('actionbar: slot ids in order (Research, Build, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'build', 'ship', null, null, 'menu'])); check('actionbar: labels (Research / Build / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Build', 'Ship', null, null, 'Menu'])); const hex = /^#[0-9a-fA-F]{6}$/; check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null)); check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number'); check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number'); check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200); console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);