orbit/dev/saves-ui-test.mjs

225 lines
10 KiB
JavaScript

/**
* Save-system UI integration driver (headless browser — NOT a Node test).
*
* Drives the REAL game through the full save flow and checks every seam:
*
* GameScene: MENU button → sub-bar folds up → Load grayed (empty bank)
* → Save Game → the 10-slot pop-up (save mode)
* → doSave(1) writes the bank
* → overwrite confirm opens, CANCEL leaves the save intact
* → the panel reopens in LOAD mode, empty slots inert
* → LOAD confirm → the restore stages → cut to the menu
* MenuScene: Load Game is live (the bank has a save) → its own load
* pop-up → LOAD confirm → the staged restore rides into a
* fresh GameScene — ship back where it was, the session
* time, the tether field.
*
* Served by dev/saves-ui-test.html; the results land in
* `window.__SAVES_UI__` for the CDP runner (dev/cdp-firefox.mjs):
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/saves-ui-test.html
*/
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 { MenuScene } from '../js/scenes/MenuScene.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
// A deterministic galaxy (same system every run) + a quiet run (no audio
// files to fetch in headless).
globalThis.__ORBIT_DEV_SEED = 'SAVETEST';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, MenuScene]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
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}`);
};
// WAIT — throttling-proof. This headless box starves setTimeout and
// stretches its compositor clock, so the only honest barrier is the
// GAME'S OWN CLOCK: Phaser v4 sets scene time.now from the engine loop
// time (shared by all scenes, monotonic across scene transitions). We
// poll it on rAF until it has advanced past the base.
const gameClock = () => {
try {
const s = window.game.scene.getScenes(true)[0];
if (s && typeof s.time.now === 'number') return s.time.now;
} catch {}
return null;
};
const wait = (ms) => new Promise((resolve) => {
const base = gameClock();
if (base === null) { // pre-boot: fall back to wall clock
const start = performance.now();
setTimeout(() => resolve(), ms);
return;
}
const poll = () => {
const now = gameClock();
if (now !== null && now - base >= ms) return resolve();
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
});
const run = async () => {
const scene = game.scene.getScene('GameScene');
await wait(900); // boot: galaxy, system, deck, sub-bar, panel
check('boots into GameScene', game.scene.isActive('GameScene'));
check('the sub-bar + panel exist', !!scene.menuSubBar && !!scene.savePanel);
// ---- the sub-bar folds up out of MENU --------------------------------
scene.menuAction();
await wait(450);
check('MENU folds the sub-bar up', scene.menuSubBar.isOpen);
// Every button must sit INSIDE the bar (and the canvas) — the body is
// drawn about the container origin, so the row must be centered on it.
check('all sub-bar buttons are inside the bar and the canvas',
scene.menuSubBar.buttons.every((s) => {
const bar = scene.menuSubBar;
const wx = bar.x + s.btn.x;
const half = s.btn.style.width / 2;
return Math.abs(s.btn.x) <= bar.W / 2 + 1
&& wx - half >= -1 && wx + half <= scene.scale.width + 1;
}));
check('Load Game is grayed while the bank is empty',
scene.menuSubBar.buttons.find((b) => b.id === 'load').btn.disabled === true);
// ---- the pop-up: SAVE mode --------------------------------------------
scene.subBarAction('save');
await wait(450);
check('Save Game opens the 10-slot pop-up (save mode)',
scene.savePanel.isOpen && scene.savePanel.mode === 'save' && scene.savePanel.cards.length === 10);
// Geometry sanity: the panel and every card/footer button must sit at
// FINITE positions inside the canvas (NaN y = the use-before-assign bug).
check('panel + cards + footer have finite positions inside the canvas',
Number.isFinite(scene.savePanel.W) && Number.isFinite(scene.savePanel.H)
&& scene.savePanel.cards.every((c) => {
const x = scene.savePanel.x + c.x;
const y = scene.savePanel.y + c.y;
return Number.isFinite(x) && Number.isFinite(y)
&& x >= -1 && x <= scene.scale.width + 1 && y >= -1 && y <= scene.scale.height + 1;
})
&& [scene.savePanel.cancelBtn, scene.savePanel.downloadBtn].every((b) =>
Number.isFinite(scene.savePanel.x + b.x) && Number.isFinite(scene.savePanel.y + b.y)));
// A direct write to slot 1 (the same path the confirm dialog uses).
scene.savePanel.doSave(1);
await wait(120);
const rec1 = scene.saveManager.get(1);
check('doSave(1) writes the bank', rec1 !== null && rec1.seed === scene.galaxy.seed && rec1.ship.x === scene.ship.x);
// Capture fidelity: the bank's session time is the scene's at capture
// (a frame or two may have ticked since — small tolerance).
check('the bank captured the session time',
rec1.playTimeMs > 0 && rec1.playTimeMs <= Math.round(scene.playTimeMs) + 200 && rec1.playTimeMs >= Math.round(scene.playTimeMs) - 400);
// ---- overwrite confirm: open, CANCEL, the save is intact --------------
scene.savePanel.confirmOverwrite(1, rec1);
await wait(300);
check('overwrite raises the confirm dialog', scene.savePanel.confirm.isOpen);
const before = JSON.stringify(scene.saveManager.get(1));
scene.savePanel.confirm.cancel();
await wait(300);
check('CANCEL closes the dialog', scene.savePanel.confirm.isOpen === false);
check('CANCEL left the slot intact', JSON.stringify(scene.saveManager.get(1)) === before);
// ---- the pop-up: LOAD mode --------------------------------------------
scene.savePanel.close();
await wait(350);
scene.savePanel.show('load');
await wait(450);
check('the panel reopens in LOAD mode', scene.savePanel.isOpen && scene.savePanel.mode === 'load');
check('empty slots are inert in load mode',
scene.savePanel.cards[1].disabled === true && scene.savePanel.cards[0].disabled === false);
// ---- load confirm: stage the restore, cut to the menu -----------------
const shipX = scene.ship.x;
const shipY = scene.ship.y;
scene.savePanel.confirmLoad(1, scene.saveManager.get(1));
await wait(300);
scene.savePanel.confirm.fireConfirm();
await wait(700); // close anim → onLoadComplete → scene.start
check('a confirmed LOAD cuts to the main menu', game.scene.isActive('MenuScene'));
// ---- the menu side ------------------------------------------------------
const menu = game.scene.getScene('MenuScene');
await wait(400);
check('the menu shows a (live) Load Game button',
!!menu.loadGameBtn && menu.loadGameBtn.disabled === false);
menu.openLoadPanel();
await wait(450);
check('the menu opens the load pop-up', menu.savePanel.isOpen && menu.savePanel.mode === 'load');
menu.savePanel.confirmLoad(1, menu.saveManager.get(1));
await wait(300);
menu.savePanel.confirm.fireConfirm();
await wait(1100); // close anim → cut → GameScene.create (galaxy + restore)
check('the game is back from the menu', game.scene.isActive('GameScene'));
const gs = game.scene.getScene('GameScene');
const rec = gs.saveManager.get(1);
check('the ship is back where the save parked it',
rec && Math.abs(gs.ship.x - rec.ship.x) < 0.01 && Math.abs(gs.ship.y - rec.ship.y) < 0.01);
check('the saved ship was where it had flown', Math.abs(rec.ship.x - shipX) < 0.01 && Math.abs(rec.ship.y - shipY) < 0.01);
// Restore fidelity: the restored value is the BANK's value (rec1), plus
// the session time accumulated since GameScene.create (the wait above +
// a few frames). update() caps per-frame accumulation at 100ms
// (anti-fast-forward), which matters on throttled headless frames — so
// the upper bound is generous; the lower bound is exact (restore is a
// hard set, accumulation only adds).
const restoredT = gs.playTimeMs;
check(`the session time restored (bank=${rec1.playTimeMs}ms, now=${Math.round(restoredT)}ms)`,
restoredT >= rec1.playTimeMs && restoredT <= rec1.playTimeMs + 4000);
check('the tether field restored', gs.tetherField.tethers.length === (rec.tethers ?? []).length && gs.tetherField.tethers.length >= 1);
check('discovery survived (registry hand-off)', gs.discovery instanceof Object && gs.discovery.distance > 0);
// The sub-bar again after the load: Load Game is live now.
scene.menuAction?.call(gs);
await wait(450);
check('after the load, Load Game is live in the sub-bar',
gs.menuSubBar.isOpen && gs.menuSubBar.buttons.find((b) => b.id === 'load').btn.disabled === false);
};
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.__SAVES_UI__ = { pass, results };
done = true;
console.log(`SAVES-UI ${pass ? 'PASS' : 'FAIL'} (${results.filter((r) => r.pass).length}/${results.length})`);
});
// Hard stop: if the flow never finishes, the runner will report the
// in-flight results as a failure. (rAF + monotonic clock — the same
// throttling-proof primitive as wait().)
const hardStop = (t0) => {
if (performance.now() - t0 >= 300000) {
if (!done) {
window.__SAVES_UI__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] };
console.log('SAVES-UI FAIL (timed out)');
}
return;
}
requestAnimationFrame(() => hardStop(t0));
};
requestAnimationFrame(() => hardStop(performance.now()));