Add build console with tether-l2 install, dev server, and diagnostics

- Introduce the BUILD deck slot on planet surfaces: full-screen BuildWindow
  (looping feed video, category tabs, build list, dossier + cost + BUILD
  button) backed by pure BuildModel/BuildState rules
- Move tether level 2 from a research effect to a one-off planet build
  (200 minerals, 20 s, requires L1 tether on the world); home world starts
  with tether-l1 pre-installed as a rule re-asserted after load
- Add `node dev/server.mjs` static server sending Cache-Control: no-store to
  stop browsers heuristically caching ES modules (the "new data + old JS" bug)
- Fix world-name casing leak: CommsPanel keeps canonical spelling in the
  landing payload; GameScene.startLanding normalizes via WorldNames.canonicalPlanetName
  so name-keyed build/tether lookups match
- Add in-game diagnostics (orbitDiag/orbitDiagBrief), dev/build-check.html
  self-check page, and Node tests for builds, world names, and save staging
- Persist builds (built records + in-flight remaining time) in the save bank;
  tick on the game-loop clock so a build survives takeoff mid-build
This commit is contained in:
Brian Fertig 2026-09-05 18:55:29 -06:00
parent d7f8e2101f
commit 1d1cb2c150
27 changed files with 3205 additions and 91 deletions

View File

@ -10,10 +10,21 @@ Any static file server works (Python, Node, Caddy, nginx, …):
```sh
cd orbit
python3 -m http.server 8080
node dev/server.mjs 8080
# → http://localhost:8080
```
> **Why not `python3 -m http.server`?** It sends `Last-Modified` with no
> `Cache-Control`, so browsers apply *heuristic* caching to the ES
> modules. While iterating, the browser then keeps running an **old**
> `js/*.js` module graph (e.g. a `GameScene.js` from before a feature
> landed) long after the files on disk changed — while the JSON data is
> fresh. That mix (new data + old JS) is the classic "it still behaves
> like the old version" bug, and clearing the JSON cache or hard-resetting
> the game does not fix it. The dev server above sends
> `Cache-Control: no-store` on every response, so a normal reload always
> runs what's on disk.
> Must be served over **http(s)** — opening `index.html` via `file://` won't
> work, because the game uses ES modules and `fetch`es its JSON config.
@ -92,6 +103,25 @@ python3 -m http.server 8080
`data/research/` (the first is **Exploration**: Tether Level 14,
Tether Anchoring, Signal Amplification). Unlocks and in-progress work
persist in the save bank — and resume correctly on load
- **The Build console** — the **BUILD** deck button (on a planet surface,
where the deck's SHOP/BUILD/TAKE OFF live) opens a full-screen window:
the left pane loops the muted 2:3 `assets/videos/build.mp4` build feed
(scanlines, sweep band, REC pulse), the right pane holds the category
tabs (**Planet / Cargo**, `data/builds.json`) and a **list** of the
category's buildable items — not a tree: builds are one-off installs on
the planet. Locked items are grayed out with their missing requirements
(research, tether level); installed ones read **BUILT ✓**. Selecting a
build shows its dossier (glyph + description) with the **highlighted
cost** and a **BUILD** button that appears only when the build is
available and affordable (one build at a time, `maxConcurrent: 1`). The
home world starts with **Tether Level 1** already installed; **Tether
Level 2** needs the level-2 tether research plus a world holding a
level-1 tether, and installs in **20 s for 200 minerals** — the planet's
tether range then grows from 5120 to 6400 px (world state that outlives
the surface stay). While a build runs the whole deck is locked (BUILD
stays open to watch the progress); completion fires the build's
`effects` and a toast. Build records + the in-progress build persist in
the save bank — and resume with their remaining time on load
- Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest
@ -117,7 +147,7 @@ orbit/
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/
│ │ └── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
@ -131,7 +161,8 @@ orbit/
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow, BuildWindow
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser
@ -163,19 +194,34 @@ Beyond flying, Orbit is built around two progression verbs:
asks `GameScene` to start a run. `effects` on a finished node carry the
payload (e.g. `{ tether: { level: 2 } }``TetherField.setLevel`), so new
effect kinds plug in without touching the tree data.
- **Building** (`data/builds.json`) — *cost-based*, paid in **credits and
minerals** (`resources`). A build improves the **ship**, a **planet**, or a
**space station** (`category`). `builds` is an empty map; the `_template`
entry documents the shape (including `repeatable`, for things like extra
mining rigs). The build UI is next — the research side is live now.
- **Building** (`data/builds.json`) — *cost-based*, paid in **minerals**
(and credits, when they land; `resources`). A build is a **one-off
install on a planet** (`category: planet | cargo`): it names its `cost`,
its `duration` (seconds), the research that unlocks it (`requires`,
mirroring the research tree's `unlocks.builds`), optional world-state
gates (`planetRequires`, e.g. the tether level on that world), and its
`effects` (e.g. `tether: {level: 2}` → the world's tether field
strengthens). The BUILD deck button (on a planet surface) opens the
console window (`js/ui/BuildWindow.js`): looping build feed, category
tabs, the build LIST (locked items grayed with their missing gates),
per-build dossier + highlighted cost + BUILD button. While a build runs
the deck is locked (one at a time) and the state ticks on the game-loop
clock — so a build started on the surface keeps running if the player
takes off. The rules are pure (`js/build/BuildModel.js`, `BuildState.js`
— Node-tested in `dev/builds.test.mjs`); the window is a passive view
that asks `GameScene.beginBuild()` to run the gates, charge the cost,
and start the clock, and the scene applies `effects` on completion.
- **The command deck** (`data/actionbar.json`, rendered by
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**. The slots
fire `onAction` and are otherwise inert; two slots are reserved. Dressed
with the menu's CRT language — scanlines over the strip and the GlitchText
RGB pull-apart on labels and the panel outline during bursts. All
layout, palette, and motion (boot flicker, rail comet, glitch bursts, RGB
split, scanlines) live in the config.
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the
screen. Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**
in space; on a planet surface the same bar re-deals itself —
**Shop, Build, Ship, ·, Take Off, Menu** (`surface.buttons`). The
slots fire `onAction` (GameScene / SurfaceScene own the behavior);
two slots are reserved. Dressed with the menu's CRT language —
scanlines over the strip and the GlitchText RGB pull-apart on labels
and the panel outline during bursts. All layout, palette, and motion
(boot flicker, rail comet, glitch bursts, RGB split, scanlines) live in
the config.
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
runtime data — loaders and tests ignore them.
@ -189,6 +235,9 @@ node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs ea
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
node dev/builds.test.mjs # the build console's pure rules + state machine (Node)
node dev/world-names.test.mjs # world-name casing: display casing must never leak into the data
node dev/saves.test.mjs # the save bank + capture/restore hand-off (incl. builds)
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
@ -206,6 +255,17 @@ node dev/cdp-shot.mjs "http://127.0.0.1:8081/dev/research-shot.html" shot.png \
"window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" 90000
```
`dev/build-check.html` plays the real Build flow (menu → New Game → home
world → build console) and prints a plain-text report of the L1/L2 row
states. Every real page (index.html) also carries a read-only console
diagnostic: opening the Build console logs a one-line `[orbit-diag v…]`
summary (planet, home, built records, tether level, L1/L2 row states,
stale-JS probe), and `orbitDiag()` in DevTools prints the full report
(data contract, save bank, scene wiring, errors since boot). If
`orbitDiag` is *undefined*, the browser served a cached older
`js/main.js` — run the dev server above, or hard-reload with the cache
disabled (DevTools → Network → Disable cache).
## Phaser
Phaser 4.2.1 is vendored at `lib/phaser.min.js` (UMD build, MIT license —

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
assets/videos/build.mp4 Normal file

Binary file not shown.

View File

@ -1,50 +1,113 @@
{
"_comment": "BUILDABLE ITEMS — cost-based improvements. A build costs the `cost` below (in `resources`) and takes effect when purchased; it is AVAILABLE once every research id in its `requires` is complete — ids are \"<category>/<node id>\" pointing at data/research/<category>.json → nodes (e.g. \"exploration/tether_l2\"). The research side declares the same relationship in its node's `unlocks.builds`; dev/research-builds.test.mjs keeps the two in lock-step. `category` says where it applies: `ship` = improve the player's ship, `planet` = improve things on planets, `station` = improve space stations, `general` = new capabilities for the player; `targets` (optional) lists the surfaces a build can be placed on (defaults to `[category]` when absent). `repeatable` builds can be bought more than once (treat as false when absent). Add a build = one entry under `builds` (the key is the build's id). Keys starting with `_` (like `_template`) are documentation, not data. The build state/UI is not implemented yet — this file is the data layer that will drive it.",
"_comment": "Build console (the deck's BUILD button on a planet surface → js/ui/BuildWindow.js). Research UNLOCKS a build (data/research/*.json → unlocks.builds); building then COSTS resources + time on a planet and applies the effect (e.g. the level-2 tether). Pure rules/state: js/build/BuildModel.js + BuildState.js (dev/builds.test.mjs). One build at a time (maxConcurrent), ticked by the SurfaceScene while on the surface; the state (js/scenes/GameScene.js → buildState) outlives the stay and saves with the run.",
"enabled": true,
"timeUnit": "seconds",
"maxConcurrent": 1,
"categories": [
{
"id": "planet",
"label": "Planet",
"accent": "#ffc94d"
},
{
"id": "cargo",
"label": "Cargo",
"accent": "#7ce8a4"
}
],
"defaultCategory": "planet",
"video": {
"file": "assets/videos/build.mp4",
"aspect": [2, 3]
},
"resources": {
"credits": {
"label": "Credits",
"description": "Money — the galaxy's currency."
"icon": "credit",
"color": "#7ce8a4"
},
"minerals": {
"label": "Minerals",
"description": "Raw materials — mined from resource worlds."
"icon": "mineral",
"color": "#00e5ff"
}
},
"builds": {
"tether-l2": {
"_comment": "Placeholder entry — proves the research→build unlock wiring (exploration/tether_l2 `unlocks.builds` ↔ this `requires`). Cost/effect are first guesses: tune when the build panel lands.",
"label": "Tether Ring · Level 2",
"description": "Anchor a level-2 tether ring on a planet or space station you hold. Its 6,400 m field stitches into your home tether — open space where there was a wall.",
"category": "general",
"targets": ["planet", "station"],
"cost": {
"credits": 400,
"minerals": 60
"tether-l1": {
"label": "Tether - Level 1",
"description": "The world's first tether: a level-1 field (range 5120 px) holding the ship's travel rim around the planet. Every world starts with its tether already in place.",
"icon": "tether",
"category": "planet",
"targets": ["planet"],
"cost": {},
"duration": 0,
"requires": [],
"repeatable": false,
"starting": ["home"],
"effects": {},
"theme": {
"color": "#00e5ff"
}
},
"tether-l2": {
"label": "Tether - Level 2",
"description": "Extends the planet's tether to level 2: the field's range grows from 5120 px to 6400 px around the world. Requires the level-2 tether research and a planet already holding a level-1 tether. Installed in 20 seconds for 200 minerals.",
"icon": "tether2",
"category": "planet",
"targets": ["planet"],
"cost": {
"minerals": 200
},
"duration": 20,
"requires": ["exploration/tether_l2"],
"planetRequires": {
"tetherLevel": 1
},
"repeatable": false,
"effects": {
"tether": { "level": 2, "anchor": "target" }
"tether": {
"level": 2,
"anchor": "target"
}
},
"theme": {
"color": "#00e5ff"
}
}
},
"_template": {
"label": "Buildable Item",
"description": "What this is and what it does.",
"category": "ship",
"targets": ["ship"],
"_comment": "Shape of one entry in `builds` (the _ keys are documentation, never builds).",
"label": "string — shown in the list and the detail readout",
"description": "string — detail readout text",
"icon": "tether | tether2 | anchor | signal | diamond — procedural glyph (js/research/ResearchIcons.js)",
"category": "planet | cargo — which tab of the console (registry above)",
"targets": ["planet"],
"cost": {
"credits": 250,
"minerals": 40
"credits": 0,
"minerals": 200
},
"requires": ["exploration/<node id>"],
"duration": "number — seconds (timeUnit above); 0 = instant",
"requires": ["exploration/tether_l2"],
"repeatable": false,
"effects": {},
"starting": ["home"],
"planetRequires": {
"tetherLevel": 1
},
"effects": {
"tether": {
"level": 2,
"anchor": "target"
}
},
"theme": {
"color": "#ffc94d"
"color": "#00e5ff"
}
}
}

View File

@ -1,5 +1,5 @@
{
"_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level1), see data/tether.json and docs/PROJECT_NOTES.md). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level1), see data/tether.json and docs/PROJECT_NOTES.md) — except Tether Level 2, which is the BLUEPRINT only (effects: {}): the player installs it as a TETHER - LEVEL 2 build on a world holding a level-1 tether (data/builds.json → tether-l2; the build carries the effect). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"starting": ["tether_l1"],
"nodes": {
"tether_l1": {
@ -13,12 +13,12 @@
},
"tether_l2": {
"label": "Tether Level 2",
"description": "A second stage of field power. The boundary ring pulls out to 6,400 m — the far reaches of a compact system drop inside your reach, and the keep-out wall moves with you. Also unlocks anchoring level-2 tether rings on worlds and stations you hold (build: tether-l2).",
"description": "A second stage of field power. The blueprint for a stronger tether: install it with a TETHER - LEVEL 2 build on a world that holds a level-1 tether (200 minerals, 20 s — data/builds.json) and that world's boundary ring pulls out to 6,400 m.",
"icon": "tether",
"duration": 60,
"requires": ["tether_l1"],
"unlocks": { "builds": ["tether-l2"], "research": ["tether_l3", "tether_anchors"] },
"effects": { "tether": { "level": 2 } }
"effects": {}
},
"tether_l3": {
"label": "Tether Level 3",

17
dev/build-check.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Build check (dev)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/build-check.mjs"></script>
</body>
</html>

147
dev/build-check.mjs Normal file
View File

@ -0,0 +1,147 @@
/**
* Dev-only: the BUILD system self-check plays the real flow
* (menu NEW GAME land on the home world open the Build console)
* and paints a plain-text report (top-left, outside the canvas) that
* answers "why is my home world's Tether - Level 1 not BUILT?":
*
* 1. Is the browser running the current JS? (stale-module probe
* a hard cache-bypass reload is the usual cure)
* 2. Which world is home, and what does the build state say about it?
* 3. What tether does the home world hold (L2's planet gate)?
* 4. The console's actual row states before and after the
* tether_l2 research L1 must read BUILT; L2 must flip from
* LOCKED (research) to BUILDABLE (home already holds a L1 tether).
*
* node dev/slow-server.mjs 8080 # or any static server on the repo
* open http://127.0.0.1:8080/dev/build-check.html
*
* Headless screenshot:
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/build-check.html" \
* out.png "document.getElementById('report').textContent.includes('SELF-CHECK DONE')"
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { MenuScene } from '../js/scenes/MenuScene.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const game = new Phaser.Game({ ...createGameConfig(), scene: [MenuScene, GameScene, SurfaceScene] });
window.game = game;
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:72%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
setReport('SELF-CHECK RUNNING… (menu → new game → home → build console)');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(fn, tries = 150, everyMs = 100) {
for (let i = 0; i < tries; i++) {
const v = fn();
if (v) return v;
await sleep(everyMs);
}
return null;
}
try {
// 0) Stale-JS probe — does the loaded GameScene carry the build system
// at all? (A browser module cache from an older session is the
// classic cause of "I cleared the JSON cache but it's still wrong.")
const GS = GameScene;
const jsProbe = {
buildStateSeam: typeof GS.prototype._seedStartingBuilds === 'function',
beginBuild: typeof GS.prototype.beginBuild === 'function',
buildsConfig: Object.keys(data.builds?.builds ?? {}).join(',') || '(none — stale data/builds.json?)',
};
// 1) The real menu flow: NEW GAME (fresh seed, registry reset).
const menu = await waitFor(() => { const m = game.scene.getScene('MenuScene'); return m?.newGameBtn ? m : null; });
if (!menu) throw new Error('menu never booted');
menu.startNewGame();
const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship && g?.buildState ? g : null; }, 200);
if (!gs) throw new Error('GameScene never booted');
await sleep(800);
const home = gs.planet.discoveryName;
const builtMap = Object.fromEntries([...gs.buildState.built.entries()].map(([k, v]) => [k, [...v]]));
const homeTether = gs.tetherField.tethers.filter((t) => t.label === home || (t.x === 0 && t.y === 0)).map((t) => `${t.id}:L${t.level}`);
// 2) Land on the home world the game's own way.
gs.startLanding(gs.commsTargetFor(gs.planet));
const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.buildWindow ? c : null; });
if (!ss) throw new Error('surface never booted');
await sleep(700);
// 3) The console's truth — the same pure functions the window paints.
const { rowState, missingRequirements } = await import('../js/build/BuildModel.js');
const win = ss.buildWindow;
win.open();
await sleep(400);
const row = (id) => {
const r = win.lists.get('planet')?.rows?.find((x) => x.id === id);
if (!r) return 'NO ROW';
const ctx = win.ctxFor(id);
const missing = missingRequirements(r.def, ctx);
return `${rowState(r.def, ctx, win._activeOnPlanet(), id).toUpperCase()}${missing.length ? ' (needs: ' + missing.join(' + ') + ')' : ''}`;
};
const before = `L1: ${row('tether-l1')} L2: ${row('tether-l2')}`;
// 4) The research is "complete" → L2's only remaining gate on home is
// the planet's own tether level (it holds L1, so it must pass).
gs.researchState.unlock('exploration', 'tether_l2');
win.refresh();
await sleep(200);
const after = `L1: ${row('tether-l1')} L2: ${row('tether-l2')}`;
win.close();
const l1ok = builtMap[home]?.includes('tether-l1');
const l2ok = after.includes('L2: AVAILABLE');
const lines = [
'SELF-CHECK DONE — the home world starts with its Tether - Level 1 BUILT.',
`JS probe: seed=${jsProbe.buildStateSeam} beginBuild=${jsProbe.beginBuild} builds=[${jsProbe.buildsConfig}]`,
`home world: ${home}`,
`built records: ${JSON.stringify(builtMap)}`,
`home tether: ${homeTether.join(', ') || 'NONE'}`,
`console rows (fresh): ${before}`,
`console rows (research done): ${after}`,
l1ok && l2ok
? 'RESULT: OK — if YOUR game differs, the browser is running stale JS'
+ ' (hard reload, Ctrl+Shift+R) or you are on a DIFFERENT world (home is the one with the'
+ ' tether barrier ring, tagged "Home World" in comms).'
: `RESULT: PROBLEM — L1 built=${l1ok}, L2 available=${l2ok}`,
errors.length ? `ERRORS:\n${errors.slice(0, 3).join('\n')}` : 'no console errors',
];
setReport(lines);
window.__BUILD_CHECK = { ready: true, lines, errors };
console.info('build-check: report painted');
} catch (err) {
// Even a failed run should hand back the useful facts: which JS is
// actually loaded (stale-cache probe), what state exists, what broke.
const gs = game.scene.getScene('GameScene');
const seedFn = typeof gs?._seedStartingBuilds === 'function';
setReport([
`FATAL: ${err.message}`,
`JS probe: seed=${seedFn} beginBuild=${typeof gs?.beginBuild === 'function'}` +
(seedFn
? ''
: ' ← STALE JS — the browser served a cached older module. Use `node dev/server.mjs 8080`' +
' (sends Cache-Control: no-store) and/or DevTools → Network → Disable cache, then reload.'),
`home=${gs?.planet?.discoveryName ?? 'n/a'} builtMap=${gs?.buildState ? JSON.stringify(Object.fromEntries([...gs.buildState.built.entries()].map(([k, v]) => [k, [...v]]))) : 'n/a'}`,
errors.length ? `ERRORS:\n${errors.slice(0, 3).join('\n')}` : 'no console errors',
]);
window.__BUILD_CHECK = { ready: true, fatal: String(err.message), errors };
console.error('build-check: fatal', err);
}

192
dev/builds.test.mjs Normal file
View File

@ -0,0 +1,192 @@
/**
* Build console data-layer test (dev tool, run with Node no browser):
*
* node dev/builds.test.mjs
*
* Pins the contract behind the build console (the deck's BUILD button on a
* planet surface js/ui/BuildWindow.js):
* - the BuildModel rules (data/builds.json): categories, the per-category
* build list, starting installs, the research + planet gates, the cost
* lines and affordability;
* - the BuildState machine: the per-planet built records, the single
* in-progress build (one at a time), progress 01 over the duration,
* tick() completion, and the save/restore round-trip (the in-flight
* build keeps its remaining time across a save/load).
*
* The scene-side enforcement (GameScene.beginBuild / completeBuild) is the
* authoritative pass the model/state here are the rules it enforces.
*/
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'));
const manifest = read('manifest.json');
const research = read('research.json');
const exploration = read('research/exploration.json');
const builds = read('builds.json');
const actionbar = read('actionbar.json');
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// ----------------------------------------------------------------------
// The pure modules (no Phaser — Node-safe)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, builds, actionbar });
const {
categories,
loadBuilds,
startingPairs,
missingRequirements,
isAvailable,
rowState,
costLines,
canAfford,
} = await import('../js/build/BuildModel.js');
const { BuildState } = await import('../js/build/BuildState.js');
// A hand-composed world view (the same ctx the window composes from seams):
// fresh run — nothing researched beyond the starting set, home holds a
// level-1 tether, nothing built anywhere.
const ctx = (over = {}) => ({
isResearchUnlocked: (c, n) => c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n)),
tetherLevel: () => over.tetherLevel ?? 1,
isBuilt: typeof over.isBuilt === 'function' ? over.isBuilt : () => false,
});
// ----------------------------------------------------------------------
// 1. The category registry + per-category build lists
// ----------------------------------------------------------------------
check('model: categories() is the console tab registry', categories().every((c) => c.id && c.label));
check('model: the console has the Planet + Cargo tabs', ['planet', 'cargo'].every((id) => categories().some((c) => c.id === id)));
const planet = loadBuilds('planet');
const cargo = loadBuilds('cargo');
check('model: loadBuilds("planet") holds both tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2']);
check('model: loadBuilds("planet") keeps registry entry (label/accent)', planet.label === 'Planet' && typeof planet.accent === 'string');
check('model: loadBuilds("cargo") is empty today (no cargo modules yet)', Object.keys(cargo.builds).length === 0);
check('model: an unknown category falls back to the default', loadBuilds('nope').id === builds.defaultCategory);
// ----------------------------------------------------------------------
// 2. Starting installs — the home world begins with Tether - Level 1
// ----------------------------------------------------------------------
const pairs = startingPairs();
check('model: startingPairs() seeds home with tether-l1', pairs.some(([p, b]) => p === 'home' && b === 'tether-l1'));
const fresh = new BuildState();
for (const [p, b] of pairs) {
const name = p === 'home' ? 'Home World' : p; // the scene's 'home' → name resolution
fresh.markBuilt(name, b);
}
check('state: a fresh run has tether-l1 installed on the home world', fresh.isBuilt('Home World', 'tether-l1'));
check('state: a fresh run has nothing built elsewhere', fresh.isBuilt('Other', 'tether-l1') === false && fresh.isBuilt('Home World', 'tether-l2') === false);
// A save captured before the starting seed existed (or by an older
// iteration) carries a `built` set without the home world's L1. The
// scene re-asserts the `starting` installs after the restore
// (GameScene._seedStartingBuilds — they are a RULE, not save data), so
// a resumed run still shows Tether - Level 1 installed on home.
const stale = new BuildState().fromJSON({ built: {}, active: null });
check('state: a stale save alone does NOT carry the home install', stale.isBuilt('Home World', 'tether-l1') === false);
for (const [p, b] of pairs) if (p === 'home') stale.markBuilt('Home World', b); // the re-seed
check('state: re-asserting the starting pair restores the home install', stale.isBuilt('Home World', 'tether-l1'));
// ----------------------------------------------------------------------
// 3. Availability — the research + planet gates (tether-l2)
// ----------------------------------------------------------------------
const tl2 = builds.builds['tether-l2'];
check('model: tether-l2 is LOCKED before the research', isAvailable(tl2, ctx()) === false);
check('model: missingRequirements names the missing research first', missingRequirements(tl2, ctx())[0]?.startsWith('RESEARCH:'));
check('model: tether-l2 is READY once researched (planet holds L1)', isAvailable(tl2, ctx({ research: ['tether_l2'] })) === true);
check('model: no missing requirements once researched', missingRequirements(tl2, ctx({ research: ['tether_l2'] })).length === 0);
check('model: tether-l2 stays LOCKED without a level-1 tether on the planet', isAvailable(tl2, ctx({ research: ['tether_l2'], tetherLevel: 0 })) === false);
check('model: the missing-requirements line names the tether gate', missingRequirements(tl2, ctx({ research: ['tether_l2'], tetherLevel: 0 })).some((s) => s.includes('TETHER')));
// a planet holding a level-2 tether satisfies the "≥ L1" gate
check('model: a stronger existing tether satisfies the gate', isAvailable(tl2, ctx({ research: ['tether_l2'], tetherLevel: 3 })) === true);
// already built on the planet → never offered again (one-off)
check('model: an installed build is not available again', isAvailable(tl2, ctx({ research: ['tether_l2'], isBuilt: () => true })) === false);
// ----------------------------------------------------------------------
// 4. Row states — the list's paint source
// ----------------------------------------------------------------------
check('model: rowState reads built', rowState(tl2, ctx({ isBuilt: () => true }), null, 'tether-l2') === 'built');
const activeSpec = { planet: 'Home World', build: 'tether-l2', startedAt: 0, durationMs: 20_000 };
check('model: rowState reads active (the build on THIS planet)', rowState(tl2, ctx({ research: ['tether_l2'] }), activeSpec, 'tether-l2') === 'active');
check('model: rowState reads available', rowState(tl2, ctx({ research: ['tether_l2'] }), null, 'tether-l2') === 'available');
check('model: rowState reads locked', rowState(tl2, ctx(), null, 'tether-l2') === 'locked');
// ----------------------------------------------------------------------
// 5. Cost — the highlighted cost line + affordability
// ----------------------------------------------------------------------
const lines = costLines(tl2);
check('model: costLines(tether-l2) = 200 minerals', lines.length === 1 && lines[0].res === 'minerals' && lines[0].amount === 200);
check('model: canAfford is false below the cost', canAfford(tl2, 199) === false);
check('model: canAfford is true at/above the cost', canAfford(tl2, 200) === true && canAfford(tl2, 5000) === true);
check('model: a free build has no cost lines', costLines(builds.builds['tether-l1']).length === 0);
check('model: a free build is always affordable', canAfford(builds.builds['tether-l1'], 0) === true);
// ----------------------------------------------------------------------
// 6. BuildState — the single in-progress build
// ----------------------------------------------------------------------
const s = new BuildState();
check('state: start() claims the slot', s.start('Home World', 'tether-l2', 20_000, 1000) === true);
check('state: a second start() is refused while one runs', s.start('Other', 'tether-l2', 20_000, 1000) === false);
check('state: start() refuses an already-installed build', (() => { const q = new BuildState(); q.markBuilt('Home World', 'tether-l1'); return q.start('Home World', 'tether-l1', 1000, 0) === false; })());
check('state: start() refuses an invalid duration', (() => { const q = new BuildState(); return q.start('Home World', 'tether-l2', 0, 0) === false; })());
const p0 = s.progress(1000);
const p1 = s.progress(110_000);
check('state: progress is 0 at start, 1 at the deadline', p0.fraction === 0 && p1.fraction === 1);
check('state: progress carries the remaining time', p0.remainingMs === 20_000 && p1.remainingMs === 0);
check('state: tick() reports nothing before the deadline', s.tick(20_999).length === 0 && s.getActive() !== null);
const done = s.tick(21_000);
check('state: tick() reports the completion at the deadline', done.length === 1 && done[0].planet === 'Home World' && done[0].build === 'tether-l2');
check('state: the completion marks it built on the planet', s.isBuilt('Home World', 'tether-l2') === true);
check('state: the build slot is free after completion', s.getActive() === null);
check('state: tick() is idempotent after the deadline', s.tick(999_999).length === 0);
// ----------------------------------------------------------------------
// 7. Save/restore round-trip — the in-flight build keeps its remaining time
// ----------------------------------------------------------------------
const r = new BuildState();
r.markBuilt('Home World', 'tether-l1');
r.start('Home World', 'tether-l2', 20_000, 1_000); // started at loop-time 1000 ms
const saved = r.toJSON(11_000); // saved 10 s into a 20 s build
check('save: toJSON carries the built records', JSON.stringify(saved.built['Home World']) === JSON.stringify(['tether-l1']));
check('save: toJSON carries the in-flight build + remaining time (10 s)', saved.active?.build === 'tether-l2' && saved.active?.remainingMs === 10_000);
const loaded = new BuildState().fromJSON(saved);
check('save: fromJSON restores the built records', loaded.isBuilt('Home World', 'tether-l1') === true);
check('save: fromJSON leaves the slot free (restoreActive re-claims it)', loaded.getActive() === null);
loaded.restoreActive(saved.active, 500_000); // reloaded an hour later
check('save: restoreActive keeps the build in flight', loaded.getActive() !== null && loaded.getActive().build === 'tether-l2');
check('save: the restored build finishes 10 s after the load (halfway at load, done at +10 s)', loaded.progress(500_000).fraction < 1 && loaded.progress(510_000).fraction >= 1);
// an old save without builds (no field at all) loads as a clean state
const oldSave = new BuildState().fromJSON(null);
check('save: an old save without a builds field loads clean', oldSave.built.size === 0 && oldSave.getActive() === null);
// restoreActive is a no-op for an already-installed build (the effect
// already landed — the record wins over a stale in-flight entry)
const dup = new BuildState();
dup.markBuilt('Home World', 'tether-l2');
dup.restoreActive({ planet: 'Home World', build: 'tether-l2', durationMs: 20_000, remainingMs: 5000 }, 0);
check('save: restoreActive skips a build the planet already holds', dup.getActive() === null && dup.isBuilt('Home World', 'tether-l2') === true);
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -116,12 +116,16 @@ for (const id of Object.keys(nodes)) {
check('exploration: the tree is a DAG (no cycles)', !cyclic);
check('exploration: has at least one root (a node with no requires)', Object.values(nodes).some((n) => !(n.requires ?? []).length));
// tether level chain: each level's effect names the next level up
const levelsOk = [2, 3, 4].every((lvl) => {
// tether level chain: L2 is a BUILD (research unlocks it, the build
// applies it — data/builds.json → tether-l2), so it carries no immediate
// effect; L3/L4 still apply their tether raise on research completion.
const l2NoEffect = !nodes.tether_l2 || !nodes.tether_l2.effects?.tether;
const levelsOk = [3, 4].every((lvl) => {
const n = nodes[`tether_l${lvl}`];
return n && n.effects?.tether?.level === lvl;
});
check('exploration: tether_l2/l3/l4 each raise the tether to their level', levelsOk);
check('exploration: tether_l3/l4 each raise the tether to their level', levelsOk);
check('exploration: tether_l2 carries no immediate tether effect (the build applies it)', l2NoEffect);
// ----------------------------------------------------------------------
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
@ -183,27 +187,42 @@ check('state: restored project finishes 51s after the load', restored.progress(5
check('state: an old save without research restores as a fresh start', (() => { const s = new ResearchState(); s.restoreActive(null, 0); return s.getActive() === null; })());
// ----------------------------------------------------------------------
// 5. Builds — credits + minerals (the next deck feature)
// 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js)
// ----------------------------------------------------------------------
check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string');
check('builds: enabled switch present', typeof builds.enabled === 'boolean');
check('builds: one build at a time (maxConcurrent === 1)', builds.maxConcurrent === 1);
check('builds: categories is a non-empty array', Array.isArray(builds.categories) && builds.categories.length > 0);
check('builds: every category has id/label/accent', builds.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? '')));
check('builds: defaultCategory names a real category', (builds.categories ?? []).some((c) => c.id === builds.defaultCategory));
check('builds: video file configured', typeof builds.video?.file === 'string' && builds.video.file.length > 0);
check('builds: video file exists on disk', fs.existsSync(join(root, builds.video?.file ?? '')));
check('builds: video aspect is [w, h] > 0', Array.isArray(builds.video?.aspect) && builds.video.aspect.every((n) => typeof n === 'number' && n > 0));
check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string');
check('builds: credits resource defined (future seam)', typeof builds.resources?.credits?.label === 'string');
check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds));
// the tether-l2 build — the first research→build unlock, wired both ways
const tl2 = builds.builds['tether-l2'] ?? {};
check('builds: tether-l2 entry exists (the level-2 tether build)', !!tl2);
check('builds: tether-l2 is available on a planet OR a station', JSON.stringify(tl2.targets ?? []) === JSON.stringify(['planet', 'station']));
check('builds: tether-l2 is a planet build', JSON.stringify(tl2.targets ?? []) === JSON.stringify(['planet']));
check('builds: tether-l2 is gated on exploration/tether_l2 (authoritative side)', JSON.stringify(tl2.requires ?? []) === JSON.stringify(['exploration/tether_l2']));
check('builds: tether-l2 is a one-off with a credits+minerals cost', tl2.repeatable === false && typeof tl2.cost?.credits === 'number' && typeof tl2.cost?.minerals === 'number');
check('builds: tether-l2 needs a level-1 tether on the planet', tl2.planetRequires?.tetherLevel === 1);
check('builds: tether-l2 costs 200 minerals, 20 s, one-off', tl2.repeatable === false && tl2.cost?.minerals === 200 && tl2.duration === 20);
check('builds: tether_l2 declares the build in its unlocks (declaration side)', (nodes.tether_l2?.unlocks?.builds ?? []).includes('tether-l2'));
check('builds: tether-l2 effect raises a level-2 tether on the target', tl2.effects?.tether?.level === 2 && tl2.effects?.tether?.anchor === 'target');
// the tether-l1 build — the home world's starting install
const tl1 = builds.builds['tether-l1'] ?? {};
check('builds: tether-l1 entry exists (the starting install)', !!tl1);
check('builds: tether-l1 is already installed on home (starting)', JSON.stringify(tl1.starting ?? []) === JSON.stringify(['home']));
check('builds: tether-l1 is a free, instant one-off', tl1.repeatable === false && !(tl1.cost?.minerals > 0) && tl1.duration === 0);
const bt = builds._template ?? {};
for (const k of ['label', 'description', 'category', 'targets', 'cost', 'requires', 'repeatable', 'effects', 'theme']) {
check(`builds._template documents "${k}"`, k in bt);
}
check('builds._template.category is a known kind', ['ship', 'planet', 'station', 'general'].includes(bt.category));
check('builds._template.cost pays in credits + minerals', typeof bt.cost?.credits === 'number' && typeof bt.cost?.minerals === 'number');
check('builds._template.category is a console tab', ['planet', 'cargo'].some((c) => String(bt.category).includes(c)));
check('builds._template.cost pays in minerals', typeof bt.cost?.minerals === 'number');
check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'boolean');
check('builds._template.requires is an array', Array.isArray(bt.requires));
@ -220,5 +239,12 @@ check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.sc
check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
check('actionbar: sane geometry (height 40200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
// the surface deck (SurfaceScene) — Shop for Research, Build, Take Off
const sSlots = actionbar.surface?.buttons ?? [];
check('actionbar: surface deck has six slots', sSlots.length === 6);
check('actionbar: surface slot ids in order (Shop, Build, Ship, ·, Take Off, Menu)', JSON.stringify(sSlots.map((s) => s.id)) === JSON.stringify(['shop', 'build', 'ship', null, 'takeoff', 'menu']));
check('actionbar: the BUILD slot is on the surface deck', sSlots.some((s) => s.id === 'build'));
check('actionbar: the BUILD slot carries a label + accent', (() => { const b = sSlots.find((s) => s.id === 'build'); return b && typeof b.label === 'string' && hex.test(b.accent ?? ''); })());
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -226,6 +226,22 @@ const makeStorage = (fail = false) => {
check('prepare: a legacy record (no minerals) still loads, field absent',
regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined);
// BUILDS state: an in-flight build (remaining time on the loop clock)
// rides the pending restore, like research.
const buildsRec = {
built: { Terra: ['tether-l1'] },
active: { planet: 'Keth', build: 'tether-l2', startedAt: 1000, durationMs: 20000, remainingMs: 15000 },
};
const regBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regBuilds, makeRec({ builds: buildsRec }));
check('prepare: builds staged (built records + the in-flight build)',
regBuilds.get(PENDING_RESTORE_KEY).builds?.built?.Terra?.includes('tether-l1') === true
&& regBuilds.get(PENDING_RESTORE_KEY).builds?.active?.remainingMs === 15000);
const regNoBuilds = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regNoBuilds, makeRec()); // pre-build-system save
check('prepare: a legacy record (no builds field) stages null builds',
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
// consumeRestore: exactly once.
const first = consumeRestore(reg);
const second = consumeRestore(reg);

76
dev/server.mjs Normal file
View File

@ -0,0 +1,76 @@
/**
* dev/server.mjs Orbit's dev static server.
*
* node dev/server.mjs [port] (default 8080)
*
* Why not `python3 -m http.server`? That server sends `Last-Modified`
* with no `Cache-Control`, so browsers apply HEURISTIC caching to the
* ES modules. During iterative development the browser then keeps
* running an OLD js/*.js module graph (stale GameScene.js, stale
* BuildWindow.js, ) long after the files on disk changed while
* re-fetched JSON data is fresh. New data + old JS is exactly how you
* get "the build console behaves like an older version" no matter how
* often you clear the JSON cache or hard-reset the game.
*
* This server sends `Cache-Control: no-store` on every response, so a
* normal reload always runs what's on disk. Zero dependencies (node).
*/
import http from 'node:http';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.argv[2] ?? 8080);
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.otf': 'font/otf',
'.ttf': 'font/ttf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
let p = decodeURIComponent(url.pathname);
if (p.endsWith('/')) p += 'index.html';
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT + path.sep) && file !== ROOT) {
res.writeHead(403).end('forbidden');
return;
}
try {
const data = await fs.readFile(file);
res.writeHead(200, {
'Content-Type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
'Content-Length': data.length,
// The whole point: browsers must never cache these.
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
});
res.end(data);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(`404 — not found: ${url.pathname}\n(serving ${ROOT})`);
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`orbit dev server → http://127.0.0.1:${PORT} (no-store caching; root ${ROOT})`);
});

37
dev/world-names.test.mjs Normal file
View File

@ -0,0 +1,37 @@
/**
* dev/world-names.test.mjs casing-safe world-name resolution.
*
* The comms panel displays names uppercased; that display casing must
* never leak into the landing handoff, because name-keyed world state
* (build records, tether labels) is keyed by the world's canonical
* spelling. Run: `node dev/world-names.test.mjs`.
*/
import assert from 'node:assert/strict';
import { canonicalPlanetName } from '../js/utils/WorldNames.js';
// The user's exact case: display-cased surface name → canonical world.
assert.equal(canonicalPlanetName('ALKHAQO', ['Alkhaqo']), 'Alkhaqo');
assert.equal(canonicalPlanetName('alkhaqo', ['Alkhaqo']), 'Alkhaqo');
// Exact match passes through untouched (spelling preserved).
assert.equal(canonicalPlanetName('Alkhaqo', ['Alkhaqo']), 'Alkhaqo');
assert.equal(canonicalPlanetName('VRYNNEX', ['VRYNNEX', 'Other']), 'VRYNNEX');
// Case-insensitive match returns the list's canonical spelling.
assert.equal(canonicalPlanetName('DRAONVEXUS', ['Other', 'Draonvexus']), 'Draonvexus');
// Unknown names pass through untouched (no crash, no rewrite).
assert.equal(canonicalPlanetName('Zzz-9', ['Alkhaqo']), 'Zzz-9');
// Degenerate inputs.
assert.equal(canonicalPlanetName('', ['Alkhaqo']), '');
assert.equal(canonicalPlanetName(null, ['Alkhaqo']), '');
assert.equal(canonicalPlanetName(undefined, ['Alkhaqo']), '');
assert.equal(canonicalPlanetName('ALKHAQO', null), 'ALKHAQO');
assert.equal(canonicalPlanetName('ALKHAQO', undefined), 'ALKHAQO');
// Whitespace is trimmed before matching; null/blank entries are skipped.
assert.equal(canonicalPlanetName(' ALKHAQO ', ['Alkhaqo']), 'Alkhaqo');
assert.equal(canonicalPlanetName('ALKHAQO', [null, '', ' ', 'Alkhaqo']), 'Alkhaqo');
console.log('world-names: all checks passed ✔');

View File

@ -293,17 +293,21 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
once every id it names is researched. `dev/research-builds.test.mjs`
keeps both sides in lock-step (the test fails if one names the other and
the other doesn't name back, or an id dangles).
- Builds also carry `category` (ship / planet / station / general),
- Builds also carry `category` (planet / cargo — the console's tabs),
optional `targets` (the surfaces it can be placed on — defaults to
`[category]`; the tether ring targets `planet` + `station`), `cost`
(credits + minerals), `repeatable`, `effects`, `theme`.
- The first build entry, `tether-l2` (level-2 tether ring, anchored on a
world or station), is the placeholder proving the wiring — cost/effect
are first guesses; tune them when the build panel lands.
`[category]`), `cost` (minerals today; credits when they land),
`repeatable`, `starting` (pre-installed on fresh runs — e.g. the home
world's level-1 tether), optional `planetRequires` (world-state gates,
e.g. `tetherLevel`), `effects`, `theme`.
- `tether-l2` (the level-2 tether ring, anchored on a world that holds a
level-1 tether) is the first build — 200 minerals, 20 s, effect
`tether: {level: 2}` on the target world. The build console is live
(the deck's BUILD slot, `js/ui/BuildWindow.js` — see the section below);
tune cost/duration freely in `data/builds.json`.
- The console's dossier shows the line (`UNLOCKS: TETHER LEVEL 3 · TETHER
ANCHORING · BUILD · TETHER RING · LEVEL 2`), read through
ANCHORING · BUILD · TETHER - LEVEL 2`), read through
`ResearchModel.unlocksOf(tree, id)` + `buildDefs()` — the one read
point the build UI will use too.
point the build UI uses too.
**Code layering (same rules as the tether):**
- `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues`
@ -352,6 +356,125 @@ 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
run through CDP for a screenshot.
## Builds — the surface install (cost-based, one at a time)
The BUILD deck slot (on a planet surface — the deck re-deals itself there:
Shop, Build, Ship, ·, Take Off, Menu) opens the **Build console**
(`js/ui/BuildWindow.js`, same visual language as the Research console —
left pane loops the muted 2:3 `assets/videos/build.mp4` feed, right pane
holds the category tabs + a **list** of the category's buildable items —
not a tree: builds are one-off installs on the planet — + the selected
build's dossier). Locked items are grayed with their missing gates
(research / tether level); installed items read **BUILT ✓**; the BUILD
button appears only when the build is available AND affordable.
**Division of labor (the Research split, exactly):**
- `js/build/BuildModel.js` — PURE (no Phaser): `categories`, `loadBuilds`
(category → its builds), `startingPairs` (pre-installed on fresh runs —
`tether-l1.starting: ["home"]`), `missingRequirements` (research +
`planetRequires.tetherLevel`), `isAvailable`, `rowState` (built /
active / available / locked), `costLines`, `canAfford`. Node-tested by
`dev/builds.test.mjs`.
- `js/build/BuildState.js` — PURE: the run's build records (`built`:
planet → set of buildIds) + the single in-progress build (`active`,
`maxConcurrent: 1`); `start/progress/tick/restoreActive`,
`toJSON(now)/fromJSON`. Node-tested.
- `js/ui/BuildWindow.js` — the scene-facing window (depth 80, above the
save panel). A **passive view**: it asks via `onBuild(buildId)`;
`SurfaceScene.beginSurfaceBuild``GameScene.beginBuild` enforces the
rules (one at a time, not installed, research gate, planet gate,
mineral cost — the full amount paid up front), then `BuildState.start`
runs the clock. The window re-renders from `BuildState` + `BuildModel`
only.
- `GameScene``beginBuild`, `completeBuild`, `_applyBuildEffects` (the
**effects seam**: `tether {level: N}` → the world's tether strengthens
to at least N via `TetherField.setLevel` — never a downgrade — + toast;
`capability "flag"``scene.researchCapabilities`; unknown shapes log
and no-op). The state lives on the GameScene (`buildState`), so the
records + the in-progress build outlive the surface stay AND save with
the run (`record.builds`).
**The clock (important — different from research):** the build's time
base is the **game-loop clock** (`game.loop.now` — the engine's global
monotonic ms), NOT a scene's `time.now`. A scene's clock freezes while
the scene sleeps, and the GameScene SLEEPS while the surface is active —
so the SurfaceScene ticks `buildState.tick(game.loop.now)` in `update()`
while on the surface, and the GameScene ticks it in space (a build
started on the surface keeps running if the player takes off mid-build —
then completes in space, effect and all). Research keeps its scene-clock
base (`GameScene.time.now` — the scene is awake while it runs). Saves
capture the build's `remainingMs` on the loop clock; `restoreActive`
rebuilds `startedAt` so the build finishes at the same wall time after
load. (Note: for an AWAKE scene, `scene.time.now` IS the loop timestamp
in this build — TimePlugin.update sets `now = t` — the two only diverge
while a scene sleeps.)
**Deck lock:** while a build runs, every deck action except BUILD is
refused (`SurfaceScene.deckAction`) — one build at a time. The BUILD slot
stays open: the window shows the in-progress build + its progress.
**Save:** `record.builds = { built: { planet: [buildId, …] }, active:
{ planet, build, durationMs, remainingMs } | null }`. A save from before
builds exist loads as fresh — old saves keep working (`dev/saves.test.mjs`
covers the staging).
**The starting installs are a rule, not save data:** a fresh run is
seeded with `tether-l1` installed on the home world (the player starts
with their home tether — "every world starts with its tether already in
place"), and `GameScene.applyRestore` RE-ASSERTS the `starting` pairs
AFTER a load (`_seedStartingBuilds`, idempotent) — a save captured before
the seed existed (or by an older iteration) replaces the seeded state and
must not un-install the home world's level-1 tether. A player resuming a
run always has Tether - Level 1 built on home (pinned in
`dev/builds.test.mjs`).
**Name casing (a real bug this caught):** world state is keyed by the
world's CANONICAL name (its discovery name — build records, tether
labels). The comms panel DISPLAYS names uppercased — and used to carry
that display casing back into the landing handoff (`lastTarget.name`),
so a surface reached through the panel read its own home world as
"nothing installed, no tether" (L1 available, L2's tether gate failing).
Fixed at the source: `CommsPanel` keeps the canonical spelling in the
data payload (uppercase is display-only), and `GameScene.startLanding`
normalizes the name against the known worlds before launch
(`js/utils/WorldNames.js` → `canonicalPlanetName`, Node-tested in
`dev/world-names.test.mjs`) — so `tetherLevelFor` and the surface's
`planetName` always match the world-state keys.
**SFX:** begin → `construct`, complete → `discovery`, refused action →
`ui_close`, window open/close → `ui_window`/`ui_close` (all existing
`data/sfx.json` keys).
**Diagnostics (in the real game):** `js/dev/BuildDiag.js` (installed by
`js/main.js`) is a read-only observer — it never mutates state.
- Opening the Build console logs a one-line `[orbit-diag v…]` summary:
planet, home, `isHome`, the `built` map, the planet's tether level, the
L1/L2 row states (with missing gates), a stale-JS probe
(`jsSeed=true|false`), and the loaded `tether-l1.starting` field.
- `orbitDiag()` in DevTools → Console prints the full report: env, JS
probes, the `data/builds.json` contract as loaded, home/current
planet, built records, tether objects, research state, the save bank's
`builds` per slot, pending restore, scene wiring, errors since boot.
- If `orbitDiag` is **undefined**, the browser served a cached older
`js/main.js` → serve with `node dev/server.mjs` (sends
`Cache-Control: no-store` on every response) or hard-reload with the
cache disabled. (A plain `python3 -m http.server` lets browsers
heuristically cache the ES modules — the classic "new data + old JS"
trap that makes an old `GameScene.js` keep running after edits.)
- `dev/build-check.html` plays the real Build flow (menu → New Game →
home world → build console) and prints the L1/L2 row states before and
after the `tether_l2` research; its failure path also reports the
stale-JS probe, so even a failed run is diagnostic.
**Verified:** `dev/builds.test.mjs` (data contract from builds.json —
categories/resources/builds entries — the pure model: requirements,
availability, row states, cost/afford, starting pairs — the state
machine: start/guards/progress/tick/completion — save/restore round-trip
incl. remaining-time preservation), `dev/research-builds.test.mjs`
(research↔build unlock lock-step, incl. `tether_l2` carrying NO effect —
the build carries it), `dev/saves.test.mjs` (the builds field rides the
pending restore; legacy records load clean).
## Reputation — standing on planets & space stations (data layer; factions later)
The player holds a REPUTATION (standing) on each planet and space station:
@ -408,6 +531,13 @@ The player holds a REPUTATION (standing) on each planet and space station:
`js/ui/`) is **not** added to the scene display list — the `scene.add.*`
factories do that. Call `scene.add.existing(this)` in the constructor
(Ship/Planet already do; GlitchText/MenuButton now do too).
- **`Container.add(child, index)` — the v3 varargs form is gone.**
`cont.add(a, b, c)` adds ONLY `a` (the second arg is an insert index,
the rest are silently dropped); the dropped children stay at scene level
and paint *under* the window's opaque backplate, so they simply vanish.
Multi-add is the ARRAY form: `cont.add([a, b, c])` (ActionBar/CommsPanel
use it; BuildWindow rows/button and ResearchWindow's action button
regressed on the varargs form until fixed, Sept 2026).
- Text colors go straight to the canvas: v4 writes `fillStyle =
style.color`, so a **numeric** color is an invalid fillStyle and the text
silently renders **black**. Text styles and `setColor()` must get CSS
@ -572,12 +702,15 @@ The player holds a REPUTATION (standing) on each planet and space station:
one JSON file + one line in the registry + one line in the manifest
(`js/ui/ResearchWindow.js`, `js/research/*`, `data/research.json`,
`data/research/exploration.json`, dev/research-builds.test.mjs)
- [ ] Tether progression (research side is done; the build side is next):
anchor tethers on planets/stations and upgrade levels beyond the
Exploration tree (the `add`/`setLevel`/`onChange` seams are in place;
the build entry `tether-l2` + the research→build unlock wiring exist
in `data/builds.json` — the costs panel and the apply-effect seam
come next)
- [x] Tether progression: level 2 is a **build** — research
`tether_l2` (blueprint, no effect) → BUILD slot on a world holding a
level-1 tether → 200 minerals / 20 s → the world's tether strengthens
to level 2 (6400 px), effect and all, outliving the stay. Levels 34
are still research-direct (`effects.tether.level` on the home
tether); anchoring extra rings on planets/stations remains
(the `add`/`setLevel`/`onChange` seams are in place)
(`data/builds.json → tether-l2`, `js/build/*`,
`js/ui/BuildWindow.js`, `dev/builds.test.mjs`)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy (the reputation layer
already resolves standing through `owner` — `Reputation.
@ -606,9 +739,17 @@ The player holds a REPUTATION (standing) on each planet and space station:
scanline tile recipe as `CyberOverlay` (clipped to the bar) and the
`GlitchText` RGB pull-apart (icons + labels at all times, panel outline
during bursts) — see `actionbar.animation.rgb` / `actionbar.scanline`
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
`requires`; the Build slot on the deck opens it; a credits/minerals
readout in the HUD
- [x] Build panel: the BUILD slot (on a planet surface) opens the console
— looping build feed, category tabs (Planet / Cargo), the build
LIST (locked items grayed with their missing gates), per-build
dossier + highlighted cost + BUILD button (available + affordable
only). Pay minerals up front, run the clock (game-loop clock —
survives takeoff mid-build), apply `effects` on completion, respect
`requires` + `planetRequires`, one at a time, deck locked while it
runs, records + in-progress build save with the run and resume with
their remaining time
(`js/ui/BuildWindow.js`, `js/build/BuildModel.js`,
`js/build/BuildState.js`, `data/builds.json`, dev/builds.test.mjs)
- [x] Ship base stats: `data/ship.json → stats` (hullIntegrity 100,
shields 0, cargoHold 100, mineralStorage 250), exposed as
`ship.stats` — combat/trading/mining systems will read them as

132
js/build/BuildModel.js Normal file
View File

@ -0,0 +1,132 @@
/**
* BuildModel the data layer for the build console (the deck's BUILD
* button on a planet surface). Pure functions over data/builds.json
* no scene, no Phaser (dev/builds.test.mjs runs it in Node; the browser
* side is js/ui/BuildWindow.js).
*
* Research UNLOCKS a build (data/research/*.json unlocks.builds);
* building then costs resources + time on a planet and applies the effect
* (e.g. the level-2 tether). The availability rules here mirror the
* research model (js/research/ResearchModel.js):
*
* - `requires` "category/node" ids that must be researched
* - `planetRequires` world-state gates (e.g. tetherLevel 1)
* - `isBuilt` one-off builds are not built twice on a planet
* - `cost` paid in full when the build starts (minerals/credits)
*
* categories() data/builds.json categories
* loadBuilds(catId) { id, label, accent, builds, order }
* defById(id) the definition for one build id (any category)
* startingPairs() [ [planet, buildId], ] pre-installed on a fresh run
* missingRequirements(def) unmet gates (research + planet) as strings
* isAvailable(def, ctx) gates met AND not already built on the planet
* rowState(def, ctx, active, id) 'built' | 'active' | 'available' | 'locked'
* costLines(def) [{ res, amount }] for the cost readout
* canAfford(def, minerals) every line is covered
*
* `ctx` is the caller's view of the world (the window composes it from
* scene seams; the tests compose it by hand):
* { isResearchUnlocked(catId, nodeId), tetherLevel(planetName),
* isBuilt(planetName, buildId) }
*/
import { config } from '../config/Config.js';
import { buildDefs } from '../research/ResearchModel.js';
/** The category registry (data/builds.json → categories). */
export function categories() {
const cats = config.get('builds.categories', []);
return Array.isArray(cats) ? cats : [];
}
/**
* Load one build category (the id its builds + registry entry).
* Unknown ids resolve to the default category; a missing registry entry
* is a warning (dev tools) with a neutral accent.
*/
export function loadBuilds(catId) {
const cats = categories();
const meta = cats.find((c) => c.id === catId)
?? cats.find((c) => c.id === config.get('builds.defaultCategory', cats[0]?.id))
?? null;
if (!meta) {
console.warn('[builds] no categories in data/builds.json');
return { id: String(catId), label: String(catId), accent: '#8fa3c8', builds: {}, order: [] };
}
const defs = buildDefs();
const builds = {};
for (const [id, def] of Object.entries(defs)) {
if (def?.category === meta.id) builds[id] = def;
}
const order = Object.keys(builds);
if (!order.length) console.warn(`[builds] category "${meta.id}" has no builds (data/builds.json)`);
return { ...meta, builds, order };
}
/** Look up one build definition by id (any category); null if unknown. */
export function defById(id) {
const defs = buildDefs();
return Object.prototype.hasOwnProperty.call(defs, id) ? defs[id] : null;
}
/**
* [planet, buildId] pairs pre-installed on a fresh run the home world
* starts with its level-1 tether (data/builds.json tether-l1.starting).
* 'home' is the seed-independent home key (the scene resolves it to the
* home world's name before seeding BuildState).
*/
export function startingPairs() {
const out = [];
for (const [id, def] of Object.entries(buildDefs())) {
for (const p of def?.starting ?? []) out.push([p, id]);
}
return out;
}
/** Unmet gates for a build, as short strings (research first, then the planet). */
export function missingRequirements(def, ctx) {
const out = [];
for (const req of def?.requires ?? []) {
const i = req.indexOf('/');
if (i < 0) continue;
const cat = req.slice(0, i);
const node = req.slice(i + 1);
if (!ctx.isResearchUnlocked(cat, node)) out.push(`RESEARCH: ${node.toUpperCase()}`);
}
const needTether = def?.planetRequires?.tetherLevel;
if (typeof needTether === 'number' && (ctx.tetherLevel() ?? 0) < needTether) {
out.push(`TETHER ≥ L${needTether}`);
}
return out;
}
/** Gates met (research + planet) AND not already built on this planet. */
export function isAvailable(def, ctx) {
if (!def) return false;
if (ctx.isBuilt()) return false;
return missingRequirements(def, ctx).length === 0;
}
/** The row's state for the list (the list paints from this). */
export function rowState(def, ctx, active, id) {
if (ctx.isBuilt()) return 'built';
if (active && active.build === id) return 'active';
return isAvailable(def, ctx) ? 'available' : 'locked';
}
/** Cost lines for the readout (data → minerals/credits amounts). */
export function costLines(def) {
const cost = def?.cost;
if (!cost || typeof cost !== 'object') return [];
return Object.entries(cost)
.filter(([, amount]) => typeof amount === 'number' && amount > 0)
.map(([res, amount]) => ({ res, amount }));
}
/**
* Can the player pay the full cost. `wallet` is a number (shorthand for
* { minerals: n } today's economy) or a { res: amount } map.
*/
export function canAfford(def, wallet) {
const w = typeof wallet === 'number' ? { minerals: wallet } : wallet;
return costLines(def).every((l) => (w?.[l.res] ?? 0) >= l.amount);
}

150
js/build/BuildState.js Normal file
View File

@ -0,0 +1,150 @@
/**
* BuildState the player's build progress (pure data, no scene).
*
* The run's build records, held by the GameScene (js/scenes/GameScene.js)
* so they outlive the surface stay (SurfaceScene) and save with the run
* (js/save/SaveData.js) the same split as ResearchState:
*
* - `built` which builds are installed on which planets
* (Map<planetName, Set<buildId>>); one-off builds land here
* on completion and are never built twice on a planet;
* - `active` the single in-progress build (data/builds.json
* maxConcurrent: one at a time): { planet, build,
* startedAt, durationMs }.
*
* The build is ticked by whichever scene is awake the SurfaceScene
* while on the surface (the GameScene sleeps then), the GameScene in
* space both with the game-loop clock (game.loop.now), the one clock
* that keeps running across scene switches:
* progress(time) { planet, build, fraction, remainingMs } | null
* tick(time) [ { planet, build, durationMs }, ] (marks built)
*
* `remainingMs` is captured at save time; on load restoreActive() rebuilds
* `startedAt` so the build finishes at the same wall time (the loop clock
* is global, so a build started on the surface finishes in space too).
*/
export class BuildState {
constructor() {
this.built = new Map(); // planetName → Set<buildId>
this.active = null; // { planet, build, startedAt, durationMs }
}
// ── built records ────────────────────────────────────────────────────────
markBuilt(planet, build) {
if (!planet || !build) return;
let set = this.built.get(planet);
if (!set) {
set = new Set();
this.built.set(planet, set);
}
set.add(build);
}
isBuilt(planet, build) {
const set = this.built.get(planet);
return !!(set && build && set.has(build));
}
buildsOn(planet) {
return [...(this.built.get(planet) ?? [])];
}
// ── the single in-progress build ─────────────────────────────────────────
getActive() {
return this.active;
}
/**
* Claim the build slot. Fails when one is already running, the build is
* already installed on this planet, or the duration is invalid.
*/
start(planet, build, durationMs, now) {
if (this.active) return false;
if (this.isBuilt(planet, build)) return false;
if (typeof durationMs !== 'number' || !(durationMs > 0)) return false;
const t = Number.isFinite(now) ? now : 0;
this.active = { planet, build, startedAt: t, durationMs };
return true;
}
/**
* Progress readout (01) for the active build.
* @param {number} time ms on the ticking scene's clock (SurfaceScene)
*/
progress(time) {
if (!this.active) return null;
const { startedAt, durationMs } = this.active;
return {
planet: this.active.planet,
build: this.active.build,
fraction: Math.max(0, Math.min(1, (time - startedAt) / durationMs)),
remainingMs: Math.max(0, durationMs - (time - startedAt)),
};
}
/**
* Complete every build whose deadline has passed (marks it built on the
* planet) and return the completions the caller applies the effects
* (the scene owns world state).
*/
tick(time) {
const out = [];
if (this.active && time - this.active.startedAt >= this.active.durationMs) {
const a = this.active;
this.active = null;
this.markBuilt(a.planet, a.build);
out.push({ planet: a.planet, build: a.build, durationMs: a.durationMs });
}
return out;
}
// ── save / restore ───────────────────────────────────────────────────────
/**
* @param {number} now ms capture remainingMs for the active build
*/
toJSON(now) {
return {
built: Object.fromEntries([...this.built.entries()].map(([p, s]) => [p, [...s]])),
active: this.active ? {
planet: this.active.planet,
build: this.active.build,
startedAt: this.active.startedAt,
durationMs: this.active.durationMs,
remainingMs: this.progress(now)?.remainingMs ?? 0,
} : null,
};
}
fromJSON(json) {
const j = json ?? {};
for (const [p, ids] of Object.entries(j.built ?? {})) {
for (const b of ids ?? []) this.markBuilt(p, b);
}
this.active = null; // restoreActive() re-claims it with the live clock
return this;
}
/**
* Rebuild the in-progress build so it finishes at the same wall time.
* Skipped when the build is already installed on the planet (or when
* the save predates the build system).
*/
restoreActive(spec, now) {
if (!spec || !spec.planet || !spec.build) return;
if (this.isBuilt(spec.planet, spec.build)) return;
const remaining = spec.remainingMs;
if (typeof remaining !== 'number' || !(remaining > 0)) return;
const t = Number.isFinite(now) ? now : 0;
this.active = {
planet: spec.planet,
build: spec.build,
startedAt: t - (spec.durationMs - remaining),
durationMs: spec.durationMs,
};
}
reset() {
this.built.clear();
this.active = null;
}
}

192
js/dev/BuildDiag.js Normal file
View File

@ -0,0 +1,192 @@
/**
* js/dev/BuildDiag.js in-game diagnostics for the Build console.
*
* Installed by js/main.js on every real page load (index.html). It is
* deliberately read-only and defensive: it observes the live game and
* prints a report it never mutates state.
*
* Console commands (DevTools Console, in the RUNNING game):
*
* orbitDiag() full report (also logged). Copy-paste the
* returned string when asking for help.
* orbitDiagBrief() one-line summary.
*
* The Build console ALSO logs the brief line itself every time it opens
* (`[orbit-diag v…] planet=… L1=… L2=…`), so you do not have to remember
* the command.
*
* If `orbitDiag` 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 { defById, rowState, missingRequirements } from '../build/BuildModel.js';
export const DIAG_V = 3;
const _errors = [];
const _errCap = (msg) => {
if (_errors.length < 8) _errors.push(String(msg).slice(0, 300));
};
function sceneOf(key) {
const g = globalThis.window?.game;
try {
return g?.scene?.getScene(key) ?? null;
} catch {
return null;
}
}
function builtMapOf(gs) {
const b = gs?.buildState?.built;
if (!b || typeof b.entries !== 'function') return null;
return Object.fromEntries([...b.entries()].map(([k, v]) => [k, [...v]]));
}
function homeNameOf(gs) {
return gs?.planet?.discoveryName ?? gs?.homeWorldName ?? null;
}
function tetherFor(gs, name) {
const list = gs?.tetherField?.tethers;
if (!Array.isArray(list) || !name) return null;
const hit = list.find((t) => t.label === name) ?? list.find((t) => t.x === 0 && t.y === 0);
return hit ? { id: hit.id, level: hit.level, label: hit.label } : null;
}
/**
* The exact row state the Build window paints for one build id, using
* the same pure functions and context as js/ui/BuildWindow.js.
*/
function rowFor(gs, ss, id) {
const planet = ss?.planetName ?? homeNameOf(gs);
const def = defById(id);
if (!def || !planet) return 'NO DEF/PLANET';
const ctx = {
isResearchUnlocked: (c, n) => gs?.researchState?.isUnlocked?.(c, n) ?? false,
tetherLevel: () => gs?.tetherLevelFor?.(planet) ?? 0,
isBuilt: () => gs?.buildState?.isBuilt?.(planet, id) ?? false,
};
const active = gs?.buildState?.getActive?.();
const activeOnPlanet = active && active.planet === planet ? active : null;
const st = rowState(def, ctx, activeOnPlanet, id);
const missing = missingRequirements(def, ctx);
return st.toUpperCase() + (missing.length ? ` (needs: ${missing.join(' + ')})` : '');
}
function saveBank() {
try {
const raw = globalThis.localStorage?.getItem?.('orbit.saves.v1');
if (!raw) return null;
const bank = JSON.parse(raw);
const slots = Object.entries(bank.slots ?? {}).map(([slot, rec]) => ({
slot,
seed: rec?.record?.seed ?? rec?.seed ?? null,
savedAt: rec?.savedAt ?? rec?.record?.savedAt ?? null,
builds: rec?.record?.builds ?? null,
}));
return { key: 'orbit.saves.v1', slots };
} catch (e) {
return { error: String(e.message ?? e) };
}
}
function pendingRestore() {
try {
const raw = globalThis.localStorage?.getItem?.('orbit.pendingRestore');
if (!raw) return null;
const rec = JSON.parse(raw);
return { present: true, seed: rec?.seed ?? null, builds: rec?.builds ?? null };
} catch (e) {
return { error: String(e.message ?? e) };
}
}
function dataFacts() {
const out = { buildsLoaded: !!config.get('builds.categories', null) };
const l1 = defById('tether-l1');
const l2 = defById('tether-l2');
out['tether-l1'] = l1
? { starting: l1.starting ?? null, repeatable: l1.repeatable ?? false }
: 'NOT IN DATA (stale data/builds.json?)';
out['tether-l2'] = l2
? {
cost: l2.cost ?? null,
duration: l2.duration ?? null,
requires: l2.requires ?? null,
planetRequires: l2.planetRequires ?? null,
}
: 'NOT IN DATA (stale data/builds.json?)';
return out;
}
/** One-line summary — the same facts a support request needs. */
export function brief() {
const gs = sceneOf('GameScene');
const ss = sceneOf('SurfaceScene');
const home = homeNameOf(gs);
const planet = ss?.planetName ?? home;
const bm = builtMapOf(gs);
const isHome = planet != null && planet === home;
const t = tetherFor(gs, planet);
const line =
`[orbit-diag v${DIAG_V}] ` +
`planet=${planet ?? '?'} home=${home ?? '?'} isHome=${isHome ? 'YES' : 'no'} ` +
`builtMap=${JSON.stringify(bm ?? null)} ` +
`tether=${t ? `${t.id}:L${t.level}` : 'NONE'} ` +
`L1=${rowFor(gs, ss, 'tether-l1')} L2=${rowFor(gs, ss, 'tether-l2')} ` +
`jsSeed=${typeof gs?._seedStartingBuilds === 'function'} ` +
`dataL1starting=${JSON.stringify(defById('tether-l1')?.starting ?? null)} ` +
`errors=${_errors.length}`;
console.info(line);
return line;
}
/** Full report. Logs it, returns the string (copy-paste friendly). */
export function full() {
const gs = sceneOf('GameScene');
const ss = sceneOf('SurfaceScene');
const home = homeNameOf(gs);
const planet = ss?.planetName ?? home;
const scenes = {};
for (const k of ['MenuScene', 'GameScene', 'SurfaceScene']) {
const s = sceneOf(k);
scenes[k] = s ? (s.status ?? 'present') : 'absent';
}
const lines = [
`ORBIT BUILD DIAG v${DIAG_V}`,
`[env] url=${globalThis.location?.href ?? '?'} ua=${String(globalThis.navigator?.userAgent ?? '?').slice(0, 90)}`,
`[js] GameScene=${gs ? 'present' : 'ABSENT'} seedFn=${typeof gs?._seedStartingBuilds === 'function'} ` +
`beginBuild=${typeof gs?.beginBuild === 'function'} buildState=${!!gs?.buildState}`,
`[data] ${JSON.stringify(dataFacts())}`,
`[run] home=${home ?? 'n/a'} currentPlanet=${planet ?? 'n/a (not on surface)'} ` +
`ssGameSceneIsGs=${ss ? String(!!gs && ss.gameScene === gs) : 'n/a'} ` +
`pendingRestore=${JSON.stringify(pendingRestore())}`,
`[state] builtMap=${JSON.stringify(builtMapOf(gs) ?? null)}`,
` homeTether=${JSON.stringify(tetherFor(gs, home) ?? null)} ` +
`planetTether=${JSON.stringify(tetherFor(gs, planet) ?? null)}`,
` researchTetherL2=${gs?.researchState?.isUnlocked?.('exploration', 'tether_l2') ?? 'n/a'} ` +
`activeBuild=${JSON.stringify(gs?.buildState?.getActive?.() ?? null)}`,
`[rows] L1=${rowFor(gs, ss, 'tether-l1')}`,
` L2=${rowFor(gs, ss, 'tether-l2')}`,
`[saves] ${JSON.stringify(saveBank())}`,
`[scenes] ${JSON.stringify(scenes)}`,
`[errors since boot] ${_errors.length ? _errors.join(' | ') : 'none'}`,
];
const text = lines.join('\n');
console.info(text);
return text;
}
/** Install the console commands. Idempotent. */
export function installBuildDiag() {
if (typeof globalThis === 'undefined') return;
if (!globalThis.addEventListener) return;
globalThis.addEventListener('error', (e) => _errCap(e.message));
globalThis.addEventListener('unhandledrejection', (e) => _errCap(`rejection: ${e.reason}`));
globalThis.orbitDiag = () => full();
globalThis.orbitDiagBrief = () => brief();
console.info(`[orbit-diag v${DIAG_V}] installed — type orbitDiag() in the console for a full report`);
}

View File

@ -5,6 +5,7 @@ import { createGameConfig } from './config/GameConfig.js';
import { MenuScene } from './scenes/MenuScene.js';
import { GameScene } from './scenes/GameScene.js';
import { SurfaceScene } from './scenes/SurfaceScene.js';
import { installBuildDiag } from './dev/BuildDiag.js';
/**
* Orbit entry point.
@ -37,6 +38,11 @@ async function boot() {
config.init(data);
console.info('orbit — config loaded', Object.keys(data).join(', '));
// Read-only console diagnostics (orbitDiag / orbitDiagBrief). See
// js/dev/BuildDiag.js — it also self-logs a one-line report whenever
// the Build console opens.
installBuildDiag();
await awaitFonts();
console.info('orbit — fonts ready (or timed out)');

View File

@ -6,7 +6,7 @@
*/
import { toColor } from '../utils/Color.js';
export const ICON_NAMES = ['tether', 'anchor', 'signal', 'diamond'];
export const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond'];
export function iconKey(name, color = 0x00e5ff) {
const n = ICON_NAMES.includes(name) ? name : 'diamond';
@ -71,6 +71,31 @@ export function ensureIcon(scene, name, color = 0x00e5ff) {
ctx.stroke();
}
diamond(cx + 38, cy, 8);
} else if (n === 'tether2') {
// Level-2 tether: two bright concentric level rings + core + tick
// marks + TWO satellites riding the outer ring (L1 shows one).
const rings = [
[52, 1.0],
[34, 0.85],
];
for (const [r, a] of rings) {
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(cx, cy, 8, 0, Math.PI * 2);
ctx.fill();
for (const a of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 55, cy + Math.sin(a) * 55);
ctx.lineTo(cx + Math.cos(a) * 61, cy + Math.sin(a) * 61);
ctx.stroke();
}
diamond(cx + 52 * Math.cos(-Math.PI / 3), cy + 52 * Math.sin(-Math.PI / 3), 8);
diamond(cx + 52 * Math.cos(Math.PI / 3), cy + 52 * Math.sin(Math.PI / 3), 8);
} else if (n === 'anchor') {
// Anchor ring + stock + crossbar + flukes (the relay bolted to a body).
ctx.globalAlpha = 0.9;

View File

@ -12,6 +12,7 @@
* reputation: Reputation.toJSON(),
* tethers: [{ id, x, y, level, label }],
* research: ResearchState.toJSON() | null,
* builds: BuildState.toJSON() | null,
* playTimeMs }
*
* captureState(scene) GameScene record (the Save panel calls it)
@ -40,11 +41,24 @@ export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
*
* @param {object} scene the GameScene (reads: registry, galaxy,
* systemRecord, ship, discovery, reputation, tetherField, playTimeMs)
* @param {number} [now] fallback live clock (ms) see below; each
* subsystem captures on its own time base.
* @returns {object} the record (ready for SaveManager.put)
*/
export function captureState(scene) {
export function captureState(scene, now) {
const galaxy = scene.galaxy;
if (!galaxy) throw new Error('no galaxy to save');
// Each subsystem's in-flight timer is captured on ITS OWN time base:
// - research: the GameScene's clock (beginResearch uses it — the scene
// is awake while research runs, so it is live);
// - builds: the game-loop clock (game.loop.now — the build's time base,
// global + monotonic, and still live while the GameScene SLEEPS
// during a surface stay; the caller's `now` or Date.now only as a
// last resort).
const researchNow = Number.isFinite(scene.time?.now) ? scene.time.now : Date.now();
const buildNow = Number.isFinite(scene.game?.loop?.now)
? scene.game.loop.now
: (Number.isFinite(now) ? now : Date.now());
const rec = {
app: 'orbit',
format: SAVE_FORMAT,
@ -71,10 +85,16 @@ export function captureState(scene) {
label: t.label ?? '',
})),
// Research — the unlocked set + the in-flight project (its remaining
// time is captured NOW; a save predating research has no field, and
// the restore treats the absence as "no research" (old saves load).
// time is captured NOW on the scene's own clock; a save predating
// research has no field, and the restore treats the absence as "no
// research" (old saves load).
research: scene.researchState
? scene.researchState.toJSON(scene.time?.now ?? Date.now())
? scene.researchState.toJSON(researchNow)
: null,
// Builds — the installed set + the in-flight build (remaining time on
// the game-loop clock — the build's time base; see above).
builds: scene.buildState
? scene.buildState.toJSON(buildNow)
: null,
playTimeMs: Math.round(scene.playTimeMs ?? 0),
};
@ -114,6 +134,7 @@ export function prepareLoad(registry, record) {
ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [],
research: record.research ?? null,
builds: record.builds ?? null,
playTimeMs: Number(record.playTimeMs) || 0,
});
}

View File

@ -8,6 +8,7 @@ import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { canonicalPlanetName } from '../utils/WorldNames.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
@ -30,7 +31,10 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable } from '../research/ResearchModel.js';
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { BuildState } from '../build/BuildState.js';
import { startingPairs } from '../build/BuildModel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
@ -124,6 +128,20 @@ export class GameScene extends Phaser.Scene {
}
}
// The build console's feed (data/builds.json → video): a muted 2:3
// loop behind the BUILD window on a planet surface (SurfaceScene's
// BuildWindow reads the shared cache). A missing file just leaves the
// window's NO SIGNAL plate up — the console still works.
if (config.get('builds.enabled', true)) {
const buildVideo = String(config.get('builds.video.file') ?? '');
if (buildVideo) {
const url = /^(https?:)?\/\//.test(buildVideo) || buildVideo.startsWith('assets/')
? buildVideo
: `assets/videos/${buildVideo}`;
this.load.video(BuildWindow.VIDEO_KEY, url);
}
}
// Sound effects (data/sfx.json → enabled). Skipped entirely when the
// master switch is off — no load cost, no files fetched.
if (config.get('sfx.enabled', true)) {
@ -305,13 +323,6 @@ export class GameScene extends Phaser.Scene {
);
this.tetherToastAt = null;
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time.
if (this._pendingRestore) {
this.applyRestore(this._pendingRestore);
this._pendingRestore = null;
}
// Discovery: which objects the player has found (within discovery
// distance of an edge — data/game.json), tracked per system. Kept in
// the shared registry so it survives scene restarts; serializable for
@ -457,6 +468,30 @@ export class GameScene extends Phaser.Scene {
.setVisible(false),
};
// ---- BUILD CONSOLE (the deck's BUILD button, on a planet surface) ----
// The rules live in data/builds.json (js/build/BuildModel.js); the
// progress in BuildState — the run's build records (which builds are
// installed on which planets) plus the single in-progress build (one
// at a time). The SurfaceScene is the passive view (BuildWindow,
// depth 80) and TICKS the state in its update() (this scene sleeps
// while the surface is active). The build's time base is the game-
// LOOP clock (game.loop.now) — global + monotonic — so a build keeps
// running if the player takes off mid-build (then we tick it here).
// This scene owns the effects (the tether field is world state, so it
// outlives the stay) and the save data (record.builds).
this.buildState = new BuildState();
this._seedStartingBuilds();
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time — and the saved
// research + build state. AFTER the state objects above exist: the
// restore mutates them (applyRestore), so it must come last in the
// creation order.
if (this._pendingRestore) {
this.applyRestore(this._pendingRestore);
this._pendingRestore = null;
}
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION
@ -946,6 +981,17 @@ export class GameScene extends Phaser.Scene {
}
this.researchWindow?.update(_time); // the console's living details (open state)
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
// Builds: a build started on a surface keeps running in space if the
// player took off mid-build (game-loop clock — see beginBuild); when
// its deadline passes HERE we apply the effect (the world change is
// world state: the tether range outlives the stay).
if (this.buildState) {
const _buildDone = this.buildState.tick(this.game.loop.now);
if (_buildDone.length) {
for (const c of _buildDone) this.completeBuild(c.planet, c.build);
this.playSfx('discovery'); // the 'something new is here' voice
}
}
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.)
@ -1368,6 +1414,15 @@ export class GameScene extends Phaser.Scene {
console.info(`[orbit] comms: landing disabled for ${target.name}`);
return;
}
// Name-keyed world state (build records, tether labels) is keyed by
// the world's CANONICAL spelling — resolve whatever casing the
// target arrived in (UI display paths uppercase) to that spelling
// before it crosses into the surface (js/utils/WorldNames.js).
const name = canonicalPlanetName(target.name, [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
]);
// A live scan would leave the camera roll/zoom mid-wobble — the scene
// sleeps (or shuts down) and never restores it. Cut it, and drop the
// compass emission (a flight-scene navigation aid — no use on the
@ -1384,10 +1439,10 @@ export class GameScene extends Phaser.Scene {
// Frame OBJECT — Number() of it is NaN, and the video/music lookups
// need the number).
this.scene.launch('SurfaceScene', {
frame: Number(target.sheetFrame ?? 0),
name: target.name,
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(target.name),
tetherLevel: this.tetherLevelFor(name),
});
this.scene.sleep();
}
@ -1693,6 +1748,113 @@ export class GameScene extends Phaser.Scene {
}
}
// ------------------------------------------------------------ builds
/**
* Begin a build on the surface planet (the BUILD button SurfaceScene
* here). The BuildWindow only offers the button when the rules allow
* it; this is the scene-side enforcement one build at a time, not
* already installed, research + planet gates, the full cost paid up
* front (minerals) then the state runs the clock. The build's time
* base is the game-loop clock passed as `now` (global + monotonic, so
* a build keeps running if the player takes off mid-build).
*
* @param {string} planetName the surface planet (BuildState's record key)
* @param {string} buildId the build (data/builds.json builds)
* @param {number} now the game-loop clock (ms)
* @returns {{ ok: boolean, reason?: string }} the refusal, if any
*/
beginBuild(planetName, buildId, now) {
const state = this.buildState;
const def = buildDefs()[buildId];
if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' };
if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' };
if (state.isBuilt(planetName, buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' };
}
// research gate — the blueprint must be researched (the authoritative
// side; the research tree's unlocks.builds is the declaration side).
for (const req of def.requires ?? []) {
const i = req.indexOf('/');
if (i < 0) continue;
const cat = req.slice(0, i);
const node = req.slice(i + 1);
if (!this.researchState?.isUnlocked(cat, node)) {
return { ok: false, reason: `REQUIRES RESEARCH — ${String(node).toUpperCase()}` };
}
}
// planet gate — the world must hold the tether level the build needs
const needTether = def.planetRequires?.tetherLevel;
if (typeof needTether === 'number' && this.tetherLevelFor(planetName) < needTether) {
return { ok: false, reason: `REQUIRES A LEVEL-${needTether} TETHER ON THIS WORLD` };
}
// cost — the full amount, paid up front (minerals are the build
// currency today — data/builds.json → cost)
const cost = Number(def.cost?.minerals ?? 0);
if (cost > 0) {
if (this.ship.minerals < cost) return { ok: false, reason: `NEED ${cost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - cost);
this.refreshMineralHud();
}
const durMs = Math.max(1, Number(def.duration ?? 0) * 1000);
state.start(planetName, buildId, durMs, now ?? this.time.now);
this.playSfx('construct'); // the power-up tick (the research voice)
return { ok: true };
}
/**
* A build's clock ran out (the SurfaceScene's tick here): apply its
* effect to the world. The world change outlives the surface stay a
* level-2 tether on the way back to space means a level-2 range.
*/
completeBuild(planetName, buildId) {
const def = buildDefs()[buildId];
if (def?.effects) this._applyBuildEffects(planetName, def.effects);
}
/**
* Apply a build's effects (data/builds.json builds.<id>.effects).
* tether { level: N } the world's tether strengthens to at least N
* (never a downgrade) the ship's travel range widens with it
* capability "flag" a scene capability set (future systems read it)
* Unknown shapes are logged and skipped data can lead code a step.
*/
_applyBuildEffects(planetName, effects) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
// The tether anchored on this world — the same lookup as
// tetherLevelFor (by label, or by anchor sitting on the world's
// center — the home tether is anchored at the origin with the
// home world's name).
const obj =
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
(planetName === this.planet?.discoveryName ? this.planet : null);
const t = this.tetherField?.tethers.find(
(tt) => tt.label === planetName ||
(obj && tt.x === obj.x && tt.y === obj.y),
);
if (t) {
if (t.level < lvl) {
this.tetherField.setLevel(t.id, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
} else {
console.warn(`[orbit] build: no tether on "${planetName}" to strengthen`, spec);
}
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else {
console.warn(`[orbit] build: unknown effect ${type}`, spec);
}
}
}
/** The RESEARCH slot's progress bar (update() calls it every frame). */
_deckResearchBar(time) {
const bar = this._researchDeckBar;
@ -1950,6 +2112,25 @@ export class GameScene extends Phaser.Scene {
* saved session time. Discovery + galaxy are already restored in the
* registry (this scene's create() read them).
*/
/**
* The `starting` installs (data/builds.json `starting`): rule-level
* pre-builds the player owns from the first frame the home world's
* level-1 tether ("every world starts with its tether already in
* place"). 'home' is the seed-independent home key; resolve it to the
* home world's name (BuildState keys records by planet name the same
* key the tether field and the surface scene use). Idempotent: create()
* seeds a fresh state, and applyRestore() re-seeds AFTER a load a save
* captured before the seed existed (or by an older iteration) replaces
* the seeded state, and must not un-install the home world's starting
* tether. A player resuming a run always has L1 on home.
*/
_seedStartingBuilds() {
for (const [planet, build] of startingPairs()) {
const name = planet === 'home' ? this.planet?.discoveryName : planet;
if (name) this.buildState.markBuilt(name, build);
}
}
applyRestore(r) {
if (r.ship) {
this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0);
@ -1978,6 +2159,45 @@ export class GameScene extends Phaser.Scene {
console.warn('[orbit] restore: research skipped', e);
}
}
// Builds — the installed set + the in-flight build (fresh clock — the
// game-loop clock: the build's time base, shared across scenes).
if (r.builds && this.buildState) {
try {
const fresh = new BuildState().fromJSON(r.builds);
// World state is keyed by the world's CANONICAL spelling. Saves
// written while the comms panel leaked its display casing into
// the landing handoff can carry uppercased planet keys —
// re-key them (merging into the canonical entry) so the surface
// and the tether field see the same world again.
const known = [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
];
for (const [k, v] of [...fresh.built.entries()]) {
const c = canonicalPlanetName(k, known);
if (c === k) continue;
fresh.built.delete(k);
const existing = fresh.built.get(c);
fresh.built.set(c, existing ? new Set([...existing, ...v]) : v);
}
this.buildState.reset();
this.buildState.built = fresh.built;
const activeSpec =
r.builds.active && typeof r.builds.active.planet === 'string'
? { ...r.builds.active, planet: canonicalPlanetName(r.builds.active.planet, known) }
: (r.builds.active ?? null);
this.buildState.restoreActive(activeSpec, this.game.loop.now);
} catch (e) {
console.warn('[orbit] restore: builds skipped', e);
}
}
// The starting installs are a RULE (data/builds.json → `starting`),
// not save data — the load above just replaced the seeded state, so
// re-assert them: the home world's level-1 tether is installed from
// the first frame, even on a run resumed from a save that predates
// the seed (or captured before it was recorded).
if (this.buildState) this._seedStartingBuilds();
// The camera was centered on the spawn — recentre on the restored ship.
this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2);
this.playSfx('construct');

View File

@ -3,11 +3,14 @@ import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { buildDefs } from '../research/ResearchModel.js';
/**
* SURFACE the planet's surface, started ON TOP of the sleeping
@ -74,6 +77,7 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
this.noteG = null; // console-note glyphs (consoleNote)
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
}
/** Pick this world's clips (data/landing.json) and queue them. */
@ -453,6 +457,26 @@ export class SurfaceScene extends Phaser.Scene {
stateScene: this.gameScene ?? this,
});
// The BUILD console (the deck's BUILD slot) — the passive view over
// gameScene.buildState (the run's build records + the single
// in-progress build): the same split as the research console (state
// + effects on the GameScene, the view here). This scene TICKS the
// state in update() — the GameScene sleeps while we own the loop, so
// this clock is the build's time base. A completion applies its
// effects on the game scene (the planet's tether range — world state
// that outlives this stay).
this.buildWindow = this.gameScene?.buildState
? new BuildWindow(this, {
state: this.gameScene.buildState,
planetName: this.planetName,
minerals: () => this.gameScene?.ship?.minerals ?? 0,
tetherLevel: () => this.gameScene?.tetherLevelFor(this.planetName) ?? 0,
researchUnlocked: (cat, node) =>
this.gameScene?.researchState?.isUnlocked(cat, node) ?? false,
onBuild: (buildId) => this.beginSurfaceBuild(buildId),
})
: null;
// ESC — topmost open thing first (mirror of GameScene.escAction).
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
@ -467,11 +491,24 @@ export class SurfaceScene extends Phaser.Scene {
/**
* The deck's button presses (ActionBar onAction). SHOP swaps the
* looping surface clip for the world's shop clip (and back); BUILD /
* SHIP are seams for the surface economy (data/builds.json plugs in
* later); TAKE OFF goes back to the ship; MENU is the save door.
* looping surface clip for the world's shop clip (and back); BUILD
* opens the build console (the world's buildable items
* data/builds.json); SHIP is a seam for the surface economy; TAKE OFF
* goes back to the ship; MENU is the save door.
*/
deckAction(id) {
// While a build runs, the deck is locked (one at a time —
// data/builds.json → maxConcurrent). The BUILD slot stays open — it
// shows the in-progress build and its progress.
if (this.gameScene?.buildState?.getActive() && id !== 'build') {
this.consoleNote('BUILD IN PROGRESS — COMMAND DECK LOCKED');
this.playSfx('ui_close');
return;
}
if (id === 'build') {
this.buildWindow?.open();
return;
}
if (id === 'shop') {
this.toggleShop();
return;
@ -519,8 +556,44 @@ export class SurfaceScene extends Phaser.Scene {
}
}
/** ESC: confirm dialog → save pop-up → sub-bar. */
/**
* The BUILD button the scene-side enforcement on the GameScene
* (rules, research + planet gates, the mineral cost
* GameScene.beginBuild). The window only offers the button when the
* rules allow it; this is the belt-and-braces pass (the same shape as
* GameScene.beginResearch). A refusal is a console note.
*/
beginSurfaceBuild(buildId) {
const g = this.gameScene;
if (!g) return;
// The build's time base is the GAME LOOP clock (game.loop.now) — the
// global monotonic ms that advances while ANY scene is awake, so it
// stays right across the surface → space transition if the player
// takes off mid-build (a scene's own clock is per-scene and freezes
// while that scene sleeps — the loop clock is the build's
// authoritative base).
const res = g.beginBuild(this.planetName, buildId, this.game.loop.now);
if (!res.ok) {
this.consoleNote(res.reason ?? 'BUILD REFUSED');
this.playSfx('ui_close');
return;
}
// (GameScene.beginBuild plays the 'construct' power-up tick — the
// research voice — the same single-voice rule as beginResearch.)
this.buildWindow?.refresh(); // IN PROGRESS — the button + row repaint
}
/** The scene's SFX voice (ActionBar / SavePanel call `playSfx?.(name)`). */
playSfx(name, o = {}) {
playSfxOn(this, name, o);
}
/** ESC: build console → confirm dialog → save pop-up → sub-bar. */
escAction() {
if (this.buildWindow?.isOpen) {
this.buildWindow.close();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
@ -622,6 +695,7 @@ export class SurfaceScene extends Phaser.Scene {
}
onPointerDown(pointer) {
if (this.buildWindow?.isOpen) return; // the console owns the click
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
@ -717,6 +791,22 @@ export class SurfaceScene extends Phaser.Scene {
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
this.updateHud(time); // the upper-left name/type decode (surface stage only)
// The build console: tick the single in-progress build (one at a
// time) on the game-loop clock (game.loop.now — global, monotonic,
// survives scene restarts; see beginSurfaceBuild). A completion
// applies its effects on the game scene (the planet's tether range —
// world state that outlives this stay).
if (this.gameScene?.buildState) {
const done = this.gameScene.buildState.tick(this.game.loop.now);
if (done.length) {
for (const c of done) this.gameScene.completeBuild(c.planet, c.build);
const def = buildDefs()[done[0].build];
this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`);
this.playSfx('discovery'); // the 'something new is here' voice
}
}
this.buildWindow?.update(time);
}
shutdown() {
@ -735,6 +825,8 @@ export class SurfaceScene extends Phaser.Scene {
this.noteG = null;
}
this.destroyHud();
this.buildWindow?.destroy();
this.buildWindow = null;
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();

1457
js/ui/BuildWindow.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -327,17 +327,24 @@ export class CommsPanel extends Phaser.GameObjects.Container {
open(wx, wy, sx, sy, o = {}) {
if (this.scene === null || this.active === false) return;
this.scene.playSfx?.('ui_window'); // the window whoosh (the scene is the voice)
const name = String(o.name ?? 'UNKNOWN').toUpperCase();
// UPPERCASE IS THE PANEL'S DISPLAY CONVENTION — the data payload must
// keep the world's canonical name: it rides the landing handoff
// (onAction → GameScene.startLanding → SurfaceScene) as the key for
// name-keyed world state (build records, tether labels). Corrupting
// the casing there makes the surface read its own home world as
// "nothing installed, no tether".
const display = String(o.name ?? 'UNKNOWN').toUpperCase();
this.settled = !!o.settled;
this.rep = Math.round(o.reputation ?? 0);
this.canLand = o.canLand !== false;
this._name = name;
this._name = display;
// Carry the FULL payload the scene handed us (it may include landing
// data like `isPlanet`/`frame`) — the scene's onAction gets this back
// verbatim, so nothing the scene needs may be pruned here.
// verbatim, so nothing the scene needs may be pruned here. `name`
// stays the canonical spelling from `...o` (display casing above is
// for the decode text only).
this.lastTarget = {
...o,
name,
settled: this.settled,
key: o.key ?? null,
reputation: this.rep,
@ -346,7 +353,7 @@ export class CommsPanel extends Phaser.GameObjects.Container {
// The name decodes in (the console pulls it out of static)…
this.nameText.setText('');
this.nameDec = new ScrambleDecode(name, this.scene.time.now + 160, decodeDur(name.length));
this.nameDec = new ScrambleDecode(display, this.scene.time.now + 160, decodeDur(display.length));
this.kindText.setText(o.kindLabel ? `· ${String(o.kindLabel).toUpperCase()} ·` : '');
if (this.settled) {

View File

@ -866,7 +866,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
})
.setScrollFactor(0);
const bar = s.graphics().setScrollFactor(0);
cont.add(g, label, bar);
// v4 Container.add(child, index) — multi-add is the ARRAY form.
cont.add([g, label, bar]);
this.dBtn = { g, label, bar, rect: { x: ax, y: ay, w: bw, h: bh }, hover: false, mode: 'locked' };
const btnRect = new Phaser.Geom.Rectangle(ax, ay, bw, bh);
g.setInteractive({

View File

@ -436,7 +436,10 @@ export class SavePanel extends Phaser.GameObjects.Container {
doSave(slot) {
try {
const rec = captureState(this.stateScene);
// The live clock: this scene's own (the game scene sleeps while a
// surface stay is active, so its clock is stale for the in-flight
// research/build remaining-time capture).
const rec = captureState(this.stateScene, this.scene.time?.now);
this.sm.put(slot, rec);
this.toast.show(`${config.get('save.toast.saved', 'GAME SAVED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
const records = this.sm.listSlots().map((s) => s.record);

33
js/utils/WorldNames.js Normal file
View File

@ -0,0 +1,33 @@
/**
* WorldNames casing-safe world-name resolution. Pure (no Phaser),
* Node-tested (dev/world-names.test.mjs).
*
* World state is keyed by the world's CANONICAL name (its discovery
* name build records, tether labels, the home key). UI paths that
* display names uppercase them (the comms panel's decode shows
* "ALKHAQO"); if that display casing leaks back into the data path
* (the landing handoff), the name-keyed lookups isBuilt(planet),
* tetherLevelFor(planet) silently miss, and the world reads as
* "nothing installed / no tether" on top of its own home world.
*
* canonicalPlanetName(name, worlds) the canonical spelling of a known
* world regardless of the casing `name` arrived in; unknown names pass
* through untouched.
*/
export function canonicalPlanetName(name, worlds) {
const n = String(name ?? '').trim();
if (!n || !Array.isArray(worlds)) return n;
// 1) exact (any casing in the list is accepted, its spelling wins)
for (const w of worlds) {
const s = String(w ?? '').trim();
if (s && s === n) return s;
}
// 2) case-insensitive
const l = n.toLowerCase();
for (const w of worlds) {
const s = String(w ?? '').trim();
if (s && s.toLowerCase() === l) return s;
}
return n;
}