472 lines
18 KiB
JavaScript
472 lines
18 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
};
|