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:
parent
d9fc9df829
commit
a3a229a72d
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue