diff --git a/assets/videos/gasgiant-shop-03.mp4 b/assets/videos/gasgiant-shop-03.mp4 new file mode 100644 index 0000000..ec24180 Binary files /dev/null and b/assets/videos/gasgiant-shop-03.mp4 differ diff --git a/assets/videos/gasgiant-surface-03.mp4 b/assets/videos/gasgiant-surface-03.mp4 new file mode 100644 index 0000000..038777d Binary files /dev/null and b/assets/videos/gasgiant-surface-03.mp4 differ diff --git a/data/landing.json b/data/landing.json index ff458a7..cba0580 100644 --- a/data/landing.json +++ b/data/landing.json @@ -48,7 +48,7 @@ { "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" }, - { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }, + { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" } diff --git a/dev/system-category.test.mjs b/dev/system-category.test.mjs index fbb5699..af6c0dd 100644 --- a/dev/system-category.test.mjs +++ b/dev/system-category.test.mjs @@ -67,8 +67,8 @@ check('research: the SYSTEM category is registered + dynamic', 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, + SYSTEM_CATEGORY, MAP_NODE, GATES_NODE, systemNodeId, systemIdOfGatesNode, + buildSystemTree, navPoints, navPointIds, navChart, 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'); @@ -168,6 +168,44 @@ 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); +// navPoints / navChart — the diagnostic's pure core (orbitNav builds on this) +check('navPoints: each NAV point carries its kind (home/planet/station/gate)', + JSON.stringify(navPoints(content)) === JSON.stringify([ + { id: 'home', kind: 'home' }, + { id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' }, + { id: `${SYS}-s1`, kind: 'station' }, + { id: `${SYS}-j1`, kind: 'gate' }, { id: `${SYS}-j2`, kind: 'gate' }, + ])); + +const chartMissing = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content); +check('navChart: reports the discovered/missing split (5 of 6, j2 still out)', + chartMissing.discovered === 5 && chartMissing.total === 6 && chartMissing.complete === false + && JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`]) + && chartMissing.points.find((p) => p.id === `${SYS}-j2`)?.discovered === false); + +const chartDone = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]), SYS, content); +check('navChart: complete once every NAV point is discovered (6 of 6)', + chartDone.discovered === 6 && chartDone.total === 6 && chartDone.complete === true && chartDone.missing.length === 0); + +const chartNoState = navChart(null, SYS, content); +check('navChart: a missing discovery state shows nothing discovered (0 of 6)', + chartNoState.discovered === 0 && chartNoState.total === 6 && chartNoState.complete === false); + +// node id <-> system id — the "researched but the gates stay dark" regression. +// A node OBJECT has no `id` field (its id is the key in the tree's `nodes` +// map), so the scene must recover the system id from the id it's PASSED, not +// from `node.id` (which is undefined). systemIdOfGatesNode is that pure core. +check('systemIdOfGatesNode: recovers the system id from a gates node id', + systemIdOfGatesNode(`${SYS}_gates`) === SYS); +check('systemIdOfGatesNode: nullish id -> empty string (the scene guards on it)', + systemIdOfGatesNode(undefined) === '' && systemIdOfGatesNode(null) === '' && systemIdOfGatesNode('') === ''); +check('systemIdOfGatesNode: a NON-gates node id is left as-is (only the _gates suffix is stripped)', + systemIdOfGatesNode(`${SYS}_map`) === `${SYS}_map`); +// The round trip the scene relies on: build the id, then recover the id back. +check('round trip: systemNodeId() then systemIdOfGatesNode() yields the system id', + systemIdOfGatesNode(systemNodeId(SYS, GATES_NODE)) === SYS + && systemIdOfGatesNode(systemNodeId('S000137', GATES_NODE)) === 'S000137'); + // -- activation: the system's gates + the linked systems' RETURN gates ---- const galaxy = { jumpNetwork: { diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 2afe099..fb08b8e 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -423,6 +423,27 @@ one shared category without collisions: `activationKeys`), and per the gate ACTIVITY rule each activated gate anchors a level-1 tether at its own position. + **Diagnosing "I charted everything but it stays locked":** the console + command `orbitNav()` (DevTools → Console, space view — `js/dev/NavDiag.js`, + installed by `main.js`) prints this system's live NAV chart: every NAV + point with a ✓/✗, the discovered/total count, and exactly which objects + are still undiscovered. `orbitNavBrief()` gives the one-line summary. + It builds on the pure `navChart()` core above. + + **Activation wiring (a real bug lived here):** completing the tech runs + `GameScene._applyResearchEffects`, whose `activateGates` branch recovers + the system id from the **node id** it's passed — via the pure + `systemIdOfGatesNode(nodeId)` (`S000137_gates` → `S000137`). A node + **object** carries no `id` field (its id is the key in the tree's + `nodes` map), so reading `node.id` yields `undefined` and the activation + silently never ran ("researched the jumpgates but they stay dark" — it + was *not* a name/case mismatch; the id lookup is by system id, never by + name). `GameScene._onEnterSystem()` now **reconciles** on every entry: + if the system's gates tech is unlocked it re-runs + `_activateSystemJumpgates` (idempotent + silent when already online), + so a missed activation self-heals on the next entry/jump/reload without + a re-research. + 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)` @@ -472,7 +493,14 @@ than follow-on tech. Each node's `unlocks` is the declaration side: - `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), + hooks), `systemIdOfGatesNode(nodeId)` (recover the system id a gates + node id encodes — the node object has no `id` field, so the scene passes + the id in; the pure core of the activation wiring), + `navPoints(content)` (the chart's NAV points + their kind), + `navPointIds(content)` (the chart's NAV points, ids only), + `navChart(discovery, systemId, content)` (the live discovered/missing + split that gates the jumpgate tech — the pure core of the `orbitNav()` + console diagnostic in `js/dev/NavDiag.js`), `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), diff --git a/js/dev/NavDiag.js b/js/dev/NavDiag.js new file mode 100644 index 0000000..ea86f77 --- /dev/null +++ b/js/dev/NavDiag.js @@ -0,0 +1,131 @@ +/** + * js/dev/NavDiag.js — in-game diagnostics for the SYSTEM category's NAV chart. + * + * Installed by js/main.js on every real page load (index.html). Read-only and + * defensive: it observes the live game and prints a report — it never mutates + * state. + * + * The jumpgate tech unlocks only when EVERY NAV point of the current system + * is discovered (js/research/SystemCategory.js — navChart). This tells you + * exactly which ones are still missing, so "I visited everything but the + * gates stay locked" resolves in one glance instead of by guesswork. + * + * Console commands (DevTools → Console, in the RUNNING game — the space view, + * not a planet surface): + * + * orbitNav() → full chart (also logged). Copy-paste the returned + * string when asking for help. + * orbitNavBrief() → one-line summary. + * + * If `orbitNav` is NOT DEFINED in your console, the browser served an OLDER + * js/main.js (stale module cache) — hard-reload with the cache bypassed + * (Ctrl+Shift+R, or DevTools → Network → Disable cache) and check again. The + * version marker below changes with each revision. + */ +import { config } from '../config/Config.js'; +import { navChart } from '../research/SystemCategory.js'; + +export const NAV_DIAG_V = 1; + +const KIND_LABEL = { + home: 'home world', + planet: 'planet', + station: 'space station', + gate: 'jump gate', +}; + +function sceneOf(key) { + try { + return globalThis.window?.game?.scene?.getScene(key) ?? null; + } catch { + return null; + } +} + +/** + * The current system's live NAV chart, with readable names + type labels + * (resolved from the scene's discoverable set — the same names the compass + * and discovery toasts show). + */ +function chartOf(gs) { + const systemId = gs?.systemRecord?.id ?? null; + const system = gs?.systemRecord?.name ?? systemId ?? '?'; + const chart = navChart(gs?.discovery, systemId, gs?.systemContent); + const names = new Map((gs?.discoverableObjects?.() ?? []).map((o) => [o.id, o])); + const points = chart.points.map((p) => { + const o = names.get(p.id); + const name = o?.name ?? (p.id === 'home' ? gs?.homeWorldName ?? p.id : p.id); + return { ...p, name, typeLabel: o?.typeLabel ?? KIND_LABEL[p.kind] ?? p.kind }; + }); + return { system, systemId, chart, points }; +} + +const ABSENT = `[orbit-nav v${NAV_DIAG_V}] GameScene absent — run this from the space view (not a planet surface)`; + +/** One-line summary — the same facts a support request needs. */ +export function brief() { + const gs = sceneOf('GameScene'); + if (!gs) { + console.info(ABSENT); + return ABSENT; + } + const { system, systemId, chart, points } = chartOf(gs); + const missing = points.filter((p) => !p.discovered).map((p) => p.name); + const state = chart.complete + ? 'COMPLETE → jumpgates RESEARCHABLE' + : 'INCOMPLETE → jumpgates LOCKED'; + const line = + `[orbit-nav v${NAV_DIAG_V}] ` + + `system=${system}[${systemId ?? '?'}] ` + + `chart=${chart.discovered}/${chart.total} ${state} ` + + `missing=${JSON.stringify(missing)}`; + console.info(line); + return line; +} + +/** Full report. Logs it, returns the string (copy-paste friendly). */ +export function full() { + const gs = sceneOf('GameScene'); + if (!gs) { + console.info(ABSENT); + return ABSENT; + } + const { system, systemId, chart, points } = chartOf(gs); + const dist = config.get('game.discovery.distance', 540); + const state = chart.complete + ? 'COMPLETE — the jumpgate tech should be RESEARCHABLE' + : 'INCOMPLETE — the jumpgate tech is LOCKED'; + const missing = points.filter((p) => !p.discovered); + const lines = [ + `ORBIT NAV CHART v${NAV_DIAG_V}`, + `[system] ${system} [${systemId ?? '?'}]`, + `[chart] ${chart.discovered} of ${chart.total} NAV points discovered → ${state}`, + ]; + for (const p of points) { + lines.push( + ` ${p.discovered ? '✓' : '✗'} ${p.kind.padEnd(7)} ${String(p.name).padEnd(22)} ` + + `(${p.typeLabel})${p.discovered ? '' : ' ← undiscovered'}`, + ); + } + if (missing.length) { + lines.push(`[missing] ${missing.map((p) => p.name).join(', ')}`); + lines.push( + `[tip] fly within ${dist} px of each missing object to discover it — ` + + 'jump gates and free-space stations are the ones easy to fly past', + ); + } else { + lines.push(`[ok] every NAV point is discovered — research the jumpgate tech in the console`); + } + const text = lines.join('\n'); + console.info(text); + return text; +} + +/** Install the console commands. Idempotent. */ +export function installNavDiag() { + if (typeof globalThis === 'undefined') return; + if (!globalThis.addEventListener) return; + globalThis.orbitNav = () => full(); + globalThis.orbitNavBrief = () => brief(); + console.info(`[orbit-nav v${NAV_DIAG_V}] installed — type orbitNav() in the console to see this system's NAV chart`); +} diff --git a/js/main.js b/js/main.js index fad61be..fc7fad3 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,7 @@ import { MenuScene } from './scenes/MenuScene.js'; import { GameScene } from './scenes/GameScene.js'; import { SurfaceScene } from './scenes/SurfaceScene.js'; import { installBuildDiag } from './dev/BuildDiag.js'; +import { installNavDiag } from './dev/NavDiag.js'; /** * Orbit — entry point. @@ -43,6 +44,11 @@ async function boot() { // the Build console opens. installBuildDiag(); + // The SYSTEM category's NAV chart (orbitNav / orbitNavBrief). See + // js/dev/NavDiag.js — which NAV points of the current system are still + // undiscovered, i.e. why the jumpgate tech may still be locked. + installNavDiag(); + await awaitFonts(); console.info('orbit — fonts ready (or timed out)'); diff --git a/js/research/SystemCategory.js b/js/research/SystemCategory.js index 3617782..d01f5b5 100644 --- a/js/research/SystemCategory.js +++ b/js/research/SystemCategory.js @@ -49,6 +49,20 @@ 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); @@ -137,15 +151,57 @@ export function buildSystemTree(o = {}) { * @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); +/** + * The system's NAV points (with their KIND), in a stable order: the central + * body ('home'), 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. + * + * @param {object} content — a generated system content (ensureContent) + * @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}> + */ +export function navPoints(content) { + const out = [{ id: 'home', kind: 'home' }]; // the central body — discoverable in every system + 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') ids.push(s.id); + 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') ids.push(j.id); - return ids; + 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, + }; } /** diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index adfc782..0f351cb 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -37,7 +37,9 @@ import { categories, loadCategory, isAvailable, buildDefs } from '../research/Re import { SYSTEM_CATEGORY, MAP_NODE, + GATES_NODE, systemNodeId, + systemIdOfGatesNode, buildSystemTree, isNavComplete, activationKeys, @@ -1928,7 +1930,7 @@ export class GameScene extends Phaser.Scene { if (durMs <= 0) { // Instant tech (duration 0) — applies the moment it's requested. state.unlock(catId, id); - this._applyResearchEffects(node.effects, node); + this._applyResearchEffects(node.effects, node, id); this._completeResearchFx(catId, id, node); return; } @@ -1948,7 +1950,7 @@ export class GameScene extends Phaser.Scene { if (!state) return; state.unlock(catId, id); const node = this._treeFor(catId)?.nodes?.[id]; - if (node?.effects) this._applyResearchEffects(node.effects, node); + if (node?.effects) this._applyResearchEffects(node.effects, node, id); this._completeResearchFx(catId, id, node); } @@ -1967,6 +1969,14 @@ export class GameScene extends Phaser.Scene { _onEnterSystem() { if (!this.researchState || !this.systemRecord) return; this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE)); + // Invariant: if this system's gates tech is researched, its gates are + // active. Reconcile on entry — idempotent (silent when already online), + // and it recovers any system whose activation was missed at completion + // without a re-research (the node.id bug) or a save round-trip. + const gatesId = systemNodeId(this.systemRecord.id, GATES_NODE); + if (this.researchState.isUnlocked(SYSTEM_CATEGORY, gatesId)) { + this._activateSystemJumpgates(this.systemRecord.id); + } this.researchWindow?.refresh(); } @@ -1994,7 +2004,12 @@ export class GameScene extends Phaser.Scene { _activateSystemJumpgates(sysId) { const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1)); const keys = activationKeys(this.galaxy, sysId); + // Idempotent: only the NOT-yet-active keys count as new. Re-entering a + // system (a jump, a load, a refresh) re-runs this — the early return + // keeps it silent when everything is already online. + const fresh = keys.filter((k) => !this.activatedGates.has(k)); for (const k of keys) this.activatedGates.add(k); + if (fresh.length === 0) return; // Current system — the gate entities wake + anchor their tethers. for (const gt of this.systemGates ?? []) { @@ -2026,14 +2041,16 @@ export class GameScene extends Phaser.Scene { /** * Apply a tech's effects (node.effects, data/research/.json — or - * the SYSTEM category's live tree). Known shapes: + * the SYSTEM category's live tree). `id` is the node's id (the key in the + * tree's `nodes` map — the node object itself carries no `id` field). + * Known shapes: * tether { level: N } → the home world's tether field strengthens * capability "flag" → a scene capability set (future systems read it) * activateGates true → the SYSTEM category: the system's jump gates * + the linked systems' return gates go ACTIVE * Unknown shapes are logged and skipped — data can lead code a step. */ - _applyResearchEffects(effects, node) { + _applyResearchEffects(effects, node, id) { if (!effects || typeof effects !== 'object') return; for (const [type, spec] of Object.entries(effects)) { if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) { @@ -2048,11 +2065,13 @@ export class GameScene extends Phaser.Scene { this.researchCapabilities = this.researchCapabilities ?? new Set(); this.researchCapabilities.add(spec); } else if (type === 'activateGates' && spec) { - // SYSTEM category — the node id is "_gates". - const sysId = String(node?.id ?? '').replace(/_gates$/, ''); + // SYSTEM category — the node id is "_gates". The node object + // carries no `id` field (it's the key in the tree's `nodes` map), so + // `id` (passed in) is the source of truth — NOT `node.id`. + const sysId = systemIdOfGatesNode(id); if (sysId) this._activateSystemJumpgates(sysId); } else { - console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id); + console.warn(`[orbit] research: unknown effect ${type}`, spec, id); } } }