340 lines
12 KiB
JavaScript
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()));
|