/** * Mining flow (headless browser — NOT a Node test). * * Drives the REAL game's input pipeline (mousedown/keydown on the canvas) * and walks the whole arm sequence against a seeded galaxy: * * click a rock → the menu opens at the click (MINE ASTEROIDS) * a click OUTSIDE it → closes, and does not fly the ship * click MINE ASTEROIDS → "extending" (ship locked, console toast * "Extending Mining Arm...") * a world click meanwhile → is held (no fly, no abort) * the ~1.5 s reach → "mining": the beam is live, ore motes ride it * click the rock again → the menu now says STOP MINING * click STOP MINING → the beam retracts, then the arm is idle * ESC while mining → breaks the beam * ESC with the menu open → closes it * click CANCEL → closes, no mining * * HEADLESS PUMP: this box's headless Firefox freezes rAF and timers once * the first paint is done, so the page can't run itself. The runner * (dev/cdp-firefox.mjs) keeps waking the JS thread with execute/sync — * so the whole flow is a synchronous stage machine, and each wake calls * `window.__MINING_PUMP__()`, which (a) steps the Phaser loop manually * (`game.loop.step(performance.now())` — one frame per call, which also * processes the queued pointer/keyboard events) and (b) advances the * next stage. Long game-time waits (the 1.5 s arm extension) may span * many runner polls — the stage machine RESUMES where it left off. * Results land in `window.__MINING__`: * * python3 -m http.server 8080 * node dev/cdp-firefox.mjs http://localhost:8080/dev/mining-test.html \ * 'window.__MINING_PUMP__(); return window.__MINING__;' */ 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 = 'MINING'; 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 ? '✔' : '✘ 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; }; /** * Resumable wait. Returns: * true — `pred()` is met * false — `inProgress()` stopped holding, or the wait outlived * `maxTotalMs` (wall clock) → the wait FAILED * null — the CPU budget for THIS wake is spent but `inProgress()` * still holds → the stage must `return`; the runner's next * wake re-enters the same stage and keeps waiting. */ 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); } }; // ---------------------------------------------------------------------- // Input (real events on the canvas / window) // ---------------------------------------------------------------------- const press = (x, y) => { const canvas = game.canvas; const r = canvas.getBoundingClientRect(); const zoom = r.width / canvas.width; const mk = (type, buttons) => new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0, buttons, clientX: r.left + x * zoom, clientY: r.top + y * zoom, }); canvas.dispatchEvent(mk('mouseover', 0)); canvas.dispatchEvent(mk('mousedown', 1)); canvas.dispatchEvent(mk('mouseup', 0)); }; const esc = () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true, })); /** World → canvas screen coords (zoom 1, no rotation: screen = world − scroll). */ const w2s = (wx, wy) => { const c = game.scene.getScene('GameScene').cameras.main; return { x: wx - c.scrollX, y: wy - c.scrollY }; }; const btnScreen = (btn) => { const s = game.scene.getScene('GameScene'); return w2s(s.miningPopup.x + btn.x, s.miningPopup.y + btn.y); }; const rockScreen = () => { const s = game.scene.getScene('GameScene'); return w2s(rock.wx, rock.wy); }; const openSpaceScreen = () => { const s = game.scene.getScene('GameScene'); return { x: s.scale.width / 2, y: s.scale.height / 2 }; }; // ---------------------------------------------------------------------- // The stage machine (resumable across runner polls) // ---------------------------------------------------------------------- let stage = 0; let cluster = null; let rock = null; let finished = false; let sawExtendingToast = 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.__MINING__ = { pass: ok, results: all, errors }; console.log(ok ? 'MINING PASS' : 'MINING FAIL'); }; const scene = () => game.scene.getScene('GameScene'); const fail = (label) => { check(label, false); finish(false); }; const stepMachine = () => { let s = null; try { s = scene(); } catch { /* not booted yet */ } if (!s || !s.ship || !s.mining || !s.miningPopup) { pump(10, 400); // let the scene manager finish booting return; } // Small helper for this scene: pump a little so a dispatched input // event is processed + a couple of frames of reaction settle. const settle = (ms = 250) => pump(10, ms); switch (stage) { // ---- 0) park the ship by a rock, click the rock --------------------- case 0: { if (!s.asteroidClusters || s.asteroidClusters.length === 0) { fail('the seeded system has asteroid clusters'); return; } cluster = s.asteroidClusters[0]; rock = cluster.members[0]; s.ship.stop(); const sx = rock.wx - 190; s.ship.setPosition(sx, rock.wy); s.cameras.main.setScroll( (sx + rock.wx) / 2 - s.scale.width / 2, rock.wy - s.scale.height / 2, ); settle(200); press(rockScreen().x, rockScreen().y); stage = 1; return; } // ---- 1) the menu opened at the click --------------------------------- case 1: { const r = wait(() => s.miningPopup.isOpen === true, () => true, // a click either opens the menu or it never will null, 1500, 20000); if (r === null) return; if (!r) fail('a click on a rock opens the mining menu'); check('a click on a rock opens the mining menu', true); check('the primary button says MINE ASTEROIDS', s.miningPopup.primaryBtn.labelText.text === 'MINE ASTEROIDS'); check('the header names the cluster', s.miningPopup.headerText.text.toUpperCase().includes(String(cluster.discoveryName).toUpperCase())); check('the rock click did NOT fly the ship', s.ship.target === null); check('the rock click did NOT start mining', s.mining.state === 'idle'); press(openSpaceScreen().x, openSpaceScreen().y); // open space — outside the panel stage = 2; return; } // ---- 2) the outside click closed it, without flying ------------------ case 2: { const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('a click outside the menu closes it'); check('a click outside the menu closes it', true); check('the dismissing click does not fly the ship', s.ship.target === null); check('the dismissing click does not start mining', s.mining.state === 'idle'); press(rockScreen().x, rockScreen().y); // re-open stage = 3; return; } // ---- 3) press MINE ASTEROIDS ----------------------------------------- case 3: { const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('the menu re-opens on the rock'); check('the menu re-opens on the rock', true); const b = btnScreen(s.miningPopup.primaryBtn); press(b.x, b.y); stage = 4; return; } // ---- 4) extending: locked + the console call -------------------------- case 4: { const r = wait( () => s.mining.state === 'extending' || s.mining.state === 'mining', () => ['extending', 'mining'].includes(s.mining.state) || s.mining.state === 'idle', () => { const t = (s.consoleToastG || []).map((x) => x.text).join(' '); if (/extending mining arm/i.test(t)) sawExtendingToast = true; }, 1200, 60000); if (r === null) return; if (!r) fail('MINE ASTEROIDS starts the extension'); check('MINE ASTEROIDS starts the extension', true); check('the menu closed on the button press', s.miningPopup.isOpen === false); check('the ship is locked while the arm extends', s.ship.target === null && s.mining.isLocked === true); if (!sawExtendingToast) { // One more chance while the arm is still out (the toast holds the // whole extension window). const r2 = wait( () => s.mining.state === 'mining' || /extending mining arm/i.test((s.consoleToastG || []).map((x) => x.text).join(' ')), () => s.mining.state !== 'idle', null, 1200, 60000); if (r2 === null) { stage = 4; return; } sawExtendingToast = /extending mining arm/i.test((s.consoleToastG || []).map((x) => x.text).join(' ')) || sawExtendingToast; } check('the console says Extending Mining Arm...', sawExtendingToast); // A world click mid-extension holds (no fly, no abort). if (s.mining.state === 'extending') press(openSpaceScreen().x, openSpaceScreen().y); stage = 5; return; } // ---- 5) the reach completes into mining -------------------------------- case 5: { const r = wait(() => s.mining.state === 'mining', () => s.mining.state === 'extending' || s.mining.state === 'mining', null, 1200, 90000); if (r === null) return; if (!r) fail('the extension completes into mining'); check('the extension completes into mining', true); check('clicks held while the arm extends (no fly)', s.ship.target === null); check('the beam is live', !!s.mining.beam && (s.mining.beam.state === 'in' || s.mining.beam.state === 'steady')); check('the beam is locked to the clicked rock', !!s.mining.beam && s.mining.beam.member === rock && s.mining.member === rock); // Let the beam settle to steady + the ore stream build. const r2 = wait( () => !!s.mining.beam && s.mining.beam.state === 'steady' && s.mining.beam.particles.some((p) => p.active), () => !!s.mining.beam && s.mining.state === 'mining', null, 1200, 90000); if (r2 === null) return; if (!r2) fail('the beam settles and ore motes ride it'); check('the beam is live and steady', true); check('ore motes ride the beam toward the ship', true); press(rockScreen().x, rockScreen().y); // the beam target, again stage = 6; return; } // ---- 6) the target menu now offers STOP MINING ------------------------ case 6: { const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('the menu opens on the mining target'); check('the menu opens on the mining target', true); check('the primary button now says STOP MINING', s.miningPopup.primaryBtn.labelText.text === 'STOP MINING'); const b = btnScreen(s.miningPopup.primaryBtn); press(b.x, b.y); stage = 7; return; } // ---- 7) stop → retract -------------------------------------------------- case 7: { const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle', () => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000); if (r === null) return; if (!r) fail('STOP MINING retracts the beam'); check('STOP MINING retracts the beam', true); check('the ship is free again while it retracts', s.mining.isLocked === false); stage = 8; return; } // ---- 8) the beam is reaped and the arm is idle --------------------------- case 8: { const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, () => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000); if (r === null) return; if (!r) fail('the beam is reaped and the arm is idle'); check('the beam is reaped and the arm is idle', true); press(rockScreen().x, rockScreen().y); // mine again (ESC test) stage = 9; return; } // ---- 9) menu again; start a second mining ------------------------------- case 9: { const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('the menu opens again (ESC test)'); const b = btnScreen(s.miningPopup.primaryBtn); press(b.x, b.y); stage = 10; return; } // ---- 10) mining is live again; ESC breaks it ------------------------------ case 10: { const r = wait(() => s.mining.state === 'mining', () => ['extending', 'mining'].includes(s.mining.state), null, 1200, 90000); if (r === null) return; if (!r) fail('mining is live again (ESC test)'); check('mining is live again (ESC test)', true); esc(); stage = 11; return; } // ---- 11) the ESC stop landed ---------------------------------------------- case 11: { const r = wait(() => s.mining.state === 'retracting' || s.mining.state === 'idle', () => ['retracting', 'mining', 'idle'].includes(s.mining.state), null, 1200, 60000); if (r === null) return; if (!r) fail('ESC breaks the beam'); check('ESC breaks the beam', true); stage = 12; return; } // ---- 12) arm idle after the ESC stop --------------------------------------- case 12: { const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, () => ['retracting', 'idle'].includes(s.mining.state), null, 1200, 60000); if (r === null) return; if (!r) fail('the arm is idle after the ESC stop'); check('the arm is idle after the ESC stop', true); press(rockScreen().x, rockScreen().y); // menu again (ESC-closes test) stage = 13; return; } // ---- 13) ESC closes an open menu --------------------------------------------- case 13: { const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('the menu opens again (ESC-closes test)'); esc(); stage = 14; return; } // ---- 14) the menu closed; now CANCEL ------------------------------------------ case 14: { const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('ESC closes the open menu'); check('ESC closes the open menu', true); check('...and did not start mining', s.mining.state === 'idle'); press(rockScreen().x, rockScreen().y); stage = 15; return; } // ---- 15) CANCEL closes without mining -------------------------------------------- case 15: { const r = wait(() => s.miningPopup.isOpen === true, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('the menu opens again (CANCEL test)'); const c = btnScreen(s.miningPopup.cancelBtn); press(c.x, c.y); stage = 16; return; } // ---- 16) done ---------------------------------------------------------------------- case 16: { const r = wait(() => s.miningPopup.isOpen === false, () => true, null, 1500, 20000); if (r === null) return; if (!r) fail('CANCEL closes the menu'); check('CANCEL closes the menu', true); check('CANCEL does not start mining', s.mining.state === 'idle'); check('CANCEL does not fly the ship', s.ship.target === null); finish(results.every((x) => x.pass)); return; } } }; window.__MINING_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); } };