orbit/dev/compass-far.mjs

199 lines
8.5 KiB
JavaScript

/**
* Compass FAR-display integration driver (headless browser — NOT a Node
* test). Boots the real GameScene and checks the near/far split of the
* off-screen compass (js/ui/DiscoveryCompass.js):
*
* - the ship is parked 3000 px from the home world (home is OFF-SCREEN
* but NEAR — full display), opposite a system planet (which is then
* > 5120 px away — FAR — compact display);
* - every discovered object gets a compass entry; FAR entries are the
* small text-less box + shrunken arrow, NEAR entries the full readout;
* - hovering a FAR chip expands it back to the full readout (type +
* name, arrow back to full) AFTER the pointer has rested
* (expandDelay — a flick over doesn't pop it), and hovering off
* folds it back in only after the grace (collapseDelay) runs out;
* - the scene's click guard (compass.contains) covers the small chip's
* generous hit slack but not a click well off it.
*
* Served by dev/compass-far.html; the results land in
* `window.__COMPASS_FAR__` for the CDP runner (dev/cdp-firefox.mjs):
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/compass-far.html \
* 'return window.__COMPASS_FAR__;'
*/
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';
import { toColor } from '../js/utils/Color.js';
import { themeColor } from '../js/utils/Theme.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 = 'COMPASSFAR';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // boots straight into the flight scene
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). Poll the
// game's own clock (shared engine loop time) on rAF until it advances.
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) {
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);
});
const run = async () => {
const scene = game.scene.getScene('GameScene');
const bootT0 = Date.now();
while (!game.scene.isActive('GameScene') || !scene.ship || !scene.compass) {
if (Date.now() - bootT0 > 90000) throw new Error('GameScene never came up');
await new Promise((r) => setTimeout(r, 50));
}
await wait(300); // let the first frames settle
const cfg = config.get('game.discovery.compass', {});
check('far display configured (5120 / 0.6 / 26)',
cfg.farDistance === 5120 && cfg.farArrowScale === 0.6 && cfg.smallBox === 26);
// --- Park the ship: NEAR the home world, FAR from a system planet ----
// Home world at the origin: 3000 px away is off-screen but within
// farDistance (full display). The system planets sit ≥ 6400 px from the
// origin; the ship parks on the ray OPPOSITE one of them, so that
// planet is ≥ 6400 + 3000 px away (far beyond farDistance — compact).
const planet = scene.systemPlanets[0];
check('the test system has a planet to be far away', !!planet);
if (!planet) return finish();
const ang = Math.atan2(planet.y, planet.x);
const shipX = -Math.cos(ang) * 3000;
const shipY = -Math.sin(ang) * 3000;
scene.ship.setPosition(shipX, shipY);
scene.ship.setTarget(shipX, shipY); // no drift
const dHome = Math.hypot(shipX, shipY);
const dPlanet = Math.hypot(planet.x - shipX, planet.y - shipY);
check('ship split: home near (< 5120), planet far (> 5120)',
dHome < cfg.farDistance && dPlanet > cfg.farDistance);
// --- Discover everything (the compass shows DISCOVERED objects) ------
const sysId = scene.systemRecord.id;
const objs = scene.discoverableObjects();
const colorOf = {};
for (const o of objs) colorOf[o.id] = o.color;
const idOf = (e) => [...scene.compass.entries.entries()].find(([, en]) => en === e)?.[0];
let known = scene.discovery.bySystem.get(sysId) ?? new Set();
for (const o of objs) known.add(o.id);
scene.discovery.bySystem.set(sysId, known);
// Give the compass a couple of frames to reconcile its entries.
await wait(250);
check('compass built one entry per discovered object',
scene.compass.entries.size === objs.length);
const farE = [...scene.compass.entries.values()].filter((e) => e.far);
const nearE = [...scene.compass.entries.values()].filter((e) => !e.far);
check('at least one FAR entry and one NEAR entry', farE.length > 0 && nearE.length > 0);
check('FAR entries are the compact display (small box, no text, small arrow)',
farE.length > 0 && farE.every((e) => e.mode === 'small'
&& e.w === cfg.smallBox && e.h === cfg.smallBox
&& e.typeText.alpha < 0.1
&& (!e.nameText || e.nameText.alpha < 0.1)
&& e.arrow.scale < 0.9));
check('NEAR entries keep the full readout (text, full arrow)',
nearE.length > 0 && nearE.every((e) => e.mode === 'full'
&& e.typeText.alpha > 0.9
&& e.arrow.scale > 0.99));
// --- Hover a FAR chip: expand → read → (press) → fold back ----------
const e = farE[0];
scene.compass.onChipOver(e); // the chip's pointerover handler
check('hover IN: the small box brightens, but the expand WAITS (intent)',
e.mode === 'small' && e.expandAt != null);
// expandDelay + the 130 ms text fade + headroom, on the game clock.
await wait(900);
check('hover IN (after the rest): the far chip expands to the full readout',
e.mode === 'full' && e.w === e.baseW && e.h === e.baseH
&& e.typeText.alpha > 0.9
&& e.arrow.scale > 0.99);
check('hover IN: the chip keeps the target\u2019s accent color',
e.neon === (colorOf[idOf(e)] ? toColor(colorOf[idOf(e)]) : themeColor('neon', 0x00e5ff)),
);
// The press path still works from the expanded state (autopilot seam):
// onSelect is the scene's autopilotTo — the chip's own pointerdown fires
// pressChip, which calls onSelect; check the seam is wired, not the flight.
check('press seam intact (onSelect bound to the scene\u2019s autopilot)',
typeof scene.compass.onSelect === 'function');
scene.compass.onChipOut(e); // the chip's pointerout handler
check('hover OUT: a stray exit doesn\u2019t yank it down (grace armed)',
e.mode === 'full' && e.collapseAt != null);
// Grace (2000 ms) + the fold fade + headroom.
await wait(2600);
check('hover OUT (grace runs out): folds back to the compact display (still far)',
e.mode === 'small' && e.w === cfg.smallBox && e.typeText.alpha < 0.1);
// --- Hover back within the grace: the chip STAYS up ------------------
scene.compass.onChipOver(e);
await wait(900);
check('hover again: it expands once more (the seam is repeatable)',
e.mode === 'full' && e.typeText.alpha > 0.9);
scene.compass.onChipOut(e);
check('... and hovering back in during the grace cancels the fold',
(() => { scene.compass.onChipOver(e); return e.collapseAt === null && e.mode === 'full'; })());
// Leave it for good: folded back to the small box before the guard tests.
scene.compass.onChipOut(e);
await wait(2600);
check('final leave: back to the compact display', e.mode === 'small' && e.w === cfg.smallBox);
// --- The scene's click guard around the small box --------------------
const px = e.chipRoot.x, py = e.chipRoot.y;
check('contains: the small chip\u2019s generous slack is a chip click',
scene.compass.contains(px, py) === true);
check('contains: a click well off the small chip is a fly-here',
scene.compass.contains(px + 40, py) === false);
finish();
};
function finish() {
const pass = results.every((r) => r.pass) && results.length > 0;
window.__COMPASS_FAR__ = { pass, results };
}
game.events.once('ready', async () => {
try {
await run();
} catch (err) {
window.__COMPASS_FAR__ = { pass: false, error: String(err && err.stack || err) };
}
});