orbit/dev/builds.test.mjs

284 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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") holds the three ship-hold bays (8/12/16)', !!cargo.builds['cargo-bay-8'] && !!cargo.builds['cargo-bay-12'] && !!cargo.builds['cargo-bay-16']);
// The cargo bays cost BOTH resources — trade income (credits) + the mining
// hold (minerals) — so the affordability wallet must cover both lines.
check('model: cargo bays cost credits + minerals (8: 500 cr / 200 min, 12: 800 cr / 350 min, 16: 1200 cr / 500 min)',
cargo.builds['cargo-bay-8'].cost.credits === 500 && cargo.builds['cargo-bay-8'].cost.minerals === 200 &&
cargo.builds['cargo-bay-12'].cost.credits === 800 && cargo.builds['cargo-bay-12'].cost.minerals === 350 &&
cargo.builds['cargo-bay-16'].cost.credits === 1200 && cargo.builds['cargo-bay-16'].cost.minerals === 500);
check('model: cargo bays cost BOTH lines (credits AND minerals)', costLines(cargo.builds['cargo-bay-8']).some((l) => l.res === 'credits') && costLines(cargo.builds['cargo-bay-8']).some((l) => l.res === 'minerals'));
check('model: a full wallet affords the 8-bay build (500 cr + 200 min)', canAfford(cargo.builds['cargo-bay-8'], { minerals: 200, credits: 500 }));
check('model: a credits wallet BELOW the cost does not afford it', !canAfford(cargo.builds['cargo-bay-8'], { minerals: 100000, credits: 499 }));
check('model: a minerals wallet BELOW the cost does not afford it', !canAfford(cargo.builds['cargo-bay-8'], { minerals: 199, credits: 100000 }));
check('model: a minerals-only wallet (old economy) does NOT afford a credits+minerals build', !canAfford(cargo.builds['cargo-bay-8'], 100000));
check('model: loadBuilds("mining") holds the five ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-arm-premium'] && !!miningCat.builds['mining-storage-improved'] && !!miningCat.builds['mining-storage-advanced']);
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 armPremium = builds.builds['mining-arm-premium'];
const storageImproved = builds.builds['mining-storage-improved'];
const storageAdvanced = builds.builds['mining-storage-advanced'];
check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(armPremium) && isShipScoped(storageImproved) && isShipScoped(storageAdvanced));
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 premium arm effect is 4.0 minerals/s (4× the base arm)', armPremium.effects?.mining?.rate === 4.0);
check('model: the improved storage effect is a 350 hold', storageImproved.effects?.mining?.capacity === 350);
check('model: the advanced storage effect is a 500 hold', storageAdvanced.effects?.mining?.capacity === 500);
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: premium arm is LOCKED before the research', isAvailable(armPremium, ctx()) === false);
check('model: premium arm is READY once researched', isAvailable(armPremium, ctx({ miningResearch: ['premium_arm'] })) === true);
check('model: premium arm is gated by its OWN research (advanced_arm does not unlock it)', isAvailable(armPremium, ctx({ miningResearch: ['advanced_arm'] })) === false);
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: advanced storage is LOCKED before the research', isAvailable(storageAdvanced, ctx()) === false);
check('model: advanced storage is READY once researched', isAvailable(storageAdvanced, ctx({ miningResearch: ['advanced_storage'] })) === true);
check('model: advanced storage is gated by its OWN research (improved_storage does not unlock it)', isAvailable(storageAdvanced, ctx({ miningResearch: ['improved_storage'] })) === false);
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);
// builtOn — the world a build was installed on (the console's
// "INSTALLED ON …" / "SHIP-WIDE · INSTALLED FROM …" lines name this
// world, never the planet the console happens to sit on)
check('state: builtOn names the world the build was installed on', shipS.builtOn('mining-arm-improved') === 'Kepler-186f');
check('state: builtOn is null for an uninstalled build', shipS.builtOn('mining-arm-advanced') === null);
check('state: builtOn is null for unknown ids', shipS.builtOn(null) === null);
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 / 400 minerals for the arms, 200 / 300 for the holds) + 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: premium arm costs 400 minerals, 20 s', costLines(armPremium).every((l) => l.res === 'minerals' && l.amount === 400) && armPremium.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: advanced storage costs 300 minerals, 20 s', costLines(storageAdvanced).every((l) => l.res === 'minerals' && l.amount === 300) && storageAdvanced.duration === 20);
check('model: mining builds are unaffordable below their cost', canAfford(armImproved, 199) === false && canAfford(armAdvanced, 299) === false && canAfford(armPremium, 399) === false && canAfford(storageImproved, 199) === false && canAfford(storageAdvanced, 299) === false);
check('model: mining builds are affordable at their cost', canAfford(armImproved, 200) === true && canAfford(armAdvanced, 300) === true && canAfford(armPremium, 400) === true && canAfford(storageImproved, 200) === true && canAfford(storageAdvanced, 300) === 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);