orbit/dev/system-category.test.mjs

257 lines
14 KiB
JavaScript
Raw Permalink 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.

/**
* SYSTEM research category test (dev tool, run with Node — no browser):
*
* node dev/system-category.test.mjs
*
* Pins the contract behind the per-solar-system tech tree (the console's
* SYSTEM tab — categories flagged `dynamic` in research.json, built at
* runtime by js/research/SystemCategory.js; copy + duration in
* data/gates.json → activation):
* - config: the activation section (45 s research, the two tech's
* label/description templates with their `{system}` placeholder);
* - buildSystemTree: the tree contract (ids embed the system id, the
* map tech granted on entry, the jumpgate tech gated by the chart),
* checked through the REAL code path (ResearchModel issues/layout/
* isAvailable — with the `available`/`lockNote` hooks) and
* ResearchState (one project at a time, save/restore);
* - the chart gate: navPointIds = the scene's discoverable set minus
* the asteroid clusters; isNavComplete is the every-NAV-point rule;
* - activation: activationKeys = the system's gates + the linked
* systems' RETURN gates (pure, from the jump network alone);
* applyActivation flips the content records idempotently.
*/
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'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// ----------------------------------------------------------------------
// Config — the activation section (data/gates.json)
// ----------------------------------------------------------------------
const gates = read('gates.json');
const research = read('research.json');
check('gates: activation section present', !!gates.activation && typeof gates.activation === 'object');
check('gates: researchDuration is a positive number (seconds — research.timeUnit)',
typeof gates.activation?.researchDuration === 'number' && gates.activation.researchDuration > 0);
check('gates: the 45 s brief duration', gates.activation?.researchDuration === 45);
check('gates: tetherLevel ≥ 1 (an activated gate anchors a tether — ACTIVITY)',
Number.isInteger(gates.activation?.tetherLevel) && gates.activation.tetherLevel >= 1);
check('gates: mapTech label template names the system',
typeof gates.activation?.mapTech?.label === 'string' && gates.activation.mapTech.label.includes('{system}'));
check('gates: mapTech description is the brief copy',
gates.activation?.mapTech?.description ===
'Added the Solar System of {system} to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates.');
check('gates: gatesTech label template names the system',
typeof gates.activation?.gatesTech?.label === 'string' && gates.activation.gatesTech.label.includes('{system}'));
check('gates: gatesTech description is the brief copy',
gates.activation?.gatesTech?.description ===
"With system NAV data complete, we have enough information to plot courses through this system's jumpgates.");
check('research: the SYSTEM category is registered + dynamic',
(() => { const c = research.categories?.find((c) => c.id === 'system'); return !!c && c.dynamic === true && typeof c.label === 'string'; })());
// ----------------------------------------------------------------------
// The REAL code path
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, gates });
const {
SYSTEM_CATEGORY, MAP_NODE, GATES_NODE, systemNodeId, systemIdOfGatesNode,
buildSystemTree, navPoints, navPointIds, navChart, isNavComplete, activationKeys, applyActivation,
} = await import('../js/research/SystemCategory.js');
const { issues, layoutTree, isAvailable, missingRequires, unlockIssues } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
const SYS = 'S000042';
const NAME = 'Kepler Reach';
// A live chart check the tests can flip (the scene wires the same seam
// to its Discovery state).
let chartComplete = false;
const tree = buildSystemTree({
systemId: SYS,
systemName: NAME,
accent: research.categories.find((c) => c.id === SYSTEM_CATEGORY)?.accent,
isComplete: () => chartComplete,
});
const mapId = systemNodeId(SYS, MAP_NODE);
const gatesId = systemNodeId(SYS, GATES_NODE);
// -- tree contract ------------------------------------------------------
check('tree: id is the shared SYSTEM category', tree.id === SYSTEM_CATEGORY && SYSTEM_CATEGORY === 'system');
check('tree: node ids embed the system id (unique per system)', mapId === `${SYS}_map` && gatesId === `${SYS}_gates`);
check('tree: order + starting (the map tech first, granted on entry)',
JSON.stringify(tree.order) === JSON.stringify([mapId, gatesId]) && JSON.stringify(tree.starting) === JSON.stringify([mapId]));
const map = tree.nodes[mapId];
const gNode = tree.nodes[gatesId];
check('tree: map tech — the brief label + copy',
map.label === 'Kepler Reach Map' &&
map.description === 'Added the Solar System of Kepler Reach to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates.');
check('tree: map tech — duration 0 (granted, never researched), no requires',
map.duration === 0 && JSON.stringify(map.requires) === JSON.stringify([]));
check('tree: map tech — icon from the procedural glyph roster', map.icon === 'map');
check('tree: gates tech — the brief label + copy',
gNode.label === 'Unlock Kepler Reach Jumpgates' &&
gNode.description === "With system NAV data complete, we have enough information to plot courses through this system's jumpgates.");
check('tree: gates tech — 45 s, requires the map, the activation effect',
gNode.duration === 45 && JSON.stringify(gNode.requires) === JSON.stringify([mapId]) && gNode.effects?.activateGates === true);
check('tree: map tech declares the gates tech in its unlocks', JSON.stringify(map.unlocks?.research ?? []) === JSON.stringify([gatesId]));
check('tree: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).length === 0);
check('tree: unlockIssues(tree) is clean (mirror + build wiring agree)', unlockIssues(tree).length === 0);
const layout = layoutTree(tree);
check('tree: layout — 2 rows, map root → gates leaf', layout.rows === 2 && layout.level[mapId] === 0 && layout.level[gatesId] === 1);
// -- the availability hooks (the chart gate) -----------------------------
const state = new ResearchState();
state.unlock(SYSTEM_CATEGORY, mapId); // entering the system grants the map
check('hooks: the gates tech waits on the chart (map unlocked, chart incomplete)',
!isAvailable(tree, state, gatesId));
check('hooks: missingRequires is EMPTY (the gate is the chart, not a require)',
JSON.stringify(missingRequires(tree, state, gatesId)) === JSON.stringify([]));
check('hooks: lockNote explains the locked gates tech',
typeof tree.lockNote(state, gatesId) === 'string' && tree.lockNote(state, gatesId).includes('KEPLER REACH'));
check('hooks: lockNote is quiet on the map tech + when the chart is complete',
tree.lockNote(state, mapId) === null && (chartComplete = true) === true && tree.lockNote(state, gatesId) === null);
chartComplete = true;
check('hooks: chart complete → the gates tech is researchable', isAvailable(tree, state, gatesId));
// -- the full run: one project at a time, save/restore -------------------
state.start(SYSTEM_CATEGORY, gatesId, 45_000, 0);
check('run: the in-flight project is the gates tech', state.getActive()?.id === gatesId);
check('run: a second project is refused (one at a time)', state.start(SYSTEM_CATEGORY, gatesId, 1000, 0) === false);
const saved = state.toJSON(20_000); // saved 20 s into the 45 s project
const restored = new ResearchState();
for (const k of saved.unlocked) restored.unlock(...k.split('::'));
restored.restoreActive(saved.active, 900_000); // reloaded later
check('run: save carries the remaining time (45 s 20 s = 25 s)', saved.active?.remainingMs === 25_000);
check('run: restore keeps the project in flight (25 s left)',
restored.getActive()?.id === gatesId && restored.progress(900_000).fraction < 1 && restored.progress(925_000).fraction >= 1);
// -- the chart gate: NAV points + completion ------------------------------
// A NON-home system (no homeName — its center is empty, the star is
// invisible flavor): NAV points = planets + space stations + gates.
const content = {
name: NAME,
type: 'anchored',
planets: [{ name: 'K-1' }, { name: 'K-2' }],
settlements: [
{ id: `${SYS}-s1`, anchor: { type: 'space' } },
{ id: `${SYS}-s2`, anchor: { type: 'planet' } }, // planet-anchored — NOT a free-space station
],
jumps: [{ id: `${SYS}-j1`, to: 'S000043' }, { id: `${SYS}-j2`, to: 'S000044' }],
asteroids: [{ id: `${SYS}-a1` }], // clusters are objects, not NAV points
};
const POINT_IDS = ['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`];
check('nav: non-home — the NAV points = planets + space stations + gates (no central body)',
JSON.stringify(navPointIds(content)) === JSON.stringify(POINT_IDS));
check('nav: the home system (homeName present) carries the central NAV point first',
JSON.stringify(navPointIds({ homeName: 'Terra', ...content })) ===
JSON.stringify(['home', ...POINT_IDS]));
const makeDiscovery = (found) => {
const set = new Set(found);
return { isDiscovered: (_sys, id) => set.has(id) };
};
check('nav: incomplete while any NAV point is undiscovered',
isNavComplete(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false);
check('nav: complete once EVERY NAV point is discovered',
isNavComplete(makeDiscovery(POINT_IDS), SYS, content) === true);
check('nav: a missing discovery state never counts as complete', isNavComplete(null, SYS, content) === false);
// navPoints / navChart — the diagnostic's pure core (orbitNav builds on this)
check('navPoints: each NAV point carries its kind (planet/station/gate)',
JSON.stringify(navPoints(content)) === JSON.stringify([
{ id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' },
{ id: `${SYS}-s1`, kind: 'station' },
{ id: `${SYS}-j1`, kind: 'gate' }, { id: `${SYS}-j2`, kind: 'gate' },
]));
check('navPoints: the home system leads with the central NAV point',
JSON.stringify(navPoints({ homeName: 'Terra', ...content })[0]) === JSON.stringify({ id: 'home', kind: 'home' }));
const chartMissing = navChart(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
check('navChart: reports the discovered/missing split (4 of 5, j2 still out)',
chartMissing.discovered === 4 && chartMissing.total === 5 && chartMissing.complete === false
&& JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`])
&& chartMissing.points.find((p) => p.id === `${SYS}-j2`)?.discovered === false);
const chartDone = navChart(makeDiscovery(POINT_IDS), SYS, content);
check('navChart: complete once every NAV point is discovered (5 of 5)',
chartDone.discovered === 5 && chartDone.total === 5 && chartDone.complete === true && chartDone.missing.length === 0);
const chartNoState = navChart(null, SYS, content);
check('navChart: a missing discovery state shows nothing discovered (0 of 5)',
chartNoState.discovered === 0 && chartNoState.total === 5 && chartNoState.complete === false);
// node id <-> system id — the "researched but the gates stay dark" regression.
// A node OBJECT has no `id` field (its id is the key in the tree's `nodes`
// map), so the scene must recover the system id from the id it's PASSED, not
// from `node.id` (which is undefined). systemIdOfGatesNode is that pure core.
check('systemIdOfGatesNode: recovers the system id from a gates node id',
systemIdOfGatesNode(`${SYS}_gates`) === SYS);
check('systemIdOfGatesNode: nullish id -> empty string (the scene guards on it)',
systemIdOfGatesNode(undefined) === '' && systemIdOfGatesNode(null) === '' && systemIdOfGatesNode('') === '');
check('systemIdOfGatesNode: a NON-gates node id is left as-is (only the _gates suffix is stripped)',
systemIdOfGatesNode(`${SYS}_map`) === `${SYS}_map`);
// The round trip the scene relies on: build the id, then recover the id back.
check('round trip: systemNodeId() then systemIdOfGatesNode() yields the system id',
systemIdOfGatesNode(systemNodeId(SYS, GATES_NODE)) === SYS
&& systemIdOfGatesNode(systemNodeId('S000137', GATES_NODE)) === 'S000137');
// -- activation: the system's gates + the linked systems' RETURN gates ----
const galaxy = {
jumpNetwork: {
gates: new Map([
[SYS, ['S000043', 'S000044']],
['S000043', [SYS]], // the gate in 43 jumps BACK to the system
['S000044', ['S000045']], // the gate in 44 does NOT jump back (one-way shortcut)
['S000045', ['S000044']],
]),
},
};
check('activation: the keys = the system gates + the return gates that exist',
JSON.stringify(activationKeys(galaxy, SYS)) === JSON.stringify([`${SYS}>S000043`, `S000043>${SYS}`, `${SYS}>S000044`]));
check('activation: completing the linked systems gates reaches back (the return gate)',
JSON.stringify(activationKeys(galaxy, 'S000043')) === JSON.stringify([`S000043>${SYS}`, `${SYS}>S000043`]));
check('activation: no galaxy / no gates → no keys (defensive)',
JSON.stringify(activationKeys(null, SYS)) === JSON.stringify([]) &&
JSON.stringify(activationKeys({ jumpNetwork: {} }, SYS)) === JSON.stringify([]));
// applyActivation flips the right records, idempotently
const flipContent = (active = false) => ({
jumps: [
{ id: `${SYS}-j1`, to: 'S000043', active },
{ id: `${SYS}-j2`, to: 'S000044', active },
{ id: `${SYS}-j3`, to: 'S000099', active: true }, // already on
],
});
{
const c = flipContent();
const activated = new Set([`${SYS}>S000043`]);
const n1 = applyActivation(c, SYS, activated);
const n2 = applyActivation(c, SYS, activated); // again — nothing new
check('activation: flips exactly the activated gates (not the others, not the already-on)',
n1 === 1 && n2 === 0 && c.jumps[0].active === true && c.jumps[1].active === false && c.jumps[2].active === true);
check('activation: other systems stay untouched (a key of a different system)',
applyActivation(flipContent(), 'S000099', new Set([`${SYS}>S000043`])) === 0);
}
// ----------------------------------------------------------------------
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);