Fix jumpgate activation bug and add NAV chart diagnostics

- `_applyResearchEffects` now receives the node id explicitly instead of
  reading `node.id`, which is undefined (the id is the key in the tree's
  `nodes` map, not a field on the object) — this was silently skipping
  `activateGates` so researched jumpgates stayed dark
- Add `systemIdOfGatesNode()` as the pure inverse of `systemNodeId()` for
  the gates node, with round-trip tests
- Reconcile gate activation in `_onEnterSystem()`: if the gates tech is
  already unlocked, re-run `_activateSystemJumpgates` (idempotent) so a
  missed activation self-heals on the next entry/jump/reload
- Make `_activateSystemJumpgates` idempotent by tracking fresh keys and
  early-returning when everything is already online
- Add `navPoints()` and `navChart()` to SystemCategory.js as pure,
  testable cores for the jumpgate unlock condition (discovered/missing
  split over all NAV points)
- Add `js/dev/NavDiag.js` with `orbitNav()` / `orbitNavBrief()` console
  commands that print the live NAV chart and which objects are still
  undiscovered, installed by `main.js` on every page load
- Update `data/landing.json` to reference the correct gasgiant surface/shop
  videos for slot 03
- Add tests for `navPoints`, `navChart`, and `systemIdOfGatesNode`
- Document the activation wiring bug, the `orbitNav()` diagnostic, and the
  new pure functions in PROJECT_NOTES.md
This commit is contained in:
Brian Fertig 2026-09-06 17:41:29 -06:00
parent 5a67dbb206
commit ff557f87ba
9 changed files with 296 additions and 18 deletions

Binary file not shown.

Binary file not shown.

View File

@ -48,7 +48,7 @@
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" }, { "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" },
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" } { "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }

View File

@ -67,8 +67,8 @@ check('research: the SYSTEM category is registered + dynamic',
const { config } = await import('../js/config/Config.js'); const { config } = await import('../js/config/Config.js');
config.init({ research, gates }); config.init({ research, gates });
const { const {
SYSTEM_CATEGORY, MAP_NODE, GATES_NODE, systemNodeId, SYSTEM_CATEGORY, MAP_NODE, GATES_NODE, systemNodeId, systemIdOfGatesNode,
buildSystemTree, navPointIds, isNavComplete, activationKeys, applyActivation, buildSystemTree, navPoints, navPointIds, navChart, isNavComplete, activationKeys, applyActivation,
} = await import('../js/research/SystemCategory.js'); } = await import('../js/research/SystemCategory.js');
const { issues, layoutTree, isAvailable, missingRequires, unlockIssues } = await import('../js/research/ResearchModel.js'); const { issues, layoutTree, isAvailable, missingRequires, unlockIssues } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js'); const { ResearchState } = await import('../js/research/ResearchState.js');
@ -168,6 +168,44 @@ 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); 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); 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 (home/planet/station/gate)',
JSON.stringify(navPoints(content)) === JSON.stringify([
{ id: 'home', kind: 'home' },
{ 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' },
]));
const chartMissing = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
check('navChart: reports the discovered/missing split (5 of 6, j2 still out)',
chartMissing.discovered === 5 && chartMissing.total === 6 && 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(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]), SYS, content);
check('navChart: complete once every NAV point is discovered (6 of 6)',
chartDone.discovered === 6 && chartDone.total === 6 && chartDone.complete === true && chartDone.missing.length === 0);
const chartNoState = navChart(null, SYS, content);
check('navChart: a missing discovery state shows nothing discovered (0 of 6)',
chartNoState.discovered === 0 && chartNoState.total === 6 && 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 ---- // -- activation: the system's gates + the linked systems' RETURN gates ----
const galaxy = { const galaxy = {
jumpNetwork: { jumpNetwork: {

View File

@ -423,6 +423,27 @@ one shared category without collisions:
`activationKeys`), and per the gate ACTIVITY rule each activated gate `activationKeys`), and per the gate ACTIVITY rule each activated gate
anchors a level-1 tether at its own position. anchors a level-1 tether at its own position.
**Diagnosing "I charted everything but it stays locked":** the console
command `orbitNav()` (DevTools → Console, space view — `js/dev/NavDiag.js`,
installed by `main.js`) prints this system's live NAV chart: every NAV
point with a ✓/✗, the discovered/total count, and exactly which objects
are still undiscovered. `orbitNavBrief()` gives the one-line summary.
It builds on the pure `navChart()` core above.
**Activation wiring (a real bug lived here):** completing the tech runs
`GameScene._applyResearchEffects`, whose `activateGates` branch recovers
the system id from the **node id** it's passed — via the pure
`systemIdOfGatesNode(nodeId)` (`S000137_gates` → `S000137`). A node
**object** carries no `id` field (its id is the key in the tree's
`nodes` map), so reading `node.id` yields `undefined` and the activation
silently never ran ("researched the jumpgates but they stay dark" — it
was *not* a name/case mismatch; the id lookup is by system id, never by
name). `GameScene._onEnterSystem()` now **reconciles** on every entry:
if the system's gates tech is unlocked it re-runs
`_activateSystemJumpgates` (idempotent + silent when already online),
so a missed activation self-heals on the next entry/jump/reload without
a re-research.
The gates node's chart gate is an **availability HOOK**, not a `requires` 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): 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)` the tree object carries optional `available(state, id)` / `lockNote(state, id)`
@ -472,7 +493,14 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
- `js/research/SystemCategory.js` — PURE (no Phaser): the dynamic - `js/research/SystemCategory.js` — PURE (no Phaser): the dynamic
SYSTEM category. `buildSystemTree({systemId, systemName, accent, SYSTEM category. `buildSystemTree({systemId, systemName, accent,
isComplete})` (the per-system tree + the `available`/`lockNote` isComplete})` (the per-system tree + the `available`/`lockNote`
hooks), `navPointIds(content)` (the chart's NAV points), hooks), `systemIdOfGatesNode(nodeId)` (recover the system id a gates
node id encodes — the node object has no `id` field, so the scene passes
the id in; the pure core of the activation wiring),
`navPoints(content)` (the chart's NAV points + their kind),
`navPointIds(content)` (the chart's NAV points, ids only),
`navChart(discovery, systemId, content)` (the live discovered/missing
split that gates the jumpgate tech — the pure core of the `orbitNav()`
console diagnostic in `js/dev/NavDiag.js`),
`isNavComplete(discovery, systemId, content)` (every NAV point `isNavComplete(discovery, systemId, content)` (every NAV point
discovered), `activationKeys(galaxy, systemId)` (the system's gates + discovered), `activationKeys(galaxy, systemId)` (the system's gates +
the linked systems' return gates, from the jump network alone), the linked systems' return gates, from the jump network alone),

131
js/dev/NavDiag.js Normal file
View File

@ -0,0 +1,131 @@
/**
* js/dev/NavDiag.js in-game diagnostics for the SYSTEM category's NAV chart.
*
* Installed by js/main.js on every real page load (index.html). Read-only and
* defensive: it observes the live game and prints a report it never mutates
* state.
*
* The jumpgate tech unlocks only when EVERY NAV point of the current system
* is discovered (js/research/SystemCategory.js navChart). This tells you
* exactly which ones are still missing, so "I visited everything but the
* gates stay locked" resolves in one glance instead of by guesswork.
*
* Console commands (DevTools Console, in the RUNNING game the space view,
* not a planet surface):
*
* orbitNav() full chart (also logged). Copy-paste the returned
* string when asking for help.
* orbitNavBrief() one-line summary.
*
* If `orbitNav` is NOT DEFINED in your console, the browser served an OLDER
* js/main.js (stale module cache) hard-reload with the cache bypassed
* (Ctrl+Shift+R, or DevTools Network Disable cache) and check again. The
* version marker below changes with each revision.
*/
import { config } from '../config/Config.js';
import { navChart } from '../research/SystemCategory.js';
export const NAV_DIAG_V = 1;
const KIND_LABEL = {
home: 'home world',
planet: 'planet',
station: 'space station',
gate: 'jump gate',
};
function sceneOf(key) {
try {
return globalThis.window?.game?.scene?.getScene(key) ?? null;
} catch {
return null;
}
}
/**
* The current system's live NAV chart, with readable names + type labels
* (resolved from the scene's discoverable set the same names the compass
* and discovery toasts show).
*/
function chartOf(gs) {
const systemId = gs?.systemRecord?.id ?? null;
const system = gs?.systemRecord?.name ?? systemId ?? '?';
const chart = navChart(gs?.discovery, systemId, gs?.systemContent);
const names = new Map((gs?.discoverableObjects?.() ?? []).map((o) => [o.id, o]));
const points = chart.points.map((p) => {
const o = names.get(p.id);
const name = o?.name ?? (p.id === 'home' ? gs?.homeWorldName ?? p.id : p.id);
return { ...p, name, typeLabel: o?.typeLabel ?? KIND_LABEL[p.kind] ?? p.kind };
});
return { system, systemId, chart, points };
}
const ABSENT = `[orbit-nav v${NAV_DIAG_V}] GameScene absent — run this from the space view (not a planet surface)`;
/** One-line summary — the same facts a support request needs. */
export function brief() {
const gs = sceneOf('GameScene');
if (!gs) {
console.info(ABSENT);
return ABSENT;
}
const { system, systemId, chart, points } = chartOf(gs);
const missing = points.filter((p) => !p.discovered).map((p) => p.name);
const state = chart.complete
? 'COMPLETE → jumpgates RESEARCHABLE'
: 'INCOMPLETE → jumpgates LOCKED';
const line =
`[orbit-nav v${NAV_DIAG_V}] ` +
`system=${system}[${systemId ?? '?'}] ` +
`chart=${chart.discovered}/${chart.total} ${state} ` +
`missing=${JSON.stringify(missing)}`;
console.info(line);
return line;
}
/** Full report. Logs it, returns the string (copy-paste friendly). */
export function full() {
const gs = sceneOf('GameScene');
if (!gs) {
console.info(ABSENT);
return ABSENT;
}
const { system, systemId, chart, points } = chartOf(gs);
const dist = config.get('game.discovery.distance', 540);
const state = chart.complete
? 'COMPLETE — the jumpgate tech should be RESEARCHABLE'
: 'INCOMPLETE — the jumpgate tech is LOCKED';
const missing = points.filter((p) => !p.discovered);
const lines = [
`ORBIT NAV CHART v${NAV_DIAG_V}`,
`[system] ${system} [${systemId ?? '?'}]`,
`[chart] ${chart.discovered} of ${chart.total} NAV points discovered → ${state}`,
];
for (const p of points) {
lines.push(
` ${p.discovered ? '✓' : '✗'} ${p.kind.padEnd(7)} ${String(p.name).padEnd(22)} ` +
`(${p.typeLabel})${p.discovered ? '' : ' ← undiscovered'}`,
);
}
if (missing.length) {
lines.push(`[missing] ${missing.map((p) => p.name).join(', ')}`);
lines.push(
`[tip] fly within ${dist} px of each missing object to discover it — ` +
'jump gates and free-space stations are the ones easy to fly past',
);
} else {
lines.push(`[ok] every NAV point is discovered — research the jumpgate tech in the console`);
}
const text = lines.join('\n');
console.info(text);
return text;
}
/** Install the console commands. Idempotent. */
export function installNavDiag() {
if (typeof globalThis === 'undefined') return;
if (!globalThis.addEventListener) return;
globalThis.orbitNav = () => full();
globalThis.orbitNavBrief = () => brief();
console.info(`[orbit-nav v${NAV_DIAG_V}] installed — type orbitNav() in the console to see this system's NAV chart`);
}

View File

@ -6,6 +6,7 @@ import { MenuScene } from './scenes/MenuScene.js';
import { GameScene } from './scenes/GameScene.js'; import { GameScene } from './scenes/GameScene.js';
import { SurfaceScene } from './scenes/SurfaceScene.js'; import { SurfaceScene } from './scenes/SurfaceScene.js';
import { installBuildDiag } from './dev/BuildDiag.js'; import { installBuildDiag } from './dev/BuildDiag.js';
import { installNavDiag } from './dev/NavDiag.js';
/** /**
* Orbit entry point. * Orbit entry point.
@ -43,6 +44,11 @@ async function boot() {
// the Build console opens. // the Build console opens.
installBuildDiag(); installBuildDiag();
// The SYSTEM category's NAV chart (orbitNav / orbitNavBrief). See
// js/dev/NavDiag.js — which NAV points of the current system are still
// undiscovered, i.e. why the jumpgate tech may still be locked.
installNavDiag();
await awaitFonts(); await awaitFonts();
console.info('orbit — fonts ready (or timed out)'); console.info('orbit — fonts ready (or timed out)');

View File

@ -49,6 +49,20 @@ export function systemNodeId(systemId, kind) {
return `${systemId}_${kind}`; return `${systemId}_${kind}`;
} }
/**
* The inverse of systemNodeId() for the gates node: recover the system id a
* research node id encodes ("" when `nodeId` is nullish). A node OBJECT
* carries no `id` field its id is the KEY in the tree's `nodes` map so
* callers must pass the id in (from the window / ResearchState), not read
* `node.id`. That was the "researched but the gates stay dark" bug.
*
* @param {string} nodeId e.g. `S000137_gates` `S000137`
* @returns {string}
*/
export function systemIdOfGatesNode(nodeId) {
return String(nodeId ?? '').replace(/_gates$/, '');
}
const fillTemplate = (template, systemName) => const fillTemplate = (template, systemName) =>
String(template ?? '').split('{system}').join(systemName); String(template ?? '').split('{system}').join(systemName);
@ -137,15 +151,57 @@ export function buildSystemTree(o = {}) {
* @param {object} content a generated system content (ensureContent) * @param {object} content a generated system content (ensureContent)
* @returns {string[]} the discoverable ids to check (order: stable) * @returns {string[]} the discoverable ids to check (order: stable)
*/ */
export function navPointIds(content) { /**
const ids = []; * The system's NAV points (with their KIND), in a stable order: the central
ids.push('home'); // the central body — discoverable in every system * body ('home'), then the planets (by name), the free-space stations
for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') ids.push(p.name); * (settlement id), and the jump gates (gate id). Asteroid clusters are NOT
* NAV points they're objects you can mine, not chart waypoints.
*
* @param {object} content a generated system content (ensureContent)
* @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}>
*/
export function navPoints(content) {
const out = [{ id: 'home', kind: 'home' }]; // the central body — discoverable in every system
for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') out.push({ id: p.name, kind: 'planet' });
for (const s of content?.settlements ?? []) { for (const s of content?.settlements ?? []) {
if (s && s.anchor?.type === 'space' && typeof s.id === 'string') ids.push(s.id); if (s && s.anchor?.type === 'space' && typeof s.id === 'string') out.push({ id: s.id, kind: 'station' });
} }
for (const j of content?.jumps ?? []) if (j && typeof j.id === 'string') ids.push(j.id); for (const j of content?.jumps ?? []) if (j && typeof j.id === 'string') out.push({ id: j.id, kind: 'gate' });
return ids; return out;
}
/** The NAV-point discovery ids, in a stable order (see navPoints). */
export function navPointIds(content) {
return navPoints(content).map((p) => p.id);
}
/**
* The live NAV chart for one system which NAV points are discovered and
* which are still missing (the exact set that gates the jumpgate tech).
* Pure + Node-testable; the console diagnostic `orbitNav()` builds on it.
*
* @param {object} discovery the scene's Discovery state (isDiscovered)
* @param {string} systemId the system to chart
* @param {object} content that system's generated content
* @returns {{
* total:number, discovered:number, complete:boolean, missing:string[],
* points:Array<{id:string, kind:string, discovered:boolean}>
* }}
*/
export function navChart(discovery, systemId, content) {
const canQuery = typeof discovery?.isDiscovered === 'function';
const points = navPoints(content).map((p) => ({
...p,
discovered: canQuery ? !!discovery.isDiscovered(systemId, p.id) : false,
}));
const missing = points.filter((p) => !p.discovered);
return {
total: points.length,
discovered: points.length - missing.length,
complete: missing.length === 0,
missing: missing.map((p) => p.id),
points,
};
} }
/** /**

View File

@ -37,7 +37,9 @@ import { categories, loadCategory, isAvailable, buildDefs } from '../research/Re
import { import {
SYSTEM_CATEGORY, SYSTEM_CATEGORY,
MAP_NODE, MAP_NODE,
GATES_NODE,
systemNodeId, systemNodeId,
systemIdOfGatesNode,
buildSystemTree, buildSystemTree,
isNavComplete, isNavComplete,
activationKeys, activationKeys,
@ -1928,7 +1930,7 @@ export class GameScene extends Phaser.Scene {
if (durMs <= 0) { if (durMs <= 0) {
// Instant tech (duration 0) — applies the moment it's requested. // Instant tech (duration 0) — applies the moment it's requested.
state.unlock(catId, id); state.unlock(catId, id);
this._applyResearchEffects(node.effects, node); this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node); this._completeResearchFx(catId, id, node);
return; return;
} }
@ -1948,7 +1950,7 @@ export class GameScene extends Phaser.Scene {
if (!state) return; if (!state) return;
state.unlock(catId, id); state.unlock(catId, id);
const node = this._treeFor(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, id);
this._completeResearchFx(catId, id, node); this._completeResearchFx(catId, id, node);
} }
@ -1967,6 +1969,14 @@ export class GameScene extends Phaser.Scene {
_onEnterSystem() { _onEnterSystem() {
if (!this.researchState || !this.systemRecord) return; if (!this.researchState || !this.systemRecord) return;
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE)); this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
// Invariant: if this system's gates tech is researched, its gates are
// active. Reconcile on entry — idempotent (silent when already online),
// and it recovers any system whose activation was missed at completion
// without a re-research (the node.id bug) or a save round-trip.
const gatesId = systemNodeId(this.systemRecord.id, GATES_NODE);
if (this.researchState.isUnlocked(SYSTEM_CATEGORY, gatesId)) {
this._activateSystemJumpgates(this.systemRecord.id);
}
this.researchWindow?.refresh(); this.researchWindow?.refresh();
} }
@ -1994,7 +2004,12 @@ export class GameScene extends Phaser.Scene {
_activateSystemJumpgates(sysId) { _activateSystemJumpgates(sysId) {
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1)); const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
const keys = activationKeys(this.galaxy, sysId); const keys = activationKeys(this.galaxy, sysId);
// Idempotent: only the NOT-yet-active keys count as new. Re-entering a
// system (a jump, a load, a refresh) re-runs this — the early return
// keeps it silent when everything is already online.
const fresh = keys.filter((k) => !this.activatedGates.has(k));
for (const k of keys) this.activatedGates.add(k); for (const k of keys) this.activatedGates.add(k);
if (fresh.length === 0) return;
// Current system — the gate entities wake + anchor their tethers. // Current system — the gate entities wake + anchor their tethers.
for (const gt of this.systemGates ?? []) { for (const gt of this.systemGates ?? []) {
@ -2026,14 +2041,16 @@ export class GameScene extends Phaser.Scene {
/** /**
* Apply a tech's effects (node.effects, data/research/<cat>.json or * Apply a tech's effects (node.effects, data/research/<cat>.json or
* the SYSTEM category's live tree). Known shapes: * the SYSTEM category's live tree). `id` is the node's id (the key in the
* tree's `nodes` map the node object itself carries no `id` field).
* 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 * activateGates true the SYSTEM category: the system's jump gates
* + the linked systems' return gates go ACTIVE * + 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, id) {
if (!effects || typeof effects !== 'object') return; if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) { for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) { if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
@ -2048,11 +2065,13 @@ export class GameScene extends Phaser.Scene {
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) { } else if (type === 'activateGates' && spec) {
// SYSTEM category — the node id is "<systemId>_gates". // SYSTEM category — the node id is "<systemId>_gates". The node object
const sysId = String(node?.id ?? '').replace(/_gates$/, ''); // carries no `id` field (it's the key in the tree's `nodes` map), so
// `id` (passed in) is the source of truth — NOT `node.id`.
const sysId = systemIdOfGatesNode(id);
if (sysId) this._activateSystemJumpgates(sysId); 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, id);
} }
} }
} }