257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
/**
|
||
* 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);
|