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