Bump tether radius growth to 2× and refresh HUD on build completion

- Change radiusGrowth from 1.25 to 2.0 so level N doubles the previous
  level (L2 = 10240, L3 = 20480, L4 = 40960 px)
- Update all data descriptions, docs, and test defaults to match
- Add refreshHudTetherLevel() in SurfaceScene so the HUD line re-decodes
  when a tether build completes while on-surface
- Rename rocky-planet-02 asset (typo fix), add 03-a/03-b variants,
  point rocky frames at indices 6–8 in planets.json
- Add dev/tether-flow harness to exercise the full L2 build flow end-to-end
This commit is contained in:
Brian Fertig 2026-09-05 19:52:54 -06:00
parent 1d1cb2c150
commit 4fd1f05359
18 changed files with 173 additions and 19 deletions

View File

@ -117,7 +117,7 @@ node dev/server.mjs 8080
home world starts with **Tether Level 1** already installed; **Tether home world starts with **Tether Level 1** already installed; **Tether
Level 2** needs the level-2 tether research plus a world holding a 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 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 tether range then grows from 5120 to 10240 px (world state that outlives
the surface stay). While a build runs the whole deck is locked (BUILD the surface stay). While a build runs the whole deck is locked (BUILD
stays open to watch the progress); completion fires the build's stays open to watch the progress); completion fires the build's
`effects` and a toast. Build records + the in-progress build persist in `effects` and a toast. Build records + the in-progress build persist in

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -57,7 +57,7 @@
}, },
"tether-l2": { "tether-l2": {
"label": "Tether - Level 2", "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.", "description": "Extends the planet's tether to level 2: the field's range grows from 5120 px to 10240 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", "icon": "tether2",
"category": "planet", "category": "planet",
"targets": ["planet"], "targets": ["planet"],

View File

@ -41,10 +41,10 @@
}, },
"videos": [ "videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" } { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }
] ]
} }

View File

@ -6,7 +6,7 @@
"scale": 1.0, "scale": 1.0,
"frames": { "frames": {
"terran": [0, 1, 2], "terran": [0, 1, 2],
"rocky": [0, 1, 2], "rocky": [6, 7, 8],
"gas": [3, 4, 5], "gas": [3, 4, 5],
"ice": [0, 1, 2], "ice": [0, 1, 2],
"lava": [0, 1, 2] "lava": [0, 1, 2]

View File

@ -1,5 +1,5 @@
{ {
"_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level1), see data/tether.json and docs/PROJECT_NOTES.md) — 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.", "_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 2^(level1), see data/tether.json and docs/PROJECT_NOTES.md) — except Tether Level 2, which is the BLUEPRINT only (effects: {}): the player installs it as a TETHER - LEVEL 2 build on a world holding a level-1 tether (data/builds.json → tether-l2; the build carries the effect). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"starting": ["tether_l1"], "starting": ["tether_l1"],
"nodes": { "nodes": {
"tether_l1": { "tether_l1": {
@ -13,7 +13,7 @@
}, },
"tether_l2": { "tether_l2": {
"label": "Tether Level 2", "label": "Tether Level 2",
"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.", "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 10,240 m.",
"icon": "tether", "icon": "tether",
"duration": 60, "duration": 60,
"requires": ["tether_l1"], "requires": ["tether_l1"],
@ -22,7 +22,7 @@
}, },
"tether_l3": { "tether_l3": {
"label": "Tether Level 3", "label": "Tether Level 3",
"description": "Third stage: 8,000 m of tether. The outer belt of an average system sits comfortably inside the ring — less time parked on the line, more time working it.", "description": "Third stage: 20,480 m of tether. The outer belt of an average system sits comfortably inside the ring — less time parked on the line, more time working it.",
"icon": "tether", "icon": "tether",
"duration": 150, "duration": 150,
"requires": ["tether_l2"], "requires": ["tether_l2"],
@ -31,7 +31,7 @@
}, },
"tether_l4": { "tether_l4": {
"label": "Tether Level 4", "label": "Tether Level 4",
"description": "Fourth stage: 10,000 m. At this level every object of a ten-body system — all the worlds and stations — lies inside one ring around your anchor, and the barrier stops meaning anything out here.", "description": "Fourth stage: 40,960 m. At this level every object of a ten-body system — all the worlds and stations — lies inside one ring around your anchor, and the barrier stops meaning anything out here.",
"icon": "tether", "icon": "tether",
"duration": 300, "duration": 300,
"requires": ["tether_l3"], "requires": ["tether_l3"],

View File

@ -1,7 +1,7 @@
{ {
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms).", "_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms).",
"level1Radius": 5120, "level1Radius": 5120,
"radiusGrowth": 1.25, "radiusGrowth": 2.0,
"maxLevel": 12, "maxLevel": 12,
"homeId": "home", "homeId": "home",
"homeLevel": 1, "homeLevel": 1,

View File

@ -78,7 +78,7 @@ check('distribution has target/min/max/jitter', Number.isFinite(D.targetObjects)
check('starting system minimum ≥ 2', (D.startingSystemMinClusters ?? 2) >= 2); check('starting system minimum ≥ 2', (D.startingSystemMinClusters ?? 2) >= 2);
const P = A.placement ?? {}; const P = A.placement ?? {};
check('placement rules present', P.minObjectSpacing === 1024 && P.minRadius > 0 && P.maxRadius >= P.minRadius); check('placement rules present', P.minObjectSpacing === 1024 && P.minRadius > 0 && P.maxRadius >= P.minRadius);
const TETHER = config.get('tether.level1Radius', 5120) * Math.pow(config.get('tether.radiusGrowth', 1.25), Math.max(1, Math.floor(config.get('tether.homeLevel', 1))) - 1); const TETHER = config.get('tether.level1Radius', 5120) * Math.pow(config.get('tether.radiusGrowth', 2.0), Math.max(1, Math.floor(config.get('tether.homeLevel', 1))) - 1);
// --- Name synthesis --------------------------------------------------------- // --- Name synthesis ---------------------------------------------------------
{ {

17
dev/tether-flow.html Normal file
View File

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

108
dev/tether-flow.mjs Normal file
View File

@ -0,0 +1,108 @@
/**
* Dev-only: TETHER-L2 BUILD FLOW plays the real flow end-to-end
* (menu NEW GAME land on the home world build Tether - Level 2
* the build completes) and reports what the tether field does with it:
*
* before: home tether id/level/radius
* build: beginBuild() result (ok / refusal reason)
* after : home tether id/level/radius, tetherLevelFor(home),
* the surface HUD's cached level, built records
*
* node dev/server.mjs 8080
* node dev/cdp-firefox.mjs http://127.0.0.1:8080/dev/tether-flow.html \
* 'return window.__TETHER_FLOW__;' 40000
*/
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 origWarn = console.warn.bind(console);
console.warn = (...a) => { errors.push(a.map(String).join(' ')); origWarn(...a); };
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(fn, tries = 200, everyMs = 100) {
for (let i = 0; i < tries; i++) {
const v = fn();
if (v) return v;
await sleep(everyMs);
}
return null;
}
const tetherInfo = (gs) => {
const home = gs.planet?.discoveryName;
const t = gs.tetherField?.tethers?.find(
(tt) => tt.label === home || (tt.x === 0 && tt.y === 0),
);
return t ? { id: t.id, x: t.x, y: t.y, level: t.level, radius: t.radius } : null;
};
try {
// 1) Menu → NEW GAME (the real entry).
const menu = await waitFor(() => game.scene.getScene('MenuScene')?.newGameBtn ? game.scene.getScene('MenuScene') : 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?.tetherField ? g : null; }, 300);
if (!gs) throw new Error('GameScene never booted');
await sleep(500);
const home = gs.planet.discoveryName;
const before = tetherInfo(gs);
const beforeLevel = gs.tetherLevelFor(home);
// 2) Research is done (the build's only research gate).
gs.researchState.unlock('exploration', 'tether_l2');
// 3) Land on the home world the game's own way, wait for the surface
// stage (the stall guard advances after ~5 s without a clip).
gs.startLanding(gs.commsTargetFor(gs.planet));
const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.phase === 'surface' ? c : null; }, 150, 200);
if (!ss) throw new Error('surface stage never reached');
await sleep(300);
// 4) Start the Tether - Level 2 build (the scene-side rules apply).
// The cost (200 minerals) is covered — a fresh ship holds none.
gs.ship.setMinerals(200);
const res = gs.beginBuild(ss.planetName, 'tether-l2', game.loop.now);
const active = gs.buildState.getActive();
// 5) Fast-forward the build to completion (the test does not wait out
// the 20 s), then let the surface's own tick apply the effects.
if (active) active.startedAt -= active.durationMs;
// …and let the HUD line finish re-decoding to the new level.
await sleep(2500);
const after = tetherInfo(gs);
const out = {
home,
before,
beforeLevel,
build: { res, active: active ?? null },
after,
afterLevel: gs.tetherLevelFor(home),
hudLevel: ss.tetherLevel,
hudLine: ss.hudLine ? ss.hudLine.text : null,
built: gs.buildState.isBuilt(ss.planetName, 'tether-l2'),
arcs: after ? gs.tetherField._arcs.get(after.id).length : null,
errors: errors.slice(0, 5),
};
window.__TETHER_FLOW__ = out;
console.info('tether-flow:', JSON.stringify(out));
} catch (err) {
window.__TETHER_FLOW__ = { fatal: String(err.message), errors: errors.slice(0, 5) };
console.error('tether-flow: fatal', err);
}

View File

@ -66,7 +66,7 @@ const dist = (x, y, t) => Math.hypot(x - t.x, y - t.y);
{ {
const r1 = Tether.radiusForLevel(1); const r1 = Tether.radiusForLevel(1);
check(`level 1 radius is 5120 px (got ${r1})`, r1 === 5120); check(`level 1 radius is 5120 px (got ${r1})`, r1 === 5120);
const growth = config.get('tether.radiusGrowth', 1.25); const growth = config.get('tether.radiusGrowth', 2.0);
const r2 = Tether.radiusForLevel(2); const r2 = Tether.radiusForLevel(2);
check(`level 2 = 5120 × growth (${growth}) (got ${r2})`, Math.abs(r2 - 5120 * growth) < 1e-9); check(`level 2 = 5120 × growth (${growth}) (got ${r2})`, Math.abs(r2 - 5120 * growth) < 1e-9);
const maxLevel = config.get('tether.maxLevel', 12); const maxLevel = config.get('tether.maxLevel', 12);

View File

@ -247,10 +247,10 @@ The ship starts with **one level-1 tether** anchored on the home world
`maxLevel`), the starting tether (`homeId`/`homeLevel`), and every `maxLevel`), the starting tether (`homeId`/`homeLevel`), and every
visual/feel parameter. visual/feel parameter.
**Upgrade economics (planned):** radius(level) = 5120 × 1.25^(level1) — **Upgrade economics (planned):** radius(level) = 5120 × 2^(level1) —
level 4 = 10000 px, which reaches every object of a ≤ 10-object system level 2 = 10240 px, which reaches every object of a ≤ 10-object system
(all within 10240 px of the home world); the far 8-ring of the rare (all within 10240 px of the home world); the far 8-ring of the rare
11-object system (up to ~13380 px out) wants one level more. Extra 11-object system (up to ~13380 px out) fits inside level 3 (20480 px). Extra
tethers anchored on planets/stations will be the "expand your reach" verb; tethers anchored on planets/stations will be the "expand your reach" verb;
the seams are in place. the seams are in place.
@ -705,7 +705,7 @@ The player holds a REPUTATION (standing) on each planet and space station:
- [x] Tether progression: level 2 is a **build** — research - [x] Tether progression: level 2 is a **build** — research
`tether_l2` (blueprint, no effect) → BUILD slot on a world holding a `tether_l2` (blueprint, no effect) → BUILD slot on a world holding a
level-1 tether → 200 minerals / 20 s → the world's tether strengthens level-1 tether → 200 minerals / 20 s → the world's tether strengthens
to level 2 (6400 px), effect and all, outliving the stay. Levels 34 to level 2 (10240 px), effect and all, outliving the stay. Levels 34
are still research-direct (`effects.tether.level` on the home are still research-direct (`effects.tether.level` on the home
tether); anchoring extra rings on planets/stations remains tether); anchoring extra rings on planets/stations remains
(the `add`/`setLevel`/`onChange` seams are in place) (the `add`/`setLevel`/`onChange` seams are in place)

View File

@ -562,7 +562,7 @@ function relaxRockGaps(members, gapFactor, maxIter = 96) {
function homeTetherRadius() { function homeTetherRadius() {
const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1)));
const base = config.get('tether.level1Radius', 5120); const base = config.get('tether.level1Radius', 5120);
const growth = config.get('tether.radiusGrowth', 1.25); const growth = config.get('tether.radiusGrowth', 2.0);
return base * Math.pow(growth, level - 1); return base * Math.pow(growth, level - 1);
} }

View File

@ -423,6 +423,34 @@ export class SurfaceScene extends Phaser.Scene {
if (done) this.hudLines = null; // the text holds — stop the per-frame churn if (done) this.hudLines = null; // the text holds — stop the per-frame churn
} }
/**
* The world's tether level changed under us (a tether build completed
* on this world): the HUD's second line was a snapshot from landing,
* so refresh it "Tether Level N" re-decodes to the new level. A
* no-op when the level is unchanged or the HUD is not up (take-off
* stage, no surface clip).
*/
refreshHudTetherLevel(time) {
const level = Math.max(0, Math.round(Number(this.gameScene?.tetherLevelFor(this.planetName) ?? 0)));
if (level === this.tetherLevel) return;
this.tetherLevel = level;
if (!this.hudLine) return;
const parts = [];
if (this.planetType) parts.push(this.planetType);
parts.push(`Tether Level ${this.tetherLevel}`);
const line = parts.join(', ');
const idx = this.hudLines?.findIndex((h) => h.text === this.hudLine) ?? -1;
if (idx >= 0) {
// Still decoding: retarget the in-flight scramble at the new line.
const ln = this.hudLines[idx];
ln.value = line;
ln.dec = new ScrambleDecode(line, time, ln.dur);
ln.settled = false;
} else {
this.hudLine.setText(line); // settled — swap the held text in place
}
}
/** The HUD belongs to the surface stage — the take-off clip owns the screen. */ /** The HUD belongs to the surface stage — the take-off clip owns the screen. */
destroyHud() { destroyHud() {
this.hudName?.destroy(); this.hudName?.destroy();
@ -804,6 +832,7 @@ export class SurfaceScene extends Phaser.Scene {
const def = buildDefs()[done[0].build]; const def = buildDefs()[done[0].build];
this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`); this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`);
this.playSfx('discovery'); // the 'something new is here' voice this.playSfx('discovery'); // the 'something new is here' voice
this.refreshHudTetherLevel(time); // the HUD's level line is a landing snapshot
} }
} }
this.buildWindow?.update(time); this.buildWindow?.update(time);

View File

@ -40,7 +40,7 @@ export class Tether {
/** radius(level) = level1Radius × radiusGrowth^(level1) (data/tether.json). */ /** radius(level) = level1Radius × radiusGrowth^(level1) (data/tether.json). */
static radiusForLevel(level) { static radiusForLevel(level) {
const l1 = config.get('tether.level1Radius', 5120); const l1 = config.get('tether.level1Radius', 5120);
const g = config.get('tether.radiusGrowth', 1.25); const g = config.get('tether.radiusGrowth', 2.0);
const n = Math.max(1, Math.round(level ?? 1)); const n = Math.max(1, Math.round(level ?? 1));
return l1 * Math.pow(g, n - 1); return l1 * Math.pow(g, n - 1);
} }