254 lines
18 KiB
JavaScript
254 lines
18 KiB
JavaScript
/**
|
|
* 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);
|