/** * 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); }); // 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 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))); // 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); // 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); // 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()));