Fix escort compass chip click not retargeting the ship

- `escortCompassTarget()` now emits a `destination` kind carrying the world's discovery id (a planet's name / a station's settlement id) instead of reusing the `waypoint`/idx shape, which autopilot resolved to `route.waypoints[0]` — the escort's rendezvous point in the destination system
- `autopilotTo()` gains an `escort:obj:` branch that resolves the payload to the world's discovery entry, and maps a stale content-record-id payload onto the live entity so the click no longer silently no-ops
- `_escortDestinationObject()` now matches the live planet/station by discovery name (a Planet's `name` is its class label) and returns the entity's `discoveryId` for compass/autopilot identity
- Compass offscreen listing suppresses the destination world's own slot when the escort arrow stands it, mirroring the existing route-gate rule
- Add headless unit test (`dev/escort-compass.test.mjs`) covering planet/station destinations, the stale record-id payload, and the old `patrol:wp:` shape, plus a Playwright repro (`dev/escort-compass-click.mjs`) that drives a real mouse click on the chip in a live browser
This commit is contained in:
Brian Fertig 2026-09-23 22:30:47 -06:00
parent c6c3f8087f
commit b66b3b73e3
3 changed files with 371 additions and 19 deletions

View File

@ -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);

155
dev/escort-compass.test.mjs Normal file
View File

@ -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:<objectId>`
// chip click → autopilotTo('escort:obj:<objectId>')
// 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');

View File

@ -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:<objectId>` — 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:<gateId>` — 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;
}