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:
parent
d7f8e2101f
commit
1d1cb2c150
90
README.md
90
README.md
|
|
@ -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 1–4,
|
||||
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 |
Binary file not shown.
103
data/builds.json
103
data/builds.json
|
|
@ -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-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": {
|
||||
"_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"],
|
||||
"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": {
|
||||
"credits": 400,
|
||||
"minerals": 60
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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^(level−1), 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^(level−1), 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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 0→1 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);
|
||||
|
|
@ -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 40–200 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
});
|
||||
|
|
@ -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 ✔');
|
||||
|
|
@ -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 3–4
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (0…1) 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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)');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue