orbit/dev/scan-test.mjs

282 lines
11 KiB
JavaScript

/**
* Deep scan 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 (deckAction('scan')), and
* walks the whole pulse:
*
* deckAction('scan') → the sweep arms (busy), the TARGET SET is the
* discoverable objects inside the tether union, a SECOND press while
* busy does not re-arm it
* the charge ends → the front is out: the radius GROWS MONOTONICALLY,
* the camera rolls/breathes with the wave (zoom/rotation off rest),
* the barrier is EXCITED when the front touches a tether rim
* the sweep completes → every in-region object was HIT (the front
* reaches the union boundary, so dist ≤ maxR for all of them), the
* camera is RESTORED (zoom 1, rotation 0), the console says
* "SCAN COMPLETE", and the object list is logged (the result seam)
* a SECOND scan runs clean (state resets between sweeps)
* no console errors were captured
*
* The pulse's frame-stepping is driven by the runner's pump
* (window.__SCAN_PUMP__ steps one game frame + advances the stage
* machine) — same headless pump as dev/mining-test.mjs. Results land in
* `window.__SCAN__`.
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/scan-test.html \
* 'window.__SCAN_PUMP__(); return window.__SCAN__;'
*/
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);
// A deterministic galaxy (same clusters every run) + quiet audio in headless.
globalThis.__ORBIT_DEV_SEED = 'SCAN';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // GameScene boots first
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.__SCAN__ = { pass: ok, results: all, errors, dbg: window.__DBG__STATE__ || null };
console.log(ok ? 'SCAN PASS' : 'SCAN FAIL');
};
const fail = (label) => {
check(label, false);
finish(false);
};
/** Resumable wait across runner wakes (see mining-test.mjs). */
let stage = 0;
let stageSince = -1;
let stageSinceMs = 0;
const wait = (pred, inProgress, note, cpuBudgetMs = 1200, maxTotalMs = 60000) => {
if (stageSince !== stage) { stageSince = stage; stageSinceMs = performance.now(); }
const t0 = performance.now();
for (;;) {
if (pred()) {
if (note) note();
return true;
}
if (!inProgress()) return false;
if (performance.now() - stageSinceMs > maxTotalMs) return false;
if (performance.now() - t0 > cpuBudgetMs) return null;
pump(8, 120);
}
};
// ----------------------------------------------------------------------
// The stage machine
// ----------------------------------------------------------------------
let sweep1 = null; // { total, hits, excites, sawCameraWobble, radiusSamples }
let exciteSpy = 0;
let sawCompleteToast = false;
let sawEmitToast = false;
const scene = () => game.scene.getScene('GameScene');
const stepMachine = () => {
const s = scene();
switch (stage) {
// ---- 0) scene booted ------------------------------------------------
case 0: {
const r = wait(() => s && s.ship && s.scanPulse && s.tetherField,
() => true, null, 1200, 30000);
if (r === null) return;
if (!r) fail('the scene booted (ship + scan pulse + tether field)');
check('the scene booted (ship + scan pulse + tether field)', true);
// Spy on the barrier excitation (the scan → TetherField seam).
const tf = s.tetherField;
const origExcite = tf.excite.bind(tf);
tf.excite = (i) => { exciteSpy++; return origExcite(i); };
const objs = s.discoverableObjects();
const inRegion = objs.filter((o) => s.tetherField.contains(o.x, o.y));
check('the tether region holds at least one discoverable object', inRegion.length >= 1);
check('the home world is inside its tether region',
inRegion.some((o) => o.id === 'home'));
stage = 1;
return;
}
// ---- 1) press SCAN — the sweep arms ----------------------------------
case 1: {
s.deckAction('scan'); // the real dispatch path (ActionBar → deckAction)
const armed = s.scanPulse.started && s.scanPulse.busy;
if (!armed) fail('the SCAN button arms the sweep');
if (s.scanObjects === null) fail('the sweep built its target set');
check('the SCAN button arms the sweep', true);
check('the sweep built its target set (objects in the tether region)', true);
const total = s.scanObjects.length;
check('the target set matches the in-region discoverables',
total >= 1 && total === s.discoverableObjects().filter((o) => s.tetherField.contains(o.x, o.y)).length);
check('the home world is in the target set', s.scanObjects.some((o) => o.id === 'home'));
const t0front = s.scanPulse.frontT0;
s.deckAction('scan'); // a second press while busy — must be ignored
check('a second SCAN press while busy does not re-arm the sweep',
s.scanPulse.frontT0 === t0front);
sweep1 = {
total,
hits: 0,
excites: 0,
sawCameraWobble: false,
radiusSamples: [],
};
stage = 2;
return;
}
// ---- 2) the sweep is out — watch it run ------------------------------
case 2: {
const done = wait(
() => !s.scanPulse.busy,
() => {
// While the front is out, sample it (growth + camera + excite).
if (sweep1 && !sweep1.sealed) {
const r = s.scanPulse.radius;
if (r > 0) {
if (sweep1.radiusSamples.length === 0 || r > sweep1.radiusSamples[sweep1.radiusSamples.length - 1]) {
sweep1.radiusSamples.push(r);
}
const cam = s.cameras.main;
if (Math.abs(cam.rotation) > 1e-5 || Math.abs(cam.zoom - 1) > 1e-4) {
sweep1.sawCameraWobble = true;
}
sweep1.hits = s.scanObjects.filter((o) => o.hit).length;
}
}
return s.scanPulse.started; // the sweep was armed (never un-armed early)
},
null,
1500,
40000,
);
if (done === null) return;
if (!done) fail('the sweep ran to completion');
check('the sweep ran to completion', true);
// The radius must have grown (front emitted and expanded).
check('the front radius grew during the sweep',
sweep1.radiusSamples.length >= 2 && sweep1.radiusSamples[sweep1.radiusSamples.length - 1] > sweep1.radiusSamples[0]);
// The camera wobbled with the wave.
check('the camera rolled/breathed while the wave passed', sweep1.sawCameraWobble);
// Every in-region object was hit (dist ≤ maxR for all of them).
check(`every in-region object was hit (${sweep1.hits}/${sweep1.total})`,
sweep1.hits === sweep1.total && sweep1.total >= 1);
// The barrier was excited where the wave was absorbed.
sweep1.excites = exciteSpy;
check('the barrier was excited as the wave was absorbed', exciteSpy >= 1);
// The result seam fired (finishScan logged the object list).
const logged = (window.__SCAN_LOGS__ || []).some((l) => l.includes('scan complete'));
check('the scan-complete seam logged the in-region objects', logged);
// The camera was restored.
const cam = s.cameras.main;
check('the camera zoom was restored', Math.abs(cam.zoom - 1) < 0.002);
check('the camera rotation was restored', Math.abs(cam.rotation) < 1e-3);
// The console said SCAN COMPLETE.
const toast = s.consoleToastG && s.consoleToastG[1] ? s.consoleToastG[1].text : '';
check('the console reported the sweep (SCAN COMPLETE)', /SCAN COMPLETE/.test(toast));
sawCompleteToast = true;
// The sweep state reset — a second scan must be armable.
check('the sweep state reset (busy=false after completion)', !s.scanPulse.busy);
check('the target set was released', s.scanObjects === null);
stage = 3;
return;
}
// ---- 3) a second scan runs clean --------------------------------------
case 3: {
s.deckAction('scan');
if (!s.scanPulse.busy) fail('a second SCAN press arms a fresh sweep');
check('a second SCAN press arms a fresh sweep', true);
const r = wait(() => !s.scanPulse.busy, () => true, null, 1500, 40000);
if (r === null) return;
if (!r) fail('the second sweep ran to completion');
check('the second sweep ran to completion', true);
check('the second sweep hit every in-region object again',
(window.__SCAN_LOGS__ || []).filter((l) => l.includes('scan complete')).length >= 2);
const cam = s.cameras.main;
check('the camera was restored after the second sweep',
Math.abs(cam.zoom - 1) < 0.002 && Math.abs(cam.rotation) < 1e-3);
stage = 4;
return;
}
// ---- 4) done -----------------------------------------------------------
case 4: {
finish(results.every((x) => x.pass));
return;
}
}
};
window.__SCAN_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);
}
};