/** * 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 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++; }; // ---------------------------------------------------------------------- // The pure modules (no Phaser — Node-safe) // ---------------------------------------------------------------------- const { config } = await import('../js/config/Config.js'); config.init({ research, exploration, mining, builds, actionbar }); const { categories, loadBuilds, startingPairs, missingRequirements, isAvailable, rowState, costLines, canAfford, isShipScoped, } = 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))) || (c === 'mining' && (over.miningResearch ?? []).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 + Mining tabs', ['planet', 'cargo', 'mining'].every((id) => categories().some((c) => c.id === id))); const planet = loadBuilds('planet'); const cargo = loadBuilds('cargo'); const miningCat = loadBuilds('mining'); check('model: loadBuilds("planet") holds the three tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2'] && !!planet.builds['tether-l3']); 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: loadBuilds("mining") holds the three ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-storage-improved']); check('model: loadBuilds("mining") keeps registry entry (label/accent)', miningCat.label === 'Mining' && typeof miningCat.accent === 'string'); 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); // the tether-l3 build — same two gates, one level up (L2 research + a // level-2 tether on the planet) const tl3 = builds.builds['tether-l3']; check('model: tether-l3 is LOCKED before the research', isAvailable(tl3, ctx()) === false); check('model: tether-l3 is READY once researched (planet holds L2)', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 2 })) === true); check('model: tether-l3 stays LOCKED without a level-2 tether on the planet', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 1 })) === false); check('model: a level-3 tether satisfies the level-2 gate', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 3 })) === true); // ---------------------------------------------------------------------- // 3b. The Mining builds — ship upgrades (research gate only, no planet // gate; the effect is the ship's arm rate / hold capacity) // ---------------------------------------------------------------------- const armImproved = builds.builds['mining-arm-improved']; const armAdvanced = builds.builds['mining-arm-advanced']; const storageImproved = builds.builds['mining-storage-improved']; check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(storageImproved)); check('model: isShipScoped is false for planet builds', isShipScoped(tl2) === false); check('model: the improved arm effect is 1.5 minerals/s', armImproved.effects?.mining?.rate === 1.5); check('model: the advanced arm effect is 2.0 minerals/s', armAdvanced.effects?.mining?.rate === 2.0); check('model: the improved storage effect is a 350 hold', storageImproved.effects?.mining?.capacity === 350); check('model: improved arm is LOCKED before the research', isAvailable(armImproved, ctx()) === false); check('model: improved arm is READY once researched (NO planet gate — the effect is the ship)', isAvailable(armImproved, ctx({ miningResearch: ['improved_arm'] })) === true); check('model: advanced arm is LOCKED before the research', isAvailable(armAdvanced, ctx()) === false); check('model: advanced arm is READY once researched', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'] })) === true); check('model: improved storage is LOCKED before the research', isAvailable(storageImproved, ctx()) === false); check('model: improved storage is READY once researched', isAvailable(storageImproved, ctx({ miningResearch: ['improved_storage'] })) === true); check('model: a mining build installed on ANY planet reads built here (ctx.isBuilt = isBuiltAnywhere)', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'], isBuilt: () => true })) === false); check('model: each mining build is gated by its OWN research (improved arm research does not unlock the advanced arm)', isAvailable(armAdvanced, ctx({ miningResearch: ['improved_arm'] })) === false); // SHIP-SCOPED build records: built on one planet ⇒ built on the ship, // wherever it lands (BuildState.isBuiltAnywhere — the console + the // beginBuild gate both read it for targets:["ship"] builds). const shipS = new BuildState(); shipS.markBuilt('Kepler-186f', 'mining-arm-improved'); check('state: isBuiltAnywhere sees a ship-scoped build installed on ANY planet', shipS.isBuiltAnywhere('mining-arm-improved') === true); check('state: isBuiltAnywhere is false for an uninstalled build', shipS.isBuiltAnywhere('mining-arm-advanced') === false); check('state: the per-planet isBuilt is unchanged (the record still keys the planet)', shipS.isBuilt('Kepler-186f', 'mining-arm-improved') === true && shipS.isBuilt('Home World', 'mining-arm-improved') === false); check('model: rowState reads built for a mining build (ship-scoped ctx)', rowState(armImproved, ctx({ miningResearch: ['improved_arm'], isBuilt: () => true }), null, 'mining-arm-improved') === 'built'); check('model: rowState reads available for a researched mining build', rowState(armImproved, ctx({ miningResearch: ['improved_arm'] }), null, 'mining-arm-improved') === 'available'); check('model: rowState reads locked before the research', rowState(armImproved, ctx(), null, 'mining-arm-improved') === 'locked'); // the costs (200 / 300 / 200 minerals) + the build time (20 s each) check('model: improved arm costs 200 minerals, 20 s', costLines(armImproved)[0] !== undefined && costLines(armImproved).every((l) => l.res === 'minerals' && l.amount === 200) && armImproved.duration === 20); check('model: advanced arm costs 300 minerals, 20 s', costLines(armAdvanced).every((l) => l.res === 'minerals' && l.amount === 300) && armAdvanced.duration === 20); check('model: improved storage costs 200 minerals, 20 s', costLines(storageImproved).every((l) => l.res === 'minerals' && l.amount === 200) && storageImproved.duration === 20); check('model: mining builds are unaffordable below their cost', canAfford(armImproved, 199) === false && canAfford(armAdvanced, 299) === false && canAfford(storageImproved, 199) === false); check('model: mining builds are affordable at their cost', canAfford(armImproved, 200) === true && canAfford(armAdvanced, 300) === true && canAfford(storageImproved, 200) === true); // ---------------------------------------------------------------------- // 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); const lines3 = costLines(builds.builds['tether-l3']); check('model: costLines(tether-l3) = 300 minerals', lines3.length === 1 && lines3[0].res === 'minerals' && lines3[0].amount === 300); check('model: tether-l3 is unaffordable below 300 minerals', canAfford(builds.builds['tether-l3'], 299) === false && canAfford(builds.builds['tether-l3'], 300) === 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);