orbit/js/research/SystemCategory.js

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;
}