/** * 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); } };