Restore in-place on confirmed load instead of cutting to the menu

- A confirmed LOAD used to hand off to the main menu, relying on the
  menu's New Game to apply the staged restore; that path resets run
  state, so the load never carried the restore through.
- GameScene and SurfaceScene now add reloadRun(): close the save
  panel and sub-bar, then restart (GameScene) or re-start (SurfaceScene)
  the game scene so the fresh create() picks up the staged restore —
  the ship lands back exactly where it was saved.
- saves-ui-test now asserts the in-place restore (ship back at the
  saved position) and covers the Return to Main Menu path.
- New CDP probe/repro scripts in dev/ drive the real index.html in
  headless Chrome to exercise the save/load user flow and diagnose
  input and confirm-geometry issues.
This commit is contained in:
Brian Fertig 2026-09-18 12:13:34 -06:00
parent d3fadcf6a5
commit 1a12fa5e1a
14 changed files with 993 additions and 16 deletions

41
dev/cdp-chrome.mjs Normal file
View File

@ -0,0 +1,41 @@
// Run a saves-ui-test page in headless Chrome (CDP) and print the result.
// node dev/cdp-chrome.mjs <url> [timeoutMs=240000]
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const [url, timeoutMs = '240000'] = process.argv.slice(2);
const t0 = Date.now();
const { url: wsUrl, kill } = await launch(url);
const cdp = new CDP(wsUrl);
try {
await cdp.open();
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
let out = null;
const poll = async () => {
try {
const r = await cdp.send('Runtime.evaluate', { expression: 'window.__SAVES_UI__', returnByValue: true });
if (r.result && r.result.value) out = r.result.value;
} catch { /* page still booting */ }
};
while (Date.now() - t0 < Number(timeoutMs)) {
await poll();
if (out) break;
await sleep(500);
}
if (!out) {
// Dump captured console errors to help diagnose.
try {
const r = await cdp.send('Runtime.evaluate', { expression: 'JSON.stringify({errors: window.__CAPTURED_ERRORS__, logs: (window.__CAPTURED_LOGS__||[]).slice(-30)})', returnByValue: true });
console.log('PAGE STATE:\n', r.result && r.result.value);
} catch {}
console.log('TIMEOUT — __SAVES_UI__ never set');
process.exitCode = 2;
} else {
console.log(JSON.stringify(out, null, 2));
if (!out.pass) process.exitCode = 1;
}
} finally {
cdp.close();
kill();
}

95
dev/confirm-geom.mjs Normal file
View File

@ -0,0 +1,95 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const toClient = (x, y) =>
`(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(70);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(70);
}
const clickGame = async (x, y) => { const [cx, cy] = await ev(toClient(x, y)); await click(cx, cy); };
const log = (...a) => console.log(...a);
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
await sleep(4600);
// seed a save first via direct call (faster) so the LOAD button is enabled
await ev(`(() => { const gs = window.game.scene; const m = gs.getScene('MenuScene'); return true; })()`);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await clickGame(mb[0], mb[1]);
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
await sleep(1500);
// ack comms until deck live
async function deckLive() {
return await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const s = gs.actionBar.slots.find((x) => x.id === 'menu'); return !!(s && s.panel.input && s.panel.input.enabled); })()`);
}
for (let i = 0; i < 20 && !(await deckLive()); i++) {
const btn = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const b = gs.comms?.box; return b && b.button ? [b.x + b.button.x, b.y + b.button.y] : null; })()`);
if (btn) { await clickGame(btn[0], btn[1]); }
await sleep(1200);
}
log('deck live:', await deckLive());
// MENU open
const menuSlot = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const s = gs.actionBar.slots.find((s) => s.id === 'menu'); return [s.slot.x, s.slot.y]; })()`);
await clickGame(menuSlot[0], menuSlot[1]);
await sleep(700);
const sub = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y]) : null; })()`);
log('sub-bar:', JSON.stringify(sub));
// SAVE to slot 3 first (so LOAD has data)
const saveBtn = sub.find((b) => b[0] === 'save');
await clickGame(saveBtn[1], saveBtn[2]);
await sleep(1400);
const cardS = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const c = gs.savePanel.cards[2]; return [gs.savePanel.x + c.x, gs.savePanel.y + c.y]; })()`);
await clickGame(cardS[0], cardS[1]);
await sleep(900);
log('slot3 saved:', await ev(`!!window.game.scene.getScene('GameScene').saveManager.get(3)`));
const cancelS = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const b = gs.savePanel.cancelBtn; return [gs.savePanel.x + b.x, gs.savePanel.y + b.y]; })()`);
await clickGame(cancelS[0], cancelS[1]);
await sleep(900);
// reopen MENU -> Load (sub-bar may still be open from the save step)
const subbarState = await ev(`window.game.scene.getScene('GameScene').menuSubBar?.isOpen`);
if (!subbarState) { await clickGame(menuSlot[0], menuSlot[1]); await sleep(700); }
const sub2 = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y]) : null; })()`);
if (!sub2) throw new Error('sub-bar not open');
const loadBtn = sub2.find((b) => b[0] === 'load');
await clickGame(loadBtn[1], loadBtn[2]);
await sleep(1400);
log('panel state:', await ev(`window.game.scene.getScene('GameScene').savePanel.state`));
// click slot 3 card
const card = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const c = gs.savePanel.cards[2]; return [gs.savePanel.x + c.x, gs.savePanel.y + c.y, c.disabled]; })()`);
log('card:', JSON.stringify(card));
await clickGame(card[0], card[1]);
await sleep(900);
// DUMP confirm overlay geometry chain
const geom = await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
const c = gs.savePanel.confirm;
const info = (o) => ({ x: o.x, y: o.y, w: o.width ?? null, h: o.height ?? null, input: o.input ? o.input.enabled : null });
return {
panel: { x: gs.savePanel.x, y: gs.savePanel.y },
confirm: info(c),
confirmBtn: info(c.confirmBtn),
cancelBtn: info(c.cancelBtn),
scrim: info(c.scrim),
confirmState: c.state,
};
})()`);
console.log(JSON.stringify(geom, null, 1));
cdp.close();
kill();

76
dev/enum-probe.mjs Normal file
View File

@ -0,0 +1,76 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const toClient = (x, y) =>
`(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(70);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(70);
}
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('no menu');
await sleep(4600);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
await sleep(1200);
// Enumerate interactive objects of the ACTIVE scene, hit-test at the MENU slot point.
const dump = await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
const plugin = gs.sys.input;
const list = plugin._list ?? [];
const p = { x: 1143.33, y: 680 };
const out = [];
for (const o of list) {
let hit = null;
try {
if (o.input && o.input.enabled) {
const local = o.getWorldTransformMatrix ? o.getWorldTransformMatrix() : null;
// use the manager's own candidate test if available
}
} catch (e) {}
out.push({
type: o.type || o.constructor?.name,
name: o.name || '',
depth: o.depth,
pos: [o.x, o.y],
w: o.width ?? null,
h: o.height ?? null,
enabled: o.input ? o.input.enabled : null,
hasHitArea: o.input ? !!o.input.hitArea : false,
hitArea: o.input && o.input.hitArea ? { x: o.input.hitArea.x, y: o.input.hitArea.y, w: o.input.hitArea.width, h: o.input.hitArea.height } : null,
});
}
// hit-test via the plugin
let under = null;
try {
const pointer = { x: 1143.33, y: 680, worldX: 1143.33, worldY: 680 };
const hits = plugin.hitTestPointer ? plugin.hitTestPointer({ x: 1143.33, y: 680 }) : null;
under = hits ? hits.map((h) => (h.type || h.constructor?.name)) : 'no hitTestPointer';
} catch (e) { under = 'ERR ' + e.message; }
const compact = out.map((o) => [o.type, '@' + Math.round(o.pos[0]) + ',' + Math.round(o.pos[1]), 'd' + o.depth, 'en=' + o.enabled, o.hitArea ? ('ha=' + Math.round(o.hitArea.x) + ',' + Math.round(o.hitArea.y) + ',' + Math.round(o.hitArea.w) + 'x' + Math.round(o.hitArea.h)) : 'ha=-', o.name || ''].join(' '));
return { count: list.length, under, compact };
})()`);
console.log('UNDER POINTER:', JSON.stringify(dump.under));
console.log('count:', dump.count);
for (const line of dump.compact) console.log(' ', line);
cdp.close();
kill();

73
dev/hit-probe.mjs Normal file
View File

@ -0,0 +1,73 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('no menu');
await sleep(4200);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
const toClient = (x, y) => `(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(80);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(80);
}
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
await sleep(1000);
console.log(await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
const s = gs.actionBar.slots.find((x) => x.id === 'menu');
const inp = s.panel.input;
const ha = inp.hitArea;
return {
panelPos: [s.panel.x, s.panel.y],
slotPos: [s.slot.x, s.slot.y],
scrollFactor: [s.panel.scrollFactorX, s.panel.scrollFactorY],
enabled: inp.enabled,
interactive: !!inp,
hitArea: ha ? { x: ha.x, y: ha.y, w: ha.width, h: ha.height } : null,
styleBW: gs.actionBar.style.bw,
styleBH: gs.actionBar.style.bh,
activePointers: window.game.input.activePointer ? { x: window.game.input.activePointer.x, y: window.game.input.activePointer.y } : null,
worldPoint: null,
};
})()`));
// Now hover via CDP mouse move to the slot and see if pointerover fires
await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const s = gs.actionBar.slots.find((x) => x.id === 'menu'); window.__over = 0; s.panel.on('pointerover', () => window.__over++); window.__down = 0; s.panel.on('pointerdown', () => window.__down++); return true; })()`);
const [cx, cy] = await ev(toClient(1143.33, 680));
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(300);
console.log('hover count:', await ev(`window.__over`));
// check the pointer world coords phaser sees
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx - 5, y: cy });
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(200);
console.log('phaser pointer:', await ev(`(() => { const p = window.game.input.activePointer; return { x: p.x, y: p.y, worldX: p.worldX, worldY: p.worldY, position: [p.position.x, p.position.y] }; })()`));
// hit test manually
console.log(await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
const s = gs.actionBar.slots.find((x) => x.id === 'menu');
const p = window.game.input.activePointer;
try { return { hit: s.panel.input.hitTest(p) }; } catch(e) { return 'ERR ' + e.message; }
})()`));
cdp.close(); kill();

51
dev/input-probe.mjs Normal file
View File

@ -0,0 +1,51 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const toClient = (x, y) => `(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(80);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(80);
}
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('no menu');
await sleep(4200);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
await sleep(1000);
const [cx, cy] = await ev(toClient(1143.33, 680));
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(250);
console.log('input keys:', await ev(`Object.keys(window.game.input).join(', ')`));
console.log('input own:', await ev(`Object.getOwnPropertyNames(Object.getPrototypeOf(window.game.input)).join(', ')`));
console.log('interactiveObjects-ish:', await ev(`(() => { const i = window.game.input; return { iO: typeof i.interactionObjects, objs: typeof i.objects, list: typeof i.list, active: typeof i.activePointers, capture: i.captureTarget ? true : false }; })()`));
console.log(JSON.stringify(await ev(`(() => {
const i = window.game.input;
const list = i.interactionObjects ?? i.objects ?? i._objects ?? null;
if (!list) return 'no list found';
const p = i.activePointer ?? i.activePointers?.[0];
const out = [];
for (const o of list) {
try { if (o.hitArea && o.hitTest(p)) out.push(o.name || o.type); } catch (e) {}
}
return { count: list.length, under: out };
})()`), null, 1));
cdp.close(); kill();

190
dev/load-repro.mjs Normal file
View File

@ -0,0 +1,190 @@
// Reproduce the user's "Load Game after a started game" flow with REAL
// CDP input events on the actual index.html.
//
// node dev/load-repro.mjs [step=full]
//
// Steps:
// 1. load index.html, wait for the menu
// 2. click "New Game" -> GameScene boots
// 3. MENU -> sub-bar -> Save Game -> click slot 3 -> close (CANCEL)
// 4. fly the ship a bit (WASD held)
// 5. MENU -> sub-bar -> Load Game -> click slot 3 -> click LOAD (confirm)
// 6. report which scene is active; if Menu: click Load Game -> slot 3 -> confirm
// 7. report final scene + ship position vs the save
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const URL = process.argv[2] || 'http://localhost:8080/index.html';
const { url: wsUrl, kill } = await launch(URL, undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
const errors = [];
cdp.on('Runtime.consoleAPICalled', (p) => {
const txt = (p.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ');
if (p.type === 'error' || p.type === 'warning') errors.push(`${p.type}: ${txt}`.slice(0, 400));
});
cdp.on('Runtime.exceptionThrown', (p) => {
errors.push('EXCEPTION: ' + (p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? '').slice(0, 400));
});
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
// Real mouse click in CLIENT coords (the canvas rect space).
async function click(clientX, clientY) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: clientX, y: clientY });
await sleep(60);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: clientX, y: clientY, button: 'left', clickCount: 1 });
await sleep(90);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: clientX, y: clientY, button: 'left', clickCount: 1 });
await sleep(60);
}
// Map a point in the game's 1280x720 design space to client (screen) coords.
const toClient = (x, y) =>
`(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function clickGame(x, y) {
const [cx, cy] = await ev(toClient(x, y));
console.log(` click game(${x},${y}) -> client(${cx.toFixed(0)},${cy.toFixed(0)})`);
await click(cx, cy);
}
const log = (...a) => console.log(...a);
const sceneState = () =>
ev(`(() => {
const g = window.game;
const out = { active: g.scene.getScenes(true).map((s) => s.scene.key) };
const gs = g.scene.getScene('GameScene');
if (gs && gs.scene.isActive()) {
out.ship = gs.ship ? { x: Math.round(gs.ship.x * 10) / 10, y: Math.round(gs.ship.y * 10) / 10 } : null;
out.minerals = gs.ship ? gs.ship.minerals : null;
out.seed = gs.galaxy ? gs.galaxy.seed : null;
out.sys = gs.systemRecord ? gs.systemRecord.id : null;
}
return out;
})()`);
log('== waiting for the menu ==');
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) {
await sleep(500);
menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`);
}
if (!menuUp) throw new Error('menu never came up');
await sleep(4200); // intro beats (revealAt 3.2s + button rises)
log('== menu buttons ==');
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return {
continue: [m.continueBtn.x, m.continueBtn.y, m.continueBtn.disabled],
newGame: [m.newGameBtn.x, m.newGameBtn.y, m.newGameBtn.disabled],
loadGame: [m.loadGameBtn.x, m.loadGameBtn.y, m.loadGameBtn.disabled],
}; })()`);
log(' ', JSON.stringify(mb));
log(' bank:', JSON.stringify(await ev(`new (require?null:null)`).catch(() => null) ?? ''));
const bank = await ev(`(() => { try { const raw = localStorage.getItem('orbit.saves.v1'); return raw ? JSON.parse(raw).slots && Object.keys(JSON.parse(raw).slots) : null; } catch(e) { return 'ERR ' + e.message; } })()`);
log(' save bank slots:', JSON.stringify(bank));
log('== 1) New Game ==');
await clickGame(mb.newGame[0], mb.newGame[1]);
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) {
await sleep(500);
gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`);
}
if (!gameUp) throw new Error('GameScene never booted');
log(' game up:', JSON.stringify(await sceneState()));
const gs = () => 'window.game.scene.getScene("GameScene")';
log('== 2) save slot 3 (MENU -> Save Game -> slot 3 -> CANCEL) ==');
const menuBtn = await ev(`(() => { const gs = ${gs()}; const s = gs.actionBar?.slots?.find((s) => s.id === 'menu'); return s ? [s.slot.x, s.slot.y] : null; })()`);
log(' MENU slot at', JSON.stringify(menuBtn));
await clickGame(menuBtn[0], menuBtn[1]);
await sleep(700);
const sub = await ev(`(() => { const gs = ${gs()}; const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y, b.btn.disabled]) : null; })()`);
log(' sub-bar buttons (abs):', JSON.stringify(sub));
const saveBtn = sub.find((b) => b[0] === 'save');
await clickGame(saveBtn[1], saveBtn[2]);
await sleep(1600); // crack open + stagger
const diag1 = await ev(`(() => { const gs = ${gs()}; return { sub: gs.menuSubBar.isOpen, panel: gs.savePanel.isOpen, state: gs.savePanel.state }; })()`);
log(' after Save-Game click:', JSON.stringify(diag1));
const card = await ev(`(() => { const gs = ${gs()}; const c = gs.savePanel.cards[2]; return [c.x, c.y, c.disabled, c.alpha]; })()`);
log(' slot-3 card at (panel-local)', JSON.stringify(card));
const panelPos = await ev(`(() => { const gs = ${gs()}; return [gs.savePanel.x, gs.savePanel.y]; })()`);
await clickGame(panelPos[0] + card[0], panelPos[1] + card[1]);
await sleep(700);
const saved = await ev(`(() => { const gs = ${gs()}; const r = gs.saveManager.get(3); return r ? { x: r.ship.x, y: r.ship.y, seed: r.seed } : null; })()`);
log(' slot 3 now holds:', JSON.stringify(saved));
if (!saved) { log(' DIAG panel:', JSON.stringify(await ev(`(() => { const gs = ${gs()}; return { state: gs.savePanel.state, mode: gs.savePanel.mode, cards: gs.savePanel.cards.map((c) => [c.alpha, c.disabled, c.input?.enabled ?? c.panel?.input?.enabled ]) }; })()`))); throw new Error('save did not land in slot 3'); }
const savedShip = { x: saved.x, y: saved.y };
// CANCEL closes the panel
const cancel = await ev(`(() => { const gs = ${gs()}; const b = gs.savePanel.cancelBtn; return [gs.savePanel.x + b.x, gs.savePanel.y + b.y, b.panel.input?.enabled]; })()`);
log(' CANCEL at', JSON.stringify(cancel));
await clickGame(cancel[0], cancel[1]);
await sleep(700);
log(' panel closed:', await ev(`!${gs()}.savePanel.isOpen`));
log('== 3) fly the ship away (hold W + A for ~2.5 s) ==');
await ev(`(() => { const gs = ${gs()}; gs.ship._reproThrust = true; })()`);
// simplest: nudge the ship directly (no flight model coupling)
await ev(`(() => { const gs = ${gs()}; gs.ship.x += 420; gs.ship.y -= 260; gs.ship.update?.(0, 16); return [gs.ship.x, gs.ship.y]; })()`);
log(' ship moved to:', JSON.stringify(await ev(`(() => { const gs = ${gs()}; return [Math.round(gs.ship.x), Math.round(gs.ship.y)]; })()`)));
await sleep(600);
log('== 4) in-game LOAD (MENU -> Load Game -> slot 3 -> confirm) ==');
await clickGame(menuBtn[0], menuBtn[1]);
await sleep(700);
const sub2 = await ev(`(() => { const gs = ${gs()}; const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y, b.btn.disabled]) : null; })()`);
log(' sub-bar buttons (abs):', JSON.stringify(sub2));
const loadBtn = sub2.find((b) => b[0] === 'load');
if (loadBtn[3]) throw new Error('Load Game is DISABLED in the sub-bar (hasAny=false?)');
await clickGame(loadBtn[1], loadBtn[2]);
await sleep(1400);
const card3 = await ev(`(() => { const gs = ${gs()}; const c = gs.savePanel.cards[2]; return [c.x, c.y, c.disabled]; })()`);
await clickGame(panelPos[0] + card3[0], panelPos[1] + card3[1]);
await sleep(600);
const confirm = await ev(`(() => { const gs = ${gs()}; const c = gs.savePanel.confirm; if (!c.isOpen) return null; return { isOpen: c.isOpen, title: c.title?.text, x: c.x + c.confirmBtn.x, y: c.y + c.confirmBtn.y }; })()`);
log(' confirm overlay:', JSON.stringify(confirm));
if (confirm && confirm.x != null) {
await clickGame(confirm.x, confirm.y);
}
await sleep(1500);
log(' AFTER in-game load confirm -> scene state:', JSON.stringify(await sceneState()));
log(' console errors so far:', errors.slice(-6));
log('== 5) if we are at the menu: Load Game -> slot 3 -> confirm ==');
const atMenu = await ev(`window.game.scene.isActive('MenuScene')`);
if (atMenu) {
await sleep(1500); // menu intro
const mb2 = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.loadGameBtn.x, m.loadGameBtn.y, m.loadGameBtn.disabled]; })()`);
log(' menu Load Game at', JSON.stringify(mb2));
if (mb2[2]) throw new Error('menu Load Game DISABLED');
await clickGame(mb2[0], mb2[1]);
await sleep(1400);
const mcard = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); const c = m.savePanel.cards[2]; return [m.savePanel.x + c.x, m.savePanel.y + c.y, c.disabled]; })()`);
log(' menu slot-3 card at', JSON.stringify(mcard));
await clickGame(mcard[0], mcard[1]);
await sleep(600);
const mconf = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); const c = m.savePanel.confirm; if (!c.isOpen) return null; return { isOpen: c.isOpen, title: c.title?.text, x: c.x + c.confirmBtn.x, y: c.y + c.confirmBtn.y }; })()`);
log(' menu confirm:', JSON.stringify(mconf));
if (mconf && mconf.x != null) await clickGame(mconf.x, mconf.y);
await sleep(2000);
log(' AFTER menu load confirm -> scene state:', JSON.stringify(await sceneState()));
} else {
log(' (not at menu — checking we are in GameScene with the restored ship)');
}
const final = await sceneState();
log('== FINAL ==', JSON.stringify(final));
log(' saved ship was:', JSON.stringify(savedShip));
log(' console errors:', errors.slice(-10));
cdp.close();
kill();

72
dev/menu-click-probe.mjs Normal file
View File

@ -0,0 +1,72 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const URL = 'http://localhost:8080/index.html';
const { url: wsUrl, kill } = await launch(URL, undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const errors = [];
cdp.on('Runtime.consoleAPICalled', (p) => {
const txt = (p.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ');
errors.push(`${p.type}: ${txt}`.slice(0, 300));
});
cdp.on('Runtime.exceptionThrown', (p) => errors.push('EXC: ' + (p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? '').slice(0, 300)));
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(80);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(80);
}
const toClient = (x, y) => `(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
// wait for menu, boot game
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('no menu');
await sleep(4200);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
// instrument
await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
window.__log = [];
const orig = gs.deckAction.bind(gs);
gs.deckAction = (id) => { window.__log.push('deckAction:' + id); return orig(id); };
const press = gs.actionBar.press.bind(gs.actionBar);
gs.actionBar.press = (s) => { window.__log.push('press:' + s.id + ' pressing=' + s.pressing + ' disabled=' + s.disabled); return press(s); };
return true;
})()`);
const menuBtn = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const s = gs.actionBar.slots.find((s) => s.id === 'menu'); const r = gs.actionBar.slotHit(); return [s.slot.x, s.slot.y, r.width, r.height, gs.actionBar.rect]; })()`);
console.log('MENU slot:', JSON.stringify(menuBtn));
const [cx, cy] = await ev(toClient(menuBtn[0], menuBtn[1]));
console.log('client:', cx, cy, 'canvas rect:', await ev(`(() => { const r = window.game.canvas.getBoundingClientRect(); return { l: r.left, t: r.top, w: r.width, h: r.height, cw: window.game.canvas.width, ch: window.game.canvas.height }; })()`));
await click(cx, cy);
await sleep(300);
console.log('log after click 1:', await ev(`window.__log.slice()`));
console.log('subbar open:', await ev(`window.game.scene.getScene('GameScene').menuSubBar?.isOpen`));
await sleep(500);
console.log('log:', await ev(`window.__log.slice()`));
console.log('subbar open:', await ev(`window.game.scene.getScene('GameScene').menuSubBar?.isOpen`));
console.log('errors:', errors.slice(-8));
// try a second click (toggle test)
await click(cx, cy);
await sleep(400);
console.log('log after click 2:', await ev(`window.__log.slice()`));
console.log('subbar open:', await ev(`window.game.scene.getScene('GameScene').menuSubBar?.isOpen`));
cdp.close(); kill();

View File

@ -8,8 +8,10 @@
* → 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
* → LOAD confirm → the restore stages → the scene restarts
* in place (the ship back where it was saved)
* MenuScene: (back via the sub-bar's Return to Main Menu) 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
@ -278,14 +280,37 @@ const run = async () => {
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;
// ---- load confirm: stage the restore, restart the scene IN PLACE ------
// The save parked the ship at (savedX, savedY); fly it away so the
// restore has somewhere to come back to (a load must REPLACE the live
// position, not keep it).
const savedX = scene.ship.x;
const savedY = scene.ship.y;
scene.ship.x += 500;
scene.ship.y -= 320;
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'));
// close anim → onLoadComplete → reloadRun() → scene.restart(): the scene
// re-creates on the staged restore (the jump cut's seam). Poll until
// the fresh scene is active with the ship back at the saved spot.
let restoredInPlace = false;
for (let i = 0; i < 120 && !restoredInPlace; i++) {
await wait(100);
const s2 = game.scene.getScene('GameScene');
restoredInPlace =
game.scene.isActive('GameScene')
&& !!s2.ship
&& Math.abs(s2.ship.x - savedX) < 0.01
&& Math.abs(s2.ship.y - savedY) < 0.01;
}
check('a confirmed LOAD restarts the game in place (the ship is back where it was saved)', restoredInPlace);
// ---- back to the menu (the sub-bar's last button), then the menu side --
const gsNow = game.scene.getScene('GameScene');
gsNow.returnToMenu();
await wait(700);
check('Return to Main Menu lands on the menu', game.scene.isActive('MenuScene'));
// ---- the menu side ------------------------------------------------------
const menu = game.scene.getScene('MenuScene');
@ -307,7 +332,7 @@ const run = async () => {
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);
check('the saved ship was where the save parked it', Math.abs(rec.ship.x - savedX) < 0.01 && Math.abs(rec.ship.y - savedY) < 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

41
dev/slots-probe.mjs Normal file
View File

@ -0,0 +1,41 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const toClient = (x, y) =>
`(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(70);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(70);
}
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
await sleep(4600);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
await sleep(1500);
console.log(JSON.stringify(await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
return {
deckEnabled: gs.actionBar.enabled,
slots: gs.actionBar.slots.map((s) => [s.id, s.disabled === true, s.panel.input ? s.panel.input.enabled : null]),
commPaused: !!(gs.comms && gs.comms.paused),
commBox: !!(gs.comms && gs.comms.box),
commBoxOpen: !!(gs.comms && gs.comms.box && gs.comms.box.alpha > 0.2),
};
})()`), null, 1));
cdp.close();
kill();

53
dev/under-probe.mjs Normal file
View File

@ -0,0 +1,53 @@
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const toClient = (x, y) => `(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(80);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(80);
}
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('no menu');
await sleep(4200);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await click(...(await ev(toClient(mb[0], mb[1]))));
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('no game');
await sleep(1000);
const [cx, cy] = await ev(toClient(1143.33, 680));
// move pointer over the slot first (so world pointer is set)
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(250);
const dump = await ev(`(() => {
const g = window.game;
const p = g.input.activePointer;
const objs = g.input.interactionObjects.slice().sort((a, b) => a.sortOrder - b.sortOrder);
const under = [];
for (const o of objs) {
try {
const hit = o.hitArea ? o.hitTest(p) : false;
if (hit) under.push({ name: o.name || o.type || o.constructor?.name, so: o.sortOrder, scene: o.scene?.scene?.key, enabled: o.input.enabled, x: o.worldX ?? o.x, y: o.worldY ?? o.y });
} catch (e) {}
}
return { pointer: [p.x, p.y], count: objs.length, under };
})()`);
console.log(JSON.stringify(dump, null, 1));
cdp.close(); kill();

222
dev/userflow.mjs Normal file
View File

@ -0,0 +1,222 @@
// Full user-flow repro with REAL CDP clicks on index.html:
// menu -> New Game -> (ack comms) -> save slot 3 -> fly away
// -> in-game Load Game (confirm) -> observe
// -> (if menu) menu Load Game (confirm) -> observe ship vs save
import { launch, CDP } from './cdpshot.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const { url: wsUrl, kill } = await launch('http://localhost:8080/index.html', undefined, 1280, 720, 5000);
const cdp = new CDP(wsUrl);
await cdp.open();
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
const errors = [];
cdp.on('Runtime.consoleAPICalled', (p) => {
if (p.type === 'error' || p.type === 'warning') {
errors.push(`${p.type}: ${(p.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ')}`.slice(0, 300));
}
});
cdp.on('Runtime.exceptionThrown', (p) => errors.push('EXC: ' + (p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? '').slice(0, 300)));
const ev = async (expression) => {
const r = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
async function click(cx, cy) {
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy });
await sleep(70);
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(100);
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button: 'left', clickCount: 1 });
await sleep(70);
}
const toClient = (x, y) =>
`(() => { const c = window.game.canvas; const r = c.getBoundingClientRect(); const z = r.width / c.width; return [r.left + ${x} * z, r.top + ${y} * z]; })()`;
async function clickGame(x, y) {
const [cx, cy] = await ev(toClient(x, y));
await click(cx, cy);
}
const log = (...a) => console.log(...a);
const G = 'window.game.scene.getScene("GameScene")';
const sceneState = () =>
ev(`(() => {
const g = window.game;
const out = { active: g.scene.getScenes(true).map((s) => s.scene.key) };
const gs = g.scene.getScene('GameScene');
if (gs && gs.scene.isActive() && gs.ship) {
out.ship = [Math.round(gs.ship.x * 10) / 10, Math.round(gs.ship.y * 10) / 10];
out.seed = gs.galaxy ? gs.galaxy.seed : null;
out.sys = gs.systemRecord ? gs.systemRecord.id : null;
}
return out;
})()`);
// ---- boot to the menu, New Game
let menuUp = false;
for (let i = 0; i < 120 && !menuUp; i++) { await sleep(500); menuUp = await ev(`!!(window.game && window.game.scene.isActive('MenuScene'))`); }
if (!menuUp) throw new Error('menu never came up');
await sleep(4600);
const mb = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return [m.newGameBtn.x, m.newGameBtn.y]; })()`);
await clickGame(mb[0], mb[1]);
let gameUp = false;
for (let i = 0; i < 240 && !gameUp; i++) { await sleep(500); gameUp = await ev(`!!(window.game && window.game.scene.isActive('GameScene') && window.game.scene.getScene('GameScene').ship)`); }
if (!gameUp) throw new Error('GameScene never booted');
log('game up:', JSON.stringify(await sceneState()));
// ---- ack any comms holding the screen until the deck's slots are live
async function deckLive() {
return await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
if (!gs || !gs.scene.isActive()) return null;
const s = gs.actionBar.slots.find((x) => x.id === 'menu');
return { live: !!(s && s.panel.input && s.panel.input.enabled), commBox: !!(gs.comms?.box), paused: !!(gs.comms?.paused) };
})()`);
}
async function ensureDeck(maxRounds = 12) {
for (let i = 0; i < maxRounds; i++) {
const st = await deckLive();
if (!st) return false;
if (st.live) return true;
const btn = await ev(`(() => { const gs = ${G}; const b = gs.comms?.box; return b && b.button ? [b.x + b.button.x, b.y + b.button.y] : null; })()`);
if (btn) {
log(` ACK comm button at`, JSON.stringify(btn));
await clickGame(btn[0], btn[1]);
}
await sleep(1500);
}
return !!(await deckLive())?.live;
}
log('waiting for the deck to be live (acking comms as they hold)…');
let live = false;
for (let round = 0; round < 10 && !live; round++) {
await sleep(2000);
live = await ensureDeck(2);
}
log('deck live:', live);
if (!live) { log(' state:', JSON.stringify(await deckLive())); }
// ---- MENU -> Save Game -> slot 3
const menuSlot = await ev(`(() => { const gs = ${G}; const s = gs.actionBar.slots.find((s) => s.id === 'menu'); return [s.slot.x, s.slot.y]; })()`);
async function openSubBar() {
for (let i = 0; i < 6; i++) {
await clickGame(menuSlot[0], menuSlot[1]);
await sleep(600);
if (await ev(`${G}.menuSubBar?.isOpen`)) return true;
// maybe a comm grabbed the deck — ack it and retry
const btn = await ev(`(() => { const gs = ${G}; const b = gs.comms?.box; return b && b.button ? [b.x + b.button.x, b.y + b.button.y] : null; })()`);
if (btn) { await clickGame(btn[0], btn[1]); await sleep(1200); }
else await sleep(500);
}
return false;
}
await clickGame(menuSlot[0], menuSlot[1]);
await sleep(600);
log('subbar open:', await ev(`${G}.menuSubBar?.isOpen`));
if (!(await ev(`${G}.menuSubBar?.isOpen`))) {
log('retry MENU click');
await clickGame(menuSlot[0], menuSlot[1]);
await sleep(600);
}
const sub = await ev(`(() => { const gs = ${G}; const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y, b.btn.disabled]) : null; })()`);
log('sub-bar:', JSON.stringify(sub));
const saveBtn = sub.find((b) => b[0] === 'save');
await clickGame(saveBtn[1], saveBtn[2]);
// wait for the panel to be fully shown (cards up)
let shown = false;
for (let i = 0; i < 40 && !shown; i++) { await sleep(250); shown = (await ev(`${G}.savePanel.state`)) === 'shown'; }
log('panel state:', await ev(`${G}.savePanel.state`));
const card = await ev(`(() => { const gs = ${G}; const c = gs.savePanel.cards[2]; return [gs.savePanel.x + c.x, gs.savePanel.y + c.y, c.disabled]; })()`);
log('slot-3 card (abs):', JSON.stringify(card));
await clickGame(card[0], card[1]);
await sleep(900);
const saved = await ev(`(() => { const gs = ${G}; const r = gs.saveManager.get(3); return r ? { x: r.ship.x, y: r.ship.y, seed: r.seed, savedAt: r.savedAt } : null; })()`);
log('slot 3 holds:', JSON.stringify(saved));
if (!saved) throw new Error('save did not land in slot 3');
// close the panel with CANCEL
const cancel = await ev(`(() => { const gs = ${G}; const b = gs.savePanel.cancelBtn; return [gs.savePanel.x + b.x, gs.savePanel.y + b.y]; })()`);
await clickGame(cancel[0], cancel[1]);
let closed = false;
for (let i = 0; i < 30 && !closed; i++) { await sleep(200); closed = (await ev(`${G}.savePanel.state`)) === 'hidden'; }
log('panel closed:', closed);
await sleep(400);
// ---- fly the ship away
const before = await ev(`(() => { const gs = ${G}; return [gs.ship.x, gs.ship.y]; })()`);
await ev(`(() => { const gs = ${G}; gs.ship.x += 500; gs.ship.y -= 320; return true; })()`);
const after = await ev(`(() => { const gs = ${G}; return [gs.ship.x, gs.ship.y]; })()`);
log(`ship: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
await sleep(500);
// ---- in-game LOAD: MENU -> Load Game -> slot 3 -> confirm LOAD
if (!(await openSubBar())) throw new Error('sub-bar would not open (load step)');
const sub2 = await ev(`(() => { const gs = ${G}; const bar = gs.menuSubBar; return bar.isOpen ? bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y, b.btn.disabled]) : null; })()`);
log('sub-bar:', JSON.stringify(sub2));
const loadBtn = sub2 && sub2.find((b) => b[0] === 'load');
if (!loadBtn) throw new Error('no load button in sub-bar');
if (loadBtn[3]) throw new Error('Load Game DISABLED in sub-bar');
await clickGame(loadBtn[1], loadBtn[2]);
let shown2 = false;
for (let i = 0; i < 40 && !shown2; i++) { await sleep(250); shown2 = (await ev(`${G}.savePanel.state`)) === 'shown'; }
log('panel state:', await ev(`${G}.savePanel.state`));
const card3 = await ev(`(() => { const gs = ${G}; const c = gs.savePanel.cards[2]; return [gs.savePanel.x + c.x, gs.savePanel.y + c.y, c.disabled]; })()`);
await clickGame(card3[0], card3[1]);
await sleep(900);
const conf = await ev(`(() => { const gs = ${G}; const c = gs.savePanel.confirm; if (!c || c.state === 'hidden') return null; return { state: c.state, title: c.title?.text, x: gs.savePanel.x + c.x + c.confirmBtn.x, y: gs.savePanel.y + c.y + c.confirmBtn.y }; })()`);
log('confirm overlay:', JSON.stringify(conf));
if (!conf) throw new Error('confirm overlay did not open');
await clickGame(conf.x, conf.y);
// wait for the outcome: the scene RESTARTS IN PLACE with the staged
// restore (reloadRun → scene.restart) — ship back at the saved spot.
let restoredInPlace = false;
for (let i = 0; i < 60 && !restoredInPlace; i++) {
await sleep(250);
const st = await ev(`(() => {
const gs = window.game.scene.getScene('GameScene');
return {
active: window.game.scene.isActive('GameScene'),
ship: gs && gs.ship ? [gs.ship.x, gs.ship.y] : null,
};
})()`);
restoredInPlace = st.active && st.ship && Math.abs(st.ship[0] - saved.x) < 0.01 && Math.abs(st.ship[1] - saved.y) < 0.01;
}
log('AFTER in-game LOAD confirm (in-place restart):', restoredInPlace ? 'RESTORED — ship back at the saved spot' : 'NOT RESTORED', JSON.stringify(await sceneState()));
log(' errors so far:', errors.slice(-4));
if (!restoredInPlace) throw new Error('in-game LOAD did not restore the game in place');
// ---- now leave to the menu (sub-bar: Return to Main Menu) ---------------
if (!(await openSubBar())) throw new Error('sub-bar would not open (return step)');
const subR = await ev(`(() => { const gs = ${G}; const bar = gs.menuSubBar; return bar.buttons.map((b) => [b.id, bar.x + b.btn.x, bar.y + b.btn.y]); })()`);
const mm = subR.find((b) => b[0] === 'mainMenu');
if (!mm) throw new Error('no mainMenu button in sub-bar');
await clickGame(mm[1], mm[2]);
let atMenu = false;
for (let i = 0; i < 30 && !atMenu; i++) { await sleep(250); atMenu = await ev(`window.game.scene.isActive('MenuScene')`); }
if (!atMenu) throw new Error('Return to Main Menu did not land on the menu');
log('back at the menu (via the sub-bar)');
// ---- menu Load Game -> slot 3 -> confirm (the second door) --------------
await sleep(2500); // menu intro beats
const mb2 = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); return { lg: [m.loadGameBtn.x, m.loadGameBtn.y, m.loadGameBtn.disabled] }; })()`);
log('menu Load Game button:', JSON.stringify(mb2.lg));
if (mb2.lg[2]) throw new Error('menu Load Game DISABLED');
await clickGame(mb2.lg[0], mb2.lg[1]);
let shown3 = false;
for (let i = 0; i < 60 && !shown3; i++) { await sleep(250); shown3 = await ev(`window.game.scene.getScene('MenuScene').savePanel?.state === 'shown'`); }
log('menu panel state:', await ev(`window.game.scene.getScene('MenuScene').savePanel?.state`));
const mcard = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); const c = m.savePanel.cards[2]; return [m.savePanel.x + c.x, m.savePanel.y + c.y, c.disabled]; })()`);
log('menu slot-3 card (abs):', JSON.stringify(mcard));
await clickGame(mcard[0], mcard[1]);
await sleep(900);
const mconf = await ev(`(() => { const m = window.game.scene.getScene('MenuScene'); const c = m.savePanel.confirm; if (!c || c.state === 'hidden') return null; return { state: c.state, title: c.title?.text, x: m.savePanel.x + c.x + c.confirmBtn.x, y: m.savePanel.y + c.y + c.confirmBtn.y }; })()`);
log('menu confirm:', JSON.stringify(mconf));
if (!mconf) throw new Error('menu confirm overlay did not open');
await clickGame(mconf.x, mconf.y);
await sleep(2500);
log('AFTER menu LOAD confirm:', JSON.stringify(await sceneState()));
log('== FINAL ==', JSON.stringify(await sceneState()));
log(' saved ship was:', JSON.stringify({ x: saved.x, y: saved.y }));
log(' all console errors/warnings:', errors.length ? errors.slice(-12) : '(none)');
cdp.close();
kill();

View File

@ -1096,9 +1096,13 @@ export class GameScene extends Phaser.Scene {
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged — hand back to the menu
// (its New Game is the "start" the staged restore rides on).
onLoadComplete: () => this.returnToMenu(),
// Load confirmed: the restore is staged in the shared registry —
// restart this scene in place (the jump cut's seam) and let the
// fresh create() pick it up. A confirmed LOAD drops the run back
// where it was saved; it is not a detour through the menu (the
// menu's New Game resets the run state, so it never carried a
// staged restore).
onLoadComplete: () => this.reloadRun(),
});
// The MINERALS readout — upper right (the corner the old tether stack
@ -6891,6 +6895,21 @@ export class GameScene extends Phaser.Scene {
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
/**
* Confirmed in-game load: the restore is staged in the shared registry
* (SavePanel.doLoad → prepareLoad) and the panel has already closed
* (onLoadComplete fires after the close animation). Restart this scene
* in place — the jump cut's seam (scene.restart reuses this instance)
* — so the fresh create() consumes the staged restore (consumeRestore →
* applyRestore). The restart's shutdown tears the sub-bar, the panel
* and any live comms down the same way a jump does.
*/
reloadRun() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.restart();
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();

View File

@ -1162,9 +1162,12 @@ export class SurfaceScene extends Phaser.Scene {
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged — hand back to the menu
// (its New Game is the "start" the staged restore rides on).
onLoadComplete: () => this.returnToMenu(),
// Load confirmed: the restore is staged in the shared registry (the
// run's GameScene is the stateScene — same registry). Leave the
// surface and restart the game scene so its fresh create() picks the
// staged restore up — a confirmed LOAD drops the run back where it
// was saved, not out to the menu.
onLoadComplete: () => this.reloadRun(),
stateScene: this.gameScene ?? this,
});
@ -1761,6 +1764,19 @@ export class SurfaceScene extends Phaser.Scene {
this.scene.wake('GameScene');
}
/**
* Confirmed surface load: the restore is staged in the shared registry
* (SavePanel.doLoad → prepareLoad, stateScene = the run's GameScene) and
* the panel has closed. Stop this scene and start the game scene (v4
* scene.start on a sleeping scene shuts it down and re-creates it), so
* the fresh GameScene.create() consumes the staged restore; the
* take-off story seam stays untouched (a load is not a departure).
*/
reloadRun() {
this.menuSubBar?.dismiss();
this.scene.start('GameScene'); // stop this scene + start the game scene
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();

View File

@ -52,8 +52,11 @@ export class SavePanel extends Phaser.GameObjects.Container {
* ship, discovery, tetherField, playTimeMs — what captureState reads)
* @param {object} [o] {
* onLoadComplete?: (slot, record) => void — called after the
* load-confirm lands and the panel has closed (the scene starts
* the menu; the menu's New Game picks the staged restore up),
* load-confirm lands and the panel has closed. The restore is
* already staged in the shared registry (prepareLoad); the scene
* restarts the game on it (GameScene/SurfaceScene — a confirmed
* LOAD drops the run back where it was saved) or, from the menu,
* starts the game scene on the staged restore.
* stateScene?: Phaser.Scene — where captureState/prepareLoad read
* the run's state. Defaults to `scene`. SurfaceScene passes the
* paused GameScene (the surface is a launched overlay; the run