/** * System dossier HUD test (dev tool, run with Node — no browser): * * node dev/system-hud.test.mjs * * Runs the REAL GameScene.createSystemHud()/updateHud()/toggleHud() * (js/scenes/GameScene.js) against the real galaxy + SystemReport data and * a stubbed scene, and asserts the full dossier lifecycle: * - arrival: the name decodes in FIRST, then the data lines below it * (same scramble as the menu's Galaxy Seed, js/utils/Decode.js), in * the same layout as before (y positions, hudEndY); * - while a line decodes: length stays the target's, the revealed * prefix is exact, the unrevealed tail comes from the decode * alphabet; a settled line never changes again; * - the state caret (▾) sits right of the name, starts hidden, and * appears once the name has landed (expanded state = pointing down); * - the details are OPEN by default; the dossier folds itself 10 s * after arrival — deconstructing in REVERSE build order (status line * first … subtitle last) — then the caret swings down→right; * - clicking the name (toggleHud) re-opens (caret down, lines type in * forward) and re-closes (lines erase in reverse, caret right); * - a toggle mid-animation is ignored; a manual toggle cancels the * one-shot auto-fold (a re-opened dossier stays open past 10 s); * - hudTitleContains covers the name and the caret beside it, and * nothing else; * - SFX (data/sfx.json): the construct sound plays when the dossier * types in (arrival AND manual re-open), the deconstruct sound when it * folds (auto-fold AND manual re-close), the discovery sound when a * new object is found; sfx.enabled=false loads and plays nothing. */ 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, Image: 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 detailValues = [report.subtitle, report.status]; console.log(`dossier under test:\n ${report.title}\n${detailValues.map((s) => ' ' + s).join('\n')}\n`); // --- Stub scene: what createSystemHud()/updateHud()/toggleHud() touch ----- class FakeText { constructor(x, y, str) { this.x = x; this.y = y; this.text = str; this.depth = 0; this.alpha = 1; this.angle = 0; } get width() { return this.text.length * 9; // deterministic fake metrics } get height() { return 19; } setText(s) { this.text = s; return this; } setOrigin() { return this; } setScrollFactor() { return this; } setDepth(d) { this.depth = d; return this; } setAlpha(a) { this.alpha = a; return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } destroy() { this.destroyed = true; return this; } } function makeScene() { const texts = []; // Plain object with the REAL GameScene prototype BEHIND it (updateHud // calls showCaret/rotateCaret/start* on `this` — prototype chain, since // class methods are non-enumerable and Object.assign skips them). const scene = Object.assign( Object.create(GameScene.prototype), { texts, time: { now: 0 }, add: { text: (x, y, str) => { const t = new FakeText(x, y, str); texts.push(t); return t; }, }, // Tweens run INSTANTLY in the harness: land the final value now. tweens: { add(opts) { const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets]; for (const [k, v] of Object.entries(opts)) { if (k === 'targets' || k === 'duration' || k === 'ease' || k === 'onComplete') continue; for (const tg of targets) tg[k] = v; } if (typeof opts.onComplete === 'function') opts.onComplete(); return {}; }, }, systemRecord: rec, systemContent: content, galaxy, }, ); return scene; } const STEP = 16; const step = (scene, from, to) => { for (let t = from; t <= to; t += STEP) GameScene.prototype.updateHud.call(scene, t); }; const detailFinal = (scene) => scene.hudDetail.every((d) => d.text.text === d.value); const detailEmpty = (scene) => scene.hudDetail.every((d) => d.text.text === ''); // =========================================================================== // 1) Arrival: layout, decode order, caret, open-by-default // =========================================================================== const scene = makeScene(); GameScene.prototype.createSystemHud.call(scene); const titleW = report.title.length * 9; const titleH = 19; check('title + caret + one text per detail line', scene.texts.length === 2 + detailValues.length); check('the name starts empty (it decodes in)', scene.hudTitle.text === ''); check('details start empty (open by default, not pre-printed)', scene.hudDetail.every((d) => d.text.text === '')); check('the caret is the down triangle ▾, hidden at first', scene.hudCaret.text === '\u25be' && scene.hudCaret.alpha === 0); check( 'the caret sits just right of the FINISHED name', scene.hudCaret.x === 16 + titleW + 10 && scene.hudCaret.y === 14 + titleH / 2, ); check('starts open-by-default: phase constructing, auto-fold armed, t0 unanchored', scene.hudPhase === 'constructing' && scene.autoCollapseArmed === true && scene.hudArrivalT0 === null && scene.hudTimeline.t0 === null); check('arrival timeline: name first, then the detail lines', scene.hudTimeline.mode === 'arrive' && scene.hudTimeline.lines.length === 1 + detailValues.length && scene.hudTimeline.lines[0].isTitle === true && scene.hudTimeline.lines[0].value === report.title); // Layout: same lines and y positions as the original dossier. { let y = 14; const ys = [y]; // title y += 26; ys.push(y); // subtitle y += 20; y += 2; ys.push(y); // status (faction + population) y += 20; check('y layout unchanged by the decode', [scene.hudTitle, ...scene.hudDetail.map((d) => d.text)].every((t, i) => t.y === ys[i])); check('hudEndY tracks the dossier bottom', scene.hudEndY === y + 6); } // The toggle hit-area: the name plus the caret beside it — and nothing else. const contains = (px, py) => GameScene.prototype.hudTitleContains.call(scene, px, py); check('hudTitleContains: on the name', contains(20, 20) === true); check('hudTitleContains: on the caret beside the name', contains(16 + titleW + 15, 23) === true); check('hudTitleContains: not far away', contains(500, 300) === false); check('hudTitleContains: not below the dossier', contains(16, 200) === false); // Simulate frames from the first update frame (T0), asserting the decode // contract on every line. "Settled" is read from the scene's own flag — // a mid-decode full-string match is a coincidence, not settlement. const T0 = 1000; const lines = [ { text: scene.hudTitle, target: report.title }, ...scene.hudDetail.map((d) => ({ text: d.text, target: d.value })), ]; const firstNonEmpty = new Array(lines.length).fill(null); let lengthOk = true; let tailOk = true; let settledOk = true; for (let t = T0; t <= T0 + 4000; t += STEP) { GameScene.prototype.updateHud.call(scene, t); const tl = scene.hudTimeline; for (let i = 0; i < lines.length; i++) { const { text, target } = lines[i]; if (firstNonEmpty[i] === null && text.text !== '') firstNonEmpty[i] = t; if (text.text === '') continue; if (text.text.length !== target.length) lengthOk = false; if (tl && tl.lines[i].settled) { if (text.text !== target || t < tl.lines[i].dec.t0 + tl.lines[i].dec.dur) settledOk = false; continue; } if (text.text === target) continue; // mid-decode coincidence let n = 0; while (n < target.length && text.text[n] === target[n]) n++; for (let k = n; k < text.text.length; k++) if (DECODE_CHARS.indexOf(text.text[k]) === -1) tailOk = false; } } check('first frame: every line still empty (the beat of arrival)', lines.every((_, 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('unrevealed tail always from the decode alphabet', tailOk); check('every line lands as exactly the report string', lines.every((l, i) => l.text.text === (i === 0 ? report.title : detailValues[i - 1]))); check('a settled line is final (exact, and only after its window)', settledOk); check('the arrival timeline self-clears', scene.hudTimeline === null); check('arrived OPEN: phase expanded', scene.hudPhase === 'expanded'); check('the caret appeared once the name landed (pointing down)', scene.hudCaretShown === true && scene.hudCaret.alpha === 1 && scene.hudCaret.angle === 0); // =========================================================================== // 2) The one-shot auto-fold at 10 s: reverse deconstruction + caret→right // =========================================================================== let collapseSeen = null; for (let t = T0 + 4000; t <= T0 + 13000; t += STEP) { GameScene.prototype.updateHud.call(scene, t); if (!collapseSeen && scene.hudTimeline && scene.hudTimeline.mode === 'collapse') { collapseSeen = { t, order: scene.hudTimeline.lines.map((l) => l.text), t0s: scene.hudTimeline.lines.map((l) => l.dec.t0), reverse: scene.hudTimeline.lines.map((l) => l.dec.reverse), }; } } check('the auto-fold fired at 10 s after arrival', collapseSeen !== null && collapseSeen.t >= T0 + 10000 && collapseSeen.t <= T0 + 10000 + STEP); check('deconstruction runs in REVERSE build order (status → subtitle)', collapseSeen !== null && collapseSeen.order[0] === scene.hudDetail[scene.hudDetail.length - 1].text && collapseSeen.order[1] === scene.hudDetail[scene.hudDetail.length - 2].text && collapseSeen.order[collapseSeen.order.length - 1] === scene.hudDetail[0].text); check('deconstruction lines start staggered, each playing reverse', collapseSeen !== null && collapseSeen.reverse.every(Boolean) && collapseSeen.t0s.every((v, i) => i === 0 || v > collapseSeen.t0s[i - 1])); check('after the fold: details gone, name intact', detailEmpty(scene) && scene.hudTitle.text === report.title); check('folded: phase collapsed', scene.hudPhase === 'collapsed'); check('the caret swung down→right after the last line left', scene.hudCaret.angle === -90); check('the auto-fold is one-shot (no longer armed)', scene.autoCollapseArmed === false); // =========================================================================== // 3) Player toggle: re-open (caret down, lines type in forward), re-close // =========================================================================== scene.time.now = T0 + 13000; GameScene.prototype.toggleHud.call(scene); check('toggle while folded starts a FORWARD rebuild', scene.hudPhase === 'constructing' && scene.hudTimeline.mode === 'expand'); { const tl = scene.hudTimeline; check('rebuild order is build order (subtitle → status)', tl.lines.length === detailValues.length && tl.lines[0].text === scene.hudDetail[0].text && tl.lines[tl.lines.length - 1].text === scene.hudDetail[scene.hudDetail.length - 1].text && tl.lines.every((l, i) => l.text === scene.hudDetail[i].text)); check('rebuild lines are forward decodes, staggered', tl.lines.every((l) => l.dec.reverse === false) && tl.lines.map((l) => l.dec.t0).every((v, i) => i === 0 || v > tl.lines[i - 1].dec.t0)); } check('the caret swung back down for the re-open', scene.hudCaret.angle === 0); step(scene, T0 + 13000, T0 + 16000); check('re-opened: every line back to exactly the report string', detailFinal(scene)); check('re-opened: phase expanded again', scene.hudPhase === 'expanded'); scene.time.now = T0 + 16000; GameScene.prototype.toggleHud.call(scene); check('toggle while open starts the REVERSE deconstruction again', scene.hudPhase === 'collapsing' && scene.hudTimeline.mode === 'collapse' && scene.hudTimeline.lines[0].text === scene.hudDetail[scene.hudDetail.length - 1].text); const collapseWhileOpen = scene.hudTimeline; GameScene.prototype.toggleHud.call(scene); // mid-animation: must be ignored check('a toggle mid-animation is ignored (same timeline, still collapsing)', scene.hudTimeline === collapseWhileOpen && scene.hudPhase === 'collapsing'); step(scene, T0 + 16000, T0 + 19000); check('folded again: details empty, caret right, phase collapsed', detailEmpty(scene) && scene.hudCaret.angle === -90 && scene.hudPhase === 'collapsed'); // =========================================================================== // 4) A manual toggle CANCELS the one-shot auto-fold // =========================================================================== const scene2 = makeScene(); GameScene.prototype.createSystemHud.call(scene2); step(scene2, T0, T0 + 4000); // arrive, open by default check('fresh dossier arrived open', scene2.hudPhase === 'expanded' && detailFinal(scene2)); // The player folds it at 8 s, then re-opens just before the 10 s mark — // the re-opened dossier must STAY open past 10 s (no auto-fold). scene2.time.now = T0 + 8000; GameScene.prototype.toggleHud.call(scene2); let foldStart = null; let reOpenAt = null; for (let t = T0 + 8000; t <= T0 + 13000; t += STEP) { if (foldStart === null && scene2.hudPhase === 'collapsing') foldStart = t; if (reOpenAt === null && scene2.hudPhase === 'collapsed') { scene2.time.now = t; GameScene.prototype.toggleHud.call(scene2); reOpenAt = t; } GameScene.prototype.updateHud.call(scene2, t); } check("the player's 8 s fold ran (not the auto-fold)", foldStart !== null && foldStart >= T0 + 8000 && foldStart <= T0 + 8000 + STEP); check('the re-open really ran across the 10 s mark', reOpenAt !== null && reOpenAt + 1600 > T0 + 10000); check('a manual toggle cancels the auto-fold — still OPEN past 10 s', scene2.hudPhase === 'expanded' && detailFinal(scene2) && scene2.hudCaret.angle === 0); check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false); // =========================================================================== // 5) The upper-right readout moved: the tether stack is GONE, the // MINERAL HUD (js/ui/MineralHud.js — hold fill as a bar + counting // number) owns that corner now. Its own test: dev/mineral-hud.test.mjs // =========================================================================== // =========================================================================== // 6) SFX: construct / deconstruct / discovery (data/sfx.json) // =========================================================================== { const sfxJson = config.section('sfx'); // Every sound name in data/sfx.json (the string-valued keys, excluding the _comment). const sfxNames = Object.keys(sfxJson).filter((k) => k !== '_comment' && typeof sfxJson[k] === 'string'); const s = makeScene(); s.played = []; s.audio = Object.fromEntries(sfxNames.map((n) => [`sfx_${n}`, true])); // the real preload() puts them here s.audioQueued = []; s.sound = { // Track which keys are "playing" so the shared voice's isPlaying() // guard (one mining hum across retargets) is exercised for real. _playing: new Set(), play: (key, cfg) => { s.sound._playing.add(key); s.played.push({ key, ...(cfg ?? {}) }); }, isPlaying: (key) => s.sound._playing.has(key), stopByKey: (key) => { s.sound._playing.delete(key); s.stopped.push(key); }, }; s.stopped = []; s.ship = { stop() {}, setState() {} }; // onMiningPhase's stopped/extending phases // v4 (Giedi) cache shape: cache.audio is a Cache with .has() — // cache.hasAudio() does not exist in this build. s.cache = { audio: { has: (key) => s.audio[key] === true } }; s.load = { audio: (key, url) => s.audioQueued.push([key, url]), spritesheet: () => {}, image: () => {}, video: (key, url) => (s.videoQueued ??= []).push([key, url]), // the research console's archive feed }; s.scale = { width: 1280, height: 720 }; s.time.delayedCall = (_ms, fn) => { fn(); return {}; }; // toast lifetime: instant in the harness s.add.circle = (x, y, radius, fill, alpha) => ({ x, y, radius, fill, alpha, setStrokeStyle() { return this; }, setDepth() { return this; }, destroy() { this.destroyed = true; return this; }, }); // preload: every configured SFX at its configured path. (Music queues // separately — data/music.json → game — so count SFX keys only.) GameScene.prototype.preload.call(s); const sfxQueued = s.audioQueued.filter(([k]) => String(k).startsWith('sfx_')); const queuedKey = (key) => s.audioQueued.find((e) => e[0] === key); check('preload loads every configured SFX', sfxQueued.length === sfxNames.length && sfxNames.every((n) => queuedKey(`sfx_${n}`)?.[1] === sfxJson[n])); check('the configured SFX files exist in the repo', sfxNames.every((n) => fs.existsSync(join(__dirname, '..', sfxJson[n])))); // arrival → the construct SFX (once, at the configured volume). GameScene.prototype.createSystemHud.call(s); GameScene.prototype.updateHud.call(s, 0); check('arrival types the dossier in with the construct SFX', s.played.length === 1 && s.played[0].key === 'sfx_construct' && s.played[0].volume === sfxJson.volume); // the 10 s auto-fold → the deconstruct SFX. s.played = []; GameScene.prototype.updateHud.call(s, 10000); check('the 10 s auto-fold deconstructs with the deconstruct SFX', s.played.length === 1 && s.played[0].key === 'sfx_deconstruct'); GameScene.prototype.updateHud.call(s, 12000); check('... and the dossier ends folded', s.hudPhase === 'collapsed' && detailEmpty(s)); // manual re-open → construct again; manual re-close → deconstruct again. s.played = []; s.time.now = 12000; GameScene.prototype.toggleHud.call(s); check('a manual re-open types the dossier back in with the construct SFX', s.played.length === 1 && s.played[0].key === 'sfx_construct'); step(s, 12000, 14000); // the re-open settles s.played = []; s.time.now = 14000; GameScene.prototype.toggleHud.call(s); check('a manual re-close deconstructs with the deconstruct SFX', s.played.length === 1 && s.played[0].key === 'sfx_deconstruct'); // discovery → the discovery SFX, with the toast still firing. const obj = { x: 100, y: 100, radius: 512, name: 'Kethral', typeLabel: 'planet' }; s.played = []; GameScene.prototype.celebrateDiscovery.call(s, obj); check('a new discovery plays the discovery SFX (exactly once)', s.played.length === 1 && s.played[0].key === 'sfx_discovery' && s.played[0].volume === sfxJson.volume); check('the DISCOVERED toast still appears alongside the sound', s.texts.some((t) => t.text === 'DISCOVERED — KETHRAL · PLANET')); // mining hum: starts when the beam goes live (phase 'mining'), a // retarget re-fires 'mining' without doubling it, stops on 'stopped' // (quietly — stopping mining plays no one-shot sound). setMiningLoop // rides onPhase → the real seam. s.played = []; s.sound._playing.clear(); s.stopped = []; GameScene.prototype.onMiningPhase.call(s, 'mining'); check('beam live → the mining hum starts (looping, at sfx.volume)', s.played.length === 1 && s.played[0].key === 'sfx_mining_loop' && s.played[0].loop === true && s.played[0].volume === sfxJson.volume); GameScene.prototype.onMiningPhase.call(s, 'mining'); // a retarget re-fires it check('a retarget re-fire keeps ONE hum (isPlaying guard)', s.played.length === 1); GameScene.prototype.onMiningPhase.call(s, 'stopped'); check('the sequence ends → the hum stops, quietly (no one-shot sound)', s.stopped.length === 1 && s.stopped[0] === 'sfx_mining_loop' && s.played.length === 1); // only the hum from above — nothing new on stop // guards: a missing asset or the master switch off → never plays, never throws. s.played = []; s.audio = {}; // e.g. the load was skipped GameScene.prototype.celebrateDiscovery.call(s, obj); check('a missing asset never plays (and never throws)', s.played.length === 0); s.audio = { sfx_discovery: true }; config.init({ ...config.data, sfx: { ...sfxJson, enabled: false } }); s.played = []; GameScene.prototype.celebrateDiscovery.call(s, obj); check('sfx.enabled=false → nothing plays', s.played.length === 0); s.audioQueued = []; GameScene.prototype.preload.call(s); check('sfx.enabled=false → preload loads no SFX audio', s.audioQueued.every(([k]) => !String(k).startsWith('sfx_'))); } if (failures > 0) { console.error(`\n${failures} system-hud test(s) FAILED`); process.exit(1); } console.log('\nsystem-hud: all checks passed');