orbit/dev/signal-compass-flow.mjs

251 lines
9.6 KiB
JavaScript

/**
* Signal-compass flow (headless browser — NOT a Node test).
*
* Boots the REAL GameScene (seeded galaxy), presses the command deck's SCAN
* button through the real dispatch path, and then exercises the SECONDARY
* COMPASS (js/ui/SignalCompass.js) that a completed scan lights up:
*
* after the sweep completes — the compass emission exists and the ring is
* drawing one soft "radio signal" per UNDISCOVERED in-region object at its
* bearing, at full strength (the home world, already discovered, is NOT
* among them)
* fade — age the emission into the fade window ⇒ the signals dim
* proportionally (21 s in ⇒ ≈ 80%)
* expiry — age it past fullMs+fadeMs ⇒ the signals are GONE and the
* emission is cleared
* discovery — re-scan, then discover one of the signalled objects ⇒ that
* signal drops while the others stay
* no console errors were captured
*
* The compass is a stateless renderer, so we spy on `refresh` to inspect the
* (id, position, alpha, color) set the scene computes each frame. The
* full→fade envelope itself is covered by the pure Node test
* (dev/signal-compass.test.mjs).
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/signal-compass-flow.html \
* 'window.__SIGNAL_PUMP__(); return window.__SIGNAL__;'
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
globalThis.__ORBIT_DEV_SEED = 'SCAN';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
// ----------------------------------------------------------------------
// Results + checks
// ----------------------------------------------------------------------
const results = [];
const check = (label, cond) => {
const pass = !!cond;
results.push({ label, pass });
console.log(`${pass ? '\u2714' : '\u2718 FAIL'} ${label}`);
};
// ----------------------------------------------------------------------
// Manual frame pump (the only clock this box has)
// ----------------------------------------------------------------------
const pumpErrors = [];
const pump = (maxSteps = 20, cpuBudgetMs = 700) => {
const g = window.game;
if (!g || !g.loop) return 0;
const t0 = performance.now();
let n = 0;
while (n < maxSteps) {
if (performance.now() - t0 > cpuBudgetMs) break;
try {
if (g.input && typeof g.input.update === 'function') g.input.update();
g.loop.step(performance.now());
} catch (e) {
pumpErrors.push(`pump: ${String((e && e.message) || e)}`);
break;
}
n++;
}
return n;
};
let finished = false;
const finish = (pass) => {
if (finished) return;
finished = true;
const all = [...results];
if (pumpErrors.length) for (const e of pumpErrors) all.push({ label: e, pass: false });
const errors = (window.__CAPTURED_ERRORS__ || []).concat(pumpErrors);
if (errors.length === 0) all.push({ label: 'no console errors were captured', pass: true });
const ok = all.every((r) => r.pass);
window.__SIGNAL__ = { pass: ok, results: all, errors };
console.log(ok ? 'SIGNAL PASS' : 'SIGNAL FAIL');
};
const fail = (label) => { check(label, false); finish(false); };
let stage = 0;
let stageSince = -1;
let stageSinceMs = 0;
const wait = (pred, inProgress, cpuBudgetMs = 1200, maxTotalMs = 60000) => {
if (stageSince !== stage) { stageSince = stage; stageSinceMs = performance.now(); }
const t0 = performance.now();
for (;;) {
if (pred()) return true;
if (!inProgress()) return false;
if (performance.now() - stageSinceMs > maxTotalMs) return false;
if (performance.now() - t0 > cpuBudgetMs) return null;
pump(8, 120);
}
};
const scene = () => game.scene.getScene('GameScene');
// Spy on the compass renderer: record each refresh's signal set.
let lastSignals = null;
const installSpy = (s) => {
const comp = s.signalCompass;
const orig = comp.refresh.bind(comp);
comp.refresh = (signals, time, sx, sy) => {
lastSignals = Array.isArray(signals) ? signals.map((x) => ({ ...x })) : signals;
return orig(signals, time, sx, sy);
};
};
const sysId = () => scene().systemRecord.id;
const inRegion = (s) => s.discoverableObjects().filter((o) => s.tetherField.contains(o.x, o.y));
const undiscovered = (s) => inRegion(s).filter((o) => !s.discovery.isDiscovered(s.systemRecord.id, o.id));
const ids = (list) => (list || []).map((o) => o.id).sort().join(',');
const stepMachine = () => {
const s = scene();
switch (stage) {
// ---- 0) scene booted ------------------------------------------------
case 0: {
const r = wait(() => s && s.ship && s.scanPulse && s.tetherField && s.signalCompass,
() => true, 1200, 30000);
if (r === null) return;
if (!r) fail('the scene booted (ship + scan + tether + signal compass)');
check('the scene booted (ship + scan + tether + signal compass)', true);
installSpy(s);
// Before any scan, the ring is idle (no signals).
check('before a scan, the compass has no signals',
Array.isArray(lastSignals) ? lastSignals.length === 0 : true);
stage = 1;
return;
}
// ---- 1) press SCAN and run the sweep --------------------------------
case 1: {
s.deckAction('scan');
if (!s.scanPulse.busy) fail('the SCAN button arms the sweep');
check('the SCAN button arms the sweep', true);
const r = wait(() => !s.scanPulse.busy, () => s.scanPulse.started, 1500, 40000);
if (r === null) return;
if (!r) fail('the sweep ran to completion');
check('the sweep ran to completion', true);
// Let a couple more frames settle the compass draw.
pump(6, 120);
stage = 2;
return;
}
// ---- 2) after the scan: full-strength signals, discovered excluded ---
case 2: {
const reveal = s.scanReveal;
check('a completed scan created a compass emission', !!reveal && Array.isArray(reveal.ids));
const expected = undiscovered(s);
check('signals = the undiscovered in-region objects (home world excluded)',
ids(lastSignals) === ids(expected) && expected.length >= 1);
const sigs = lastSignals || [];
check('every signal is at full strength just after the scan',
sigs.length >= 1 && sigs.every((x) => x.alpha > 0.999));
check('the home world (discovered) has NO signal',
!sigs.some((x) => x.id === 'home'));
// Each signal sits at its object's bearing (direction from the ship).
const objById = new Map(inRegion(s).map((o) => [o.id, o]));
const bearingOk = sigs.every((x) => {
const o = objById.get(x.id);
if (!o) return false;
const want = Math.atan2(o.y - s.ship.y, o.x - s.ship.x);
const got = Math.atan2(x.y - s.ship.y, x.x - s.ship.x);
return Math.abs(want - got) < 1e-9; // same position ⇒ same bearing
});
check('each signal tracks its object live position', bearingOk);
stage = 3;
return;
}
// ---- 3) fade — age the emission into the fade window ----------------
case 3: {
const full = s.signalFullMs, fade = s.signalFadeMs;
s.scanReveal.bornAt = s.time.now - (full + Math.round(fade * 0.2)); // 20% into the fade
pump(4, 120);
const a = (lastSignals || [])[0]?.alpha ?? 0;
check('aged into the fade ⇒ signals dim (≈0.8 at 20% in)',
Math.abs(a - 0.8) < 0.02);
stage = 4;
return;
}
// ---- 4) expiry — age it fully out -----------------------------------
case 4: {
s.scanReveal.bornAt = s.time.now - (s.signalFullMs + s.signalFadeMs + 5); // just past
pump(4, 120);
check('expired ⇒ the signals are gone', (lastSignals || []).length === 0);
check('expired ⇒ the emission is cleared', s.scanReveal === null);
stage = 5;
return;
}
// ---- 5) discovery drops a signal ------------------------------------
case 5: {
s.deckAction('scan'); // fresh emission
if (!s.scanPulse.busy) fail('a fresh scan re-arms the sweep');
const r = wait(() => !s.scanPulse.busy, () => s.scanPulse.started, 1500, 40000);
if (r === null) return;
pump(4, 120);
const before = (lastSignals || []).map((x) => x.id).sort().join(',');
if (!before) fail('the fresh scan lit up signals again');
check('a fresh scan lit up the signals again', true);
// Discover the first signalled object (force it into the discovery set).
const victim = (lastSignals || [])[0].id;
let set = s.discovery.bySystem.get(sysId());
if (!set) { set = new Set(); s.discovery.bySystem.set(sysId(), set); }
set.add(victim);
pump(4, 120);
const after = lastSignals || [];
check('discovering a signalled object drops ITS signal',
!after.some((x) => x.id === victim));
check('the other signals remain',
after.map((x) => x.id).sort().join(',') ===
before.split(',').filter((id) => id !== victim).sort().join(','));
stage = 6;
return;
}
// ---- 6) done ----------------------------------------------------------
case 6: {
finish(results.every((x) => x.pass));
return;
}
}
};
window.__SIGNAL_PUMP__ = () => {
try {
stepMachine();
} catch (e) {
(window.__CAPTURED_ERRORS__ || (window.__CAPTURED_ERRORS__ = [])).push(`stage: ${String((e && e.stack) || e)}`);
check(`THREW: ${String((e && e.message) || e)}`, false);
finish(false);
}
};