/** * 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 — including the * SHIP'S STATE (js/entities/Ship.js): 'normal' by default, 'mining' while * the arm's sequence runs, and ANY movement (a world click or the compass * autopilot) drops it back to 'normal' and ends the sequence: * * 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": the ship is in 'mining' state, * holds station, console says "Extending..." * a world click meanwhile → MOVES the ship: the extension ABORTS, the * ship goes back to 'normal' and flies * mine again, the ~1.5 s → "mining": beam live, ore motes ride it, * reach the ship still 'mining' * the COMPASS AUTOPILOT → MOVES the ship: 'normal' again, the beam * retracts as the ship goes * mine again; ESC → breaks the beam, the ship is 'normal' * ESC with the menu open → closes it, no mining * click CANCEL → closes, no mining, ship 'normal' * * 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); }; // A screen point guaranteed to be OFF every rock of the cluster (the // members are scattered — a fixed point can land on one) AND outside the // mining panel's footprint (a click right after a button press that // lands on the panel area is consumed as the panel's own click). const openSpaceScreen = () => { const s = game.scene.getScene('GameScene'); const cam = s.cameras.main; const W = s.scale.width, H = s.scale.height; const p = s.miningPopup; const inPanel = (wx, wy) => { const r = p && p.rect; if (!r) return false; return wx >= r.x - 20 && wx <= r.x + r.w + 20 && wy >= r.y - 20 && wy <= r.y + r.h + 20; }; const cands = [ [W / 2, H / 2], [120, H / 2], [W - 120, H / 2], [W / 2, 120], [W / 2, H - 120], [120, 120], [W - 120, H - 120], ]; for (const [cx, cy] of cands) { const wx = cam.scrollX + cx, wy = cam.scrollY + cy; let clear = true; for (const m of (cluster ? cluster.members : [])) { if (Math.hypot(m.wx - wx, m.wy - wy) < m.radius + 40) { clear = false; break; } } // Far enough from the ship that the flight stays observable (a click // next to the ship arrives instantly and clears the target). const fromShip = Math.hypot(s.ship.x - wx, s.ship.y - wy) > 400; if (clear && fromShip && !inPanel(wx, wy)) return { x: cx, y: cy }; } return { x: 120, y: 120 }; // last resort (practically unreachable) }; // ---------------------------------------------------------------------- // 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, dbg: window.__DBG__STATE__ || null }; console.log(ok ? 'MINING PASS' : 'MINING FAIL'); }; // ---------------------------------------------------------------------- // Diagnostics: the state machine can stash snapshots here; finish() // ships them back in window.__MINING__.dbg. // ---------------------------------------------------------------------- const dbg = (window.__DBG__STATE__ = {}); const snapshot = (tag) => { try { const s = scene(); const arr = (dbg[tag] = dbg[tag] || []); arr.push({ t: Math.round(s.time.now), mining: s.mining.state, ship: s.ship.state, target: s.ship.target ? [Math.round(s.ship.target.x), Math.round(s.ship.target.y)] : null, xy: [Math.round(s.ship.x), Math.round(s.ship.y)], scroll: [Math.round(s.cameras.main.scrollX), Math.round(s.cameras.main.scrollY)], popup: s.miningPopup ? s.miningPopup.state : null, stamp: s.miningPopup ? s.miningPopup.closedByButtonAt : null, }); if (arr.length > 30) arr.shift(); } catch { /* booting */ } }; const scene = () => game.scene.getScene('GameScene'); const fail = (label) => { check(label, false); finish(false); }; /** Park the ship by the rock and frame it (the test's teleport). */ const parkNearRock = () => { const s = scene(); 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, ); }; // The camera eases onto the ship (GameScene.updateCamera) — after a park // or a flight it keeps drifting for a while. Pump until it settles so a // rock's screen position is stable when we compute the next click. const settleCamera = (maxBatches = 60) => { let last = null; let still = 0; for (let i = 0; i < maxBatches; i++) { pump(6, 80); try { const cam = scene().cameras.main; const cur = [cam.scrollX, cam.scrollY]; if (last) { if (Math.abs(cur[0] - last[0]) < 0.5 && Math.abs(cur[1] - last[1]) < 0.5) { still++; if (still >= 3) return true; } else still = 0; } last = cur; } catch { /* booting */ } } return 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]; parkNearRock(); settleCamera(); 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'); check('the ship is in its NORMAL state', s.ship.state === 'normal'); 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 in the MINING state while the arm extends', s.ship.state === 'mining'); check('the ship holds station while the arm extends', s.ship.target === null); 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 MOVES the ship — which ends the // mining state (the new contract: movement beats the arm's reach). if (s.mining.state === 'extending') { const p5 = openSpaceScreen(); dbg.stage4press = p5; press(p5.x, p5.y); } stage = 5; return; } // ---- 5) the movement broke the arm; set up the real attempt ---------- case 5: { // If the "open space" click landed on another member of the cluster // (the members are scattered), it opened the menu instead of flying // — close it and click true open space. if (s.miningPopup.isOpen) { esc(); settle(150); const p = openSpaceScreen(); dbg.stage5repress = p; press(p.x, p.y); } const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, () => ['extending', 'mining', 'retracting', 'idle'].includes(s.mining.state), () => snapshot('stage5'), 1200, 90000); if (r === null) return; if (!r) fail('a world click breaks the arm (abort or retract)'); check('a world click breaks the arm (abort or retract)', true); check('the beam is gone (aborted, or retracted and reaped)', s.mining.beam === null); check('the ship is back in the NORMAL state', s.ship.state === 'normal'); check('the breaking click flew the ship', s.ship.target !== null); // Set up the next attempt: park by the rock, let the camera settle, // menu, MINE. parkNearRock(); settleCamera(); dbg.stage6press = rockScreen(); press(rockScreen().x, rockScreen().y); stage = 6; return; } // ---- 6) menu again; start mining for real ------------------------------ case 6: { const r = wait(() => s.miningPopup.isOpen === true, () => true, () => snapshot('stage6'), 1500, 20000); if (r === null) return; if (!r) { const pp = dbg.stage6press || rockScreen(); dbg.stage6 = { press: pp, rockHit: !!s.rockAt(rock.wx, rock.wy), rockScreenNow: rockScreen(), savePanelOpen: !!(s.savePanel && s.savePanel.isOpen), subbarOpen: !!(s.menuSubBar && s.menuSubBar.isOpen), actionbarHit: !!(s.actionBar && s.actionBar.contains(pp.x, pp.y)), compassHit: !!s.compass.contains(pp.x, pp.y), titleHit: !!s.hudTitleContains(pp.x, pp.y), popupState: s.miningPopup ? s.miningPopup.state : null, miningState: s.mining.state, shipTarget: s.ship.target, }; fail('the menu re-opens (mine-for-real test)'); } const b = btnScreen(s.miningPopup.primaryBtn); press(b.x, b.y); stage = 7; return; } // ---- 7) the reach completes into mining -------------------------------- case 7: { const r = wait(() => s.mining.state === 'mining', () => ['extending', 'mining'].includes(s.mining.state), null, 1200, 90000); if (r === null) return; if (!r) fail('the extension completes into mining'); check('the extension completes into mining', true); check('the ship is still in the MINING state', s.ship.state === 'mining'); 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); stage = 8; return; } // ---- 8) beam steady + ore; then the AUTOPILOT moves the ship ----------- case 8: { 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); check('the ship is in the MINING state while the beam is live', s.ship.state === 'mining'); // The compass autopilot seam — the player sends the ship elsewhere // (a different discovered object — the mining cluster's rim is right // next to the ship and the flight would "arrive" before it's // observable): movement must drop the ship back to 'normal' and end // the mining. const objs = s.discoverableObjects(); const other = objs.find((o) => o.id !== cluster.discoveryId) || objs[0]; dbg.autopilotTarget = { id: other.id, distFromShip: Math.round(Math.hypot(other.x - s.ship.x, other.y - s.ship.y)), }; s.autopilotTo(other.id); stage = 9; return; } // ---- 9) the autopilot break: normal state, beam reaped ------------------ case 9: { const r = wait(() => s.mining.state === 'idle' && s.mining.beam === null, () => ['mining', 'retracting', 'idle'].includes(s.mining.state), null, 1200, 90000); if (r === null) return; if (!r) fail('the autopilot ends the mining and reaps the beam'); check('the autopilot ends the mining and reaps the beam', true); check('the ship is back in the NORMAL state', s.ship.state === 'normal'); check('the autopilot flew the ship', s.ship.target !== null); parkNearRock(); settleCamera(); press(rockScreen().x, rockScreen().y); // mine again (ESC test) stage = 10; return; } // ---- 10) menu again; start a third mining --------------------------------- case 10: { 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 = 11; return; } // ---- 11) mining is live again; ESC breaks it -------------------------------- case 11: { 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); check('the ship is in the MINING state', s.ship.state === 'mining'); esc(); stage = 12; return; } // ---- 12) the ESC stop landed ------------------------------------------------ case 12: { 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 = 13; return; } // ---- 13) arm idle after the ESC stop; the ship is NORMAL again ------------ case 13: { 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); check('the ship is back in the NORMAL state', s.ship.state === 'normal'); press(rockScreen().x, rockScreen().y); // menu again (ESC-closes test) stage = 14; return; } // ---- 14) ESC closes an open menu -------------------------------------------- case 14: { 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 = 15; return; } // ---- 15) the menu closed; now CANCEL ---------------------------------------- case 15: { 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'); check('the ship is in the NORMAL state', s.ship.state === 'normal'); press(rockScreen().x, rockScreen().y); stage = 16; return; } // ---- 16) CANCEL closes without mining --------------------------------------- case 16: { 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 = 17; return; } // ---- 17) done ----------------------------------------------------------------- case 17: { 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); check('the ship is in the NORMAL state', s.ship.state === 'normal'); 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); } };