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); +}