orbit/dev/mining-test.mjs

472 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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