orbit/dev/system-hud.test.mjs

564 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 {};
// A usable container (the QUEST tracker registers one in createSystemHud):
const ContainerStub = class {
constructor(scene, x = 0, y = 0) {
this.scene = scene;
this.x = x;
this.y = y;
this.visible = true;
this.childrenList = [];
}
add(o) {
if (Array.isArray(o)) o.forEach((c) => this.childrenList.push(c));
else this.childrenList.push(o);
return this;
}
setVisible(v) {
this.visible = v;
return this;
}
setSize() {
return this;
}
setAlpha() {
return this;
}
setDepth() {
return this;
}
setScrollFactor() {
return this;
}
setPosition(x, y) {
this.x = x;
this.y = y;
return this;
}
setX(x) {
this.x = x;
return this;
}
destroy(recursive) {
if (this.destroyed) return;
this.destroyed = true;
if (recursive) {
for (const c of this.childrenList) {
try {
c.destroy?.();
} catch {
/* already gone */
}
}
}
}
};
const PhaserStub = {
Scene: ClassStub,
Physics: { Arcade: { Sprite: ClassStub } },
GameObjects: { Sprite: ClassStub, Container: ContainerStub, 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;
},
// The QUEST tracker (built by createSystemHud) registers its
// container + its hairline rule (NONE TRACKED state — no rows).
existing: (o) => o,
graphics: () => ({
clear() {}, lineStyle() {}, lineBetween() {}, fillStyle() {}, fillRect() {},
setScrollFactor() { return this; }, setDepth() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
destroy() { this.destroyed = true; return this; },
}),
},
// Tweens run INSTANTLY in the harness: land the final value now.
tweens: {
killTweensOf: () => {},
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 (+ the tracker\u2019s own 2 texts)',
scene.texts.length === 2 + detailValues.length + 2 &&
scene.questTracker !== undefined);
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 targets 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');