251 lines
9.6 KiB
JavaScript
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);
|
|
}
|
|
};
|