orbit/dev/ship-cargo-compass.mjs

318 lines
17 KiB
JavaScript

/**
* Dev-only: SHIP console → CARGO HOLDS → cargo → SET DESTINATION →
* COMPASS, end to end (the user's exact flow), in headless Firefox:
*
* 1. the deck's SHIP button opens the console, the CARGO HOLDS tab is
* opened, a filled bay's manifest is shown, a BEST-EXIT row is tapped
* and CONFIRMED (the real dialog — fireConfirm)
* 2. Phase A (intermediate system): the destination is set, the route
* plots, and the COMPASS marks the route's next gate in ORANGE
* (an off-screen `route:<gateId>` entry, full readout, the gate
* wears the in-world route pulse)
* 3. the ship jumps along the route (jumpThroughGate, hop by hop)
* 4. Phase B (destination system): the compass marks the DESTINATION
* OBJECT (planet/station) in ORANGE (`dest:<objectId>`), the object
* wears the SUBDUED pulse, and the ordinary entry for it is
* suppressed (no double arrow)
* 5. REACHED: the ship at the object's keep-out rim clears the
* destination, the orange marks drop, and the REACHED toast fires
*
* node dev/server.mjs 8123
* node dev/cdp-firefox.mjs \
* "http://127.0.0.1:8123/dev/ship-cargo-compass.html" \
* 'return window.__SHIP_CARGO_COMPASS__;' 120000
*/
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 { planRoute } from '../js/galaxy/Route.js';
import { circleInView } from '../js/ui/DiscoveryCompass.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitScene() {
for (let i = 0; i < 120; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.shipWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
/** The same deck-clearance a fresh run needs (the opening ACK comm owns
* the screen until it ends — the real flow is a player press, here the
* hub's own finish path). Idempotent after a jump restart too. */
async function clearComms(s) {
const hub = s.comms;
if (!hub) return;
if (hub.current) { try { hub._finish(); } catch { /* already ended */ } }
if (Array.isArray(hub.queue)) hub.queue.length = 0;
hub._setPaused(false);
try { hub.box?.close(); } catch { /* no box */ }
hub.box = null;
await sleep(450); // the input-block grace after a comm ends
}
function compassEntries(s) {
const out = [];
for (const [id, e] of s.compass.entries) {
out.push({
id,
color: e.neon,
type: e.typeText?.text ?? '',
name: e.nameText?.text ?? '',
arrowRot: e.arrow?.rotation ?? null,
arrowScale: e.arrow?.scale ?? null,
mode: e.mode ?? null,
x: e.arrow?.x ?? null,
y: e.arrow?.y ?? null,
});
}
return out;
}
/** Angle (radians, screen y-down) from the screen center to a world point. */
function angleToWorld(s, wx, wy) {
const cam = s.cameras.main;
const sx = wx - cam.scrollX;
const sy = wy - cam.scrollY;
return Math.atan2(sy - s.scale.height / 2, sx - s.scale.width / 2);
}
function angDiff(a, b) {
const d = ((a - b + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
return Math.abs(d);
}
const results = [];
const check = (label, cond, extra = {}) => {
results.push(`${cond ? 'PASS' : 'FAIL'} ${label}${cond ? '' : ' ' + JSON.stringify(extra).slice(0, 300)}`);
if (!cond) throw new Error(`${label} ${JSON.stringify(extra).slice(0, 300)}`);
};
try {
const s = await waitScene();
await sleep(800);
await clearComms(s);
// ---- seed the run: bays + goods + a FULL chart ------------------------
s.ship.setCargoHold?.(4);
s.ship.cargo.clear?.();
s.ship.cargo.add(['iron-ore', 'grain-crates']);
for (const id of s.galaxy.byId.keys()) s.visitedSystems.add(id);
check('seed: cargo aboard + every system charted', s.ship.cargo.used >= 2 && s.visitedSystems.size > 2,
{ cargo: s.ship.cargo.items, visited: s.visitedSystems.size });
// ---- the user's flow: SHIP button → CARGO HOLDS → bay → exit → CONFIRM
const win = s.shipWindow;
for (let i = 0; i < 12 && !win.isOpen; i++) { s.deckAction('ship'); await sleep(350); }
check('flow: the deck SHIP button opens the console', win.isOpen, { state: win.openState });
win._setTab('cargo');
await sleep(400);
win._selectedBay = 0;
win._buildBody(win._snap); // what tapping the filled bay does
await sleep(400);
const localId = s.systemRecord.id;
const rows = (win._exitRows ?? []).filter(Boolean);
check('flow: the bay manifest drew exit rows', rows.length >= 2, { rows: rows.length });
// The nearest remote exit (the shortest route — the test reaches the
// destination in the fewest hops).
let pick = null;
for (const r of rows) {
if (!r.onTap || r.here) continue;
const hops = planRoute(s.galaxy, localId, r.exit.systemId)?.hops ?? 99;
if (hops >= 1 && (!pick || hops < pick.hops)) pick = { r, hops };
}
check('flow: a remote exit is tappable', !!pick, { rows: rows.map((r) => r.exit?.systemId) });
pick.r.onTap();
await sleep(500);
check('flow: the tap asks SET DESTINATION', win.isDialogOpen === true, { state: win.dialog.state });
win.dialog.fireConfirm();
for (let i = 0; i < 40 && win.isDialogOpen; i++) await sleep(100);
check('flow: CONFIRM lands (dialog down)', win.isDialogOpen === false, { state: win.dialog.state });
for (let i = 0; i < 40 && win.openState !== 'closed'; i++) await sleep(100);
check('flow: the console closed itself', win.openState === 'closed', { state: win.openState });
// ---- Phase A: the route + the compass's ORANGE next-gate mark ---------
await sleep(300);
const dest = s.destination;
check('phase A: the destination is set (system + world)', !!dest?.systemId && !!dest?.objectId, { destination: dest });
const plan = s.routePlan();
check('phase A: the route plots', !!plan && plan.hops >= 1 && plan.path[0] === localId && plan.path[plan.path.length - 1] === dest.systemId,
{ plan: plan && { hops: plan.hops, path: plan.path } });
const gate = s.routeNextGate();
check('phase A: the route\'s next gate exists in this system', !!gate && gate.gate?.to === plan.next, { gate: gate?.gate?.to, next: plan.next });
// Park the ship AWAY from the gate (opposite side of the home world,
// inside the tether) so the gate is off-screen — the compass arrow is
// the off-screen half of the mark.
const hx = s.planet?.x ?? 0, hy = s.planet?.y ?? 0;
const gd = Math.hypot(gate.x - hx, gate.y - hy) || 1;
const park = 3800; // inside the home tether (5120), on the far side
s.ship.setPosition(hx - ((gate.x - hx) / gd) * park, hy - ((gate.y - hy) / gd) * park);
s.ship.stop();
await sleep(2600); // the camera settles on the ship, the compass eases
const viewA = { left: s.cameras.main.scrollX, top: s.cameras.main.scrollY, w: s.scale.width, h: s.scale.height };
check('phase A: the gate is off-screen now', !circleInView(gate.x, gate.y, gate.bound, viewA), { view: viewA, gate: { x: gate.x, y: gate.y } });
const entriesA = compassEntries(s);
const routeEntry = entriesA.find((e) => e.id === `route:${gate.discoveryId}`);
const orange = String(config.get('gates.route.compassColor', '#ff8c1a'));
const orangeInt = Number.parseInt(String(orange).replace('#', ''), 16);
const brown = String(config.get('gates.route.cargoColor', '#9c6b30'));
const brownInt = Number.parseInt(String(brown).replace('#', ''), 16);
check('phase A: the compass carries the route gate entry', !!routeEntry, { entries: entriesA.map((e) => e.id) });
check('phase A: the cargo delivery\'s route entry is BROWN', routeEntry?.color === brownInt, { got: routeEntry?.color, want: brownInt });
check('phase A: the route entry is the full readout (never folds)', routeEntry?.mode === 'full' && routeEntry?.arrowScale === 1,
{ mode: routeEntry?.mode, scale: routeEntry?.arrowScale });
check('phase A: the chip reads ROUTE GATE + the gate name', String(routeEntry?.type).toUpperCase().includes('ROUTE') && String(routeEntry?.name).toUpperCase() === String(gate.discoveryName ?? '').toUpperCase(),
{ type: routeEntry?.type, name: routeEntry?.name });
if (routeEntry?.arrowRot !== null) {
check('phase A: the arrow points at the gate', angDiff(routeEntry.arrowRot, angleToWorld(s, gate.x, gate.y)) < 0.15,
{ rot: routeEntry.arrowRot, want: angleToWorld(s, gate.x, gate.y) });
}
const ordinaryGate = entriesA.find((e) => e.id === gate.discoveryId);
check('phase A: the gate\'s ordinary entry is suppressed (no double arrow)', !ordinaryGate,
{ entries: entriesA.map((e) => e.id) });
check('phase A: the gate wears the in-world route pulse', !!gate.routePulse && gate.routePulse.on === true,
{ pulse: { on: gate.routePulse?.on, ring: gate.routePulse?.ring?.visible } });
check('phase A: the gate pulse is BROWN (the cargo mark)', gate.routePulse?.color === brownInt,
{ got: gate.routePulse?.color, want: brownInt });
// ---- jump along the route, hop by hop --------------------------------
let hops = 0;
let cur = s;
while (cur.systemRecord.id !== dest.systemId && hops < 8) {
const g = cur.routeNextGate();
if (!g) throw new Error('route lost its next gate');
const to = g.gate?.to;
cur.jumpThroughGate(g);
await sleep(900); // the jump cut (no clip: jumpDelayMs) + the restart
let s2 = null;
for (let i = 0; i < 60; i++) {
const cand = game.scene.getScene('GameScene');
if (cand && cand.ship && cand.systemRecord?.id === to) { s2 = cand; break; }
await sleep(250);
}
if (!s2) throw new Error(`never entered ${to}`);
await sleep(1200); // boot settle + compass feed
await clearComms(s2);
hops++;
cur = s2;
if (cur.systemRecord.id !== dest.systemId) {
// still on course: the next hop's gate is marked again (derived)
const g2 = cur.routeNextGate();
check(`hop ${hops}: on course — next gate marked`, !!g2, { system: cur.systemRecord.id });
const ent2 = compassEntries(cur).find((e) => e.id === `route:${g2?.discoveryId}`);
// (the gate may be on-screen here — the pulse is the on-screen half)
check(`hop ${hops}: the gate wears the pulse`, !!g2?.routePulse && g2.routePulse.on === true, { on: g2?.routePulse?.on });
}
}
check('jumps: the destination system was reached', cur.systemRecord.id === dest.systemId, { hops, at: cur.systemRecord.id });
// ---- Phase B: the DESTINATION OBJECT is the orange mark ---------------
const destObj = cur.routeDestinationObject();
check('phase B: the route\'s final stop is the destination object', !!destObj && destObj.id === dest.objectId,
{ destObj: destObj && { id: destObj.id, name: destObj.name }, want: dest.objectId });
check('phase B: no next gate in the destination system', cur.routeNextGate() === null, {});
// Park AWAY from the object (opposite side of the system's entry point)
// so it is off-screen — the compass arrow half.
const ox = destObj.x, oy = destObj.y;
// The destination system's tether is anchored at the ARRIVAL gate (the
// return gate of the last jump) — park on its far side from the object.
const lastFrom = cur.lastJumpFromSystem ?? null;
const arrivalGate = (cur.systemGates ?? []).find((g) => g.gate?.to === lastFrom) ?? cur.systemGates?.[0];
const ax = arrivalGate?.x ?? cur.planet?.x ?? 0, ay = arrivalGate?.y ?? cur.planet?.y ?? 0;
const od = Math.hypot(ox - ax, oy - ay) || 1;
const park2 = 3800; // inside the gate tether (5120), on the far side
cur.ship.setPosition(ax - ((ox - ax) / od) * park2, ay - ((oy - ay) / od) * park2);
cur.ship.stop();
await sleep(2600);
const viewB = { left: cur.cameras.main.scrollX, top: cur.cameras.main.scrollY, w: cur.scale.width, h: cur.scale.height };
check('phase B: the destination object is off-screen now', !circleInView(ox, oy, destObj.radius, viewB), { view: viewB, obj: { x: ox, y: oy } });
const entriesB = compassEntries(cur);
const destEntry = entriesB.find((e) => e.id === `dest:${destObj.id}`);
check('phase B: the compass carries the DESTINATION entry', !!destEntry, { entries: entriesB.map((e) => e.id) });
check('phase B: the cargo delivery\'s destination entry is BROWN', destEntry?.color === brownInt, { got: destEntry?.color, want: brownInt });
check('phase B: full readout + DESTINATION label', destEntry?.mode === 'full' && String(destEntry?.type).toUpperCase().includes('DESTINATION'),
{ mode: destEntry?.mode, type: destEntry?.type, name: destEntry?.name });
if (destEntry?.arrowRot !== null) {
check('phase B: the arrow points at the destination object', angDiff(destEntry.arrowRot, angleToWorld(cur, ox, oy)) < 0.15,
{ rot: destEntry.arrowRot, want: angleToWorld(cur, ox, oy) });
}
const solidB = cur.solids?.find((o) => o.discoveryId === destObj.id) ?? cur.discoverableObjects().find((o) => o.id === destObj.id);
check('phase B: the object wears the (subdued) route pulse', !!(solidB?.routePulse?.on === true),
{ pulse: solidB?.routePulse && { on: solidB.routePulse.on, ring: solidB.routePulse.ring?.visible } });
check('phase B: the object pulse is BROWN (the cargo mark)', solidB?.routePulse?.color === brownInt,
{ got: solidB?.routePulse?.color, want: brownInt });
// ---- REACHED: the ship at the keep-out rim clears the route -----------
const shipR = cur.ship.radius ?? 0;
const keepout = solidB.minCenterDistance ? solidB.minCenterDistance(shipR) : (solidB.radius + (solidB.clearance ?? 0) + shipR);
const rx = ox + keepout * 0.8, ry = oy; // inside the rim (the rim is keepout from center)
cur.ship.setPosition(rx, ry);
cur.ship.stop();
await sleep(700);
check('reached: the destination clears', cur.destination === null && cur.registry.get('destination') === null,
{ destination: cur.destination });
const entriesC = compassEntries(cur);
check('reached: the orange marks drop', !entriesC.some((e) => e.id.startsWith('dest:') || e.id.startsWith('route:')),
{ entries: entriesC.map((e) => e.id) });
check('reached: the pulse drops off the object', solidB?.routePulse?.on === false, { on: solidB?.routePulse?.on });
// ---- Phase C: NOT cargo stays ORANGE (the story/manual keeps orange) --
const sysIds = [...cur.galaxy.byId.keys()];
const otherSys = sysIds.find((id) => id !== cur.systemRecord.id && id !== cur.galaxy?.homeSystemId);
cur.setDestination(otherSys, null, 'quest'); // the map's seam (a story/manual pick)
await sleep(300);
check('phase C: a story/manual destination keeps kind != cargo', cur.destination?.kind === 'quest', { kind: cur.destination?.kind });
check('phase C: the story/manual mark resolves ORANGE', cur._routeMarkColor() === orangeInt, { got: cur._routeMarkColor(), want: orangeInt });
const gateC = cur.routeNextGate();
if (gateC) {
check('phase C: the gate pulse is ORANGE again (the story mark)', gateC.routePulse?.color === orangeInt,
{ got: gateC.routePulse?.color, want: orangeInt });
}
// ---- Phase D: the story-delivery carve-out (Special Delivery held) ---
// The player sets the home world from the cargo UI (kind 'cargo'), but
// the held 'Special Delivery' quest OWNS that delivery — the story keeps
// the route's orange (setDestination's kind guard).
const homeId = cur.galaxy?.homeSystemId;
if (homeId && homeId !== cur.systemRecord.id) {
cur.questState?.granted?.add('special_delivery'); // simulate the quest being held
cur.setDestination(homeId, 'home', 'cargo'); // the player's cargo pick
await sleep(300);
check('phase D: the story delivery carve-out flips kind to quest', cur.destination?.kind === 'quest', { kind: cur.destination?.kind });
check('phase D: the story delivery mark resolves ORANGE', cur._routeMarkColor() === orangeInt, { got: cur._routeMarkColor(), want: orangeInt });
// ...and without the quest held, the same pick is a free cargo run (brown)
cur.questState?.granted?.delete('special_delivery');
const other2 = sysIds.find((id) => id !== cur.systemRecord.id && id !== homeId && id !== otherSys) ?? otherSys;
cur.setDestination(other2, null, 'cargo');
await sleep(300);
check('phase D: without the quest, the cargo pick stays BROWN', cur.destination?.kind === 'cargo' && cur._routeMarkColor() === brownInt,
{ kind: cur.destination?.kind, color: cur._routeMarkColor() });
cur.questState?.granted?.delete('special_delivery'); // leave the ledger clean
cur.destination = null;
cur.registry.set('destination', null);
}
window.__SHIP_CARGO_COMPASS__ = { pass: true, results, hops };
console.info('[ship-cargo-compass] PASS:', results.join(' | '));
} catch (err) {
window.__SHIP_CARGO_COMPASS__ = { pass: false, error: String(err?.message ?? err), results };
console.error('[ship-cargo-compass] FAIL:', err);
}