564 lines
24 KiB
JavaScript
564 lines
24 KiB
JavaScript
/**
|
||
* 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 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');
|