Extract shared decode scramble and apply it to the system dossier HUD

- New `js/utils/Decode.js` with a pure `ScrambleDecode` class plus
  `DECODE_CHARS`, `DECODE_DURATION`, and a per-line `decodeDur()` helper,
  so one effect serves both scenes.
- MenuScene now delegates its seed scramble to the shared utility instead
  of inlining the reveal math and alphabet.
- GameScene's system dossier types itself in on arrival: each line starts
  empty and decodes left-to-right, staggered top-to-bottom, with the
  timeline anchored on the first update frame; state self-clears once all
  lines settle so `update()` pays nothing afterward.
- Add Node dev tests for the shared effect contract (`dev/decode.test.mjs`)
  and for the real GameScene dossier decode against actual galaxy/report
  data with a stubbed scene (`dev/system-hud.test.mjs`).
- Document both new test commands in the README and update the planets
  PSD asset.
This commit is contained in:
Brian Fertig 2026-09-04 09:52:05 -06:00
parent b325a19c82
commit fbd0e56f1f
7 changed files with 389 additions and 15 deletions

View File

@ -162,6 +162,8 @@ 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/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
```
`dev/test-game.html` boots straight into the GameScene (no menu click),

Binary file not shown.

77
dev/decode.test.mjs Normal file
View File

@ -0,0 +1,77 @@
/**
* ScrambleDecode test (dev tool, run with Node no browser):
*
* node dev/decode.test.mjs
*
* The shared "decode" effect (js/utils/Decode.js) the type-out the
* menu uses on the Galaxy Seed and the game uses on the system dossier
* HUD. Checks the effect's contract:
* - '' before the start time, the exact target once finished;
* - the revealed prefix always matches the target's;
* - unrevealed slots are drawn from the decode alphabet;
* - the reveal never goes backwards over time;
* - display length is stable through the window;
* - decodeDur floors short lines, caps long ones, grows in between;
* - the default window is the menu's 620 ms.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { ScrambleDecode, DECODE_CHARS, DECODE_DURATION, decodeDur } = await import(
pathToFileURL(join(__dirname, '../js/utils/Decode.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const VALUE = 'Kepler-9 · 17 planets';
const T0 = 1000;
const dur = DECODE_DURATION;
const dec = new ScrambleDecode(VALUE, T0, dur);
check('empty before the start time', dec.display(T0 - 1) === '');
check('exact target after the window', dec.display(T0 + dur + 1) === VALUE);
check('started()/finished() agree with display()', dec.started(T0 - 1) === false && dec.finished(T0 + dur) === true);
check('empty target stays empty', new ScrambleDecode('', T0, dur).display(T0 + dur / 2) === '');
// Through the window: length stable, prefix exact, rest from the alphabet,
// and the reveal monotonically non-decreasing.
let lenOk = true;
let prefixOk = true;
let charsOk = true;
let monotonic = true;
let prev = 0;
for (let t = T0; t <= T0 + dur; t += 16) {
const s = dec.display(t);
if (s.length !== VALUE.length) lenOk = false;
let n = 0;
while (n < VALUE.length && s[n] === VALUE[n]) n++;
if (s.slice(0, n) !== VALUE.slice(0, n)) prefixOk = false; // revealed part is exact
for (let i = n; i < s.length; i++) if (DECODE_CHARS.indexOf(s[i]) === -1) charsOk = false;
if (n < prev) monotonic = false;
prev = n;
}
check('display length stays constant through the window', lenOk);
check('revealed prefix always matches the target', prefixOk);
check('unrevealed slots come from the decode alphabet', charsOk);
check('reveal never goes backwards', monotonic);
// decodeDur: floored, capped, growing in between.
check('decodeDur floors short lines', decodeDur(0) >= 420 && decodeDur(1) >= 420);
check('decodeDur caps long lines', decodeDur(500) <= 880);
check('decodeDur grows with length (in range)', decodeDur(4) < decodeDur(12) && decodeDur(12) < decodeDur(40));
// The default window is the menu's 620 ms (the canonical decode timing).
check('default window is the menus 620 ms', new ScrambleDecode('x', 0).dur === 620);
if (failures > 0) {
console.error(`\n${failures} decode test(s) FAILED`);
process.exit(1);
}
console.log('\ndecode: all checks passed');

179
dev/system-hud.test.mjs Normal file
View File

@ -0,0 +1,179 @@
/**
* System dossier HUD test (dev tool, run with Node no browser):
*
* node dev/system-hud.test.mjs
*
* Runs the REAL GameScene.createSystemHud()/updateHudDecode()
* (js/scenes/GameScene.js) against the real galaxy + SystemReport data and
* a stubbed scene, then asserts the arrival-decode behavior the same
* "decode" scramble the menu uses on the Galaxy Seed (js/utils/Decode.js):
* - the dossier renders the name first, then the data lines, in the same
* layout as before (y positions, hudEndY);
* - every line starts EMPTY;
* - the system name begins decoding before any line below it (stagger);
* - while a line decodes: length stays the target's, the revealed prefix
* is exact, the unrevealed tail comes from the decode alphabet;
* - once a line lands it never changes again;
* - at the end every line is exactly the report string and the decode
* state self-clears (hudDecode === null).
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Stub just enough of Phaser for the module-level class declarations ----
const ClassStub = class {};
const PhaserStub = {
Scene: ClassStub,
Physics: { Arcade: { Sprite: ClassStub } },
GameObjects: { Sprite: ClassStub, Container: ClassStub },
Geom: { Rectangle: class {} },
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
Math: {
Linear: (a, b, t) => a + (b - a) * t,
FloatBetween: (a, b) => a + Math.random() * (b - a),
Angle: { Wrap: (a) => a },
Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)),
},
BlendModes: { ADD: 2 },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
// --- Load the real config (data/*.json) into the config singleton ----------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href);
const { DECODE_CHARS } = await import(pathToFileURL(join(__dirname, '../js/utils/Decode.js')).href);
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// --- Real galaxy + real report for one system ------------------------------
const galaxy = Galaxy.create('decode-hud-test');
const rec = galaxy.currentSystem();
const content = galaxy.ensureContent(rec.id);
const report = formatSystemReport(content);
const expected = [
report.title,
report.subtitle,
...report.settlements.map((s) => s.text),
report.summary,
`seed ${galaxy.seed}`,
];
console.log(`dossier under test:\n${expected.map((s) => ' ' + s).join('\n')}\n`);
// --- Stub scene: just what createSystemHud()/updateHudDecode() touch -------
const texts = [];
class FakeText {
constructor(x, y, str) {
this.x = x;
this.y = y;
this.text = str;
this.depth = 0;
}
setText(s) { this.text = s; return this; }
setOrigin() { return this; }
setScrollFactor() { return this; }
setDepth(d) { this.depth = d; return this; }
}
const scene = {
time: { now: 0 },
add: { text: (x, y, str) => { const t = new FakeText(x, y, str); texts.push(t); return t; } },
systemRecord: rec,
systemContent: content,
galaxy,
};
GameScene.prototype.createSystemHud.call(scene);
// --- Layout: same lines, same positions as before the decode ---------------
check('one text per dossier line (name, subtitle, settlements, summary, seed)', texts.length === expected.length);
check('every line starts empty', texts.every((t) => t.text === ''));
check('all lines pinned left/top (16, 14+)', texts.every((t) => t.x === 16 && t.y >= 14));
{
// Expected y walk: title @14 (+26, it's 17px), subtitle @40 (+20),
// +2, then one +20 per line (each line steps the cursor on, seed last),
// hudEndY = final y + 6 — exactly the original layout.
let y = 14;
const ys = [y];
y += 26;
ys.push(y); // subtitle
y += 20;
y += 2;
for (let i = 0; i < report.settlements.length; i++) { ys.push(y); y += 20; }
ys.push(y); // summary
y += 20;
ys.push(y); // seed
y += 20;
check('y layout unchanged by the decode', texts.every((t, i) => t.y === ys[i]));
check('hudEndY unchanged by the decode', scene.hudEndY === y + 6);
}
check('decode state is live (t0 unanchored until the first frame)', scene.hudDecode !== null && scene.hudDecode.t0 === null);
// --- Simulate the arrival: frames at 16 ms from a 1000 ms "first frame" ----
// "Settled" is read from the scene's own flag (dec.finished is the real
// signal — a mid-decode full-string match is a coincidence, not settlement).
const T0 = 1000;
const firstNonEmpty = new Array(expected.length).fill(null);
let prefixOk = true;
let tailAlphabetOk = true;
let lengthOk = true;
let settledContractOk = true;
for (let t = T0; t <= T0 + 4000; t += 16) {
GameScene.prototype.updateHudDecode.call(scene, t);
const hd = scene.hudDecode;
texts.forEach((text, i) => {
const target = expected[i];
if (firstNonEmpty[i] === null && text.text !== '') firstNonEmpty[i] = t;
if (text.text === '') return;
if (text.text.length !== target.length) lengthOk = false;
// Once the scene has settled the line, it is final: exact, and only at
// or after the end of its decode window (dec.t0 + dec.dur).
if (hd && hd.lines[i].settled) {
if (text.text !== target || t < hd.lines[i].dec.t0 + hd.lines[i].dec.dur) settledContractOk = false;
return;
}
if (text.text === target) return; // mid-decode coincidence — passes the checks below
// Decoding: revealed prefix exact, tail from the decode alphabet.
let n = 0;
while (n < target.length && text.text[n] === target[n]) n++;
if (text.text.slice(0, n) !== target.slice(0, n)) prefixOk = false;
for (let k = n; k < text.text.length; k++) if (DECODE_CHARS.indexOf(text.text[k]) === -1) tailAlphabetOk = false;
});
}
check('first frame: every line still empty (the beat of arrival)', texts.every((t, i) => firstNonEmpty[i] === null || firstNonEmpty[i] > T0));
check('the system name starts decoding first', firstNonEmpty[0] !== null && firstNonEmpty.every((t, i) => (t ?? Infinity) >= firstNonEmpty[0]));
check('decode order is top-to-bottom', firstNonEmpty.every((t, i) => i === 0 || (t ?? Infinity) >= (firstNonEmpty[i - 1] ?? Infinity)));
check('display length stays the targets through the window', lengthOk);
check('revealed prefix always exact, tail from the decode alphabet', prefixOk && tailAlphabetOk);
check('every line lands as exactly the report string', texts.every((t, i) => t.text === expected[i]));
check('a settled line is final (exact, and only after its window)', settledContractOk);
check('decode state self-clears after arrival', scene.hudDecode === null);
check('updateHudDecode is a no-op once cleared', (() => {
const snap = texts.map((t) => t.text);
GameScene.prototype.updateHudDecode.call(scene, T0 + 5000);
return texts.every((t, i) => t.text === snap[i]);
})());
if (failures > 0) {
console.error(`\n${failures} system-hud test(s) FAILED`);
process.exit(1);
}
console.log('\nsystem-hud: all checks passed');

View File

@ -6,6 +6,7 @@ import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
@ -320,6 +321,13 @@ export class GameScene extends Phaser.Scene {
* in open space (or "charted · unclaimed" when nobody's settled here).
* Formatted by the pure SystemReport helper; this method only renders.
*
* The dossier types itself in with the menu's decode scramble
* (js/utils/Decode.js): the system name first, then each data line below
* it, staggered the console acquiring a signal as the ship arrives.
* It runs every time the scene is created: the first start and every new
* solar system. updateHudDecode drives the reveal (anchored to the first
* frame after create the scene's TimeClock is still stale in create).
*
* The system's CONTENTS were ensured in create() (ensureGalaxy +
* ensureContent) the first touch of the lazy level-2 generation.
* (Roster/positions were fixed at the menu's New Game click; this is
@ -331,13 +339,21 @@ export class GameScene extends Phaser.Scene {
const famHeader = HEADER_FONT();
const current = this.systemRecord;
// Decode timeline (relative; anchored on the first update frame):
// a beat of arrival, then the lines type in one after another.
const LEAD_IN = 250; // ms before the signal starts
const STAGGER = 140; // ms between the start of each line
let delay = LEAD_IN;
const lines = [];
let y = 14;
const line = (text, style) => {
this.add
.text(16, y, text, style)
const line = (value, style) => {
const t = this.add
.text(16, y, '', style)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
lines.push({ text: t, value, delay, dur: decodeDur(value.length) });
delay += STAGGER;
y += 20 + (style.fontSize === '17px' ? 6 : 0);
};
@ -355,9 +371,40 @@ export class GameScene extends Phaser.Scene {
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
this.hudDecode = { t0: null, lines };
this.hudEndY = y + 6; // the tether readout (refreshTetherHud) continues below
}
/**
* The dossier's decode (js/utils/Decode.js) the same scramble as the
* menu's Galaxy Seed: each line reveals left-to-right over its own
* window, the unrevealed slots churning through the alphabet. Lines
* start staggered (name first, then the data below it). Self-clears once
* every line has landed, so update() pays nothing after arrival.
*/
updateHudDecode(time) {
const hd = this.hudDecode;
if (!hd) return;
if (hd.t0 === null) {
// First frame after create(): anchor the timeline here (this is the
// same time base update() receives; the scene's time.now is stale).
hd.t0 = time;
for (const ln of hd.lines) ln.dec = new ScrambleDecode(ln.value, hd.t0 + ln.delay, ln.dur);
}
let done = true;
for (const ln of hd.lines) {
if (ln.settled) continue;
if (!ln.dec.started(time)) {
done = false;
continue;
}
ln.text.setText(ln.dec.display(time));
if (ln.dec.finished(time)) ln.settled = true;
else done = false;
}
if (done) this.hudDecode = null;
}
/**
* Under the dossier: one line per tether level, range, anchor. Rebuilt
* whenever the field changes (TetherField.onChange), so a future
@ -394,6 +441,7 @@ export class GameScene extends Phaser.Scene {
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
this.time.update(_time, delta);
this.tweens.update();
this.updateHudDecode(_time); // the dossier types itself in (first frames only)
this.ship.update(_time, delta);
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate,

View File

@ -9,13 +9,13 @@ import { CyberOverlay } from '../visuals/CyberOverlay.js';
import { CyberShape } from '../ui/CyberShape.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js';
import { Galaxy } from '../galaxy/Galaxy.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
const SEED_MAX = 32;
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?';
/**
* Main menu the cyberpunk face of the game.
@ -359,28 +359,22 @@ export class MenuScene extends Phaser.Scene {
}
// ------------------------------------------------------------------
// Seed "decode" scramble (boot + reroll)
// Seed "decode" scramble (boot + reroll) — the shared console effect
// (js/utils/Decode.js), the one the system dossier HUD uses too.
// ------------------------------------------------------------------
startDecode(value) {
this.decode = { value, t0: this.time.now, dur: 620 };
this.decode = new ScrambleDecode(value, this.time.now, DECODE_DURATION);
}
updateDecode(time) {
if (!this.decode || !this.seedText || this.dead) return;
const { value, t0, dur } = this.decode;
const u = (time - t0) / dur;
if (u >= 1) {
if (this.decode.finished(time)) {
this.decode = null;
this.drawSeed();
return;
}
const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * value.length);
let out = '';
for (let i = 0; i < value.length; i++) {
out += i < reveal ? value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0];
}
this.seedText.setText(out);
this.seedText.setText(this.decode.display(time));
}
// ------------------------------------------------------------------

74
js/utils/Decode.js Normal file
View File

@ -0,0 +1,74 @@
/**
* The "decode" scramble the shared type-out effect of the console.
*
* The main menu uses it on the Galaxy Seed field (it "decodes" in as the
* panel assembles); the game uses it on the system dossier HUD the
* system name and every data line below it type themselves in as the
* ship arrives. One effect, two scenes.
*
* Over `dur` ms the target string reveals left-to-right while the
* unrevealed slots hold random glyphs from the decode alphabet the
* console pulling a signal out of static.
*
* Pure (no Phaser): build one per line with a start time, poll
* `display(time)` from the scene's `update()`, and drop it once
* `finished(time)`. The time base is the engine loop time the scene's
* `update(time, delta)` receives (=== `this.time.now` after that frame).
*
* const dec = new ScrambleDecode('Kepler-9', t0, DECODE_DURATION);
* // each frame: if (dec.started(time)) text.setText(dec.display(time));
* // done when: dec.finished(time)
*/
/** The alphabet unrevealed slots draw from (no vowels, no 0/1 — signal, not language). */
export const DECODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789#%+*<>?';
/** The menu's seed-field window (ms) — the canonical decode timing. */
export const DECODE_DURATION = 620;
export class ScrambleDecode {
/**
* @param {string} value the final text
* @param {number} t0 absolute start time (scene time, in ms)
* @param {number} [dur] reveal window in ms
*/
constructor(value, t0, dur = DECODE_DURATION) {
this.value = String(value ?? '');
this.t0 = t0;
this.dur = dur > 0 ? dur : 1;
}
started(time) {
return time >= this.t0;
}
finished(time) {
return (time - this.t0) / this.dur >= 1;
}
/**
* The display string at `time`: the revealed prefix of the target with
* the rest scrambled. '' before the start, the exact target once
* finished (the reveal lands at ~87% of the window, then holds).
*/
display(time) {
if (!this.started(time)) return '';
if (this.finished(time)) return this.value;
const u = (time - this.t0) / this.dur;
const reveal = Math.floor(Math.max(0, Math.min(1, u * 1.15)) * this.value.length);
let out = '';
for (let i = 0; i < this.value.length; i++) {
out += i < reveal ? this.value[i] : DECODE_CHARS[(Math.random() * DECODE_CHARS.length) | 0];
}
return out;
}
}
/**
* Per-line window for multi-line decodes (the system dossier): ~70 ms per
* character the menu's feel — floored so short lines don't flicker and
* capped so long ones don't drag.
*/
export function decodeDur(length = 0) {
return Math.max(420, Math.min(880, 320 + length * 46));
}