Add per-system research category that unlocks jump gates

- Introduce a dynamic "system" research category built at runtime with two techs: `{System} Map` (granted on entry) and `Unlock {System} Jumpgates` (researchable once every NAV point is discovered).
- Wire gate activation through the research effects seam so completing the jumpgate tech activates the system's gates plus linked return gates, anchors level-1 tethers at each activated gate, and saves/restores via a new `activatedGates` set.
- Extend ResearchModel with optional tree hooks (`available`, `lockNote`) so dynamic categories can gate research on live world state without adding static requires.
- Add map and gate procedural icons, update tests for the new category contract and icon roster, and document the system category in project notes.
This commit is contained in:
Brian Fertig 2026-09-06 15:56:38 -06:00
parent d9fc9df829
commit a3a229a72d
14 changed files with 797 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -14,5 +14,18 @@
"anchorTetherLevel": 1, "anchorTetherLevel": 1,
"barrenDistance": 8192, "barrenDistance": 8192,
"typeLabel": "Jump Gate", "typeLabel": "Jump Gate",
"theme": { "color": "#5fd4ff" } "theme": { "color": "#5fd4ff" },
"activation": {
"_comment": "ACTIVATION (the SYSTEM research category — js/research/SystemCategory.js builds the per-system tree from this): '{system} Map' is granted the moment the player is in the system (duration 0 — 'starting'), and 'Unlock {system} Jumpgates' (researchDuration, in research.timeUnit seconds) becomes researchable once EVERY NAV point of the system is discovered (the central body, all planets, all space stations, all jump gates — the scene's discoverable set minus the asteroid clusters; data/game.json → discovery.distance). Completing it activates the system's gates AND the return gates in the systems they connect to — and per the ACTIVITY rule above, each activated gate anchors a level-`tetherLevel` tether at its own position (a TETHER ANCHOR in its own right; that tether saves/restores with the run's tether list). The label/description copy is templated: `{system}` is replaced with the system name.",
"researchDuration": 45,
"tetherLevel": 1,
"mapTech": {
"label": "{system} Map",
"description": "Added the Solar System of {system} to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates."
},
"gatesTech": {
"label": "Unlock {system} Jumpgates",
"description": "With system NAV data complete, we have enough information to plot courses through this system's jumpgates."
}
}
} }

View File

@ -5,8 +5,10 @@
"maxConcurrent": 1, "maxConcurrent": 1,
"defaultCategory": "exploration", "defaultCategory": "exploration",
"categories": [ "categories": [
{ "id": "exploration", "label": "Exploration", "accent": "#00e5ff" } { "id": "exploration", "label": "Exploration", "accent": "#00e5ff" },
{ "id": "system", "label": "System", "accent": "#5fd4ff", "dynamic": true }
], ],
"_dynamic": "A category flagged `dynamic: true` has NO data/research/<id>.json — its tree is built at runtime per situation (js/research/SystemCategory.js: one tech pair per solar system, named after the system the player is in — its Map tech is granted on entry, its jumpgate tech researchable once every NAV point — planets, space stations, jump gates — is discovered, and activation flips the gates on, here and in the linked systems' return gates). Static categories keep the one-file-per-category rule; the tests exempt dynamic ones from the tree-file check.",
"video": { "video": {
"_comment": "The console's left-panel feed: loops muted while the window is open. aspect is [w, h] — the file is 544×800 (a 2:3 portrait).", "_comment": "The console's left-panel feed: loops muted while the window is open. aspect is [w, h] — the file is 544×800 (a 2:3 portrait).",
"file": "assets/videos/research-computer.mp4", "file": "assets/videos/research-computer.mp4",

View File

@ -39,6 +39,13 @@ class GameObject {
add(o) { this.children.push(o); return this; } add(o) { this.children.push(o); return this; }
remove(o) { this.children = this.children.filter((c) => c !== o); return this; } remove(o) { this.children = this.children.filter((c) => c !== o); return this; }
removeChildren() { this.children.length = 0; return this; } removeChildren() { this.children.length = 0; return this; }
removeAll(destroy = false) {
// Phaser v4 Container API (JumpGate.destroy relies on it): detach all
// children, destroying them when asked.
for (const c of this.children) if (destroy) c.destroy?.();
this.children.length = 0;
return this;
}
setOrigin() { return this; } setOrigin() { return this; }
setDepth() { return this; } setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; } setAlpha(a) { this.alpha = a; return this; }

View File

@ -54,9 +54,15 @@ check('research: categories is a non-empty array', Array.isArray(research.catego
check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? ''))); check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? '')));
check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory)); check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory));
// every category in the registry has its own tree file in the manifest // every STATIC category in the registry has its own tree file in the
const missing = research.categories.filter((c) => !manifest.files.includes(`research/${c.id}.json`)); // manifest. A category flagged `dynamic: true` (currently: `system`)
check('research: every registered category has a tree file', missing.length === 0); // has NO file — its tree is built at runtime (js/research/SystemCategory.js,
// one tech pair per solar system); dev/system-category.test.mjs pins that
// side of the contract.
const missing = research.categories.filter((c) => !c.dynamic && !manifest.files.includes(`research/${c.id}.json`));
check('research: every static registered category has a tree file', missing.length === 0);
check('research: the SYSTEM category is registered (per-solar-system techs)', research.categories.some((c) => c.id === 'system'));
check('research: the SYSTEM category is flagged dynamic (built at runtime)', (() => { const c = research.categories.find((c) => c.id === 'system'); return c?.dynamic === true; })());
// the video feed (a 2:3 portrait loop) // the video feed (a 2:3 portrait loop)
check('research: video file configured', typeof research.video?.file === 'string' && research.video.file.length > 0); check('research: video file configured', typeof research.video?.file === 'string' && research.video.file.length > 0);
@ -72,7 +78,7 @@ check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Obje
check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id])); check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id]));
check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1')); check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1'));
const ICON_NAMES = ['tether', 'anchor', 'signal', 'diamond']; // mirrors js/research/ResearchIcons.js const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
let nodeFieldsOk = true; let nodeFieldsOk = true;
let requiresOk = true; let requiresOk = true;
let unlocksOk = true; let unlocksOk = true;

View File

@ -0,0 +1,211 @@
/**
* 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,
buildSystemTree, navPointIds, 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 ------------------------------
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
};
check('nav: the NAV points = central body + planets + space stations + gates',
JSON.stringify(navPointIds(content)) === JSON.stringify(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]));
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(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false);
check('nav: complete once EVERY NAV point is discovered',
isNavComplete(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j2`, `${SYS}-j1`]), SYS, content) === true);
check('nav: a missing discovery state never counts as complete', isNavComplete(null, SYS, content) === false);
// -- 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);

View File

@ -279,11 +279,15 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
get their single gate on the star→destination ray at `barrenDistance` get their single gate on the star→destination ray at `barrenDistance`
(8192 px) instead. Every gate record carries `active: false` — gates (8192 px) instead. Every gate record carries `active: false` — gates
are DORMANT until activated (the entity renders dim, field still); are DORMANT until activated (the entity renders dim, field still);
activation is the seam for the tether mechanic: an activated gate activation is the SYSTEM research category's jumpgate tech completing
(the Research section below): the system's gates go live plus the
return gates in the systems they connect to, and each activated gate
anchors a level-1 tether so the player can leave. A gate's `rotation` anchors a level-1 tether so the player can leave. A gate's `rotation`
is the bearing from the gate to its is the bearing from the gate to its
destination star — the art (twin-pylon portal, `js/entities/JumpGate.js` destination star — the art (twin-pylon portal, `js/entities/JumpGate.js`
— procedural, Station.js-style) faces where it jumps. — procedural, Station.js-style) faces where it jumps; the entity's
`activate()` lifts the dormant dimming (the anchor tether comes from
the scene).
- **In the scene** — GameScene builds the gates as solid world objects - **In the scene** — GameScene builds the gates as solid world objects
(the ship keeps `gates.shipClearance` from them, autopilot flies to (the ship keeps `gates.shipClearance` from them, autopilot flies to
their rim), discoverable (compass arrows + toast, in the gate cyan their rim), discoverable (compass arrows + toast, in the gate cyan
@ -373,6 +377,37 @@ only present when the tech is researchable and nothing is in progress.
**Add a category = one file + one line in `research.json → categories` **Add a category = one file + one line in `research.json → categories`
+ one line in `data/manifest.json`.** + one line in `data/manifest.json`.**
**The SYSTEM category (dynamic — per solar system):** one category is
flagged `dynamic: true` in `research.json → categories` — it has **no**
data file; its tree is built at runtime for the system the player is in
(`js/research/SystemCategory.js` → `buildSystemTree`), so its two techs
are NAMED after that system. Node ids embed the system id
(`S000012_map`, `S000012_gates`) so a run can chart many systems in the
one shared category without collisions:
- **`{System} Map`** — `duration: 0`, in `starting`: granted the moment
the player is in the system (`GameScene._onEnterSystem()` unlocks it —
the jump mechanic, when it lands, calls the same seam on every
arrival). Copy: *Added the Solar System of {system} to the onboard
NAV System. Discover all NAV points to unlock the system Jumpgates.*
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
**every NAV point of the system is discovered** (the central body, all
planets, all space stations, all jump gates — the scene's discoverable
set minus the asteroid clusters); 45 s
(`data/gates.json → activation.researchDuration`). Completing it
activates the system's gates **and the return gates in the systems
they connect to** (pure, from the jump network alone —
`activationKeys`), and per the gate ACTIVITY rule each activated gate
anchors a level-1 tether at its own position.
The gates node's chart gate is an **availability HOOK**, not a `requires`
edge (its requires are all met at grant — the gate is live-world state):
the tree object carries optional `available(state, id)` / `lockNote(state, id)`
functions, consulted by `ResearchModel.isAvailable` and the console's
LOCKED readout. Static trees have no hooks. The label/description copy
and the duration live in `data/gates.json → activation` (templated —
`{system}` is replaced with the system name); the tests exempt
`dynamic: true` categories from the one-file-per-category rule.
**The UNLOCKS space (research → everything else):** a tech unlocks more **The UNLOCKS space (research → everything else):** a tech unlocks more
than follow-on tech. Each node's `unlocks` is the declaration side: than follow-on tech. Each node's `unlocks` is the declaration side:
- `unlocks.research` — the readable mirror of the children's `requires` - `unlocks.research` — the readable mirror of the children's `requires`
@ -408,7 +443,17 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
contract: `unlocksOf` (normalize `{builds, research}`), `buildDefs` contract: `unlocksOf` (normalize `{builds, research}`), `buildDefs`
(builds.json → map, `_`-keys excluded), `unlockIssues(tree)` (mirror + (builds.json → map, `_`-keys excluded), `unlockIssues(tree)` (mirror +
build wiring), `buildIssues()` (every build `requires` id resolves). build wiring), `buildIssues()` (every build `requires` id resolves).
Node-tested. `isAvailable` also consults a tree's optional `available(state, id)`
hook (the dynamic SYSTEM category's chart gate). Node-tested.
- `js/research/SystemCategory.js` — PURE (no Phaser): the dynamic
SYSTEM category. `buildSystemTree({systemId, systemName, accent,
isComplete})` (the per-system tree + the `available`/`lockNote`
hooks), `navPointIds(content)` (the chart's NAV points),
`isNavComplete(discovery, systemId, content)` (every NAV point
discovered), `activationKeys(galaxy, systemId)` (the system's gates +
the linked systems' return gates, from the jump network alone),
`applyActivation(content, systemId, keys)` (flip the records
idempotently). Node-tested by `dev/system-category.test.mjs`.
- `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`, - `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`,
`start`, `progress(time)`, `tick(time)` (→ array of completions), `start`, `progress(time)`, `tick(time)` (→ array of completions),
`restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested. `restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested.
@ -423,28 +468,50 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
`_applyResearchEffects`, `_deckResearchBar`. The **effects seam** reads `_applyResearchEffects`, `_deckResearchBar`. The **effects seam** reads
`node.effects`: `{ tether: { level: N } }``TetherField.setLevel(homeId, N)` `node.effects`: `{ tether: { level: N } }``TetherField.setLevel(homeId, N)`
+ toast; `{ capability: "flag" }``scene.researchCapabilities.add(flag)`; + toast; `{ capability: "flag" }``scene.researchCapabilities.add(flag)`;
unknown shapes log and no-op. New effect kinds plug in there without `{ activateGates: true }` (SYSTEM category) → the system's gates + the
touching tree data. linked systems' return gates go ACTIVE (`_activateSystemJumpgates` — the
activation keys join the run's `activatedGates` set, the current
system's gate entities wake, and each activated gate anchors its
level-1 tether); unknown shapes log and no-op. New effect kinds plug in
there without touching tree data.
- The SYSTEM category's world state is the run's **`activatedGates`** set
(activation keys `"<sysId>><destId>"`) — registry-backed like discovery
(`GameScene.create` reads it; `resetRunState` clears it on New Game,
`prepareLoad` restores it), and the anchored gate tethers save with the
run's tether list. On entry the scene flips the current system's gate
records BEFORE the gate entities build (the dormant look is baked at
construction), and anchors each activated gate's tether after the
field exists. `GameScene._onEnterSystem()` grants the map tech on
arrival (new run, load, and — when it lands — every jump landing).
The ResearchWindow takes the live tree via `systemTree` and re-paints
nodes live when a state flips under the open console (the chart
completes, a run starts).
**Save:** `record.research = { unlocked: ["cat::id", …], active: **Save:** `record.research = { unlocked: ["cat::id", …], active:
{category, id, durationMs, remainingMs} | null }`. `remainingMs` is captured {category, id, durationMs, remainingMs} | null }`. `remainingMs` is captured
at save time; `restoreActive(spec, now)` rebuilds `startedAt` from the fresh at save time; `restoreActive(spec, now)` rebuilds `startedAt` from the fresh
`now`. A save from before research exists loads as fresh (no unlocks, no `now`. A save from before research exists loads as fresh (no unlocks, no
active run) — old saves keep working. No auto-save: research state persists active run) — old saves keep working. The SYSTEM category's activation keys
on the next explicit player save (SavePanel), consistent with the rest of ride `record.activatedGates` (an array of `"<sysId>><destId>"`; saves from
the game. before the category load as an empty set — gates stay dormant). No
auto-save: research state persists on the next explicit player save
(SavePanel), consistent with the rest of the game.
**SFX:** begin → `construct`, complete → `discovery` (both existing **SFX:** begin → `construct`, complete → `discovery` (both existing
`data/sfx.json` keys — there are no `research_begin`/`research_complete` `data/sfx.json` keys — there are no `research_begin`/`research_complete`
keys). Open/close → `ui_window`/`ui_close`. keys). Open/close → `ui_window`/`ui_close`.
**Verified:** `dev/research-builds.test.mjs` (manifest registration, **Verified:** `dev/research-builds.test.mjs` (manifest registration,
research.json globals, the exploration tree's raw-JSON contract — DAG, node research.json globals — incl. the dynamic SYSTEM category's registration,
the exploration tree's raw-JSON contract — DAG, node
fields, effects — the real code path: ResearchModel layout/levels/determinism fields, effects — the real code path: ResearchModel layout/levels/determinism
+ the unlocks contract (unlocksOf/unlockIssues/buildIssues) + + the unlocks contract (unlocksOf/unlockIssues/buildIssues) +
ResearchState start/tick/complete/restore round-trip, builds.json (the ResearchState start/tick/complete/restore round-trip, builds.json (the
tether-l2 build, both sides of the gate, the template), actionbar.json). tether-l2 build, both sides of the gate, the template), actionbar.json).
`dev/research-shot.html` + `dev/cdp-shot.mjs` open the window and start a `dev/system-category.test.mjs` (the gates.json activation contract, the
per-system tree through the real code path — hooks, one-at-a-time,
save/restore round-trip — the NAV-point rule, the activation keys +
record flips). `dev/research-shot.html` + `dev/cdp-shot.mjs` open the window and start a
run through CDP for a screenshot. run through CDP for a screenshot.
## Builds — the surface install (cost-based, one at a time) ## Builds — the surface install (cost-based, one at a time)

View File

@ -149,6 +149,20 @@ export class JumpGate extends Phaser.GameObjects.Container {
if (!this.active) this.setAlpha(0.4); if (!this.active) this.setAlpha(0.4);
} }
/**
* Flip to ACTIVE the SYSTEM category's jumpgate research completes
* (js/research/SystemCategory.js; the scene's seam wakes every gate of
* the system plus the linked systems' return gates). The dormant
* dimming lifts and update() starts the field breathing. (Per
* data/gates.json ACTIVITY the anchor tether comes from the scene
* this entity only carries the live look.)
*/
activate() {
if (this.active) return;
this.active = true;
this.setAlpha(1);
}
/** The ring drifts; the field breathes. (GameScene.update drives this.) */ /** The ring drifts; the field breathes. (GameScene.update drives this.) */
update(time) { update(time) {
const t = time / 1000; const t = time / 1000;

View File

@ -6,7 +6,7 @@
*/ */
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
export const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond']; export const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate'];
export function iconKey(name, color = 0x00e5ff) { export function iconKey(name, color = 0x00e5ff) {
const n = ICON_NAMES.includes(name) ? name : 'diamond'; const n = ICON_NAMES.includes(name) ? name : 'diamond';
@ -136,6 +136,49 @@ export function ensureIcon(scene, name, color = 0x00e5ff) {
ctx.stroke(); ctx.stroke();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} else if (n === 'map') {
// The SYSTEM category's chart glyph — a folded map sheet with a plotted
// route between two NAV points (the per-system '{System} Map' tech).
const w = 62,
h = 50,
x = cx - w / 2,
y = cy - h / 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.stroke();
// fold line
ctx.beginPath();
ctx.moveTo(cx, y);
ctx.lineTo(cx, y + h);
ctx.stroke();
// route: two plotted points + the line between them
ctx.beginPath();
ctx.moveTo(x + w * 0.22, y + h * 0.68);
ctx.lineTo(x + w * 0.78, y + h * 0.32);
ctx.stroke();
diamond(x + w * 0.22, y + h * 0.68, 5);
diamond(x + w * 0.78, y + h * 0.32, 5);
} else if (n === 'gate') {
// The SYSTEM category's jumpgate glyph — the ring + its inner aperture
// (+ the four aperture seams), like the world's gate entity minus field.
ctx.globalAlpha = 0.95;
ctx.beginPath();
ctx.arc(cx, cy, 44, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.arc(cx, cy, 19, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 5;
ctx.globalAlpha = 0.6;
for (const a of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 28, cy + Math.sin(a) * 28);
ctx.lineTo(cx + Math.cos(a) * 37, cy + Math.sin(a) * 37);
ctx.stroke();
}
ctx.globalAlpha = 1;
} else { } else {
// diamond — the default glyph // diamond — the default glyph
ctx.beginPath(); ctx.beginPath();

View File

@ -12,6 +12,10 @@
* leaves take sequential slots in DFS order (data order of roots, then * leaves take sequential slots in DFS order (data order of roots, then
* children), each interior node sits at the mean of its children's columns * children), each interior node sits at the mean of its children's columns
* a tidy, stable arrangement for a small tech tree. * a tidy, stable arrangement for a small tech tree.
* Optional tree hooks (dynamic categories, e.g. SystemCategory): a tree
* object may carry `available(state, id)` (extra availability gate,
* consulted by isAvailable) and `lockNote(state, id)` (the window's
* LOCKED reason when the node is gated but its requires are met).
*/ */
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
@ -163,12 +167,19 @@ export function layoutTree(tree) {
* Can this node be researched right now? (known + all parents researched + * Can this node be researched right now? (known + all parents researched +
* not already researched.) `state` is a ResearchState (or anything with * not already researched.) `state` is a ResearchState (or anything with
* isUnlocked(category, id)). * isUnlocked(category, id)).
*
* A tree may also carry an optional `available(state, id)` predicate
* an EXTRA availability gate evaluated after the requires check (the
* SYSTEM category uses it for its chart-completion gate; static trees
* have none). Pure: the predicate is data the caller supplies.
*/ */
export function isAvailable(tree, state, id) { export function isAvailable(tree, state, id) {
const n = tree.nodes[id]; const n = tree.nodes[id];
if (!n) return false; if (!n) return false;
if (state.isUnlocked(tree.id, id)) return false; if (state.isUnlocked(tree.id, id)) return false;
return (n.requires ?? []).every((r) => tree.nodes[r] && state.isUnlocked(tree.id, r)); if ((n.requires ?? []).some((r) => !tree.nodes[r] || !state.isUnlocked(tree.id, r))) return false;
if (typeof tree.available === 'function' && !tree.available(state, id)) return false;
return true;
} }
/** The parents this node is still missing (for the LOCKED readout). */ /** The parents this node is still missing (for the LOCKED readout). */

View File

@ -0,0 +1,213 @@
/**
* SystemCategory the SYSTEM research category: the ONE per-solar-system
* tech tree (research.json categories, flagged `dynamic: true`).
*
* Every other category reads its tree from data/research/<id>.json
* static data, same for every run. The SYSTEM category is different by
* design: its tree is built at runtime from the system the player is
* currently in, so the two techs are NAMED after that system:
*
* "{System} Map" granted on entry (duration 0,
* in `starting` the scene
* unlocks it, ResearchState
* "already owned")
* "Unlock {System} Jumpgates" requires the map + EVERY NAV
* point of the system discovered
* (the availability hook below);
* 45 s (data/gates.json
* activation.researchDuration);
* completing it activates the
* system's gates AND the return
* gates in the systems they
* connect to.
*
* Node ids embed the system id (`S000012_map`, `S000012_gates`) so a run
* can chart many systems in the one `system` category without collisions,
* and ResearchState's "category::id" keys stay unique per system.
*
* The tree carries two optional HOOKS the static trees don't need
* ResearchModel consumes them (pure: the predicates are data the caller
* supplies, evaluated at paint time):
* tree.available(state, id) extra availability gate (the chart gate:
* the jumpgate tech is researchable only while isComplete() holds);
* tree.lockNote(state, id) the detail readout's LOCKED reason when
* the node is gated but its `requires` are all met.
*
* Pure (no Phaser) Node-testable (dev/system-category.test.mjs).
*/
import { config } from '../config/Config.js';
/** The category id (data/research.json → categories[]). */
export const SYSTEM_CATEGORY = 'system';
/** The two node kinds. */
export const MAP_NODE = 'map';
export const GATES_NODE = 'gates';
/** A system's tech node id — unique per system (the category is shared). */
export function systemNodeId(systemId, kind) {
return `${systemId}_${kind}`;
}
const fillTemplate = (template, systemName) =>
String(template ?? '').split('{system}').join(systemName);
/**
* Build the SYSTEM category's tree for one system.
*
* @param {object} o
* @param {string} o.systemId the system record's id (node-id prefix)
* @param {string} o.systemName the system name (labels + copy)
* @param {string} [o.accent] the category accent (registry, research.json)
* @param {() => boolean} [o.isComplete] live chart check: true once
* EVERY NAV point of the system is discovered (the scene wires it to
* its Discovery state; absent = the chart never completes)
* @returns {object} a ResearchModel tree (id/label/accent/nodes/order/
* starting) + the optional `available` / `lockNote` hooks.
*/
export function buildSystemTree(o = {}) {
const systemId = String(o.systemId ?? '');
const systemName = String(o.systemName ?? systemId);
const mapId = systemNodeId(systemId, MAP_NODE);
const gatesId = systemNodeId(systemId, GATES_NODE);
const duration = Math.max(0, Number(config.get('gates.activation.researchDuration', 45)) || 0);
const isComplete = typeof o.isComplete === 'function' ? o.isComplete : () => false;
const chartLockedNote =
'NAV CHART INCOMPLETE — DISCOVER ALL PLANETS, STATIONS AND JUMP GATES OF ' +
systemName.toUpperCase();
const tree = {
id: SYSTEM_CATEGORY,
label: 'System',
accent: typeof o.accent === 'string' && o.accent ? o.accent : '#5fd4ff',
nodes: {
[mapId]: {
label: fillTemplate(config.get('gates.activation.mapTech.label', '{system} Map'), systemName),
description: fillTemplate(
config.get(
'gates.activation.mapTech.description',
'Added the Solar System of {system} to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates.',
),
systemName,
),
icon: 'map',
duration: 0, // granted on entry — never researched
requires: [],
unlocks: { builds: [], research: [gatesId] },
effects: {},
},
[gatesId]: {
label: fillTemplate(config.get('gates.activation.gatesTech.label', 'Unlock {system} Jumpgates'), systemName),
description: fillTemplate(
config.get(
'gates.activation.gatesTech.description',
"With system NAV data complete, we have enough information to plot courses through this system's jumpgates.",
),
systemName,
),
icon: 'gate',
duration,
requires: [mapId],
unlocks: { builds: [], research: [] },
// The scene's effects seam reads this (GameScene._applyResearchEffects):
// activate the system's gates + the linked systems' return gates.
effects: { activateGates: true },
},
},
order: [mapId, gatesId],
starting: [mapId],
};
// The hooks (see the header) — the scene's live chart check drives both.
tree.available = (_state, id) => (id === gatesId ? isComplete() : true);
tree.lockNote = (_state, id) => (id === gatesId && !isComplete() ? chartLockedNote : null);
return tree;
}
/**
* A system's NAV points — the discovery ids the scene's
* discoverableObjects() uses for its discoverable set, minus the rocks
* (asteroid clusters are objects, not NAV points): the central body
* (every system the scene's 'home' id, the player's home world in the
* starting system and the system's central world elsewhere), every
* planet (its name), every free-space station (its settlement id),
* every jump gate (its gate id).
*
* @param {object} content a generated system content (ensureContent)
* @returns {string[]} the discoverable ids to check (order: stable)
*/
export function navPointIds(content) {
const ids = [];
ids.push('home'); // the central body — discoverable in every system
for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') ids.push(p.name);
for (const s of content?.settlements ?? []) {
if (s && s.anchor?.type === 'space' && typeof s.id === 'string') ids.push(s.id);
}
for (const j of content?.jumps ?? []) if (j && typeof j.id === 'string') ids.push(j.id);
return ids;
}
/**
* Is the system fully charted? Every NAV point discovered
* (js/galaxy/Discovery.js `discovery.isDiscovered(systemId, id)`).
* Vacuously true for a content with no NAV points (a degenerate
* one-system galaxy with no gates).
*
* @param {object} discovery the scene's Discovery state
* @param {string} systemId
* @param {object} content
* @returns {boolean}
*/
export function isNavComplete(discovery, systemId, content) {
if (!discovery || typeof discovery.isDiscovered !== 'function') return false;
return navPointIds(content).every((id) => discovery.isDiscovered(systemId, id));
}
/**
* The activation keys one completed "Unlock {System} Jumpgates" grants:
* every gate in `systemId` (keyed "<systemId>><destination>") AND the
* RETURN gates in the systems those destinations connect back to
* the gate in `<destination>` whose destination is `systemId`.
*
* Pure: computed from the jump network alone (no content generation).
*
* @param {object} galaxy the Galaxy (reads galaxy.jumpNetwork.gates:
* Map systemId destination ids)
* @param {string} systemId
* @returns {string[]} activation keys (deduplicated, stable order)
*/
export function activationKeys(galaxy, systemId) {
const gates = galaxy?.jumpNetwork?.gates;
if (!gates || typeof gates.get !== 'function') return [];
const out = [];
const push = (k) => {
if (k && !out.includes(k)) out.push(k);
};
for (const dest of gates.get(systemId) ?? []) {
push(`${systemId}>${dest}`);
if ((gates.get(dest) ?? []).includes(systemId)) push(`${dest}>${systemId}`);
}
return out;
}
/**
* Flip a generated system's gate records to ACTIVE for every activation
* key in `activated` (the "<systemId>><destination>" keys). Idempotent;
* returns how many gates changed.
*
* @param {object} content the system's generated content
* @param {string} systemId content's system id (the cache key)
* @param {Set<string>} activated the run's activation keys
* @returns {number}
*/
export function applyActivation(content, systemId, activated) {
let n = 0;
for (const j of content?.jumps ?? []) {
if (j && typeof j.to === 'string' && !j.active && activated?.has(`${systemId}>${j.to}`)) {
j.active = true;
n++;
}
}
return n;
}

View File

@ -10,7 +10,14 @@
* ship: { x, y, heading, minerals }, * ship: { x, y, heading, minerals },
* discovery: Discovery.toJSON(), * discovery: Discovery.toJSON(),
* reputation: Reputation.toJSON(), * reputation: Reputation.toJSON(),
* tethers: [{ id, x, y, level, label }], * tethers: [{ id, x, y, level, label }], // incl. the activated
* // gates' anchor tethers
* activatedGates: ["<sysId>><destId>"], // the SYSTEM research
* // category's activation
* // keys (registry-backed
* // Set, replayed on load —
* // gates + tethers re-form
* // in GameScene.create)
* research: ResearchState.toJSON() | null, * research: ResearchState.toJSON() | null,
* builds: BuildState.toJSON() | null, * builds: BuildState.toJSON() | null,
* playTimeMs } * playTimeMs }
@ -96,6 +103,12 @@ export function captureState(scene, now) {
builds: scene.buildState builds: scene.buildState
? scene.buildState.toJSON(buildNow) ? scene.buildState.toJSON(buildNow)
: null, : null,
// The SYSTEM research category's activation keys (which jump gates —
// incl. other systems' return gates — the run has activated; the
// per-run world state GameScene keeps in its registry-backed set).
// Pre-system-category saves lack the field; the restore treats the
// absence as an empty set (old saves load).
activatedGates: Array.from(scene.activatedGates ?? []),
playTimeMs: Math.round(scene.playTimeMs ?? 0), playTimeMs: Math.round(scene.playTimeMs ?? 0),
}; };
const err = SaveManager.validateRecord(rec); const err = SaveManager.validateRecord(rec);
@ -130,6 +143,14 @@ export function prepareLoad(registry, record) {
registry.set('seed', seed); registry.set('seed', seed);
registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} })); registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} }));
registry.set('reputation', Reputation.fromJSON(record.reputation)); registry.set('reputation', Reputation.fromJSON(record.reputation));
// The SYSTEM category's activation keys (a save predating the category
// has no field → an empty set). GameScene.create() reads the set, flips
// the current system's gate content before its entities build, and
// anchors the gate tethers (data/gates.json → ACTIVITY).
registry.set(
'activatedGates',
new Set(Array.isArray(record.activatedGates) ? record.activatedGates : []),
);
registry.set(PENDING_RESTORE_KEY, { registry.set(PENDING_RESTORE_KEY, {
ship: record.ship, ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [], tethers: Array.isArray(record.tethers) ? record.tethers : [],
@ -152,12 +173,13 @@ export function consumeRestore(registry) {
/** /**
* "New Game" from the main menu: a fresh run must not inherit the * "New Game" from the main menu: a fresh run must not inherit the
* previous one's discovery state, its standing with the old galaxy, or a * previous one's discovery state, its standing with the old galaxy, its
* half-staged restore. * activated jump gates, or a half-staged restore.
*/ */
export function resetRunState(registry) { export function resetRunState(registry) {
registry.set('discovery', null); registry.set('discovery', null);
registry.set('reputation', null); registry.set('reputation', null);
registry.set('activatedGates', null);
registry.set(PENDING_RESTORE_KEY, null); registry.set(PENDING_RESTORE_KEY, null);
} }

View File

@ -33,6 +33,15 @@ import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js'; import { ResearchWindow } from '../ui/ResearchWindow.js';
import { ResearchState } from '../research/ResearchState.js'; import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js'; import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
import {
SYSTEM_CATEGORY,
MAP_NODE,
systemNodeId,
buildSystemTree,
isNavComplete,
activationKeys,
applyActivation,
} from '../research/SystemCategory.js';
import { BuildWindow } from '../ui/BuildWindow.js'; import { BuildWindow } from '../ui/BuildWindow.js';
import { BuildState } from '../build/BuildState.js'; import { BuildState } from '../build/BuildState.js';
import { startingPairs } from '../build/BuildModel.js'; import { startingPairs } from '../build/BuildModel.js';
@ -209,6 +218,23 @@ export class GameScene extends Phaser.Scene {
this._pendingRestore = consumeRestore(this.registry); this._pendingRestore = consumeRestore(this.registry);
this.systemRecord = this.galaxy.currentSystem(); this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
// JUMP GATE ACTIVATION (the SYSTEM research category —
// js/research/SystemCategory.js): the run's activation keys are
// per-run world state — registry-backed (like discovery: New Game
// clears it, a load restores it; js/save/SaveData.js). A gate the
// run activated — or whose RETURN the run activated from a linked
// system — flips on here, BEFORE the gate entities below read
// `active` (the dormant look is baked at construction); the gate
// tethers (data/gates.json → ACTIVITY) attach after the field exists
// (below) and save with the run's tether list.
this.activatedGates = this.registry.get('activatedGates') ?? null;
if (!this.activatedGates) {
this.activatedGates = new Set();
this.registry.set('activatedGates', this.activatedGates);
}
for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
}
// The home world's name — dealt from the planet name bank by the // The home world's name — dealt from the planet name bank by the
// generator (the starting system is the only one with a home world), so // generator (the starting system is the only one with a home world), so
// it reads as a real place rather than a generic "Terra". Fallback keeps // it reads as a real place rather than a generic "Terra". Fallback keeps
@ -351,6 +377,14 @@ export class GameScene extends Phaser.Scene {
config.get('tether.homeLevel', 1), config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || this.homeWorldName, config.get('tether.homeLabel', '') || this.homeWorldName,
); );
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
// an active gate is a TETHER ANCHOR in its own right — the room to
// move in a barren system). Idempotent: add() replaces by id, and a
// save's tether list restores to the same ids.
const gateTetherLevel = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
for (const j of this.systemContent.jumps ?? []) {
if (j && j.active) this.tetherField.add(`gate:${j.id}`, j.x, j.y, gateTetherLevel, j.name ?? '');
}
this.tetherToastAt = null; this.tetherToastAt = null;
// Discovery: which objects the player has found (within discovery // Discovery: which objects the player has found (within discovery
@ -477,8 +511,22 @@ export class GameScene extends Phaser.Scene {
if (!tree) continue; if (!tree) continue;
for (const id of tree.starting) this.researchState.unlock(cat.id, id); for (const id of tree.starting) this.researchState.unlock(cat.id, id);
} }
// The SYSTEM category's live tree — built for the system the player is
// in (categories flagged `dynamic` in research.json have no data file):
// the '{System} Map' (granted on entry — _onEnterSystem below) and
// 'Unlock {System} Jumpgates' (chart complete → researchable → the
// gates + the linked systems' return gates go live). js/research/
// SystemCategory.js; copy + duration in data/gates.json → activation.
const sysCat = categories().find((c) => c.id === SYSTEM_CATEGORY);
this.systemTree = buildSystemTree({
systemId: this.systemRecord.id,
systemName: this.systemRecord.name,
accent: sysCat?.accent,
isComplete: () => this.isSystemNavComplete(),
});
this.researchWindow = new ResearchWindow(this, { this.researchWindow = new ResearchWindow(this, {
state: this.researchState, state: this.researchState,
systemTree: this.systemTree,
onResearch: (catId, id) => this.beginResearch(catId, id), onResearch: (catId, id) => this.beginResearch(catId, id),
}); });
// deck progress bar — drawn over the RESEARCH button while a project runs // deck progress bar — drawn over the RESEARCH button while a project runs
@ -522,6 +570,13 @@ export class GameScene extends Phaser.Scene {
this._pendingRestore = null; this._pendingRestore = null;
} }
// Entering a system charts it: the SYSTEM category's '{System} Map'
// tech (duration 0) is owned on arrival — a new run gets the starting
// system's, a load re-asserts the saved system's (the restore above
// replaced the unlocked set), and the jump mechanic (when it lands)
// calls _onEnterSystem() on every arrival.
this._onEnterSystem();
// ---- DEEP SCAN (the deck's SCAN button) ------------------------------- // ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull, // The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION // then an omnidirectional wavefront expanding across the TETHER REGION
@ -1729,7 +1784,7 @@ export class GameScene extends Phaser.Scene {
}); });
return; return;
} }
const tree = loadCategory(catId); const tree = this._treeFor(catId);
const node = tree?.nodes?.[id]; const node = tree?.nodes?.[id];
if (!node) return; if (!node) return;
if (!isAvailable(tree, state, id)) { if (!isAvailable(tree, state, id)) {
@ -1762,11 +1817,72 @@ export class GameScene extends Phaser.Scene {
const state = this.researchState; const state = this.researchState;
if (!state) return; if (!state) return;
state.unlock(catId, id); state.unlock(catId, id);
const node = loadCategory(catId)?.nodes?.[id]; const node = this._treeFor(catId)?.nodes?.[id];
if (node?.effects) this._applyResearchEffects(node.effects, node); if (node?.effects) this._applyResearchEffects(node.effects, node);
this._completeResearchFx(catId, id, node); this._completeResearchFx(catId, id, node);
} }
/** The tree for a category: the SYSTEM category's live per-system tree,
* or the static file tree (data/research/<id>.json null if missing). */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
return loadCategory(catId);
}
/** Entering a system begins its NAV chart: the SYSTEM category's
* '{System} Map' tech is granted (duration 0 never researched via the
* console). Idempotent create() calls it for the starting system (and
* again after a load, whose restore replaced the unlocked set); the
* jump mechanic (when it lands) calls it on every arrival. */
_onEnterSystem() {
if (!this.researchState || !this.systemRecord) return;
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
this.researchWindow?.refresh();
}
/** The SYSTEM category's chart gate: every NAV point of the current
* system (central body, planets, space stations, jump gates the
* discoverable set minus the asteroid clusters) discovered. */
isSystemNavComplete() {
return isNavComplete(this.discovery, this.systemRecord?.id, this.systemContent);
}
/**
* 'Unlock {System} Jumpgates' completed (SYSTEM category): the system's
* gates AND the return gates in the systems they jump to go ACTIVE.
*
* The activation keys are per-run world state (the registry-backed set
* New Game clears, a load restores). The current system's gate entities
* wake up here and, per data/gates.json ACTIVITY, each anchored gate
* carries a level-1 tether at its own position (the room to move in a
* barren system, the whole room). The linked systems' return gates flip
* their content record now (or on entry the create() pass); their
* entities + tethers materialise when the player arrives (the jump
* mechanic, later). Idempotent throughout.
*/
_activateSystemJumpgates(sysId) {
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
const keys = activationKeys(this.galaxy, sysId);
for (const k of keys) this.activatedGates.add(k);
// Current system — the gate entities wake + anchor their tethers.
for (const gt of this.systemGates ?? []) {
if (!gt.gate) continue;
if (!this.activatedGates.has(`${sysId}>${gt.gate.to}`)) continue;
gt.activate();
this.tetherField.add(`gate:${gt.gate.id}`, gt.x, gt.y, level, gt.gate.name ?? '');
}
// Linked systems — flip the cached content (ungenerated systems flip
// in the create() pass on entry; both paths are idempotent).
for (const [sid, content] of this.galaxy.contentCache) applyActivation(content, sid, this.activatedGates);
this.consoleToast(`JUMP GATES ONLINE — ${String(this.systemRecord?.name ?? sysId).toUpperCase()}`, {
glyph: '⌁',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
/** The completion ceremony: SFX, toast, the window repaint. */ /** The completion ceremony: SFX, toast, the window repaint. */
_completeResearchFx(catId, id, node) { _completeResearchFx(catId, id, node) {
this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json) this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json)
@ -1778,9 +1894,12 @@ export class GameScene extends Phaser.Scene {
} }
/** /**
* Apply a tech's effects (node.effects, data/research/<cat>.json). * Apply a tech's effects (node.effects, data/research/<cat>.json or
* the SYSTEM category's live tree). Known shapes:
* tether { level: N } the home world's tether field strengthens * tether { level: N } the home world's tether field strengthens
* capability "flag" a scene capability set (future systems read it) * capability "flag" a scene capability set (future systems read it)
* activateGates true the SYSTEM category: the system's jump gates
* + the linked systems' return gates go ACTIVE
* Unknown shapes are logged and skipped data can lead code a step. * Unknown shapes are logged and skipped data can lead code a step.
*/ */
_applyResearchEffects(effects, node) { _applyResearchEffects(effects, node) {
@ -1797,6 +1916,10 @@ export class GameScene extends Phaser.Scene {
} else if (type === 'capability' && typeof spec === 'string') { } else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set(); this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec); this.researchCapabilities.add(spec);
} else if (type === 'activateGates' && spec) {
// SYSTEM category — the node id is "<systemId>_gates".
const sysId = String(node?.id ?? '').replace(/_gates$/, '');
if (sysId) this._activateSystemJumpgates(sysId);
} else { } else {
console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id); console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id);
} }

View File

@ -119,7 +119,10 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
/** /**
* @param {Phaser.Scene} scene the owning scene (GameScene) * @param {Phaser.Scene} scene the owning scene (GameScene)
* @param {object} o { state: ResearchState, onResearch(catId, id) } * @param {object} o { state: ResearchState, onResearch(catId, id),
* systemTree?: the DYNAMIC category's live tree (SystemCategory
* the per-solar-system SYSTEM tree; categories flagged `dynamic` in
* research.json have no data file and are served from this) }
*/ */
constructor(scene, o = {}) { constructor(scene, o = {}) {
super(scene, 0, 0); super(scene, 0, 0);
@ -129,6 +132,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this.setDepth(80); // above save panel (70), sub-bar (60), deck (50) this.setDepth(80); // above save panel (70), sub-bar (60), deck (50)
this.state = o.state ?? null; this.state = o.state ?? null;
this.systemTree = o.systemTree ?? null; // the dynamic category's live tree
this.onResearch = typeof o.onResearch === 'function' ? o.onResearch : null; this.onResearch = typeof o.onResearch === 'function' ? o.onResearch : null;
this.openState = 'closed'; // closed | opening | open | closing this.openState = 'closed'; // closed | opening | open | closing
@ -138,6 +142,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this.selected = null; // { category, id } this.selected = null; // { category, id }
this.lastSelected = new Map(); this.lastSelected = new Map();
this.activeCat = null; this.activeCat = null;
this._lastSelSt = null; // the selected node's last painted state (live repaint)
this.glitch = { until: 0, next: 0, level: 0.8 }; this.glitch = { until: 0, next: 0, level: 0.8 };
this.sweep = { t0: 0, next: 0 }; this.sweep = { t0: 0, next: 0 };
this._lastPct = undefined; this._lastPct = undefined;
@ -588,6 +593,13 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
} }
// ── right: the tech trees ───────────────────────────────────────────────── // ── right: the tech trees ─────────────────────────────────────────────────
/** A category's tree: the DYNAMIC one (SystemCategory) is the scene's
* live per-system tree no data file; the rest read data/research/<id>.json. */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
return loadCategory(catId);
}
_buildTrees() { _buildTrees() {
const { rightX, treeTop, treeH, rightW } = this.geo; const { rightX, treeTop, treeH, rightW } = this.geo;
this.trees = new Map(); this.trees = new Map();
@ -601,7 +613,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
letterSpacing: 1.5, letterSpacing: 1.5,
}); });
for (const cat of categories()) { for (const cat of categories()) {
const tree = loadCategory(cat.id); const tree = this._treeFor(cat.id);
if (!tree) continue; if (!tree) continue;
for (const id of tree.order) { for (const id of tree.order) {
probe.setText(String(tree.nodes[id].label ?? id).toUpperCase()); probe.setText(String(tree.nodes[id].label ?? id).toUpperCase());
@ -613,7 +625,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
const nodeH = 48; const nodeH = 48;
for (const cat of categories()) { for (const cat of categories()) {
const tree = loadCategory(cat.id); const tree = this._treeFor(cat.id);
if (!tree) continue; if (!tree) continue;
const layout = layoutTree(tree); const layout = layoutTree(tree);
const rows = layout.rows; const rows = layout.rows;
@ -936,6 +948,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
const state = this.state; const state = this.state;
const accent = toColor(entry.tree.accent, C.neon); const accent = toColor(entry.tree.accent, C.neon);
const st = this._nodeState(node.id); const st = this._nodeState(node.id);
node.__st = st; // update() repaints on state flips (the chart completes, a run starts)
const w = node.w; const w = node.w;
const h = node.h; const h = node.h;
const g = node.g; const g = node.g;
@ -1123,6 +1136,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
for (const [catId, t] of this.trees) t.cont.setVisible(catId === category); for (const [catId, t] of this.trees) t.cont.setVisible(catId === category);
this._paintAll(category); this._paintAll(category);
this._paintDetail(true); this._paintDetail(true);
this._lastSelSt = this._nodeState(id);
} }
switchCategory(catId) { switchCategory(catId) {
@ -1135,10 +1149,12 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this.selected = { category: catId, id: last }; this.selected = { category: catId, id: last };
this._paintAll(catId); this._paintAll(catId);
this._paintDetail(true); this._paintDetail(true);
this._lastSelSt = this._nodeState(last);
} else { } else {
this.selected = null; this.selected = null;
this._paintAll(catId); this._paintAll(catId);
this._paintDetail(false); this._paintDetail(false);
this._lastSelSt = null;
} }
} }
@ -1198,6 +1214,10 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
const missingStr = missing const missingStr = missing
.map((m) => String(entry.tree.nodes[m]?.label ?? m).toUpperCase()) .map((m) => String(entry.tree.nodes[m]?.label ?? m).toUpperCase())
.join(' + '); .join(' + ');
// A tree may explain a gated node (SystemCategory's chart-completion
// gate: requires are all met, but the system isn't fully charted yet).
const lockNote =
typeof entry.tree.lockNote === 'function' ? entry.tree.lockNote(state, sel.id) : null;
let meta; let meta;
let metaColor; let metaColor;
if (active && active.id === sel.id && active.category === sel.category) { if (active && active.id === sel.id && active.category === sel.category) {
@ -1209,6 +1229,9 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
} else if (missing.length) { } else if (missing.length) {
meta = `LOCKED · REQUIRES ${missingStr}`; meta = `LOCKED · REQUIRES ${missingStr}`;
metaColor = C.amber; metaColor = C.amber;
} else if (lockNote) {
meta = `LOCKED · ${lockNote}`;
metaColor = C.amber;
} else { } else {
meta = `AVAILABLE · DURATION ${durStr}`; meta = `AVAILABLE · DURATION ${durStr}`;
metaColor = accent; metaColor = accent;
@ -1284,6 +1307,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
if (!this.activeCat) return; if (!this.activeCat) return;
this._paintAll(this.activeCat); this._paintAll(this.activeCat);
this._paintDetail(false); this._paintDetail(false);
this._lastSelSt = this.selected ? this._nodeState(this.selected.id) : null;
this._paintStatusStrip(); this._paintStatusStrip();
} }
@ -1473,12 +1497,23 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
const entry = this.activeCat ? this.trees.get(this.activeCat) : null; const entry = this.activeCat ? this.trees.get(this.activeCat) : null;
if (entry && !this.reveal.length) { if (entry && !this.reveal.length) {
for (const node of entry.nodes.values()) { for (const node of entry.nodes.values()) {
if (this._nodeState(node.id) === 'available') { const st = this._nodeState(node.id);
if (st !== node.__st) this._paintNode(node); // state flipped live (a chart completes under the open console…)
if (st === 'available') {
node.cont.setAlpha(0.88 + 0.12 * Math.sin(time * 0.0035 + node.cont.x * 0.01)); node.cont.setAlpha(0.88 + 0.12 * Math.sin(time * 0.0035 + node.cont.x * 0.01));
} else { } else {
node.cont.setAlpha(1); node.cont.setAlpha(1);
} }
} }
// …and the detail readout follows the selected node's flips (its
// button turns LOCKED → RESEARCH as the system's chart completes).
if (this.selected && this.selected.category === entry.tree.id) {
const st = this._nodeState(this.selected.id);
if (st !== this._lastSelSt) {
this._lastSelSt = st;
this._paintDetail(false);
}
}
// energy pulses down the powered edges // energy pulses down the powered edges
const pg = entry.pulseG; const pg = entry.pulseG;
pg.clear(); pg.clear();