318 lines
17 KiB
JavaScript
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);
|
|
}
|