diff --git a/assets/images/originals/gasgiant-shop-02.png b/assets/images/originals/gasgiant-shop-02.png new file mode 100644 index 0000000..0f011d6 Binary files /dev/null and b/assets/images/originals/gasgiant-shop-02.png differ diff --git a/data/gates.json b/data/gates.json index 9169c8a..1297ad5 100644 --- a/data/gates.json +++ b/data/gates.json @@ -14,5 +14,18 @@ "anchorTetherLevel": 1, "barrenDistance": 8192, "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." + } + } } diff --git a/data/research.json b/data/research.json index aba1243..8161062 100644 --- a/data/research.json +++ b/data/research.json @@ -5,8 +5,10 @@ "maxConcurrent": 1, "defaultCategory": "exploration", "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/.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": { "_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", diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs index c3dcc31..f6ccf44 100644 --- a/dev/jumpgate.test.mjs +++ b/dev/jumpgate.test.mjs @@ -39,6 +39,13 @@ class GameObject { add(o) { this.children.push(o); return this; } remove(o) { this.children = this.children.filter((c) => c !== o); 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; } setDepth() { return this; } setAlpha(a) { this.alpha = a; return this; } diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 1f64973..8ef6fb4 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -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: 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 -const missing = research.categories.filter((c) => !manifest.files.includes(`research/${c.id}.json`)); -check('research: every registered category has a tree file', missing.length === 0); +// every STATIC category in the registry has its own tree file in the +// manifest. A category flagged `dynamic: true` (currently: `system`) +// 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) 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: 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 requiresOk = true; let unlocksOk = true; diff --git a/dev/system-category.test.mjs b/dev/system-category.test.mjs new file mode 100644 index 0000000..fbb5699 --- /dev/null +++ b/dev/system-category.test.mjs @@ -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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index d635d9f..b0a6f9a 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -279,11 +279,15 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops) get their single gate on the star→destination ray at `barrenDistance` (8192 px) instead. Every gate record carries `active: false` — gates 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` is the bearing from the gate to its 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 (the ship keeps `gates.shipClearance` from them, autopilot flies to 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` + 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 than follow-on tech. Each node's `unlocks` is the declaration side: - `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` (builds.json → map, `_`-keys excluded), `unlockIssues(tree)` (mirror + 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`, `start`, `progress(time)`, `tick(time)` (→ array of completions), `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 `node.effects`: `{ tether: { level: N } }` → `TetherField.setLevel(homeId, N)` + toast; `{ capability: "flag" }` → `scene.researchCapabilities.add(flag)`; - unknown shapes log and no-op. New effect kinds plug in there without - touching tree data. + `{ activateGates: true }` (SYSTEM category) → the system's gates + the + 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 `">"`) — 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: {category, id, durationMs, remainingMs} | null }`. `remainingMs` is captured at save time; `restoreActive(spec, now)` rebuilds `startedAt` from the fresh `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 -on the next explicit player save (SavePanel), consistent with the rest of -the game. +active run) — old saves keep working. The SYSTEM category's activation keys +ride `record.activatedGates` (an array of `">"`; saves from +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 `data/sfx.json` keys — there are no `research_begin`/`research_complete` keys). Open/close → `ui_window`/`ui_close`. **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 + the unlocks contract (unlocksOf/unlockIssues/buildIssues) + ResearchState start/tick/complete/restore round-trip, builds.json (the 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. ## Builds — the surface install (cost-based, one at a time) diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 0162af2..89023f8 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -149,6 +149,20 @@ export class JumpGate extends Phaser.GameObjects.Container { 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.) */ update(time) { const t = time / 1000; diff --git a/js/research/ResearchIcons.js b/js/research/ResearchIcons.js index dcebdc7..a91802b 100644 --- a/js/research/ResearchIcons.js +++ b/js/research/ResearchIcons.js @@ -6,7 +6,7 @@ */ 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) { const n = ICON_NAMES.includes(name) ? name : 'diamond'; @@ -136,6 +136,49 @@ export function ensureIcon(scene, name, color = 0x00e5ff) { ctx.stroke(); } 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 { // diamond — the default glyph ctx.beginPath(); diff --git a/js/research/ResearchModel.js b/js/research/ResearchModel.js index 6cf0a96..b51b2d8 100644 --- a/js/research/ResearchModel.js +++ b/js/research/ResearchModel.js @@ -12,6 +12,10 @@ * 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 — * 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'; @@ -163,12 +167,19 @@ export function layoutTree(tree) { * Can this node be researched right now? (known + all parents researched + * not already researched.) `state` is a ResearchState (or anything with * 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) { const n = tree.nodes[id]; if (!n) 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). */ diff --git a/js/research/SystemCategory.js b/js/research/SystemCategory.js new file mode 100644 index 0000000..3617782 --- /dev/null +++ b/js/research/SystemCategory.js @@ -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/.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 ">") AND the + * RETURN gates in the systems those destinations connect back to — + * the gate in `` 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 ">" 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} 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; +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 2dc50ce..548f4cd 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -10,7 +10,14 @@ * ship: { x, y, heading, minerals }, * discovery: Discovery.toJSON(), * reputation: Reputation.toJSON(), - * tethers: [{ id, x, y, level, label }], + * tethers: [{ id, x, y, level, label }], // incl. the activated + * // gates' anchor tethers + * activatedGates: [">"], // the SYSTEM research + * // category's activation + * // keys (registry-backed + * // Set, replayed on load — + * // gates + tethers re-form + * // in GameScene.create) * research: ResearchState.toJSON() | null, * builds: BuildState.toJSON() | null, * playTimeMs } @@ -96,6 +103,12 @@ export function captureState(scene, now) { builds: scene.buildState ? scene.buildState.toJSON(buildNow) : 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), }; const err = SaveManager.validateRecord(rec); @@ -130,6 +143,14 @@ export function prepareLoad(registry, record) { registry.set('seed', seed); registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} })); 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, { ship: record.ship, 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 - * previous one's discovery state, its standing with the old galaxy, or a - * half-staged restore. + * previous one's discovery state, its standing with the old galaxy, its + * activated jump gates, or a half-staged restore. */ export function resetRunState(registry) { registry.set('discovery', null); registry.set('reputation', null); + registry.set('activatedGates', null); registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 5fce233..9d79d0c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -33,6 +33,15 @@ import { CommsPanel } from '../ui/CommsPanel.js'; import { ResearchWindow } from '../ui/ResearchWindow.js'; import { ResearchState } from '../research/ResearchState.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 { BuildState } from '../build/BuildState.js'; import { startingPairs } from '../build/BuildModel.js'; @@ -209,6 +218,23 @@ export class GameScene extends Phaser.Scene { this._pendingRestore = consumeRestore(this.registry); this.systemRecord = this.galaxy.currentSystem(); 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 // 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 @@ -351,6 +377,14 @@ export class GameScene extends Phaser.Scene { config.get('tether.homeLevel', 1), 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; // Discovery: which objects the player has found (within discovery @@ -477,8 +511,22 @@ export class GameScene extends Phaser.Scene { if (!tree) continue; 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, { state: this.researchState, + systemTree: this.systemTree, onResearch: (catId, id) => this.beginResearch(catId, id), }); // 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; } + // 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) ------------------------------- // The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull, // then an omnidirectional wavefront expanding across the TETHER REGION @@ -1729,7 +1784,7 @@ export class GameScene extends Phaser.Scene { }); return; } - const tree = loadCategory(catId); + const tree = this._treeFor(catId); const node = tree?.nodes?.[id]; if (!node) return; if (!isAvailable(tree, state, id)) { @@ -1762,11 +1817,72 @@ export class GameScene extends Phaser.Scene { const state = this.researchState; if (!state) return; 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); 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/.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. */ _completeResearchFx(catId, id, node) { 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/.json). - * tether { level: N } → the home world's tether field strengthens - * capability "flag" → a scene capability set (future systems read it) + * Apply a tech's effects (node.effects, data/research/.json — or + * the SYSTEM category's live tree). Known shapes: + * tether { level: N } → the home world's tether field strengthens + * 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. */ _applyResearchEffects(effects, node) { @@ -1797,6 +1916,10 @@ export class GameScene extends Phaser.Scene { } else if (type === 'capability' && typeof spec === 'string') { this.researchCapabilities = this.researchCapabilities ?? new Set(); this.researchCapabilities.add(spec); + } else if (type === 'activateGates' && spec) { + // SYSTEM category — the node id is "_gates". + const sysId = String(node?.id ?? '').replace(/_gates$/, ''); + if (sysId) this._activateSystemJumpgates(sysId); } else { console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id); } diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index b7431f3..0cb57c8 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -119,7 +119,10 @@ export class ResearchWindow extends Phaser.GameObjects.Container { /** * @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 = {}) { 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.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.openState = 'closed'; // closed | opening | open | closing @@ -138,6 +142,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { this.selected = null; // { category, id } this.lastSelected = new Map(); 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.sweep = { t0: 0, next: 0 }; this._lastPct = undefined; @@ -588,6 +593,13 @@ export class ResearchWindow extends Phaser.GameObjects.Container { } // ── 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/.json. */ + _treeFor(catId) { + if (this.systemTree && catId === this.systemTree.id) return this.systemTree; + return loadCategory(catId); + } + _buildTrees() { const { rightX, treeTop, treeH, rightW } = this.geo; this.trees = new Map(); @@ -601,7 +613,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { letterSpacing: 1.5, }); for (const cat of categories()) { - const tree = loadCategory(cat.id); + const tree = this._treeFor(cat.id); if (!tree) continue; for (const id of tree.order) { probe.setText(String(tree.nodes[id].label ?? id).toUpperCase()); @@ -613,7 +625,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { const nodeH = 48; for (const cat of categories()) { - const tree = loadCategory(cat.id); + const tree = this._treeFor(cat.id); if (!tree) continue; const layout = layoutTree(tree); const rows = layout.rows; @@ -936,6 +948,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { const state = this.state; const accent = toColor(entry.tree.accent, C.neon); 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 h = node.h; 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); this._paintAll(category); this._paintDetail(true); + this._lastSelSt = this._nodeState(id); } switchCategory(catId) { @@ -1135,10 +1149,12 @@ export class ResearchWindow extends Phaser.GameObjects.Container { this.selected = { category: catId, id: last }; this._paintAll(catId); this._paintDetail(true); + this._lastSelSt = this._nodeState(last); } else { this.selected = null; this._paintAll(catId); this._paintDetail(false); + this._lastSelSt = null; } } @@ -1198,6 +1214,10 @@ export class ResearchWindow extends Phaser.GameObjects.Container { const missingStr = missing .map((m) => String(entry.tree.nodes[m]?.label ?? m).toUpperCase()) .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 metaColor; 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) { meta = `LOCKED · REQUIRES ${missingStr}`; metaColor = C.amber; + } else if (lockNote) { + meta = `LOCKED · ${lockNote}`; + metaColor = C.amber; } else { meta = `AVAILABLE · DURATION ${durStr}`; metaColor = accent; @@ -1284,6 +1307,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { if (!this.activeCat) return; this._paintAll(this.activeCat); this._paintDetail(false); + this._lastSelSt = this.selected ? this._nodeState(this.selected.id) : null; this._paintStatusStrip(); } @@ -1473,12 +1497,23 @@ export class ResearchWindow extends Phaser.GameObjects.Container { const entry = this.activeCat ? this.trees.get(this.activeCat) : null; if (entry && !this.reveal.length) { 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)); } else { 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 const pg = entry.pulseG; pg.clear();