282 lines
11 KiB
JavaScript
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);
|
|
}
|
|
};
|