/** * Build console data-layer test (dev tool, run with Node — no browser): * * node dev/builds.test.mjs * * Pins the contract behind the build console (the deck's BUILD button on a * planet surface → js/ui/BuildWindow.js): * - the BuildModel rules (data/builds.json): categories, the per-category * build list, starting installs, the research + planet gates, the cost * lines and affordability; * - the BuildState machine: the per-planet built records, the single * in-progress build (one at a time), progress 0→1 over the duration, * tick() completion, and the save/restore round-trip (the in-flight * build keeps its remaining time across a save/load). * * The scene-side enforcement (GameScene.beginBuild / completeBuild) is the * authoritative pass — the model/state here are the rules it enforces. */ 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 builds = read('builds.json'); const actionbar = read('actionbar.json'); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // ---------------------------------------------------------------------- // The pure modules (no Phaser — Node-safe) // ---------------------------------------------------------------------- const { config } = await import('../js/config/Config.js'); config.init({ research, exploration, builds, actionbar }); const { categories, loadBuilds, startingPairs, missingRequirements, isAvailable, rowState, costLines, canAfford, } = await import('../js/build/BuildModel.js'); const { BuildState } = await import('../js/build/BuildState.js'); // A hand-composed world view (the same ctx the window composes from seams): // fresh run — nothing researched beyond the starting set, home holds a // level-1 tether, nothing built anywhere. const ctx = (over = {}) => ({ isResearchUnlocked: (c, n) => c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n)), tetherLevel: () => over.tetherLevel ?? 1, isBuilt: typeof over.isBuilt === 'function' ? over.isBuilt : () => false, }); // ---------------------------------------------------------------------- // 1. The category registry + per-category build lists // ---------------------------------------------------------------------- check('model: categories() is the console tab registry', categories().every((c) => c.id && c.label)); check('model: the console has the Planet + Cargo tabs', ['planet', 'cargo'].every((id) => categories().some((c) => c.id === id))); const planet = loadBuilds('planet'); const cargo = loadBuilds('cargo'); check('model: loadBuilds("planet") holds both tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2']); check('model: loadBuilds("planet") keeps registry entry (label/accent)', planet.label === 'Planet' && typeof planet.accent === 'string'); check('model: loadBuilds("cargo") is empty today (no cargo modules yet)', Object.keys(cargo.builds).length === 0); check('model: an unknown category falls back to the default', loadBuilds('nope').id === builds.defaultCategory); // ---------------------------------------------------------------------- // 2. Starting installs — the home world begins with Tether - Level 1 // ---------------------------------------------------------------------- const pairs = startingPairs(); check('model: startingPairs() seeds home with tether-l1', pairs.some(([p, b]) => p === 'home' && b === 'tether-l1')); const fresh = new BuildState(); for (const [p, b] of pairs) { const name = p === 'home' ? 'Home World' : p; // the scene's 'home' → name resolution fresh.markBuilt(name, b); } check('state: a fresh run has tether-l1 installed on the home world', fresh.isBuilt('Home World', 'tether-l1')); check('state: a fresh run has nothing built elsewhere', fresh.isBuilt('Other', 'tether-l1') === false && fresh.isBuilt('Home World', 'tether-l2') === false); // A save captured before the starting seed existed (or by an older // iteration) carries a `built` set without the home world's L1. The // scene re-asserts the `starting` installs after the restore // (GameScene._seedStartingBuilds — they are a RULE, not save data), so // a resumed run still shows Tether - Level 1 installed on home. const stale = new BuildState().fromJSON({ built: {}, active: null }); check('state: a stale save alone does NOT carry the home install', stale.isBuilt('Home World', 'tether-l1') === false); for (const [p, b] of pairs) if (p === 'home') stale.markBuilt('Home World', b); // the re-seed check('state: re-asserting the starting pair restores the home install', stale.isBuilt('Home World', 'tether-l1')); // ---------------------------------------------------------------------- // 3. Availability — the research + planet gates (tether-l2) // ---------------------------------------------------------------------- const tl2 = builds.builds['tether-l2']; check('model: tether-l2 is LOCKED before the research', isAvailable(tl2, ctx()) === false); check('model: missingRequirements names the missing research first', missingRequirements(tl2, ctx())[0]?.startsWith('RESEARCH:')); check('model: tether-l2 is READY once researched (planet holds L1)', isAvailable(tl2, ctx({ research: ['tether_l2'] })) === true); check('model: no missing requirements once researched', missingRequirements(tl2, ctx({ research: ['tether_l2'] })).length === 0); check('model: tether-l2 stays LOCKED without a level-1 tether on the planet', isAvailable(tl2, ctx({ research: ['tether_l2'], tetherLevel: 0 })) === false); check('model: the missing-requirements line names the tether gate', missingRequirements(tl2, ctx({ research: ['tether_l2'], tetherLevel: 0 })).some((s) => s.includes('TETHER'))); // a planet holding a level-2 tether satisfies the "≥ L1" gate check('model: a stronger existing tether satisfies the gate', isAvailable(tl2, ctx({ research: ['tether_l2'], tetherLevel: 3 })) === true); // already built on the planet → never offered again (one-off) check('model: an installed build is not available again', isAvailable(tl2, ctx({ research: ['tether_l2'], isBuilt: () => true })) === false); // ---------------------------------------------------------------------- // 4. Row states — the list's paint source // ---------------------------------------------------------------------- check('model: rowState reads built', rowState(tl2, ctx({ isBuilt: () => true }), null, 'tether-l2') === 'built'); const activeSpec = { planet: 'Home World', build: 'tether-l2', startedAt: 0, durationMs: 20_000 }; check('model: rowState reads active (the build on THIS planet)', rowState(tl2, ctx({ research: ['tether_l2'] }), activeSpec, 'tether-l2') === 'active'); check('model: rowState reads available', rowState(tl2, ctx({ research: ['tether_l2'] }), null, 'tether-l2') === 'available'); check('model: rowState reads locked', rowState(tl2, ctx(), null, 'tether-l2') === 'locked'); // ---------------------------------------------------------------------- // 5. Cost — the highlighted cost line + affordability // ---------------------------------------------------------------------- const lines = costLines(tl2); check('model: costLines(tether-l2) = 200 minerals', lines.length === 1 && lines[0].res === 'minerals' && lines[0].amount === 200); check('model: canAfford is false below the cost', canAfford(tl2, 199) === false); check('model: canAfford is true at/above the cost', canAfford(tl2, 200) === true && canAfford(tl2, 5000) === true); check('model: a free build has no cost lines', costLines(builds.builds['tether-l1']).length === 0); check('model: a free build is always affordable', canAfford(builds.builds['tether-l1'], 0) === true); // ---------------------------------------------------------------------- // 6. BuildState — the single in-progress build // ---------------------------------------------------------------------- const s = new BuildState(); check('state: start() claims the slot', s.start('Home World', 'tether-l2', 20_000, 1000) === true); check('state: a second start() is refused while one runs', s.start('Other', 'tether-l2', 20_000, 1000) === false); check('state: start() refuses an already-installed build', (() => { const q = new BuildState(); q.markBuilt('Home World', 'tether-l1'); return q.start('Home World', 'tether-l1', 1000, 0) === false; })()); check('state: start() refuses an invalid duration', (() => { const q = new BuildState(); return q.start('Home World', 'tether-l2', 0, 0) === false; })()); const p0 = s.progress(1000); const p1 = s.progress(110_000); check('state: progress is 0 at start, 1 at the deadline', p0.fraction === 0 && p1.fraction === 1); check('state: progress carries the remaining time', p0.remainingMs === 20_000 && p1.remainingMs === 0); check('state: tick() reports nothing before the deadline', s.tick(20_999).length === 0 && s.getActive() !== null); const done = s.tick(21_000); check('state: tick() reports the completion at the deadline', done.length === 1 && done[0].planet === 'Home World' && done[0].build === 'tether-l2'); check('state: the completion marks it built on the planet', s.isBuilt('Home World', 'tether-l2') === true); check('state: the build slot is free after completion', s.getActive() === null); check('state: tick() is idempotent after the deadline', s.tick(999_999).length === 0); // ---------------------------------------------------------------------- // 7. Save/restore round-trip — the in-flight build keeps its remaining time // ---------------------------------------------------------------------- const r = new BuildState(); r.markBuilt('Home World', 'tether-l1'); r.start('Home World', 'tether-l2', 20_000, 1_000); // started at loop-time 1000 ms const saved = r.toJSON(11_000); // saved 10 s into a 20 s build check('save: toJSON carries the built records', JSON.stringify(saved.built['Home World']) === JSON.stringify(['tether-l1'])); check('save: toJSON carries the in-flight build + remaining time (10 s)', saved.active?.build === 'tether-l2' && saved.active?.remainingMs === 10_000); const loaded = new BuildState().fromJSON(saved); check('save: fromJSON restores the built records', loaded.isBuilt('Home World', 'tether-l1') === true); check('save: fromJSON leaves the slot free (restoreActive re-claims it)', loaded.getActive() === null); loaded.restoreActive(saved.active, 500_000); // reloaded an hour later check('save: restoreActive keeps the build in flight', loaded.getActive() !== null && loaded.getActive().build === 'tether-l2'); check('save: the restored build finishes 10 s after the load (halfway at load, done at +10 s)', loaded.progress(500_000).fraction < 1 && loaded.progress(510_000).fraction >= 1); // an old save without builds (no field at all) loads as a clean state const oldSave = new BuildState().fromJSON(null); check('save: an old save without a builds field loads clean', oldSave.built.size === 0 && oldSave.getActive() === null); // restoreActive is a no-op for an already-installed build (the effect // already landed — the record wins over a stale in-flight entry) const dup = new BuildState(); dup.markBuilt('Home World', 'tether-l2'); dup.restoreActive({ planet: 'Home World', build: 'tether-l2', durationMs: 20_000, remainingMs: 5000 }, 0); check('save: restoreActive skips a build the planet already holds', dup.getActive() === null && dup.isBuilt('Home World', 'tether-l2') === true); console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);