orbit/dev/hold-restore.mjs

163 lines
6.8 KiB
JavaScript

/**
* The mineral HOLD across game entry points — the upper-right readout
* (js/ui/MineralHud.js) must show the player's current inventory in all
* three cases the player can enter the game:
*
* 1. NEW GAME — the hold is empty; the HUD reads 0 / capacity.
* 2. MINE — minerals come aboard; the live seam (mining's onOre →
* GameScene.refreshMineralHud) pushes them to the readout.
* 3. CONTINUE / LOAD — the save carries the hold (captureState),
* prepareLoad stages it (the exact seam both menu buttons use),
* and the fresh GameScene's applyRestore must land it BOTH on the
* ship AND the upper-right HUD. (The regression this guards: the
* HUD was seeded with the fresh ship's 0 in create() and never
* followed the restored value — the corner read 0 until the next
* mining run.)
*
* Two-scene harness: the real entry flow SWITCHES scenes (game → menu →
* game) — and this Phaser build's scene.start is a no-op on the scene
* that is already active — so a bare DummyMenu scene stands in for the
* real MenuScene. The data seams are driven directly:
* captureState → prepareLoad → scene.start('DummyMenu') →
* scene.start('GameScene') → consumeRestore → applyRestore.
* (The full UI version of this flow lives in dev/saves-ui-test.mjs.)
*
* Served by dev/hold-restore.html; results land in
* `window.__HOLD_RESTORE__` for the CDP runner:
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/hold-restore.html \
* 'return window.__HOLD_RESTORE__;'
*/
import Phaser from '../js/vendor/phaser.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { config } from '../js/config/Config.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { captureState, prepareLoad } from '../js/save/SaveData.js';
// The real MenuScene's job in this flow is to be the OTHER side of the
// scene switch — a bare scene suffices (no art, no logic).
class DummyMenu extends Phaser.Scene {
constructor() { super('DummyMenu'); }
}
const data = await ConfigLoader.load();
config.init(data);
globalThis.__ORBIT_DEV_SEED = 'HOLDTEST'; // deterministic galaxy
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, DummyMenu]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; // quiet run
const game = new Phaser.Game(gameConfig);
window.game = game;
const results = [];
const check = (label, cond) => {
const pass = !!cond;
results.push({ label, pass });
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
};
const run = async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitUntil = async (fn, label, budgetMs = 180000) => {
const t = Date.now();
while (Date.now() - t < budgetMs) {
if (fn()) return true;
await sleep(250);
}
throw new Error(
`${label} (budget ${budgetMs}ms) — page errors: ${JSON.stringify((window.__BOOT_ERRORS__ || []).slice(0, 4))}`,
);
};
// Wait for boot — poll for the scene's UI to exist (throttling-proof;
// this box starves fixed timeouts).
let s = null;
await waitUntil(() => {
s = game.scene.getScene('GameScene');
return !!s && !!s.commsPanel && !!s.ship && !!s.mineralHud;
}, 'boot', 180000);
check('boots into GameScene with the HUD', !!s && !!s.ship && !!s.mineralHud);
if (!s || !s.ship) throw new Error('scene never came up');
// ---- 1. NEW GAME -----------------------------------------------------
const cap = s.ship.stats.mineralStorage;
check(`new game: the hold is empty (ship 0, HUD 0 / ${cap})`,
s.ship.minerals === 0 && s.mineralHud.value === 0 && s.mineralHud.max === cap);
// ---- 2. MINE ----------------------------------------------------------
// The live path: mining's onOre seam fires GameScene.refreshMineralHud
// — drive the same seam directly (the beam itself is covered by the
// mining tests).
s.ship.addMinerals(37);
s.refreshMineralHud();
await sleep(700); // the 420 ms count-up settles
check(`mined: the HUD reads the live hold (37 / ${cap})`,
s.ship.minerals === 37 && s.mineralHud.value === 37
&& s.mineralHud.label.text === s.mineralHud.format(37));
// ---- 3. CONTINUE / LOAD ----------------------------------------------
// captureState = what the Save panel writes to the bank; prepareLoad =
// the exact seam Continue (newest save) and Load Game (slot) both run;
// then the flow SWITCHES game → menu → game. Reproduce that switch
// through DummyMenu (the real MenuScene's role in the flow): scene
// instances are singletons, so the recreate re-runs GameScene.create()
// on the SAME object and consumeRestore() picks up the staged state.
// (The GameScene shutdown here also exercises the entity destroy path
// — JumpGate/Station — the v4 removeAll() migration.)
const rec = captureState(s);
check('the save record carries the hold (37)', rec.ship.minerals === 37);
prepareLoad(s.registry, rec);
game.scene.start('DummyMenu');
await waitUntil(
() => game.scene.isActive('DummyMenu') && !game.scene.isActive('GameScene'),
'switch to the menu (GameScene shutdown)',
);
game.scene.start('GameScene');
const gs = game.scene.getScene('GameScene');
await waitUntil(
() => game.scene.isActive('GameScene') && gs.ship && gs.mineralHud,
'recreate GameScene from the staged restore',
);
check('the game is back from the restore (GameScene recreated)',
game.scene.isActive('GameScene') && !!gs.ship);
check('the restored hold is on the ship (37)', gs.ship.minerals === 37);
check('the restored value landed on the HUD immediately (value 37)',
gs.mineralHud.value === 37); // set() is immediate; only the label animates
await sleep(700); // the count-up settles
check('the upper-right HUD reads the RESTORED hold (37 / capacity)',
gs.mineralHud.value === 37
&& gs.mineralHud.label.text === gs.mineralHud.format(37));
};
let done = false;
game.events.once('ready', async () => {
try {
await run();
} catch (err) {
console.error(err);
results.push({ label: `DRIVER CRASHED: ${err.message}`, pass: false });
}
const pass = results.length > 0 && results.every((r) => r.pass);
window.__HOLD_RESTORE__ = { pass, results };
done = true;
console.log(`HOLD-RESTORE ${pass ? 'PASS' : 'FAIL'} (${results.filter((r) => r.pass).length}/${results.length})`);
});
// Hard stop so a hung flow can't hang the runner.
const hardStop = (t0) => {
if (performance.now() - t0 >= 600000) {
if (!done) {
window.__HOLD_RESTORE__ = { pass: false, results: [...results, { label: 'TIMED OUT (600s)', pass: false }] };
console.log('HOLD-RESTORE FAIL (timed out)');
}
return;
}
requestAnimationFrame(() => hardStop(t0));
};
requestAnimationFrame(() => hardStop(performance.now()));