311 lines
14 KiB
JavaScript
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()));
|