diff --git a/README.md b/README.md index a02bf59..fbe9a57 100644 --- a/README.md +++ b/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 — diff --git a/assets/images/originals/rockey-planet-02.png b/assets/images/originals/rockey-planet-02.png new file mode 100644 index 0000000..cdc6c0b Binary files /dev/null and b/assets/images/originals/rockey-planet-02.png differ diff --git a/assets/images/originals/rocky-planet-01.png b/assets/images/originals/rocky-planet-01.png new file mode 100644 index 0000000..5d08da0 Binary files /dev/null and b/assets/images/originals/rocky-planet-01.png differ diff --git a/assets/videos/build.mp4 b/assets/videos/build.mp4 new file mode 100644 index 0000000..da933d3 Binary files /dev/null and b/assets/videos/build.mp4 differ diff --git a/data/builds.json b/data/builds.json index 1d4556a..f9e4c74 100644 --- a/data/builds.json +++ b/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 \"/\" pointing at data/research/.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/"], + "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" } } } diff --git a/data/research/exploration.json b/data/research/exploration.json index ba1c0b0..5e38188 100644 --- a/data/research/exploration.json +++ b/data/research/exploration.json @@ -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", diff --git a/dev/build-check.html b/dev/build-check.html new file mode 100644 index 0000000..169198b --- /dev/null +++ b/dev/build-check.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Build check (dev) + + + + +
+ + + diff --git a/dev/build-check.mjs b/dev/build-check.mjs new file mode 100644 index 0000000..50f8536 --- /dev/null +++ b/dev/build-check.mjs @@ -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); +} diff --git a/dev/builds.test.mjs b/dev/builds.test.mjs new file mode 100644 index 0000000..48a2bbf --- /dev/null +++ b/dev/builds.test.mjs @@ -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); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index f75b40f..eadaccf 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -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); diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 39ebc87..34744ab 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -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); diff --git a/dev/server.mjs b/dev/server.mjs new file mode 100644 index 0000000..e96e089 --- /dev/null +++ b/dev/server.mjs @@ -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})`); +}); diff --git a/dev/world-names.test.mjs b/dev/world-names.test.mjs new file mode 100644 index 0000000..a3d0ce5 --- /dev/null +++ b/dev/world-names.test.mjs @@ -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 ✔'); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 40dac57..542a2ed 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/build/BuildModel.js b/js/build/BuildModel.js new file mode 100644 index 0000000..df71636 --- /dev/null +++ b/js/build/BuildModel.js @@ -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); +} diff --git a/js/build/BuildState.js b/js/build/BuildState.js new file mode 100644 index 0000000..78b2810 --- /dev/null +++ b/js/build/BuildState.js @@ -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>); 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 + 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; + } +} diff --git a/js/dev/BuildDiag.js b/js/dev/BuildDiag.js new file mode 100644 index 0000000..d6d5cf8 --- /dev/null +++ b/js/dev/BuildDiag.js @@ -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`); +} diff --git a/js/main.js b/js/main.js index 5098621..fad61be 100644 --- a/js/main.js +++ b/js/main.js @@ -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)'); diff --git a/js/research/ResearchIcons.js b/js/research/ResearchIcons.js index a89ba1e..dcebdc7 100644 --- a/js/research/ResearchIcons.js +++ b/js/research/ResearchIcons.js @@ -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; diff --git a/js/save/SaveData.js b/js/save/SaveData.js index eef301f..2dc50ce 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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, }); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 0b77050..fb9e3ed 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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..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'); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 8b22ddb..78749db 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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(); diff --git a/js/ui/BuildWindow.js b/js/ui/BuildWindow.js new file mode 100644 index 0000000..7609809 --- /dev/null +++ b/js/ui/BuildWindow.js @@ -0,0 +1,1457 @@ +/** + * BuildWindow — the full-screen build console (the deck's BUILD button, + * on a planet surface). + * + * ┌────────────────────────────────────────────────────────────────────────┐ + * │ BUILD ▌ KETH · ONE BUILD AT A TIME [✕] │ + * ├───────────────────────┬────────────────────────────────────────────────┤ + * │ ● BUILD FEED // LIVE │ [PLANET] [CARGO] [ …more categories… ] │ + * │ ┌─────────────────┐ │ │ + * │ │ looping video │ │ TETHER - LEVEL 1 BUILT ✓ │ + * │ │ (muted, 2:3) │ │ TETHER - LEVEL 2 READY ◆ │ + * │ │ + scanlines │ │ │ + * │ │ + sweep band │ │ │ + * │ └─────────────────┘ │ │ + * │ BUILD CONSOLE … │ ┌──────────────────────────────────────────┐ │ + * │ ┌──────────────────┐ │ │ [icon] TETHER - LEVEL 2 — meta — desc │ │ + * │ │ │ │ │ COST ◆ 200 MINERALS [BUILD] │ │ + * │ └──────────────────┘ │ └──────────────────────────────────────────┘ │ + * └───────────────────────┴────────────────────────────────────────────────┘ + * + * Left: the build feed — assets/videos/build.mp4, a 2:3 portrait that + * LOOPS MUTED while the console is open. Right: category tabs (data: + * planet / cargo) → a LIST of the category's builds (not a tree — builds + * are one-off installs on the planet) → the detail readout with the + * BUILD button. Locked builds are grayed out with their missing + * requirements; one-off builds read BUILT once installed. + * + * The rules live in data/builds.json (js/build/BuildModel.js), the + * progress in BuildState (js/build/BuildState.js) — both on the + * GameScene. The window is a passive view: it asks via onBuild(buildId) + * and the scene applies the rules, costs, effects, toasts and save data. + * While a build runs the SurfaceScene locks the deck and ticks the state; + * completion (20 s for the level-2 tether) applies the effect — the + * planet's tether range — which outlives the surface stay. + * + * Depth 80 — above the save panel (70) / deck (50). + */ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack, themeColor } from '../utils/Theme.js'; +import { ScrambleDecode } from '../utils/Decode.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; +import { playSfxOn } from '../utils/Sfx.js'; +import { CyberShape } from './CyberShape.js'; +import { + categories, + loadBuilds, + isAvailable, + missingRequirements, + rowState, + costLines, + canAfford, + defById, +} from '../build/BuildModel.js'; +import { ensureIcon } from '../research/ResearchIcons.js'; + +const HEADER = fontStack('header'); +const BODY = fontStack('body'); +const rand = (a, b) => a + Math.random() * (b - a); +const clamp01 = (v) => Math.min(1, Math.max(0, v)); +const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2); + +/** Theme palette (data/theme.json), resolved once at build time. */ +const C = { + ink: themeColor('ink', 0xeaf6ff), + dim: themeColor('dim', 0x7d92c4), + faint: themeColor('faint', 0x3d4c74), + neon: themeColor('neon', 0x00e5ff), + neon2: themeColor('neon2', 0xff2d6f), + amber: themeColor('amber', 0xffc94d), + panel: themeColor('panel', 0x0a1120), + bg: themeColor('bg', 0x04060d), +}; + +/** + * Draw a cut-corner plate with its top-left at (x, y) into an existing + * Graphics (CyberShape.points is centred on (0,0) — translate by the + * plate centre). Mirrors ResearchWindow.panel(). + */ +function panel(g, x, y, w, h, o = {}) { + const cx = x + w / 2; + const cy = y + h / 2; + const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map( + (p) => ({ x: p.x + cx, y: p.y + cy }) + ); + if (o.fill !== undefined) { + g.fillStyle(o.fill, o.fillAlpha ?? 1); + g.fillPoints(pts, true); + } + if (o.stroke !== undefined) { + g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1); + g.strokePoints(pts, true); + } +} + +/** Corner brackets framing a rect (x, y = top-left). */ +function brackets(g, x, y, w, h, o = {}) { + const color = o.color ?? C.neon; + const alpha = o.alpha ?? 0.7; + const len = o.length ?? 14; + g.lineStyle(o.lineWidth ?? 2, color, alpha); + const x2 = x + w; + const y2 = y + h; + g.lineBetween(x, y, x + len, y); + g.lineBetween(x, y, x, y + len); + g.lineBetween(x2, y, x2 - len, y); + g.lineBetween(x2, y, x2, y + len); + g.lineBetween(x, y2, x + len, y2); + g.lineBetween(x, y2, x, y2 - len); + g.lineBetween(x2, y2, x2 - len, y2); + g.lineBetween(x2, y2, x2, y2 - len); +} + +export class BuildWindow extends Phaser.GameObjects.Container { + static VIDEO_KEY = 'build_console'; + + /** + * @param {Phaser.Scene} scene the owning scene (SurfaceScene) + * @param {object} o seams: + * { state: BuildState, + * planetName: string, + * minerals: () => number, + * tetherLevel: () => number, + * researchUnlocked: (catId, nodeId) => boolean, + * onBuild: (buildId) => void } + */ + constructor(scene, o = {}) { + super(scene, 0, 0); + // v4 quirk: a directly-constructed GameObject must register itself. + this.scene.add.existing(this); + this.setScrollFactor(0); // UI — pinned to the screen + this.setDepth(80); // above save panel (70), deck (50) + + this.state = o.state ?? null; + this.planetName = o.planetName ?? ''; + this.minerals = typeof o.minerals === 'function' ? o.minerals : () => 0; + this.tetherLevel = typeof o.tetherLevel === 'function' ? o.tetherLevel : () => 0; + this.researchUnlocked = typeof o.researchUnlocked === 'function' ? o.researchUnlocked : () => false; + this.onBuild = typeof o.onBuild === 'function' ? o.onBuild : null; + + this.openState = 'closed'; // closed | opening | open | closing + this.booted = false; + this.reveal = []; // { o, d, dur, mode, baseY, t0 } + this.decodes = []; // { txt, dec } — polled ScrambleDecodes + this.selected = null; // { category, id } + this.lastSelected = new Map(); + this.activeCat = null; + this.glitch = { until: 0, next: 0, level: 0.8 }; + this.sweep = { t0: 0, next: 0 }; + this._lastPct = undefined; + this._lastMinerals = undefined; + + this._build(); + this.setVisible(true); + this.setAlpha(0); + } + + // ------------------------------------------------------------ helpers + hasVideo(key) { + const c = this.scene.cache?.video; + return !!(c && typeof c.has === 'function' && c.has(key)); + } + + destroyVideo(v) { + if (!v) return; + try { + v.off(); + v.stop(false); + v.destroy(); + } catch { + /* already gone */ + } + } + + /** Decode text into `txt` starting at `t0` (driven by update()). */ + decodeTo(txt, str, t0, dur = 520) { + this.decodes = this.decodes.filter((d) => d.txt !== txt); + if (txt.text === str) return; + txt.setText(''); + this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) }); + } + + sfx(name) { + playSfxOn(this.scene, name); + } + + /** The caller's view of the world for one build (BuildModel ctx). */ + ctxFor(buildId) { + return { + isResearchUnlocked: (c, n) => this.researchUnlocked(c, n), + tetherLevel: () => this.tetherLevel(), + isBuilt: () => this.state?.isBuilt(this.planetName, buildId) ?? false, + }; + } + + /** The in-progress build on THIS planet (null when none). */ + _activeOnPlanet() { + const a = this.state?.getActive(); + return a && a.planet === this.planetName ? a : null; + } + + // ------------------------------------------------------------ geometry + _measure() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + const m = 10; + const pad = 14; + const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m }; + const titleH = 46; + const bodyY = rect.y + titleH + pad; + const bodyH = rect.h - titleH - 2 * pad; + const a = config.get('builds.video.aspect', [2, 3]); + const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3; + const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8); + const videoW = Math.max(90, videoH * ar); + const leftX = rect.x + pad; + const leftW = videoW + 30; + const rightX = leftX + leftW + pad; + const rightW = rect.x + rect.w - pad - rightX; + const tabH = 38; + const tabGap = 10; + const detailH = 152; + const detailGap = 12; + const listTop = bodyY + tabH + tabGap; + const detailY = bodyY + bodyH - detailH; + const listH = detailY - detailGap - listTop; + return { + W, H, rect, titleH, pad, + bodyY, bodyH, + videoH, videoW, leftX, leftW, + rightX, rightW, tabH, tabGap, + detailH, detailGap, listTop, listH, detailY, + }; + } + + // ------------------------------------------------------------ build + _build() { + const s = this.scene.add; + const G = (d = 0) => { + const g = s.graphics().setScrollFactor(0).setDepth(d); + this.add(g); + return g; + }; + const T = (x, y, str, style, d = 0) => { + const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d); + this.add(t); + return t; + }; + + this.geo = this._measure(); + const { rect, titleH } = this.geo; + + // ── window body ───────────────────────────────────────────────── + const bg = G(0); + bg.clear(); + panel(bg, rect.x, rect.y, rect.w, rect.h, { + notch: 20, + fill: C.bg, + fillAlpha: 0.985, + stroke: 0x0e5f86, + strokeAlpha: 0.95, + }); + bg.fillStyle(C.neon, 0.16); + bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5); + this.bgG = bg; + + // ── title bar ─────────────────────────────────────────────────── + this.titleTxt = T(rect.x + 18, rect.y + 13, '', { + fontFamily: HEADER, + fontSize: '17px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 7, + }, 1); + this.titleCursor = G(1); + this.titleMeta = T(rect.x + rect.w - 54, rect.y + 15, '', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }, 1); + this.titleMeta.setOrigin(1, 0); + this.titleMeta.setText( + `${String(this.planetName ?? '').toUpperCase() || 'SURFACE'} · ONE BUILD AT A TIME` + ); + const railY = rect.y + titleH - 1; + this.titleRailG = G(1); + this.titleRailG.clear(); + this.titleRailG.lineStyle(1, 0x14324f, 0.8); + this.titleRailG.lineBetween(rect.x + 16, railY, rect.x + rect.w - 16, railY); + this.titleRailG.fillStyle(C.neon, 0.9); + this.titleRailG.fillRect(rect.x + 16, railY - 1, 90, 2); + + // close button (top right) + const cw = 30; + const cx = rect.x + rect.w - 18 - cw / 2; + const cy = rect.y + titleH / 2 - 2; + this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false }; + this.closeG = G(2); + this.closeTxt = T(cx, cy - 5, '✕', { + fontFamily: BODY, + fontSize: '15px', + color: toCss(C.neon2), + align: 'center', + }, 2); + const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw); + this.closeG.setInteractive({ + useHandCursor: true, + hitArea: closeRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + this.closeG.on('pointerover', () => { + this.closeBtn.hover = true; + this._paintClose(); + }); + this.closeG.on('pointerout', () => { + this.closeBtn.hover = false; + this._paintClose(); + }); + this.closeG.on('pointerdown', () => { + this.sfx('ui_click'); + this.close(); + }); + this._paintClose(); + + // glitch layers (top of the window) + this.glitchG = G(10); + this.titleGhostA = this._ghost(); + this.titleGhostB = this._ghost(); + + // ── left: the build feed (video) ──────────────────────────────── + this.videoPanel = this._buildVideoPanel(); + this.add(this.videoPanel); + this.videoPanel.depth = 1; + + // ── right: tabs + build list + detail ─────────────────────────── + this._buildTabs(); + this._buildLists(); + this.detail = this._buildDetail(); + this.add(this.detail); + this.detail.depth = 1; + } + + _ghost() { + const t = this.scene.add + .text(0, 0, '', { + fontFamily: HEADER, + fontSize: '17px', + color: toCss(C.neon), + fontStyle: 'bold', + letterSpacing: 7, + }) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD); + t.setDepth(2); + this.add(t); + return t; + } + + // ── left panel: the build feed ──────────────────────────────────────────── + _buildVideoPanel() { + const { leftX, bodyY, leftW, videoW, videoH } = this.geo; + const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY); + const s = this.scene.add; + + // header: ● BUILD FEED // LIVE + this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0); + cont.add(this.recDot); + const head = s + .text(20, 6, 'BUILD FEED // LIVE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2.5, + }) + .setScrollFactor(0); + cont.add(head); + + // frame + the clip + const vx = (leftW - videoW) / 2; + const vy = 26; + const frameG = s.graphics().setScrollFactor(0); + frameG.clear(); + brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 }); + frameG.lineStyle(1, 0x14324f, 0.55); + frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2); + frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2); + cont.add(frameG); + + this.videoCx = vx + videoW / 2; + this.videoCy = vy + videoH / 2; + this.videoRect = { x: vx, y: vy, w: videoW, h: videoH }; + + this.video = null; + if (this.hasVideo(BuildWindow.VIDEO_KEY)) { + const v = s.video(0, 0, BuildWindow.VIDEO_KEY); + v.setOrigin(0.5); + v.setScrollFactor(0); + // The feed is ambience: muted + looping, as the console requires. + v.setVolume(0); + v.setLoop(true); + if (v.video) v.video.muted = true; + const fit = (vv, iw = 0, ih = 0) => { + const el = vv.video; + const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544; + const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800; + const sc = Math.min(videoW / vw, videoH / vh); + vv.setPosition(this.videoCx, this.videoCy); + vv.setScale(sc); + }; + fit(v); + const ready = (vv, w, h) => { + if (vv !== v) return; + fit(vv, w, h); + vv.setVisible(true); + }; + v.on('created', ready); + if (v.video && v.video.readyState >= 1) ready(v, 0, 0); + cont.add(v); + this.video = v; + } else { + // NO SIGNAL plate (asset missing) — the console still works. + const ph = s.graphics().setScrollFactor(0); + ph.clear(); + panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 }); + for (let i = 0; i < 40; i++) { + ph.fillStyle(C.neon, rand(0.02, 0.08)); + ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1); + } + cont.add(ph); + const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', { + fontFamily: HEADER, + fontSize: '14px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 5, + align: 'center', + }).setScrollFactor(0); + const nsub = s.text(this.videoCx, this.videoCy + 12, 'BUILD FEED OFFLINE', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 3, + align: 'center', + }).setScrollFactor(0); + cont.add(ns); + cont.add(nsub); + } + + // scanlines over the feed (the menu's CRT recipe) + const scanKey = 'build_scanlines'; + if (!this.scene.textures.exists(scanKey)) { + const c = document.createElement('canvas'); + c.width = 4; + c.height = 8; + const ctx = c.getContext('2d'); + ctx.fillStyle = 'rgba(2,6,12,0.5)'; + ctx.fillRect(0, 0, 4, 3); + ctx.fillStyle = 'rgba(120,220,255,0.05)'; + ctx.fillRect(0, 4, 4, 1); + this.scene.textures.addCanvas(scanKey, c); + } + this.scan = s.tileSprite(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5); + cont.add(this.scan); + + // sweep band (crawls down the feed on a loop) + const swKey = 'build_sweep'; + if (!this.scene.textures.exists(swKey)) { + const c = document.createElement('canvas'); + c.width = 8; + c.height = 64; + const ctx = c.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, 0, 64); + grad.addColorStop(0, 'rgba(127,223,255,0)'); + grad.addColorStop(0.5, 'rgba(127,223,255,0.5)'); + grad.addColorStop(1, 'rgba(127,223,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 8, 64); + this.scene.textures.addCanvas(swKey, c); + } + this.sweepBand = s + .image(this.videoCx, this.videoRect.y - 40, swKey) + .setDisplaySize(videoW, 48) + .setScrollFactor(0) + .setAlpha(0) + .setBlendMode(Phaser.BlendModes.ADD); + cont.add(this.sweepBand); + this.sweepCfg = config.get('builds.fx', null) ?? {}; + + // bottom status strip + const sy = vy + videoH + 14; + const strip = s.graphics().setScrollFactor(0); + strip.clear(); + strip.lineStyle(1, 0x14324f, 0.8); + strip.lineBetween(2, sy, leftW - 2, sy); + strip.fillStyle(C.neon, 0.7); + strip.fillRect(2, sy - 1, 26, 2); + cont.add(strip); + this.statusTxt = s + .text(2, sy + 10, 'BUILD CONSOLE STANDBY — SELECT A MODULE TO BEGIN', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2, + }) + .setScrollFactor(0); + cont.add(this.statusTxt); + this.statusBar = s.graphics().setScrollFactor(0); + this.statusBarY = sy + 32; + this.statusBarW = leftW - 60; + cont.add(this.statusBar); + + return cont; + } + + // ── right: category tabs ────────────────────────────────────────────────── + _buildTabs() { + const { rightX, bodyY, tabH } = this.geo; + this.tabs = []; + let x = rightX + 4; + for (const cat of categories()) { + const accent = toColor(cat.accent, C.neon); + const label = String(cat.label ?? cat.id).toUpperCase(); + const txt = this.scene.add + .text(0, 0, label, { + fontFamily: HEADER, + fontSize: '12px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 2, + }) + .setScrollFactor(0); + const w = txt.width + 44; + const y = bodyY + tabH / 2; + const g = this.scene.add.graphics().setScrollFactor(0); + const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false }; + const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH); + g.setInteractive({ + useHandCursor: true, + hitArea: tabRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + tab.hover = true; + this.sfx('ui_hover'); + this._paintTabs(); + }); + g.on('pointerout', () => { + tab.hover = false; + this._paintTabs(); + }); + g.on('pointerdown', () => { + this.sfx('ui_click'); + this.switchCategory(cat.id); + }); + txt.setPosition(x + 26, y - 4); + g.setDepth(2); + txt.setDepth(3); + this.add(g); + this.add(txt); + this.tabs.push(tab); + x += w + 12; + } + // reserved socket — the row has room for more categories + const sock = this.scene.add.graphics().setScrollFactor(0).setDepth(2); + sock.fillStyle(0x1b3a5a, 0.5); + sock.fillRect(x + 2, bodyY + tabH / 2 - 1, 18, 2); + this.add(sock); + this._paintTabs(); + } + + _paintTabs() { + const { bodyY, tabH } = this.geo; + for (const t of this.tabs) { + t.g.clear(); + const active = t.id === this.activeCat; + const edge = active ? t.accent : t.hover ? C.ink : 0x22405f; + panel(t.g, t.x, bodyY + 1, t.w, tabH - 2, { + notch: 8, + fill: active ? t.accent : C.panel, + fillAlpha: active ? 0.16 : t.hover ? 0.85 : 0.4, + stroke: edge, + strokeAlpha: active ? 1 : t.hover ? 0.9 : 0.5, + }); + if (active) { + t.g.fillStyle(t.accent, 1); + t.g.fillRect(t.x + 8, bodyY + tabH - 1, t.w - 16, 2); + } + const dx = t.x + 14; + const dy = bodyY + tabH / 2 - 1; + t.g.fillStyle(active ? t.accent : 0x3d4c74, active ? 1 : 0.5); + t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy); + t.txt.setColor(active ? toCss(t.accent) : toCss(C.ink)); + t.txt.setAlpha(active ? 1 : t.hover ? 0.9 : 0.62); + } + } + + // ── right: the build list (one row per build, not a tree) ──────────────── + _buildLists() { + const { rightX, rightW, listTop, listH } = this.geo; + this.lists = new Map(); + const rowH = 64; + const rowGap = 12; + + for (const cat of categories()) { + const entry = loadBuilds(cat.id); + const accent = toColor(entry.accent, C.neon); + const cont = new Phaser.GameObjects.Container(this.scene, 0, 0); + cont.setDepth(1); + this.add(cont); + + const rows = []; + const n = entry.order.length; + const total = n > 0 ? n * rowH + (n - 1) * rowGap : 0; + const startY = listTop + (listH - total) / 2; + + for (let i = 0; i < n; i++) { + const id = entry.order[i]; + const def = entry.builds[id]; + const x = rightX + 8; + const y = startY + i * (rowH + rowGap); + const w = rightW - 16; + const g = this.scene.add.graphics().setScrollFactor(0); + const icon = this.scene.add + .image(x + 34, y + rowH / 2, ensureIcon(this.scene, def.icon ?? 'diamond', accent)) + .setDisplaySize(40, 40) + .setScrollFactor(0); + const label = this.scene.add + .text(x + 66, y + 13, String(def.label ?? id).toUpperCase(), { + fontFamily: HEADER, + fontSize: '13px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 2, + }) + .setScrollFactor(0); + const sub = this.scene.add + .text(x + 66, y + 35, '', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.dim), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + const tag = this.scene.add + .text(rightX + rightW - 26, y + rowH / 2 - 5, '', { + fontFamily: HEADER, + fontSize: '11px', + color: toCss(C.dim), + fontStyle: 'bold', + letterSpacing: 2, + align: 'center', + }) + .setScrollFactor(0); + tag.setOrigin(1, 0); + const bar = this.scene.add.graphics().setScrollFactor(0); + const row = { id, def, x, y, w, h: rowH, g, icon, label, sub, tag, bar, hover: false, accent }; + const hit = new Phaser.Geom.Rectangle(x, y, w, rowH); + g.setInteractive({ + useHandCursor: true, + hitArea: hit, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + row.hover = true; + this.sfx('ui_hover'); + this._paintRow(row); + }); + g.on('pointerout', () => { + row.hover = false; + this._paintRow(row); + }); + g.on('pointerdown', () => { + this.sfx('ui_click'); + this.select(cat.id, id); + }); + g.setDepth(2); + icon.setDepth(3); + label.setDepth(3); + sub.setDepth(3); + tag.setDepth(3); + bar.setDepth(3); + // v4 Container.add(child, index) — multi-add is the ARRAY form + // (add(a, b, c) would silently drop every child after `a`). + cont.add([g, icon, label, sub, tag, bar]); + rows.push(row); + } + + if (n === 0) { + // empty category (cargo has no builds yet) + const msg = this.scene.add + .text(rightX + rightW / 2, listTop + listH / 2 - 10, 'NO CARGO MODULES RESEARCHED', { + fontFamily: HEADER, + fontSize: '12px', + color: toCss(C.faint), + fontStyle: 'bold', + letterSpacing: 3, + align: 'center', + }) + .setScrollFactor(0); + const sub = this.scene.add + .text(rightX + rightW / 2, listTop + listH / 2 + 10, 'CHECK THE RESEARCH CONSOLE FOR THE BLUEPRINT', { + fontFamily: BODY, + fontSize: '10px', + color: toCss(C.faint), + letterSpacing: 2, + align: 'center', + }) + .setScrollFactor(0); + cont.add([msg, sub]); + } + + this.lists.set(cat.id, { entry, accent, cont, rows }); + } + } + + _paintRow(row) { + const g = row.g; + g.clear(); + const st = rowState(row.def, this.ctxFor(row.id), this._activeOnPlanet(), row.id); + const affordable = canAfford(row.def, this.minerals()); + const w = row.w; + const h = row.h; + + let fillA = 0.4; + let stroke = 0x22405f; + let strokeA = 0.55; + let labelColor = toCss(C.faint); + let subColor = toCss(C.faint); + let tagColor = toCss(C.faint); + let tag = 'LOCKED'; + let sub = ''; + let iconTint = 0x445566; + let iconAlpha = 0.5; + + if (st === 'available') { + const lines = costLines(row.def); + const costStr = lines.length + ? lines + .map((l) => `${l.amount} ${String(config.get(`builds.resources.${l.res}.label`, l.res))}`) + .join(' + ') + : 'NO COST'; + const dur = Number(row.def.duration ?? 0); + sub = `${costStr.toUpperCase()}${dur > 0 ? ` · ${dur}S` : ''}`; + if (affordable) { + fillA = 0.1; + stroke = row.accent; + strokeA = 0.9; + labelColor = toCss(C.ink); + subColor = toCss(C.dim); + tagColor = toCss(row.accent); + tag = 'READY'; + iconTint = 0xffffff; + iconAlpha = 1; + } else { + fillA = 0.25; + stroke = C.amber; + strokeA = 0.6; + labelColor = toCss(C.dim); + subColor = toCss(C.amber); + tagColor = toCss(C.amber); + tag = 'NEED MINERALS'; + iconTint = 0x99aabb; + iconAlpha = 0.75; + } + } else if (st === 'active') { + fillA = 0.2; + stroke = row.accent; + strokeA = 1; + labelColor = toCss(0xffffff); + subColor = toCss(row.accent); + tagColor = toCss(row.accent); + iconTint = 0xffffff; + iconAlpha = 1; + const p = this.state?.progress(this.scene.time.now); + const pct = Math.round((p?.fraction ?? 0) * 100); + sub = `IN PROGRESS · ${pct}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT`; + tag = `${pct}%`; + } else if (st === 'built') { + fillA = 0.18; + stroke = row.accent; + strokeA = 0.55; + labelColor = toCss(C.ink); + subColor = toCss(C.faint); + tagColor = toCss(C.dim); + tag = 'BUILT ✓'; + iconTint = 0xffffff; + iconAlpha = 0.85; + sub = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()}`; + } else { + // locked — grayed out, with the missing requirements + const missing = missingRequirements(row.def, this.ctxFor(row.id)); + sub = missing.length ? `NEEDS ${missing.join(' · ')}` : 'UNAVAILABLE'; + } + if (row.hover) fillA = Math.min(0.9, fillA + 0.12); + + panel(g, row.x, row.y, w, h, { notch: 10, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA }); + row.icon.setTint(iconTint); + row.icon.setAlpha(iconAlpha); + row.label.setColor(labelColor); + row.sub.setText(sub); + row.sub.setColor(subColor); + row.tag.setText(tag); + row.tag.setColor(tagColor); + + // selection brackets + const sel = this.selected; + if (sel && sel.category === this.activeCat && sel.id === row.id) { + g.lineStyle(1.5, row.accent, 1); + const r = 7; + const corners = [ + [row.x, row.y], + [row.x + w, row.y], + [row.x, row.y + h], + [row.x + w, row.y + h], + ]; + for (const [ox, oy] of corners) { + const sx = ox < row.x + w / 2 ? 1 : -1; + const sy = oy < row.y + h / 2 ? 1 : -1; + g.lineBetween(ox, oy, ox + sx * r, oy); + g.lineBetween(ox, oy, ox, oy + sy * r); + } + } + + // row progress bar (active build) + row.bar.clear(); + if (st === 'active') { + const p = this.state?.progress(this.scene.time.now)?.fraction ?? 0; + row.bar.fillStyle(0x0a1424, 0.9); + row.bar.fillRect(row.x + 66, row.y + h - 10, w - 66 - 90, 3); + row.bar.fillStyle(row.accent, 1); + row.bar.fillRect(row.x + 66, row.y + h - 10, (w - 66 - 90) * p, 3); + } + } + + _paintAll(catId) { + const list = this.lists.get(catId); + if (!list) return; + for (const row of list.rows) this._paintRow(row); + } + + // ── right: the detail readout ───────────────────────────────────────────── + _buildDetail() { + const { rightW, detailH } = this.geo; + const s = this.scene.add; + const cont = new Phaser.GameObjects.Container(this.scene, 0, 0); + + const { rightX, detailY } = this.geo; + const bg = s.graphics().setScrollFactor(0); + bg.clear(); + panel(bg, rightX, detailY, rightW, detailH, { + notch: 12, + fill: 0x081120, + fillAlpha: 0.92, + stroke: 0x1b3a5a, + strokeAlpha: 0.8, + }); + bg.fillStyle(C.neon, 0.35); + bg.fillRect(rightX + 12, detailY + 1, rightW - 24, 1.5); + cont.add(bg); + + // image frame (left of the bar) — the build's glyph + const box = 118; + const bx = rightX + 12; + const by = detailY + (detailH - box) / 2; + const frame = s.graphics().setScrollFactor(0); + frame.clear(); + brackets(frame, bx - 4, by - 4, box + 8, box + 8, { length: 10, color: C.neon, alpha: 0.6 }); + panel(frame, bx, by, box, box, { notch: 6, fill: 0x040a14, fillAlpha: 0.9, stroke: 0x14324f, strokeAlpha: 0.6 }); + cont.add(frame); + this.dIcon = s.image(bx + box / 2, by + box / 2 - 6, 'diamond').setDisplaySize(82, 82).setScrollFactor(0).setVisible(false); + cont.add(this.dIcon); + this.dCaption = s + .text(bx + box / 2, by + box - 13, '', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.faint), + letterSpacing: 3, + align: 'center', + }) + .setScrollFactor(0); + cont.add(this.dCaption); + + // text column + const tx = bx + box + 18; + this.dLabel = s + .text(tx, detailY + 14, '', { + fontFamily: HEADER, + fontSize: '15px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 2.5, + }) + .setScrollFactor(0); + cont.add(this.dLabel); + this.dMeta = s + .text(tx, detailY + 38, '', { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.dim), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + cont.add(this.dMeta); + // cost line — the highlighted resource requirement + this.dCost = s + .text(tx, detailY + 58, '', { + fontFamily: HEADER, + fontSize: '11px', + color: toCss(C.dim), + fontStyle: 'bold', + letterSpacing: 2, + }) + .setScrollFactor(0); + cont.add(this.dCost); + const btnW = 152; + const wrapW = Math.max(120, rightX + rightW - 12 - btnW - 18 - tx); + this.dDesc = s + .text(tx, detailY + 80, '', { + fontFamily: BODY, + fontSize: '11px', + color: toCss(C.dim), + letterSpacing: 0.4, + lineSpacing: 3, + wordWrap: { width: wrapW }, + }) + .setScrollFactor(0); + cont.add(this.dDesc); + + // action plate (BUILD button / status) + const bw = btnW; + const bh = 48; + const ax = rightX + rightW - 12 - bw; + const ay = detailY + (detailH - bh) / 2; + const g = s.graphics().setScrollFactor(0); + const label = s + .text(ax + bw / 2, ay + bh / 2 - 5, '', { + fontFamily: HEADER, + fontSize: '13px', + color: toCss(C.ink), + fontStyle: 'bold', + letterSpacing: 3, + align: 'center', + }) + .setScrollFactor(0); + const bar = s.graphics().setScrollFactor(0); + 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({ + useHandCursor: true, + hitArea: btnRect, + hitAreaCallback: (area, px, py) => area.contains(px, py), + }); + g.on('pointerover', () => { + if (this.dBtn.mode !== 'build') return; + this.dBtn.hover = true; + this.sfx('ui_hover'); + this._paintDetailBtn(); + }); + g.on('pointerout', () => { + this.dBtn.hover = false; + this._paintDetailBtn(); + }); + g.on('pointerdown', () => { + if (this.dBtn.mode !== 'build' || !this.selected) return; + this.sfx('ui_click'); + this.onBuild?.(this.selected.id); + }); + + return cont; + } + + // ── painting ─────────────────────────────────────────────────────────────── + _paintClose() { + const b = this.closeBtn; + const g = this.closeG; + g.clear(); + panel(g, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, { + notch: 7, + fill: C.panel, + fillAlpha: b.hover ? 0.85 : 0.4, + stroke: C.neon2, + strokeAlpha: b.hover ? 1 : 0.55, + }); + this.closeTxt.setAlpha(b.hover ? 1 : 0.8); + } + + _paintDetailBtn() { + const b = this.dBtn; + b.g.clear(); + const { x, y, w, h } = b.rect; + let label = ''; + let fillA = 0.1; + let stroke = 0x22405f; + let strokeA = 0.5; + let labelColor = toCss(C.faint); + let bar = false; + const st = this._detailStatus(); + b.mode = st; + if (st === 'build') { + label = 'BUILD'; + fillA = b.hover ? 0.32 : 0.16; + stroke = C.neon; + strokeA = 1; + labelColor = toCss(C.ink); + } else if (st === 'active') { + const p = this.state?.progress(this.scene.time.now); + label = `IN PROGRESS ${Math.round((p?.fraction ?? 0) * 100)}%`; + fillA = 0.14; + stroke = C.neon; + strokeA = 0.9; + labelColor = toCss(C.ink); + bar = true; + } else if (st === 'built') { + label = 'BUILT ✓'; + fillA = 0.1; + stroke = C.neon; + strokeA = 0.55; + labelColor = toCss(C.dim); + } else if (st === 'busy') { + label = 'BUILD IN PROGRESS'; + fillA = 0.06; + stroke = C.amber; + strokeA = 0.5; + labelColor = toCss(C.faint); + } else if (st === 'unaffordable') { + const lines = costLines(this._selDef()); + const amt = lines[0]?.amount; + label = `NEED ${amt ?? ''} MINERALS`; + fillA = 0.08; + stroke = C.amber; + strokeA = 0.6; + labelColor = toCss(C.amber); + } else { + label = 'LOCKED'; + fillA = 0.06; + stroke = 0x1b3a5a; + strokeA = 0.5; + labelColor = toCss(C.faint); + } + panel(b.g, x, y, w, h, { notch: 9, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA }); + b.label.setText(label); + b.label.setColor(labelColor); + b.bar.clear(); + if (bar) { + const p = this.state?.progress(this.scene.time.now)?.fraction ?? 0; + b.bar.fillStyle(0x0a1424, 0.9); + b.bar.fillRect(x + 16, y + h - 12, w - 32, 3); + b.bar.fillStyle(C.neon, 1); + b.bar.fillRect(x + 16, y + h - 12, (w - 32) * p, 3); + } + setInteractiveEnabled(b.g, st === 'build'); + } + + _selDef() { + if (!this.selected) return null; + return this.lists.get(this.selected.category)?.entry.builds[this.selected.id] ?? null; + } + + _detailStatus() { + const sel = this.selected; + const state = this.state; + if (!sel || !state) return 'locked'; + const active = state.getActive(); + if (active) { + if (active.planet === this.planetName && active.build === sel.id) return 'active'; + return 'busy'; + } + if (state.isBuilt(this.planetName, sel.id)) return 'built'; + const def = this._selDef(); + if (def && isAvailable(def, this.ctxFor(sel.id))) { + return canAfford(def, this.minerals()) ? 'build' : 'unaffordable'; + } + return 'locked'; + } + + select(category, id) { + const list = this.lists.get(category); + if (!list || !list.entry.builds[id]) return; + this.selected = { category, id }; + this.lastSelected.set(category, id); + if (this.activeCat !== category) { + this.activeCat = category; + this._paintTabs(); + } + for (const [catId, l] of this.lists) l.cont.setVisible(catId === category); + this._paintAll(category); + this._paintDetail(true); + } + + switchCategory(catId) { + if (this.activeCat === catId) return; + this.activeCat = catId; + this._paintTabs(); + for (const [catId2, l] of this.lists) l.cont.setVisible(catId2 === catId); + const last = this.lastSelected.get(catId) ?? this._defaultSelection(catId); + if (last) { + this.selected = { category: catId, id: last }; + this._paintAll(catId); + this._paintDetail(true); + } else { + this.selected = null; + this._paintAll(catId); + this._paintDetail(false); + } + } + + _defaultSelection(catId) { + const list = this.lists.get(catId); + if (!list) return null; + const active = this._activeOnPlanet(); + if (active && list.entry.builds[active.build]) return active.build; + for (const id of list.entry.order) { + if (isAvailable(list.entry.builds[id], this.ctxFor(id))) return id; + } + return list.entry.order[0] ?? null; + } + + _paintDetail(decode = false) { + const sel = this.selected; + const list = sel ? this.lists.get(sel.category) : null; + const def = list?.entry.builds[sel?.id]; + if (!def) { + this.dIcon.setVisible(false); + this.dLabel.setText(''); + this.dMeta.setText(''); + this.dCost.setText(''); + this.dDesc.setText(''); + this._paintDetailBtn(); + return; + } + const accent = list.accent; + + this.dIcon.setTexture(ensureIcon(this.scene, def.icon ?? 'diamond', accent)); + this.dIcon.setDisplaySize(82, 82); + this.dIcon.setVisible(true); + const idx = list.entry.order.indexOf(sel.id); + this.dCaption.setText(`${sel.category.slice(0, 3).toUpperCase()}-${String(idx + 1).padStart(2, '0')}`); + + const label = String(def.label ?? sel.id).toUpperCase(); + const desc = String(def.description ?? ''); + const now = this.scene.time.now; + if (decode) { + this.decodeTo(this.dLabel, label, now, 460); + this.decodeTo(this.dDesc, desc, now + 160, 620); + } else { + this.dLabel.setText(label); + this.dDesc.setText(desc); + } + + // meta line: status · duration + const dur = Number(def.duration ?? 0); + const durStr = dur > 0 ? `${dur}s` : 'INSTANT'; + const st = this._detailStatus(); + const missing = missingRequirements(def, this.ctxFor(sel.id)); + let meta; + let metaColor; + if (st === 'active') { + const p = this.state?.progress(this.scene.time.now); + meta = `IN PROGRESS · ${Math.round((p?.fraction ?? 0) * 100)}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT · DURATION ${durStr}`; + metaColor = accent; + } else if (st === 'built') { + meta = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()} · DURATION ${durStr}`; + metaColor = accent; + } else if (st === 'busy') { + meta = `BUILD CONSOLE BUSY — ONE BUILD AT A TIME · DURATION ${durStr}`; + metaColor = C.amber; + } else if (st === 'locked') { + meta = `LOCKED · NEEDS ${missing.map((m) => String(m).toUpperCase()).join(' · ') || '—'} · DURATION ${durStr}`; + metaColor = C.amber; + } else { + meta = `AVAILABLE · DURATION ${durStr} · ONE-OFF`; + metaColor = accent; + } + this.dMeta.setText(meta); + this.dMeta.setColor(toCss(metaColor)); + + // cost line — the highlighted resource requirement (minerals) + const lines = costLines(def); + if (!lines.length) { + this.dCost.setText('COST — NO COST · STARTING EQUIPMENT'); + this.dCost.setColor(toCss(C.faint)); + } else { + const costStr = lines + .map((l) => `${l.amount} ${String(config.get(`builds.resources.${l.res}.label`, l.res))}`) + .join(' + '); + const afford = canAfford(def, this.minerals()); + this.dCost.setText(`COST ◆ ${costStr}${afford ? '' : ' · INSUFFICIENT MINERALS'}`); + this.dCost.setColor(toCss(afford ? C.neon : C.amber)); + } + + this._paintDetailBtn(); + } + + // ── open / close / refresh ───────────────────────────────────────────────── + open() { + if (this.openState === 'open' || this.openState === 'opening') return; + this.openState = 'opening'; + this.setAlpha(0); + this._lastPct = undefined; + this._lastMinerals = undefined; + + // Default selection: the in-progress build on this planet (and its + // category), else the last selected, else the first available / first. + const active = this._activeOnPlanet(); + let cat = config.get('builds.defaultCategory') ?? categories()[0]?.id; + if (active) { + const def = defById(active.build); + if (def?.category) cat = def.category; + } + if (!this.lists.has(cat)) cat = categories()[0]?.id; + const id = active?.build ?? this._defaultSelection(cat); + if (this.activeCat !== cat) this.switchCategory(cat); + if (id && this.selected?.id !== id) this.select(cat, id); + this._paintAll(this.activeCat); + this._paintStatusStrip(); + + this.video?.play?.(); + this.sfx('ui_window'); + this._startReveal(); + + // Dev diagnostics (js/dev/BuildDiag.js): one self-explanatory line + // in the console, every time the console opens — planet, home, built + // records, tether level, and the L1/L2 row states. No-op in builds + // where the diag was not installed. + if (typeof globalThis.orbitDiagBrief === 'function') { + try { globalThis.orbitDiagBrief(); } catch { /* diag must never break the game */ } + } + } + + close() { + if (this.openState === 'closed' || this.openState === 'closing') return; + this.openState = 'closing'; + this.video?.pause?.(); + this.sfx('ui_close'); + this.scene.tweens.add({ + targets: this, + alpha: 0, + duration: 150, + ease: 'Power2', + onComplete: () => { + this.openState = 'closed'; + this.glitchG.clear(); + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + }, + }); + } + + get isOpen() { + return this.openState === 'open' || this.openState === 'opening'; + } + + /** Repaint everything from state (after a build starts / completes). */ + refresh() { + if (!this.activeCat) return; + this._paintAll(this.activeCat); + if (this.selected) this._paintDetail(false); + this._paintStatusStrip(); + } + + _paintStatusStrip() { + const active = this._activeOnPlanet(); + if (!active) { + this.statusTxt.setText('BUILD CONSOLE STANDBY — SELECT A MODULE TO BEGIN'); + this.statusTxt.setColor(toCss(C.faint)); + this.statusBar.clear(); + return; + } + const def = defById(active.build); + const p = this.state.progress(this.scene.time.now); + this.statusTxt.setText(`BUILDING — ${String(def?.label ?? active.build).toUpperCase()} · ${Math.round(p.fraction * 100)}%`); + this.statusTxt.setColor(toCss(C.neon)); + const g = this.statusBar; + g.clear(); + g.fillStyle(0x0a1424, 0.9); + g.fillRect(2, this.statusBarY, this.statusBarW, 3); + g.fillStyle(C.neon, 1); + g.fillRect(2, this.statusBarY, this.statusBarW * p.fraction, 3); + } + + // ── boot reveal ──────────────────────────────────────────────────────────── + _startReveal() { + const now = this.scene.time.now; + const list = []; + const push = (o, d, dur, mode, baseY) => list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now }); + this.decodeTo(this.titleTxt, 'BUILD', now, 520); + + if (!this.booted) { + push(this, 0, 240, 'fade'); + push(this.titleMeta, 140, 300, 'fade'); + push(this.titleCursor, 200, 200, 'fade'); + push(this.closeG, 200, 200, 'fade'); + push(this.closeTxt, 200, 200, 'fade'); + push(this.videoPanel, 260, 380, 'fade'); + this.tabs.forEach((t, i) => { + push(t.g, 300 + i * 70, 240, 'fade'); + push(t.txt, 300 + i * 70, 240, 'fade'); + }); + const entry = this.activeCat ? this.lists.get(this.activeCat) : null; + if (entry) { + entry.rows.forEach((row, i) => { + // baseY = o.y (0) — the panel shape is drawn at absolute coords, + // so the object itself must not be repositioned. + push(row.g, 380 + i * 90, 260, 'rise'); + push(row.icon, 400 + i * 90, 240, 'fade'); + push(row.label, 400 + i * 90, 240, 'fade'); + push(row.sub, 400 + i * 90, 240, 'fade'); + push(row.tag, 400 + i * 90, 240, 'fade'); + }); + } + push(this.detail, 640, 300, 'rise', this.detail.y); + this.booted = true; + this.glitch.next = now + rand(6000, 13000); + } else { + push(this, 0, 160, 'fade'); + } + this.reveal = list; + this.openState = 'open'; + } + + _glitchBurst(level, at) { + this.glitch.until = at + rand(220, 420); + this.glitch.level = level; + } + + // ── per-frame (called by the scene's update) ────────────────────────────── + update(time) { + if (!this.isOpen) return; + + // reveal timeline + if (this.reveal.length) { + let done = true; + for (const r of this.reveal) { + const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur)); + const e = easeIO(u); + if (r.mode === 'fade') r.o.setAlpha(e); + else if (r.mode === 'rise') { + r.o.setAlpha(e); + r.o.setY(r.baseY + (1 - e) * 14); + } + if (u < 1) done = false; + } + if (done) { + for (const r of this.reveal) { + r.o.setAlpha(1); + if (r.mode === 'rise') r.o.setY(r.baseY); + } + this.reveal = []; + this._paintStatusStrip(); + } + } + + // decodes (ScrambleDecode polling) + if (this.decodes.length) { + for (const d of [...this.decodes]) { + if (!d.dec.started(time)) continue; + d.txt.setText(d.dec.display(time)); + if (d.dec.finished(time)) { + d.txt.setText(d.dec.value); + const i = this.decodes.indexOf(d); + if (i >= 0) this.decodes.splice(i, 1); + } + } + } + + // ambient glitch bursts + if (time >= this.glitch.next) { + this._glitchBurst(rand(0.5, 1), time); + this.glitch.next = time + rand(6000, 13000); + } + if (time < this.glitch.until) { + const lvl = this.glitch.level ?? 0.8; + const { rect } = this.geo; + const g = this.glitchG; + g.clear(); + for (let i = 0; i < 4; i++) { + g.fillStyle(i % 2 ? C.neon : C.neon2, rand(0.03, 0.1) * lvl); + g.fillRect(rect.x + rand(-8, 8), rand(rect.y, rect.y + rect.h), rect.w, rand(2, 18)); + } + const dx = 2 + lvl * 2; + const t = this.titleTxt; + this.titleGhostA.setText(t.text).setPosition(t.x + dx, t.y).setAlpha(0.5 * lvl); + this.titleGhostB.setText(t.text).setPosition(t.x - dx, t.y).setAlpha(0.5 * lvl); + this.titleGhostB.setColor(toCss(C.neon2)); + } else if (this.titleGhostA?.alpha > 0) { + this.titleGhostA.setAlpha(0); + this.titleGhostB.setAlpha(0); + } + + // title cursor blink + const { rect, titleH } = this.geo; + const blink = Math.floor(time / 480) % 2 === 0 ? 1 : 0.15; + this.titleCursor.clear(); + this.titleCursor.fillStyle(C.neon, blink); + this.titleCursor.fillRect(this.titleTxt.x + this.titleTxt.width + 10, rect.y + 13, 9, 19); + + // sweep band over the feed + const cfg = this.sweepCfg; + if (cfg.enabled !== false && this.sweepBand) { + const [lo, hi] = cfg.everyMs ?? [4200, 8600]; + if (time >= this.sweep.next) { + this.sweep.t0 = time; + this.sweep.next = time + rand(lo, hi); + } + const dur = cfg.durationMs ?? 1500; + const u = (time - this.sweep.t0) / dur; + if (u >= 0 && u <= 1) { + const { y, h } = this.videoRect; + this.sweepBand.setY(y - 48 + u * (h + 96)); + this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5); + } else { + this.sweepBand.setAlpha(0); + } + } + + // REC dot pulse + this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006)); + + // live progress readouts (the in-progress build on this planet) + const p = this.state?.progress(time); + if (p && p.planet === this.planetName) { + const pct = Math.round(p.fraction * 100); + if (pct !== this._lastPct) { + this._lastPct = pct; + const list = this.lists.get(pct !== undefined ? this.activeCat : null); + if (list) this._paintAll(this.activeCat); + if (this.selected && this.selected.id === p.build) this._paintDetail(false); + this._paintStatusStrip(); + } + } else if (this._lastPct !== undefined) { + // the build just completed — repaint from the new state (BUILT ✓) + this._lastPct = undefined; + if (this.activeCat) this._paintAll(this.activeCat); + if (this.selected) this._paintDetail(false); + this._paintStatusStrip(); + } + + // minerals changed (mining / spend) — affordability may flip + const m = this.minerals(); + if (m !== this._lastMinerals) { + this._lastMinerals = m; + if (this.selected) this._paintDetail(false); + } + } + + // ── teardown ─────────────────────────────────────────────────────────────── + destroy() { + this.destroyVideo(this.video); + this.video = null; + super.destroy(true); + } +} + diff --git a/js/ui/CommsPanel.js b/js/ui/CommsPanel.js index 5be42e4..eb6f277 100644 --- a/js/ui/CommsPanel.js +++ b/js/ui/CommsPanel.js @@ -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) { diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index 03508da..3a55786 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -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({ diff --git a/js/ui/SavePanel.js b/js/ui/SavePanel.js index cbdd0eb..be76d50 100644 --- a/js/ui/SavePanel.js +++ b/js/ui/SavePanel.js @@ -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); diff --git a/js/utils/WorldNames.js b/js/utils/WorldNames.js new file mode 100644 index 0000000..89bca19 --- /dev/null +++ b/js/utils/WorldNames.js @@ -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; +}