/** * 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);