orbit/dev/mining-test.mjs

621 lines
24 KiB
JavaScript
Raw Permalink 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 — 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);
}
};