diff --git a/assets/videos/lava-shop-01.mp4 b/assets/videos/lava-shop-01.mp4
new file mode 100644
index 0000000..ed04a9d
Binary files /dev/null and b/assets/videos/lava-shop-01.mp4 differ
diff --git a/dev/ship-cargo-compass.html b/dev/ship-cargo-compass.html
new file mode 100644
index 0000000..1971644
--- /dev/null
+++ b/dev/ship-cargo-compass.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Orbit — Ship console → cargo → destination compass (dev test)
+
+
+
+
+
+
+
+
diff --git a/dev/ship-cargo-compass.mjs b/dev/ship-cargo-compass.mjs
new file mode 100644
index 0000000..c2069d2
--- /dev/null
+++ b/dev/ship-cargo-compass.mjs
@@ -0,0 +1,275 @@
+/**
+ * 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:` 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:`), 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 = (config.get('gates.route.compassColor', '#ff8c1a'));
+ const orangeInt = Number.parseInt(String(orange).replace('#', ''), 16);
+ check('phase A: the compass carries the route gate entry', !!routeEntry, { entries: entriesA.map((e) => e.id) });
+ check('phase A: the route entry is ORANGE', routeEntry?.color === orangeInt, { got: routeEntry?.color, want: orangeInt });
+ 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 } });
+
+ // ---- 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 destination entry is ORANGE', destEntry?.color === orangeInt, { got: destEntry?.color, want: orangeInt });
+ 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 } });
+
+ // ---- 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 });
+
+ 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);
+}