/** * SystemCategory — the SYSTEM research category: the ONE per-solar-system * tech tree (research.json → categories, flagged `dynamic: true`). * * Every other category reads its tree from data/research/.json — * static data, same for every run. The SYSTEM category is different by * design: its tree is built at runtime from the system the player is * currently in, so the two techs are NAMED after that system: * * "{System} Map" granted on entry (duration 0, * in `starting` — the scene * unlocks it, ResearchState * "already owned") * └ "Unlock {System} Jumpgates" requires the map + EVERY NAV * point of the system discovered * (the availability hook below); * 45 s (data/gates.json → * activation.researchDuration); * completing it activates the * system's gates AND the return * gates in the systems they * connect to. * * Node ids embed the system id (`S000012_map`, `S000012_gates`) so a run * can chart many systems in the one `system` category without collisions, * and ResearchState's "category::id" keys stay unique per system. * * The tree carries two optional HOOKS the static trees don't need — * ResearchModel consumes them (pure: the predicates are data the caller * supplies, evaluated at paint time): * tree.available(state, id) — extra availability gate (the chart gate: * the jumpgate tech is researchable only while isComplete() holds); * tree.lockNote(state, id) — the detail readout's LOCKED reason when * the node is gated but its `requires` are all met. * * Pure (no Phaser) — Node-testable (dev/system-category.test.mjs). */ import { config } from '../config/Config.js'; /** The category id (data/research.json → categories[]). */ export const SYSTEM_CATEGORY = 'system'; /** The two node kinds. */ export const MAP_NODE = 'map'; export const GATES_NODE = 'gates'; /** A system's tech node id — unique per system (the category is shared). */ export function systemNodeId(systemId, kind) { return `${systemId}_${kind}`; } const fillTemplate = (template, systemName) => String(template ?? '').split('{system}').join(systemName); /** * Build the SYSTEM category's tree for one system. * * @param {object} o * @param {string} o.systemId — the system record's id (node-id prefix) * @param {string} o.systemName — the system name (labels + copy) * @param {string} [o.accent] — the category accent (registry, research.json) * @param {() => boolean} [o.isComplete] — live chart check: true once * EVERY NAV point of the system is discovered (the scene wires it to * its Discovery state; absent = the chart never completes) * @returns {object} a ResearchModel tree (id/label/accent/nodes/order/ * starting) + the optional `available` / `lockNote` hooks. */ export function buildSystemTree(o = {}) { const systemId = String(o.systemId ?? ''); const systemName = String(o.systemName ?? systemId); const mapId = systemNodeId(systemId, MAP_NODE); const gatesId = systemNodeId(systemId, GATES_NODE); const duration = Math.max(0, Number(config.get('gates.activation.researchDuration', 45)) || 0); const isComplete = typeof o.isComplete === 'function' ? o.isComplete : () => false; const chartLockedNote = 'NAV CHART INCOMPLETE — DISCOVER ALL PLANETS, STATIONS AND JUMP GATES OF ' + systemName.toUpperCase(); const tree = { id: SYSTEM_CATEGORY, label: 'System', accent: typeof o.accent === 'string' && o.accent ? o.accent : '#5fd4ff', nodes: { [mapId]: { label: fillTemplate(config.get('gates.activation.mapTech.label', '{system} Map'), systemName), description: fillTemplate( config.get( 'gates.activation.mapTech.description', 'Added the Solar System of {system} to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates.', ), systemName, ), icon: 'map', duration: 0, // granted on entry — never researched requires: [], unlocks: { builds: [], research: [gatesId] }, effects: {}, }, [gatesId]: { label: fillTemplate(config.get('gates.activation.gatesTech.label', 'Unlock {system} Jumpgates'), systemName), description: fillTemplate( config.get( 'gates.activation.gatesTech.description', "With system NAV data complete, we have enough information to plot courses through this system's jumpgates.", ), systemName, ), icon: 'gate', duration, requires: [mapId], unlocks: { builds: [], research: [] }, // The scene's effects seam reads this (GameScene._applyResearchEffects): // activate the system's gates + the linked systems' return gates. effects: { activateGates: true }, }, }, order: [mapId, gatesId], starting: [mapId], }; // The hooks (see the header) — the scene's live chart check drives both. tree.available = (_state, id) => (id === gatesId ? isComplete() : true); tree.lockNote = (_state, id) => (id === gatesId && !isComplete() ? chartLockedNote : null); return tree; } /** * A system's NAV points — the discovery ids the scene's * discoverableObjects() uses for its discoverable set, minus the rocks * (asteroid clusters are objects, not NAV points): the central body * (every system — the scene's 'home' id, the player's home world in the * starting system and the system's central world elsewhere), every * planet (its name), every free-space station (its settlement id), * every jump gate (its gate id). * * @param {object} content — a generated system content (ensureContent) * @returns {string[]} the discoverable ids to check (order: stable) */ export function navPointIds(content) { const ids = []; ids.push('home'); // the central body — discoverable in every system for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') ids.push(p.name); for (const s of content?.settlements ?? []) { if (s && s.anchor?.type === 'space' && typeof s.id === 'string') ids.push(s.id); } for (const j of content?.jumps ?? []) if (j && typeof j.id === 'string') ids.push(j.id); return ids; } /** * Is the system fully charted? Every NAV point discovered * (js/galaxy/Discovery.js — `discovery.isDiscovered(systemId, id)`). * Vacuously true for a content with no NAV points (a degenerate * one-system galaxy with no gates). * * @param {object} discovery — the scene's Discovery state * @param {string} systemId * @param {object} content * @returns {boolean} */ export function isNavComplete(discovery, systemId, content) { if (!discovery || typeof discovery.isDiscovered !== 'function') return false; return navPointIds(content).every((id) => discovery.isDiscovered(systemId, id)); } /** * The activation keys one completed "Unlock {System} Jumpgates" grants: * every gate in `systemId` (keyed ">") AND the * RETURN gates in the systems those destinations connect back to — * the gate in `` whose destination is `systemId`. * * Pure: computed from the jump network alone (no content generation). * * @param {object} galaxy — the Galaxy (reads galaxy.jumpNetwork.gates: * Map systemId → destination ids) * @param {string} systemId * @returns {string[]} activation keys (deduplicated, stable order) */ export function activationKeys(galaxy, systemId) { const gates = galaxy?.jumpNetwork?.gates; if (!gates || typeof gates.get !== 'function') return []; const out = []; const push = (k) => { if (k && !out.includes(k)) out.push(k); }; for (const dest of gates.get(systemId) ?? []) { push(`${systemId}>${dest}`); if ((gates.get(dest) ?? []).includes(systemId)) push(`${dest}>${systemId}`); } return out; } /** * Flip a generated system's gate records to ACTIVE for every activation * key in `activated` (the ">" keys). Idempotent; * returns how many gates changed. * * @param {object} content — the system's generated content * @param {string} systemId — content's system id (the cache key) * @param {Set} activated — the run's activation keys * @returns {number} */ export function applyActivation(content, systemId, activated) { let n = 0; for (const j of content?.jumps ?? []) { if (j && typeof j.to === 'string' && !j.active && activated?.has(`${systemId}>${j.to}`)) { j.active = true; n++; } } return n; }