orbit/dev/gate-comms.mjs

340 lines
12 KiB
JavaScript

/**
* Dev: real-input test for the GATE comm window (v0.5):
*
* clicking a gate NO LONGER jumps (or fly-heres) — it opens the
* shared comms panel in its gate variant (js/ui/CommsPanel.js):
*
* 1. click a DORMANT gate → window opens: LINK … DORMANT,
* REQUEST JUMP grayed (disabled), CANCEL;
* the ship does NOT fly to the gate
* 2. click CANCEL → closes, no jump seam fires
* 3. click an ACTIVE gate → LINK … ACTIVE, REQUEST JUMP enabled
* 4. click REQUEST JUMP → the jump seam fires with the gate
* entity (jumpThroughGate) + closes
* 5. ESC → closes
* 6. gate window open, click ANOTHER gate → the window re-anchors
* on that gate (name + position)
*
* Real DOM mouse events hit the canvas (Phaser's input manager), so
* this exercises the full path: scene pointerdown → gateAt →
* openGateCommsPanel, and the panel's own button dispatch
* (commsAction).
*
* node dev/server.mjs 8099
* node dev/cdp-firefox.mjs http://localhost:8099/dev/gate-comms.html \
* 'return window.__GATE_COMMS__; ' 90000
*/
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 { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
const __fonts = globalThis.document?.fonts;
if (__fonts && typeof __fonts.load === 'function') {
const fams = ['header', 'body']
.map((k) => config.get(`theme.fonts.${k}.family`))
.filter((f) => typeof f === 'string' && f.length > 0);
if (fams.length > 0) {
await Promise.race([
Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))),
new Promise((r) => setTimeout(r, 2000)),
]);
}
}
// 'VESTRA's starting system holds three (dormant) gates.
globalThis.__ORBIT_DEV_SEED = 'VESTRA';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
/* ---- helpers ---------------------------------------------------------- */
const scene = () => {
const g = window.game;
if (!g) return null;
const s = g.scene.getScene('GameScene');
return s && s.sys?.isActive && s.commsPanel ? s : null;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Pump the (rAF-frozen) loop until `ok()` or the budget elapses. */
async function pump(s, ok, ms = 4000) {
const t0 = Date.now();
while (!ok() && Date.now() - t0 < ms) {
s.game.loop.step(performance.now());
await sleep(0);
}
return ok();
}
async function ready() {
const t0 = Date.now();
for (;;) {
const s = scene();
if (s && s.ship && (s.systemGates?.length ?? 0) >= 2) return s;
if (Date.now() - t0 > 45000) throw new Error('game never became ready');
await sleep(50);
}
}
/** A real mouse click at viewport (client) coordinates (v4's input
* manager listens for classic mouse events on the canvas). A leading
* mousemove refreshes the pointer so the mousedown's world coords are
* current (Phaser caches them per frame).
* NOTE: camera placement must use cam.setScroll() — plain .scrollX
* assignment does not stick — and the scene's follow must be off first.
*/
function clickAtClient(clientX, clientY) {
const canvas = document.querySelector('canvas');
const mk = (type, buttons) =>
new MouseEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
view: window,
clientX,
clientY,
button: 0,
buttons,
});
canvas.dispatchEvent(mk('mousemove', 0));
window.game.loop.step(performance.now());
canvas.dispatchEvent(mk('mousedown', 1));
canvas.dispatchEvent(mk('mouseup', 0));
}
/** ESC (Phaser's keyboard plugin reads the legacy keyCode). */
function pressEscape() {
const ev = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
composed: true,
cancelable: true,
});
Object.defineProperty(ev, 'keyCode', { get: () => 27 });
Object.defineProperty(ev, 'which', { get: () => 27 });
window.dispatchEvent(ev);
}
/** World → client coords through the camera + the CSS-scaled canvas rect. */
function worldToClient(s, wx, wy) {
const cam = s.cameras.main;
const rect = document.querySelector('canvas').getBoundingClientRect();
const scaleX = rect.width / s.scale.width;
const scaleY = rect.height / s.scale.height;
return {
clientX: rect.left + (wx - cam.scrollX) * scaleX,
clientY: rect.top + (wy - cam.scrollY) * scaleY,
};
}
/* ---- the test --------------------------------------------------------- */
window.__GATE_COMMS_TEST__ = async () => {
const out = { steps: [] };
const s = await ready();
const step = (name, pass, extra = {}) => out.steps.push({ name, pass, ...extra });
s.cameraFollowShip = false;
s.hideHint?.();
s.ship.stop();
const [gateA, gateB] = s.systemGates; // both dormant by default
// Keep the ship OUT of both gates' keepouts (solid — the ship can't
// overlap them), well off-screen from the framings below. (A click on
// a gate must not set the ship's target — that's the fly-here tell.)
s.ship.x = gateA.x + 2500;
s.ship.y = gateA.y + 2000;
const frame = (wx, wy, sx, sy) => {
s.cameras.main.setScroll(wx - sx, wy - sy);
s.cameraFollowShip = false;
};
const center = (wx, wy) => ({ sx: 640, sy: 300, wx, wy });
// The jump seam — stubbed (this harness registers ONLY GameScene;
// the real jumpThroughGate would restart the scene mid-test).
let jumped = null;
const origJump = s.jumpThroughGate;
s.jumpThroughGate = (gt) => { jumped = gt; };
// Action-dispatch log (which button id the panel fired, in order).
const actionLog = [];
const origCA = s.commsAction;
s.commsAction = (id, t) => { actionLog.push(id); origCA.call(s, id, t); };
// Button centers: panel-local y of btn1/btn2 (the gate layout is
// shorter than the settled one — read them, don't assume).
const btnPoint = (which) => {
const b = s.commsPanel[which];
return worldToClient(s, s.commsPanel.x, s.commsPanel.y + b.y);
};
// ---- 1. Real click ON the dormant gate → window opens, no fly ------
const f1 = center(gateA.x, gateA.y);
frame(gateA.x, gateA.y, f1.sx, f1.sy);
await pump(s, () => !s.commsPanel.isOpen, 1000);
const c1 = worldToClient(s, gateA.x, gateA.y);
clickAtClient(c1.clientX, c1.clientY);
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000);
const p = s.commsPanel;
step(
'click DORMANT gate → window opens + ship does NOT fly',
p.isOpen &&
p._name === gateA.discoveryName.toUpperCase() &&
p.btn1.disabled === true &&
s.ship.target === null,
{
panelName: p._name,
gateName: gateA.discoveryName,
btn1: p.btn1.text.text,
btn1Disabled: p.btn1.disabled,
shipTarget: s.ship.target,
},
);
step(
'dormant window reads LINK … DORMANT + REQUEST JUMP ghost',
p.repLabel.text === 'LINK' &&
p.repValue.text === 'DORMANT' &&
p.btn1.text.text === 'REQUEST JUMP' &&
p.btn2.text.text === 'CANCEL' &&
p.marksG.visible === false,
{ link: p.repLabel.text, status: p.repValue.text, btn1: p.btn1.text.text, btn2: p.btn2.text.text },
);
// ---- 2. Click CANCEL → closes, no jump ------------------------------
const b2 = btnPoint('btn2');
clickAtClient(b2.clientX, b2.clientY);
await pump(s, () => !s.commsPanel.isOpen, 2500);
step('click CANCEL → closes, no jump', !s.commsPanel.isOpen && jumped === null, { jumped });
// ---- 3. Click the ACTIVE gate → enabled REQUEST JUMP -----------------
gateA.active = true; // (the research tech would do this — same flag)
await pump(s, () => true, 200);
const f3 = center(gateA.x, gateA.y);
frame(gateA.x, gateA.y, f3.sx, f3.sy);
const c3 = worldToClient(s, gateA.x, gateA.y);
clickAtClient(c3.clientX, c3.clientY);
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000);
step(
'click ACTIVE gate → LINK … ACTIVE + REQUEST JUMP enabled',
s.commsPanel.isOpen &&
s.commsPanel.repValue.text === 'ACTIVE' &&
s.commsPanel.btn1.disabled === false &&
s.commsPanel.btn1.text.text === 'REQUEST JUMP',
{ status: s.commsPanel.repValue.text, btn1Disabled: s.commsPanel.btn1.disabled },
);
// ---- 4. Click REQUEST JUMP → the seam fires with the gate + closes --
jumped = null;
actionLog.length = 0;
const b4 = btnPoint('btn1');
clickAtClient(b4.clientX, b4.clientY);
await pump(s, () => !s.commsPanel.isOpen, 2500);
step(
'click REQUEST JUMP → jumpThroughGate(gate) fires + closes',
!s.commsPanel.isOpen && jumped === gateA,
{
jumpedGate: jumped?.discoveryName ?? null,
isGateA: jumped === gateA,
actionLog: [...actionLog],
btn1LocalY: s.commsPanel.btn1.y,
clickAt: [b4.clientX, b4.clientY],
panelAt: [s.commsPanel.x, s.commsPanel.y],
},
);
// ---- 5. ESC closes the gate window -----------------------------------
const c5 = worldToClient(s, gateA.x, gateA.y);
clickAtClient(c5.clientX, c5.clientY);
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000);
pressEscape();
await pump(s, () => !s.commsPanel.isOpen, 2500);
step('ESC → closes', !s.commsPanel.isOpen);
// ---- 6. Gate window open → click ANOTHER gate → re-anchors -----------
const c6 = worldToClient(s, gateA.x, gateA.y);
clickAtClient(c6.clientX, c6.clientY);
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000);
// Re-frame on gate B (the camera still shows gate A), then click it.
// The panel stays anchored at gate A (world) and never covers gate B
// (gate spacing ≥ 2·size + gateGap = 384 px > the panel's 156 px reach).
frame(gateB.x, gateB.y, 640, 300);
// gateA is ACTIVE now, gateB dormant — the re-anchor must re-read the
// target gate's state (enabled button → ghost again).
const c6b = worldToClient(s, gateB.x, gateB.y);
clickAtClient(c6b.clientX, c6b.clientY);
await pump(s, () => s.commsPanel.nameDec === null && s.commsPanel._name === gateB.discoveryName.toUpperCase(), 4000);
// The panel anchors on a SIDE of the click (pickSideAndPlace): its
// center sits H/2 + LIFT (vertical) or W/2 + LIFT (horizontal) away
// from the gate — check the adjacency, not the exact center.
const dAB = Math.hypot(s.commsPanel.x - gateB.x, s.commsPanel.y - gateB.y);
const adjOK =
Math.abs(dAB - (s.commsPanel.H / 2 + 14)) < 4 ||
Math.abs(dAB - (s.commsPanel.W / 2 + 14)) < 4;
step(
'gate window open → click another gate → re-anchors (name + state)',
s.commsPanel.isOpen &&
s.commsPanel._name === gateB.discoveryName.toUpperCase() &&
adjOK &&
s.commsPanel.repValue.text === 'DORMANT' &&
s.commsPanel.btn1.disabled === true,
{
panelName: s.commsPanel._name,
panelAt: [Math.round(s.commsPanel.x), Math.round(s.commsPanel.y)],
gateBAt: [Math.round(gateB.x), Math.round(gateB.y)],
dist: Math.round(dAB),
status: s.commsPanel.repValue.text,
btn1Disabled: s.commsPanel.btn1.disabled,
},
);
s.commsPanel.close();
s.jumpThroughGate = origJump;
s.commsAction = origCA;
out.bootErrors = window.__BOOT_ERRORS__ ?? [];
out.allPass = out.steps.every((x) => x.pass) && out.bootErrors.length === 0;
return out;
};
/* ---- auto-run (the CDP runner polls window.__GATE_COMMS__) ------------- */
let done = false;
game.events.once('ready', async () => {
try {
window.__GATE_COMMS__ = await window.__GATE_COMMS_TEST__();
} catch (err) {
console.error(err);
window.__GATE_COMMS__ = { allPass: false, steps: [], error: String(err?.stack || err) };
}
done = true;
const r = window.__GATE_COMMS__;
console.log(`GATE-COMMS ${r.allPass ? 'PASS' : 'FAIL'}`);
});
// Hard stop: if the flow never finishes, the runner will report the
// failure (same pattern as dev/saves-ui-test.mjs).
const hardStop = (t0) => {
if (performance.now() - t0 >= 240000) {
if (!done) {
window.__GATE_COMMS__ = { allPass: false, steps: [], error: 'TIMED OUT (240s)' };
console.log('GATE-COMMS FAIL (timed out)');
}
return;
}
requestAnimationFrame(() => hardStop(t0));
};
requestAnimationFrame(() => hardStop(performance.now()));