diff --git a/dev/escort-compass-click.mjs b/dev/escort-compass-click.mjs new file mode 100644 index 0000000..529a36d --- /dev/null +++ b/dev/escort-compass-click.mjs @@ -0,0 +1,140 @@ +// dev/escort-compass-click.mjs — REPRO: the escort's FINAL-STOP compass box, +// clicked in a REAL BROWSER (Playwright + vendored Phaser, true per-frame +// order). Run: +// +// node dev/server.mjs 8123 & +// node dev/escort-compass-click.mjs +// +// Sets an escort contract whose destination is the CURRENT system's planet +// (off-screen), waits for the compass to stand its magenta entry, then +// CLICKS the chip with a real mouse event and asserts the ship retargets +// to the planet's rim. + +const PORT = process.env.ORBIT_PORT || '8123'; +const BASE = `http://127.0.0.1:${PORT}`; +const { pathToFileURL } = await import('node:url'); +const { chromium } = await import(pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href); + +const consoleErrors = []; +const browser = await chromium.launch({ + headless: true, + executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome', + args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'], +}); +const page = await browser.newPage(); +page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); +page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`)); + +await page.goto(`${BASE}/index.html`, { waitUntil: 'load' }); +await page.waitForFunction(() => !!(window.game && window.game.scene && window.game.scene.getScene && window.game.scene.getScene('MenuScene')), null, { timeout: 20000 }); +await page.waitForFunction(() => { + const m = window.game?.scene?.getScene?.('MenuScene'); + return !!(m && m.overlay && m.title && m.seedValue && !m.dead); +}, null, { timeout: 20000 }); +await page.evaluate(() => { + const m = window.game.scene.getScene('MenuScene'); + if (!m.seedValue) m.seedValue = 'ESCORT01'; + m.startNewGame(); +}); +await page.waitForFunction(() => { + const s = window.game.scene.getScene('GameScene'); + return !!(s && s.systemRecord && s.ship); +}, null, { timeout: 40000 }); +console.log(' ✓ game booted, GameScene active'); + +// ---- stand the escort contract: destination = a planet of the current system +const setup = await page.evaluate(() => { + const s = window.game.scene.getScene('GameScene'); + const sysId = s.systemRecord.id; + const p = (s.systemPlanets ?? [])[0]; + if (!p) return { error: 'no planets in the current system' }; + // Park the ship far from the planet so the planet is off-screen. + const away = Math.hypot(p.x, p.y) > 10 ? 1 : -1; + s.ship.x = p.x + away * 3000; s.ship.y = p.y + 3000; + s.ship.target = null; + s.ship.thrustDir = null; + const st = s.sideQuestState; + const rec = (s.systemContent.planets ?? []).find((o) => o && o.name === p.discoveryName) ?? null; + st.accept('repro-job'); + const route = { + kind: 'escort', + targetSystemId: sysId, + waypoints: [[10, 10]], + credits: 50, xp: 5, + escort: { + phase: 'transit', + originSystemId: 'sys-elsewhere', + destSystemId: sysId, + destObjectType: 'planet', + destObjectId: rec ? rec.id : '', + destObjectName: p.discoveryName, + hops: 1, + transportMaxHull: 1200, transportHull: 1200, + transportMaxShields: 500, transportShields: 500, + escortCount: 1, name: 'Repro Convoy', + }, + }; + st.setRoute('repro-job', route); + st.setActive('repro-job'); + return { planet: { x: p.x, y: p.y, name: p.discoveryName, discoveryId: p.discoveryId }, ship: { x: s.ship.x, y: s.ship.y }, activeId: st.activeId }; +}); +console.log(' setup:', JSON.stringify(setup)); + +// Wait for the compass to stand the escort's entry. +await page.waitForFunction(() => { + const s = window.game.scene.getScene('GameScene'); + const keys = [...s.compass.entries.keys()]; + return keys.some((x) => x.startsWith('escort:obj:')); +}, null, { timeout: 15000 }).catch(() => null); +const entry = await page.evaluate(() => { + const s = window.game.scene.getScene('GameScene'); + const k = [...s.compass.entries.keys()].find((x) => x.startsWith('escort:obj:')); + if (!k) return null; + const e = s.compass.entries.get(k); + return { id: k, chip: { x: e.chipRoot.x, y: e.chipRoot.y }, target: { x: e.x, y: e.y } }; +}); +if (!entry) { + const diag = await page.evaluate(() => { + const s = window.game.scene.getScene('GameScene'); + return { keys: [...s.compass.entries.keys()], t: s.escortCompassTarget ? s.escortCompassTarget() : null }; + }); + console.log(' ✗ NO COMPASS ENTRY', JSON.stringify(diag), '\n CONSOLE', consoleErrors.slice(0, 10)); + await browser.close(); + process.exit(1); +} +console.log(` ✓ compass entry stands: ${entry.id} at chip (${Math.round(entry.chip.x)}, ${Math.round(entry.chip.y)})`); + +// ---- CLICK the chip with a real mouse event ------------------------------- +// Isolate the chain from comm timing: in transit the escort lane can fire a +// wave → an OAC comm starts and (by design) owns the input, so a click in +// that window is a legitimate no-op. Stub the gate to false to test the +// COMPASS → AUTOPILOT chain itself. +await page.evaluate(() => { + const s = window.game.scene.getScene('GameScene'); + s._commInputBlocked = () => false; + s.__setTargetCalls = []; + const oSet = s.ship.setTarget.bind(s.ship); + s.ship.setTarget = (x, y) => { s.__setTargetCalls.push({ x, y }); return oSet(x, y); }; +}); +await page.mouse.move(entry.chip.x, entry.chip.y); +await page.mouse.down(); +await page.mouse.up(); +await page.waitForTimeout(300); +// The REGRESSION assertion for the user's bug: the chip click must REACH +// autopilotTo and issue a setTarget (before the fix the whole chain was a +// silent no-op — the compass stored the content RECORD id and the +// discovery lookup missed). Whether the target SURVIVES a frame later is +// up to the rest of the game (a comm's pause contract legitimately stops +// the ship — that is orthogonal to this chain). +const result = await page.evaluate(() => { + const s = window.game.scene.getScene('GameScene'); + return { calls: s.__setTargetCalls, target: s.ship.target, state: s.ship.state }; +}); +console.log(' click result:', JSON.stringify(result)); +const ok = result.calls.length > 0; +console.log(ok + ? ` ✓ chip click drove the autopilot → setTarget(${result.calls.at(-1).x?.toFixed(1)}, ${result.calls.at(-1).y?.toFixed(1)}) — the destination world's rim (tether-clamped)` + : ' ✗ NO RETARGET — the click did nothing'); +if (consoleErrors.length) console.log(' CONSOLE ERRORS\n ' + consoleErrors.slice(0, 10).join('\n ')); +await browser.close(); +process.exit(ok ? 0 : 1); diff --git a/dev/escort-compass.test.mjs b/dev/escort-compass.test.mjs new file mode 100644 index 0000000..151a073 --- /dev/null +++ b/dev/escort-compass.test.mjs @@ -0,0 +1,155 @@ +// dev/escort-compass.test.mjs — the ESCORT destination compass → autopilot +// chain, verified headless (node dev/escort-compass.test.mjs). +// +// Replicates the real click path: +// escortCompassTarget() → { kind:'destination', objectId } +// updateDiscovery pushes → id `escort:obj:` +// chip click → autopilotTo('escort:obj:') +// and asserts the ship's target lands on the destination world's rim — +// for a PLANET destination (discoveryId = its NAME) and a STATION +// destination (discoveryId = settlement id). + +process.env.NODE_ENV = 'dev'; +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); +const fs = await import('node:fs'); +const configData = {}; +for (const f of fs.readdirSync(join(__dirname, '../data'))) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(__dirname, '../data', f), 'utf8')); +} +config.init(configData); + +const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); + +function makeScene(dest) { + const s = Object.create(GameScene.prototype); + const route = { + kind: 'escort', + targetSystemId: 'sys-dest', + waypoints: [[120, 340]], // the RENDEZVOUS point (origin system's coords) + credits: 100, + xp: 10, + escort: { + phase: 'transit', + originSystemId: 'sys-home', + destSystemId: 'sys-dest', + destObjectType: dest.objectType, + destObjectId: dest.objectId, + destObjectName: dest.objectName, + hops: 1, + }, + }; + s.ship = { x: 0, y: 0, radius: 12, setTarget: (x, y) => { s.aim = { x, y }; } }; + s.systemRecord = { id: 'sys-dest' }; + s.systemContent = { planets: dest.planets, settlements: dest.settlements }; + s.systemPlanets = dest.planets.map((p) => ({ + x: p.x, y: p.y, radius: p.radius, + name: p.class ?? 'gas', // the Planet entity's `name` is its CLASS label + discoveryId: p.name, discoveryName: p.name, + })); + s.systemStations = dest.settlements.map((st) => ({ + x: st.x, y: st.y, bound: st.bound, discoveryId: st.id, + discoveryName: st.name, settlement: { id: st.id }, + })); + s.solids = [ + ...s.systemPlanets.map((p) => ({ discoveryId: p.discoveryId, clearance: 30, edgePoint: () => ({ x: p.x - 42, y: p.y }), aimPoint: (x, y) => ({ x, y }) })), + ...s.systemStations.map((st) => ({ discoveryId: st.discoveryId, clearance: 40, edgePoint: () => ({ x: st.x - 80, y: st.y }), aimPoint: (x, y) => ({ x, y }) })), + ]; + s.asteroidClusters = []; + s.systemGates = []; + s.planet = null; + s.sideQuestState = { + activeId: 'sq-job', + isCompleted: () => false, + isClaimed: () => false, + routeFor: () => route, + }; + s.tetherField = { clampPoint: (x, y) => ({ x, y }) }; + s.showTargetMarker = (x, y) => { s.marker = { x, y }; }; + s.hideHint = () => {}; + s._commInputBlocked = () => false; + s.menuSubBar = null; + return s; +} + +// --- PLANET destination (discoveryId = the planet's NAME) --------------- +{ + const s = makeScene({ + objectType: 'planet', + objectId: 'p3', + objectName: 'Kepler-6', + planets: [{ id: 'p3', name: 'Kepler-6', x: 900, y: 400, radius: 60 }], + settlements: [], + }); + const t = s.escortCompassTarget(); + assert.ok(t, 'compass target stands'); + assert.equal(t.kind, 'destination'); + assert.equal(t.objectId, 'Kepler-6', 'objectId is the live entity discoveryId (the name)'); + const id = `escort:obj:${t.objectId}`; + s.autopilotTo(id); + assert.deepEqual(s.aim, { x: 858, y: 400 }, 'autopilot aims at the planet rim'); + console.log('planet destination: OK —', id, '→ aim', JSON.stringify(s.aim)); +} + +// --- STATION destination (discoveryId = settlement id) ------------------- +{ + const s = makeScene({ + objectType: 'station', + objectId: 's2', + objectName: 'Relay 9', + planets: [], + settlements: [{ id: 's2', name: 'Relay 9', x: 700, y: 700, bound: 80 }], + }); + const t = s.escortCompassTarget(); + assert.ok(t, 'compass target stands'); + assert.equal(t.kind, 'destination'); + assert.equal(t.objectId, 's2'); + const id = `escort:obj:${t.objectId}`; + s.autopilotTo(id); + assert.deepEqual(s.aim, { x: 620, y: 700 }, 'autopilot aims at the station keepout'); + console.log('station destination: OK —', id, '→ aim', JSON.stringify(s.aim)); +} + +// --- the OLD bug must stay fixed: a `patrol:wp:` click on an escort route +// must NOT silently fly the rendezvous coordinates (it resolves +// route.waypoints[0] — here (120,340) — which in the destination system +// is a random void point). +{ + const s = makeScene({ + objectType: 'planet', + objectId: 'p3', + objectName: 'Kepler-6', + planets: [{ id: 'p3', name: 'Kepler-6', x: 900, y: 400, radius: 60 }], + settlements: [], + }); + s.autopilotTo('patrol:wp:0'); // what the escort used to emit + assert.deepEqual(s.aim, { x: 120, y: 340 }, 'patrol:wp:0 = the rendezvous point (the old wrong aim)'); + console.log('old id shape still resolves to the rendezvous (documented):', JSON.stringify(s.aim)); +} + +// --- STALE payload shape (a content RECORD id, as the compass stood before +// the discoveryId fix — or a pre-update entry in a live session): +// the autopilot must map it onto the world's discovery entry instead +// of silently no-oping. +{ + const s = makeScene({ + objectType: 'planet', + objectId: 'p3', + objectName: 'Kepler-6', + planets: [{ id: 'p3', name: 'Kepler-6', x: 900, y: 400, radius: 60 }], + settlements: [], + }); + s.aim = null; + s.autopilotTo('escort:obj:p3'); // the record id, NOT the discovery name + assert.deepEqual(s.aim, { x: 858, y: 400 }, 'stale record-id payload still resolves to the planet rim'); + console.log('stale record-id payload: OK — escort:obj:p3 → aim', JSON.stringify(s.aim)); +} + +console.log('\nall escort compass → autopilot checks passed'); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f9cecaf..516762a 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -2929,12 +2929,16 @@ export class GameScene extends Phaser.Scene { // suppressed (the route gate's rule, a different color). const patrolT = this.activeRouteCompassTarget(); const patrolGateId = patrolT?.kind === 'gate' ? patrolT.gate?.discoveryId : null; + // The ESCORT's destination world (its magenta arrow takes the world's slot — + // the route gate's suppression rule). + const patrolDestId = patrolT?.kind === 'destination' ? patrolT.objectId : null; const offscreen = []; for (const o of objects) { if (o.id === routeGateId) continue; // the route arrow takes this slot if (o.id === destObjId) continue; // the destination arrow takes this slot if (o.id === patrolGateId) continue; // the patrol arrow takes this slot + if (o.id === patrolDestId) continue; // the escort destination arrow takes this slot if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) { offscreen.push(o); } @@ -2984,6 +2988,20 @@ export class GameScene extends Phaser.Scene { // autopilot-resolvable (the `patrol:gate:` id's own branch). alwaysFull: true, }); + } else if (patrolT.kind === 'destination' && patrolT.objectId && !circleInView(patrolT.x, patrolT.y, patrolT.radius ?? 40, view)) { + offscreen.push({ + id: `escort:obj:${patrolT.objectId}`, + x: patrolT.x, + y: patrolT.y, + radius: patrolT.radius ?? 40, + typeLabel: patrolT.typeLabel ?? config.get('sidequests.escort.compass.typeLabel', 'Escort'), + color: patrolT.color ?? config.get('sidequests.escort.compass.color', '#e87cf5'), + name: patrolT.name ?? 'DESTINATION', + // The escort's final stop — the full readout (like the route's + // destination), autopilot-resolvable (the `escort:obj:` id's own + // branch: the world's real discovery id, not a waypoint index). + alwaysFull: true, + }); } else if (patrolT.kind === 'waypoint' && !circleInView(patrolT.x, patrolT.y, 32, view)) { offscreen.push({ id: `patrol:wp:${patrolT.idx}`, @@ -3112,7 +3130,30 @@ export class GameScene extends Phaser.Scene { if (typeof id === 'string') { if (id.startsWith('route:')) effectiveId = id.slice('route:'.length); else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length); - else if (id.startsWith('patrol:')) { + else if (id.startsWith('escort:')) { + // The ESCORT's destination world (`escort:obj:` — the + // world's real discovery id, resolved like `route:`/`dest:`): + // autopiloting it by the `patrol:wp:` index shape would have aimed + // the ship at the escort's RENDEZVOUS waypoint instead. A payload + // that is a content RECORD id (an entry stood before the + // discoveryId fix, or a stale session) is mapped onto the world's + // discovery entry here — without it the lookup would miss and the + // click would silently no-op. + const payload = id.slice('escort:'.length); + if (payload.startsWith('obj:')) { + const oid = payload.slice('obj:'.length); + const objs = this.discoverableObjects(); + const recs = [ + ...(Array.isArray(this.systemContent?.planets) ? this.systemContent.planets : []), + ...(Array.isArray(this.systemContent?.settlements) ? this.systemContent.settlements : []), + ]; + const rec = recs.find((r) => r && String(r.id) === oid); + const hit = + objs.find((v) => v.id === oid) ?? + (rec ? objs.find((v) => v.name === rec.name) : undefined); + effectiveId = hit ? hit.id : oid; // no hit → the generic lookup below misses → no-op + } + } else if (id.startsWith('patrol:')) { // The active PATROL's compass entry (see updateDiscovery): // `patrol:gate:` — the yellow route gate in ANOTHER // system (the gate's own autopilot flies it — the orange route @@ -5549,10 +5590,17 @@ export class GameScene extends Phaser.Scene { for (const gt of this.systemGates ?? []) if (gt.gate?.to === originId) return { kind: 'gate', gate: gt, ...style }; return null; } - // AFTER the link: the DESTINATION. + // AFTER the link: the DESTINATION. Its own `destination` kind (NOT the + // waypoint/idx shape): the idx would resolve autopilot's `patrol:wp:` to + // route.waypoints[0] — the escort's RENDEZVOUS point — and the ship would + // fly the rendezvous coordinates inside the destination system instead + // of the world itself. if (destId === this.systemRecord.id) { const obj = this._escortDestinationObject(route); - if (obj) return { kind: 'waypoint', idx: 0, x: obj.x, y: obj.y, ...style }; + // The autopilot id is the world's DISCOVERY id (a planet's NAME, + // a station's settlement id) — its record id would miss the + // discoverable/solid lookups and the click would no-op. + if (obj) return { kind: 'destination', objectId: obj.discoveryId ?? obj.id, name: obj.name, x: obj.x, y: obj.y, radius: obj.radius, ...style }; } // Not there yet — the gate toward the next hop on the way to it. let next = null; @@ -5577,24 +5625,33 @@ export class GameScene extends Phaser.Scene { if (!content) return null; const id = String(esc.destObjectId ?? ''); const name = String(esc.destObjectName ?? ''); - const pool = [ - ...(Array.isArray(content.planets) ? content.planets : []), - ...(Array.isArray(content.settlements) ? content.settlements : []), - ]; + const planets = Array.isArray(content.planets) ? content.planets : []; + const settlements = Array.isArray(content.settlements) ? content.settlements : []; const rec = - pool.find((o) => o && String(o.id) === id && id !== '') ?? - pool.find((o) => o && String(o.name) === name && name !== ''); + planets.find((o) => o && String(o.id) === id && id !== '') ?? + settlements.find((o) => o && String(o.id) === id && id !== '') ?? + planets.find((o) => o && String(o.name) === name && name !== '') ?? + settlements.find((o) => o && String(o.name) === name && name !== ''); if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.y)) { - // The world's rendered radius — the live entity's disc/keepout when it - // stands here (a planet's disc / a station's keepout), else a fallback. - // The delivery anchors the convoy's landing just off this disc. - let radius = Number(rec.radius) || 0; - if (!radius) { - const planet = (this.systemPlanets ?? []).find((e) => e && (e.name === rec.name || e.discoveryId === rec.id)); - const station = (this.systemStations ?? []).find((e) => e && (e.settlement?.id === rec.id || e.discoveryName === rec.name)); - radius = Number(planet?.radius) || Number(station?.bound) || Number(station?.radius) || 0; - } - return { x: rec.x, y: rec.y, id: rec.id, name: rec.name, radius: radius > 0 ? radius : 40 }; + // The LIVE entity behind this record, when it stands here (a planet's + // disc / a station's keepout). Match on the entity's DISCOVERY name + // (the world's name) — the Planet's `name` field is its CLASS label, + // and its discoveryId is the world's NAME, not the record id — so + // discoveryName/discoveryId === rec.name is the planet's join key. + const isPlanet = planets.includes(rec); + const planet = (this.systemPlanets ?? []).find((e) => e && (e.discoveryName === rec.name || e.discoveryId === rec.id || e.discoveryId === rec.name)); + const station = (this.systemStations ?? []).find((e) => e && (e.settlement?.id === rec.id || e.discoveryName === rec.name)); + const live = planet ?? station ?? null; + const radius = Number(planet?.radius) || Number(station?.bound) || Number(station?.radius) || Number(rec.radius) || 40; + // discoveryId = the compass/autopilot identity (a planet's NAME, a + // station's settlement id) — the autopilot's `escort:obj:` resolution + // matches on it (the record id would miss the lookups and the click + // would no-op). + return { + x: rec.x, y: rec.y, id: rec.id, + discoveryId: live?.discoveryId ?? (isPlanet ? rec.name : rec.id), + name: rec.name, radius, + }; } return null; }