/** * 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 target’s 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');