orbit/dev/saves-ui-test.mjs

311 lines
14 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, and the hold: the minerals the
* save carried show on the ship AND the upper-right
* mineral readout (the load seam the HUD refresh guards).
*
* 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);
});
// A real mouse press at CANVAS screen coords (x, y). Phaser 4.2.1 binds
// mousedown/mouseup/mousemove on the canvas element, so a dispatched
// MouseEvent drives the full input pipeline (pointer → scene handler →
// interactive objects). FIT mode scales the canvas, so map canvas →
// client space with the rendered rect (not 1:1 pixels).
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 run = async () => {
const scene = game.scene.getScene('GameScene');
await wait(900); // boot: galaxy, system, deck, sub-bar, panel
// This box can starve the boot — poll until the scene is REALLY up
// (active + ship + HUD built) instead of trusting the fixed wait.
const bootT0 = Date.now();
while (!game.scene.isActive('GameScene') || !scene.ship || !scene.mineralHud) {
if (Date.now() - bootT0 > 90000) throw new Error('GameScene never came up');
await wait(200);
}
check('boots into GameScene', game.scene.isActive('GameScene'));
check('the sub-bar + panel exist', !!scene.menuSubBar && !!scene.savePanel);
// ---- the mineral hold (upper-right readout) ---------------------------
// FRESH GAME: the hold is empty and the HUD says exactly that (create()
// seeded the readout from the fresh ship's 0).
check('a fresh game: empty hold, HUD reads 0 / capacity',
scene.ship.minerals === 0 && scene.mineralHud.value === 0
&& scene.mineralHud.max === scene.ship.stats.mineralStorage
&& scene.mineralHud.label.text === scene.mineralHud.format(0));
// MINED: minerals aboard — the live seam (refreshMineralHud, what
// mining's onOre fires) pushes them into the readout, and the count-up
// (420 ms tween) settles on the exact value.
scene.ship.addMinerals(37);
scene.refreshMineralHud();
await wait(700);
check('the HUD reads the live hold (37 / capacity)',
scene.ship.minerals === 37 && scene.mineralHud.value === 37
&& scene.mineralHud.label.text === scene.mineralHud.format(37));
// ---- 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)));
// DOWNLOAD ALL SAVED GAMES fits INSIDE the window (right edge ≤ the
// plate's inner edge — the label outgrows the fixed 210 px box, so the
// button must be pinned by its actual width).
check('the DOWNLOAD ALL button fits inside the panel',
scene.savePanel.x + scene.savePanel.downloadBtn.x + scene.savePanel.downloadBtn.width / 2
<= scene.savePanel.x + scene.savePanel.W / 2 + 1);
// The header decodes run LONGER than the crack-open (decodeDur > openMs);
// once the window settles they must hold the FINAL strings — not the
// mid-scramble garbage the frozen drive left on screen.
await wait(1200);
check('the title decode completes (no mid-scramble garbage)',
scene.savePanel.title.text === String(config.get('save.panel.title.save', 'SAVE GAME')).toUpperCase());
check('the subtitle decode completes',
scene.savePanel.subtitle.text === String(config.get('save.panel.subtitle.save', '')).toUpperCase());
// A REAL pointer press on an empty slot saves the game — the whole
// click path (hit-test → SlotCard.press → onCard → doSave → bank), not
// a direct doSave() call.
const card2 = scene.savePanel.cards[1];
check('slot 2 is empty before the click', scene.saveManager.get(2) === null);
press(scene.savePanel.x + card2.x, scene.savePanel.y + card2.y);
await wait(250);
const rec2 = scene.saveManager.get(2);
check('a pointer click on slot 2 saves the game',
rec2 !== null && rec2.seed === scene.galaxy.seed && rec2.ship.x === scene.ship.x);
// 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);
check('the bank captured the hold (37)', rec1.ship.minerals === 37);
// 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[2].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);
// The HOLD rides the restore too — on the ship, and on the upper-right
// readout: applyRestore pushes the saved value through the HUD (the
// seam this test guards; without it the corner reads 0 until the next
// mining run). The count-up settles (420 ms) on the bank's value.
check('the restored hold is on the ship (37)', gs.ship.minerals === 37);
await wait(700);
check('the HUD reads the RESTORED hold (upper right)',
gs.mineralHud.value === 37
&& gs.mineralHud.label.text === gs.mineralHud.format(37));
// 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()));