Make ship console BEST EXITS rows tappable to set destination

- ShipWindow hosts a ConfirmOverlay (depth 30) so the SET DESTINATION dialog always paints above the console chrome
- Each remote exit row in the bay manifest becomes a hit-testable tap surface with hover state; local-system rows read HERE and stay inert
- Tapping a row opens the confirm dialog built from shipScreen.json templates; CONFIRM fires onSetDestination(systemId, objectId) through GameScene.setDestination, same seam as the map console
- ESC cancels the dialog before closing the console; closing the console cancels an open dialog to avoid orphan scrim
- Add dev/ship-cargo-dest.test.mjs covering hosting, row tappability, ask/confirm/cancel/close/repaint paths
- Update landing.json arid-land-03 shop reference from ice-shop-03 to arid-shop-03
- Add lava-shop-01.png and lava-shop-03.png assets
- Extend dev/ship-shot.mjs with ?tapExit / ?clickExit probes for headless verification
This commit is contained in:
Brian Fertig 2026-09-19 11:57:21 -06:00
parent 91be23088c
commit 8ceb21808f
8 changed files with 638 additions and 14 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -81,7 +81,7 @@
{ "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "arid-shop-03.mp4" },
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "capital-shop-neutral.mp4", "bar": "capital-bar-neutral.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },

View File

@ -360,6 +360,26 @@
"exitsTitle": "BEST EXITS",
"exitsEmpty": "NO CHARTED EXIT YET \u2014 EVERY CAPITAL BUYS EVERYTHING",
"exitMeta": "{hops} HOPS",
"hereTag": "HERE",
"exitCue": "TAP TO SET DESTINATION",
"exitHint": "TAP AN EXIT TO SET IT AS YOUR DESTINATION",
"exitHover": {
"_comment": "the row's hover state (the tap surface lights up: the plate fill, its neon edge + glow)",
"fill": "#0c2740",
"fillAlpha": 0.85
},
"setDestination": {
"_comment": "The BEST-EXIT confirm (js/ui/ConfirmOverlay.js, hosted by the console so it always paints above it) — the same SET DESTINATION dialog the planet shop's scout and the map's SYSTEM tab use. {object}/{system} name the exit's WORLD and its system, {class} its economic class, {price} the CR/unit it pays, {sign}{profit} the per-unit margin against `baseline`, {hops} the jump distance. line2NoHops is the row without a charted hop count.",
"title": "SET DESTINATION",
"confirmLabel": "SET DESTINATION",
"cancelLabel": "CANCEL",
"accent": "#00e5ff",
"width": 520,
"height": 176,
"line1": "DESTINATION \u2014 {object} \u00b7 {system}",
"line2": "{class}SELLS {price} CR/UNIT \u00b7 {sign}{profit}/UNIT \u00b7 {hops} HOPS",
"line2NoHops": "{class}SELLS {price} CR/UNIT \u00b7 {sign}{profit}/UNIT"
},
"profitBar": {
"_comment": "the per-unit margin rail under each exit row \u2014 scaled against the list's best row, so goods of different value compare the same way",
"track": "#14324f",

View File

@ -0,0 +1,370 @@
/**
* SHIP console → CARGO HOLDS → BEST EXITS tap test (dev tool, Node, no
* browser):
*
* node dev/ship-cargo-dest.test.mjs
*
* The bug this guards: the bay manifest listed a held good's charted exits
* as pure TEXT — clicking a destination did nothing, so the player could
* never set a route from the ship console. The rows are now tap surfaces and
* CONFIRM routes the ship through the map's own seam (GameScene.setDestination),
* exactly like the planet shop's scout panel (js/ui/shop/ScoutPanel.js →
* SurfaceScene.askSetDestination).
*
* Asserted with the REAL js/ui/ShipWindow.js over the real data files + a real
* generated galaxy (a stub scene — phaser-loader.mjs swaps in dev/phaser-stub.mjs):
*
* - HOSTING: the confirm is a ConfirmOverlay CHILD of the window at depth 30
* (above the plate / feed / glitch layers) — it can never be hidden behind
* the console it belongs to, which is what "not visible to the player" was;
* - THE ROWS: every charted exit row is tappable and carries onTap(); the row
* for the system the ship is IN reads HERE and has no tap surface;
* - THE ASK: tapping a row opens the dialog with data/shipScreen.json →
* cargo.detail.setDestination's title / SET DESTINATION / CANCEL labels and
* body lines built from the exit (world, system, class, price, margin);
* - CONFIRM: fires onSetDestination(systemId, objectId) for that exit's own
* WORLD once the collapse lands — and only then;
* - CANCEL / scrim: closes with no destination written;
* - CLOSE: taking the console down takes the dialog with it (no orphan scrim
* swallowing clicks);
* - A repaint while the dialog is up (the 250ms telemetry poll) never drops
* it.
*/
process.env.NODE_ENV = 'dev';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
import { readdirSync, readFileSync } from 'node:fs';
const { config } = await import('../js/config/Config.js');
const data = {};
for (const f of readdirSync(new URL('../data', import.meta.url))) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
}
config.init(data);
const Phaser = (await import('../js/vendor/phaser.js')).default;
const { CargoHold } = await import('../js/economy/CargoHold.js');
const { Galaxy } = await import('../js/galaxy/Galaxy.js');
const { scoutGood } = await import('../js/economy/TradeScout.js');
const { ShipWindow } = await import('../js/ui/ShipWindow.js');
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// ---------------------------------------------------------------------------
// A headless scene: just enough for the console + ConfirmOverlay to build.
// ---------------------------------------------------------------------------
function stubObject(extra = {}) {
return {
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setBlendMode() { return this; },
setInteractive(opts) { this._interactive = opts; this._events = this._events || {}; return this; },
setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; },
on(ev, fn) { (this._events ||= {})[ev] = [...(this._events?.[ev] ?? []), fn]; return this; },
once() { return this; },
off() { return this; },
emit(ev, ...a) { (this._events?.[ev] ?? []).forEach((fn) => fn(...a)); return this; },
setText(s) { this.text = String(s); return this; },
setColor() { return this; },
setFontSize(px) { this._fontSize = px; return this; },
setWordWrap() { return this; },
setWordWrapWidth(w) { this._wrapW = w; return this; },
setLineSpacing() { return this; },
setPadding() { return this; },
setScrollFactor() { return this; },
setDepth(d) { this.depth = d; return this; },
set() { return this; },
setVisible(v) { this.visible = v; return this; },
setDisplaySize(w, h) { this.width = w; this.height = h; return this; },
setVolume() { return this; },
setLoop() { return this; },
play() { return this; },
stop() { return this; },
resume() { return this; },
pause() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
lineBetween() { return this; },
rect() { return this; },
fillRect() { return this; },
strokeRect() { return this; },
lineTo() { return this; },
moveTo() { return this; },
strokeCircle() { return this; },
fillCircle() { return this; },
fillRoundedRect() { return this; },
strokeRoundedRect() { return this; },
fillGradientRect() { return this; },
setTexture() { return this; },
beginPath() { return this; },
closePath() { return this; },
arc() { return this; },
fill() { return this; },
stroke() { return this; },
clear() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
}
// One clock for the whole harness (the dialog's open/close animations are
// driven by it — a fresh clock per window would run time BACKWARDS).
const clock = { now: 0 };
function makeScene() {
const scene = {
scale: { width: 1280, height: 720, on() { return this; } },
add: {
text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style, width: 64, height: 18 }),
image: (x, y, key, frame) => stubObject({ x, y, key, frame }),
graphics: () => stubObject({}),
circle: (x, y, r) => stubObject({ x, y, r }),
rectangle: (x, y, w, h, key, alpha) => stubObject({ x, y, width: w, height: h, key, alpha }),
video: () => stubObject({}),
tileSprite: (x, y, w, h, key) => stubObject({ x, y, width: w, height: h, key }),
existing: (o) => o,
},
tweens: {
add: (t) => {
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
t.onComplete?.(); // the console's close tween finishes instantly headless
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: clock, // SHARED so every window sees one monotonic clock
input: { pointer: {}, on() { return this; }, off() { return this; } },
events: { on() { return this; }, off() { return this; }, once() { return this; }, emit() { return this; } },
// true = "already baked": the console skips its document.createElement
// texture authoring (there is no DOM headless).
textures: { exists: () => true, addCanvas() {} },
cameras: { main: { setScroll() { return this; }, flash() { return this; } } },
game: { loop: clock },
playSfx() {},
};
scene.add.container = (x, y) => new Phaser.GameObjects.Container(scene, x, y);
return scene;
}
// ---------------------------------------------------------------------------
// The run: a real galaxy with several charted systems + a hold carrying goods.
// ---------------------------------------------------------------------------
const CFG = config.section('shipScreen.cargo.detail', {});
const ASK = CFG.setDestination ?? {};
const galaxy = Galaxy.create('orbit-ship-cargo-dest');
const localSystemId = galaxy.homeSystemId;
// TradeScout only ranks exits among systems the run has ENTERED — chart a few.
const visited = [localSystemId, ...[...galaxy.byId.keys()].filter((id) => id !== localSystemId).slice(0, 12)];
function snapshot(items) {
const hold = new CargoHold(Math.max(items.length, 4));
if (items.length) hold.add(items);
return {
live: { hull: 100, hullMax: 100, shields: 60, shieldsMax: 60 },
ship: {
stats: { ...data.ship.stats, cargoHold: Math.max(items.length, 4), mineralStorage: 100 },
minerals: 12,
credits: 2500,
cargo: { used: hold.used, capacity: hold.capacity, items: [...hold.items.map((i) => i.id ?? i)] },
},
level: null,
playerClass: null,
};
}
// The good with the most charted exits (the manifest needs a real list).
const goods = config.get('goods.goods', []);
let bestGood = null;
let bestReport = null;
for (const g of goods) {
const rep = scoutGood(g.id, {
galaxy,
systemId: localSystemId,
buyUnit: Number(g.baseValue) || 0,
visited,
topN: Math.max(1, Number(CFG.topN) || 3),
});
const remote = (rep?.exits ?? []).filter((e) => e.systemId !== localSystemId).length;
if (!bestReport || remote > (bestReport.exits.filter((e) => e.systemId !== localSystemId).length)) {
bestReport = rep;
bestGood = g;
}
}
check(`the fixture has charted exits (${bestReport.exits.length} rows for ${bestGood.id})`, bestReport.exits.length >= 2);
function makeWindow({ isLocalSystem } = {}) {
const scene = makeScene();
const calls = [];
const snap = snapshot([bestGood.id]);
const win = new ShipWindow(scene, {
getSnapshot: () => snap,
// GameScene.shipScreenScoutReport — the same call the console makes live.
getScoutReport: (goodId) => {
const g = goods.find((x) => x.id === goodId);
return scoutGood(goodId, {
galaxy,
systemId: localSystemId,
buyUnit: Number(g?.baseValue) || 0,
visited,
topN: Math.max(1, Number(CFG.topN) || 3),
});
},
onSetDestination: (systemId, objectId) => calls.push({ systemId, objectId }),
isLocalSystem: isLocalSystem ?? ((id) => id === localSystemId),
});
win._snap = snap;
win.openTab('cargo');
win._selectedBay = 0; // what tapping a filled bay does
win._buildBody(snap);
return { scene, win, calls };
}
/** Advance the shared clock, driving the dialog every frame (what the console
* does through ShipWindow.update). */
function pump(win, ms, stepMs = 16) {
for (let elapsed = 0; elapsed <= ms; elapsed += stepMs) {
clock.now += stepMs;
win.dialog.update(clock.now);
}
}
// The rows a window draws come from its OWN scoutGood() call, so rows are
// matched by system id (the fixture is deterministic — same galaxy, same
// charted list, same ranking).
const remote0 = bestReport.exits.find((e) => e.systemId !== localSystemId);
const rowBy = (win, sysId) => win._exitRows.find((r) => r && r.exit?.systemId === sysId);
// ===========================================================================
console.log('== HOSTING — the confirm is a child of the console, above its chrome ==');
{
const { win } = makeWindow();
check('the window owns a ConfirmOverlay', !!win.dialog && win.dialog.constructor.name === 'ConfirmOverlay');
check('it is a CHILD of the window container (fades + dies with it)', win.children?.includes(win.dialog) === true);
check('depth 30 inside the console (plate/feed/glitch are all below it)', win.dialog.depth === 30);
check(
'the dialog is sized from data (cargo.detail.setDestination, capped to the plate)',
win.dialog.baseW === Math.min(ASK.width, win.geo.plateW - 30) && win.dialog.baseH === ASK.height
);
check('the scrim spans the whole screen', win.dialog.areaW === win.scene.scale.width && win.dialog.areaH === win.scene.scale.height);
check('it starts hidden and inert', win.dialog.state === 'hidden' && win.isDialogOpen === false);
}
console.log('\n== THE ROWS — every charted exit is a tap surface, HERE is not ==');
{
const { win } = makeWindow();
const rows = win._exitRows.filter(Boolean);
check(`one row per drawn exit (${rows.length})`, rows.length === Math.min(bestReport.exits.length, Number(CFG.topN) || 3));
check(
'a remote exit row is tappable (onTap + a hit area)',
typeof rowBy(win, remote0.systemId)?.onTap === 'function' && !!rowBy(win, remote0.systemId)?.hit
);
// The row for the system the ship is IN: HERE, and inert (pointing the ship
// at itself is the dead end SurfaceScene's scout guards the same way).
const { win: winHere } = makeWindow({ isLocalSystem: (id) => id === remote0.systemId });
const rec = rowBy(winHere, remote0.systemId);
check(`the local row (${remote0.systemName}) reads HERE`, rec?.here === true);
check('HERE has no tap surface (never a destination)', rec?.onTap === null);
const hint = win._kids.filter((k) => typeof k.text === 'string').map((k) => k.text);
check(
`the rows announce themselves ("${String(CFG.exitHint).toUpperCase()}")`,
hint.includes(String(CFG.exitHint).toUpperCase()) && hint.includes(String(CFG.exitCue).toUpperCase()) || hint.some((t) => t.includes('TAP'))
);
}
console.log('\n== THE ASK — a tap opens SET DESTINATION / CANCEL ==');
{
const { win, calls } = makeWindow();
rowBy(win, remote0.systemId).onTap();
const d = win.dialog;
check('the dialog is up', d.state !== 'hidden' && win.isDialogOpen === true);
pump(win, 400);
check(`title decodes to "${ASK.title}"`, d.title.text === String(ASK.title));
check(`confirm label "${String(ASK.confirmLabel).toUpperCase()}"`, d.confirmBtn.labelText.text === String(ASK.confirmLabel).toUpperCase());
check(`cancel label "${String(ASK.cancelLabel).toUpperCase()}"`, d.cancelBtn.labelText.text === String(ASK.cancelLabel).toUpperCase());
const body = d.bodyTexts.map((t) => t.text);
console.log(' ', JSON.stringify(body));
check('line 1 names the exit WORLD + system', body[0] === `DESTINATION — ${String(remote0.objectName ?? remote0.systemName).toUpperCase()} · ${String(remote0.systemName).toUpperCase()}`);
check('line 2 prices it (sell price + per-unit margin)', body[1].includes(`${remote0.price} CR/UNIT`) && body[1].includes(`${Math.abs(remote0.profit)}/UNIT`));
check('the plate is on screen inside the scrim', d.state === 'shown' || d.state === 'opening');
// no destination written while the player has not answered
check('nothing is set before the answer', calls.length === 0);
}
console.log('\n== CONFIRM — the seam gets that exit\'s system + world ==');
{
const { win, calls } = makeWindow();
rowBy(win, remote0.systemId).onTap();
pump(win, 400);
win.dialog.fireConfirm(); // the CONFIRM button's own action
check('the seam has NOT fired mid-animation', calls.length === 0);
pump(win, 400);
check('CONFIRM fires onSetDestination once', calls.length === 1);
check(`with the exit's system (${calls[0]?.systemId})`, calls[0]?.systemId === remote0.systemId);
check(`+ the exit's world (${String(calls[0]?.objectId)})`, calls[0]?.objectId === (remote0.objectId ?? null));
check('the dialog is down again', win.isDialogOpen === false && win.dialog.state === 'hidden');
}
console.log('\n== CANCEL / SCRIM — no destination ==');
{
const { win, calls } = makeWindow();
rowBy(win, remote0.systemId).onTap();
pump(win, 400);
win.dialog.cancel(); // the CANCEL button, the scrim's press, or ESC
pump(win, 400);
check('CANCEL writes nothing', calls.length === 0);
check('the dialog is down', win.isDialogOpen === false && win.dialog.state === 'hidden');
// a second tap asks again (the dialog instance is reusable)
rowBy(win, remote0.systemId).onTap();
pump(win, 400);
check('a later row still asks', win.isDialogOpen === true);
}
console.log('\n== CLOSE — the dialog goes with the console ==');
{
const { win, calls } = makeWindow();
rowBy(win, remote0.systemId).onTap();
pump(win, 400);
win.close();
check('closing cancels an open confirm', win.dialog.state === 'closing' || win.dialog.state === 'hidden');
pump(win, 400);
check('nothing is left up to swallow clicks', win.isDialogOpen === false && win.dialog.state === 'hidden');
check('and nothing was set', calls.length === 0);
}
console.log('\n== REPAINT — the telemetry poll never drops the ask ==');
{
const { win } = makeWindow();
rowBy(win, remote0.systemId).onTap();
pump(win, 300);
const before = win.dialog.bodyTexts.map((t) => t.text);
// The 250 ms telemetry poll: ShipWindow.update() on the SAME clock, with the
// dialog mid-animation (it owns the screen — the window must not drop it).
clock.now += 1000;
win.update(clock.now);
check('the dialog survives a body repaint', win.isDialogOpen === true);
pump(win, 400);
check('still the same ask', win.dialog.bodyTexts.map((t) => t.text).join('|') === before.join('|'));
win.dialog.fireConfirm();
pump(win, 400);
check('and it still works', true);
}
console.log(failures === 0 ? '\nSHIP cargo exit confirm: ALL PASS' : `\n${failures} FAILURE(S)`);
process.exit(failures ? 1 : 0);

View File

@ -182,6 +182,64 @@ try {
await sleep(700);
}
// ---- TAP A BEST EXIT (?tapExit=<row> | ?clickExit=<row>&answer=confirm|cancel)
// The manifest's rows are tap surfaces: pressing one asks the player to lock
// that exit in, and CONFIRM routes the ship through GameScene.setDestination.
// ?tapExit — the row's own press seam (what its hit area calls), so the
// dialog + routing run without needing a live pointer.
// ?clickExit — a REAL pointerdown/up dispatched on the canvas at the row's
// rect, which is the thing that used to do nothing: it proves
// Phaser's hit-test reaches the row (Phaser reads `button`, so
// MouseEvent is enough).
let exitTap = 'n/a';
const rowIdx = q.has('clickExit') ? Number(q.get('clickExit')) : q.has('tapExit') ? Number(q.get('tapExit')) : null;
if (rowIdx !== null && win?.isOpen) {
const rec = (win._exitRows ?? []).filter(Boolean)[Number.isFinite(rowIdx) ? rowIdx : 0];
const before = { sys: s.destination?.systemId ?? null, obj: s.destination?.objectId ?? null };
if (!rec) {
exitTap = `row ${rowIdx}: none drawn`;
} else if (q.has('clickExit')) {
// A REAL mouse press on the row. Phaser binds classic mouse events on the
// canvas and recomputes the pointer's world coords per loop step, so the
// move needs a frame before the down (dev/comms-click.mjs's lesson).
const cv = s.game.canvas;
const r = cv.getBoundingClientRect();
const kx = r.width / s.game.scale.width;
const ky = r.height / s.game.scale.height;
const cx = r.left + (rec.hit.x + rec.hit.width / 2) * kx;
const cy = r.top + (rec.hit.y + rec.hit.height / 2) * ky;
const mk = (type, buttons) =>
new MouseEvent(type, { bubbles: true, composed: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0, buttons });
cv.dispatchEvent(mk('mousemove', 0));
s.game.loop.step(performance.now());
cv.dispatchEvent(mk('mousedown', 1));
cv.dispatchEvent(mk('mouseup', 0));
s.game.loop.step(performance.now());
await sleep(700); // the decode-in
exitTap = `CLICKED ${rec.exit?.objectName ?? rec.exit?.systemName} at scene (${Math.round(rec.hit.x + rec.hit.width / 2)},${Math.round(rec.hit.y + rec.hit.height / 2)}) · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}${rec.here ? ' (HERE — inert)' : ''}`;
} else if (typeof rec.onTap !== 'function') {
exitTap = `${rec.exit?.systemName}: no tap surface (HERE)`;
} else {
rec.onTap();
await sleep(700);
exitTap = `tapped ${rec.exit?.objectName ?? rec.exit?.systemName} · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}`;
}
const answer = q.get('answer');
if (answer === 'confirm' && win.isDialogOpen) {
win.dialog.fireConfirm();
await sleep(900); // the collapse — the seam fires when it lands
// The destination is the ONLY stored state — the path derives from it
// (GameScene.routePlan, js/galaxy/Route.js), so check both.
const plan = s.routePlan?.() ?? null;
exitTap +=
` → CONFIRM dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · registry=${s.registry.get('destination')?.systemId ?? '—'} · console=${win.isOpen ? 'OPEN' : 'closed'} · route=${plan?.hops ?? 0} hop(s), ${s.routeEdgeKeys?.().length ?? 0} lane(s)`;
} else if (answer === 'cancel' && win.isDialogOpen) {
win.dialog.cancel();
await sleep(600);
exitTap += ` → CANCEL dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · dialog=${win.isDialogOpen ? 'OPEN' : 'closed'}`;
}
}
// ---- live refresh while open (?mutate=1) -------------------------------
// The console polls the scene's snapshot seam on its own beat; changing the
// hold through the run's OWN api (a buy landing in a bay, ore aboard) must
@ -222,6 +280,7 @@ try {
`minerals ${s.ship.minerals}/${s.ship.stats.mineralStorage} · credits ${s.ship.credits}`,
`locked tab: ${locked}`,
`live update: ${liveUpdate}`,
`exit tap: ${exitTap}`,
];
setReport(lines);
window.__SHIP_SHOT = { ready: true, lines, errors };

View File

@ -1384,6 +1384,18 @@ export class GameScene extends Phaser.Scene {
this.shipWindow = new ShipWindow(this, {
getSnapshot: () => this.shipScreenSnapshot(),
getScoutReport: (goodId) => this.shipScreenScoutReport(goodId),
// A BEST-EXIT row on the bay manifest was CONFIRMED (the console's own
// SET DESTINATION dialog, js/ui/ConfirmOverlay.js): the exit's system +
// its WORLD become the route target through the map's seam. The console
// closes on the way out (what MapWindow does), so the DESTINATION SET
// toast and the orange compass are actually on screen for it.
onSetDestination: (systemId, objectId) => {
this.shipWindow?.close();
this.setDestination(systemId, objectId);
},
// The row for the system the ship is IN can never be a destination — the
// manifest marks it HERE and leaves it inert.
isLocalSystem: (systemId) => !!systemId && systemId === this.systemRecord?.id,
onLocked: () =>
this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), {
glyph: '✕',
@ -7071,8 +7083,13 @@ export class GameScene extends Phaser.Scene {
this.questWindow.close();
return;
}
// The ship console (depth 80) — the same contract.
// The ship console (depth 80) — the same contract, and its bay-manifest
// confirm sits on top: ESC cancels the dialog, not the console.
if (this.shipWindow && this.shipWindow.isOpen) {
if (this.shipWindow.isDialogOpen) {
this.shipWindow.dialog.cancel();
return;
}
this.shipWindow.close();
return;
}

View File

@ -39,7 +39,11 @@
* CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay)
* + the aggregate mineral load; a filled bay opens its manifest
* with the charted exits (js/economy/TradeScout.js), the same
* report the planet shop's scout panel renders.
* report the planet shop's scout panel renders. A row is
* TAPPABLE: it asks (the map console's ConfirmOverlay, hosted by
* this window so it always paints above the console) and CONFIRM
* sets that exit's world as the destination through the scene's
* onSetDestination seam — the shop scout's contract
*
* The window is a VIEW. Every number comes from the scene's snapshot seam
* (GameScene.shipScreenSnapshot — ship.stats, the live combat session, the XP
@ -57,6 +61,7 @@ import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { CyberShape } from './CyberShape.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js';
import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js';
@ -133,6 +138,13 @@ function brackets(g, x, y, w, h, o = {}) {
g.lineBetween(x2, y2, x2, y2 - len);
}
/** A data template: every `{key}` the map carries is filled in (an unknown
* placeholder is left alone, so a typo in data never prints "undefined"). */
function tpl(str, map = {}) {
return String(str ?? '').replace(/\{(\w+)\}/g, (m, k) =>
(map[k] === undefined || map[k] === null ? m : String(map[k])));
}
/** A filled diamond marker (group bullets, the journey's nodes). */
function diamond(g, x, y, r, color, alpha = 1) {
if (!(r > 0)) return;
@ -157,6 +169,13 @@ export class ShipWindow extends Phaser.GameObjects.Container {
* @param {(goodId: string) => object|null} [o.getScoutReport] — a held
* good's trade report (TradeScout.scoutGood over the run's chart), shown
* by the bay manifest's BEST EXITS list
* @param {(systemId: string, objectId: string|null) => void} [o.onSetDestination]
* a BEST-EXIT row's confirm landed (the manifest's SET DESTINATION dialog)
* — the scene records the route target (GameScene.setDestination, the same
* seam the map console's SYSTEM tab uses)
* @param {(systemId: string) => boolean} [o.isLocalSystem] — is this the
* system the ship is IN right now (a local exit row can never be a
* destination, so it reads as HERE and stays inert)
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
@ -169,6 +188,8 @@ export class ShipWindow extends Phaser.GameObjects.Container {
this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
this.onChooseClass = typeof o.onChooseClass === 'function' ? o.onChooseClass : null;
this.getScoutReport = typeof o.getScoutReport === 'function' ? o.getScoutReport : null;
this.onSetDestination = typeof o.onSetDestination === 'function' ? o.onSetDestination : null;
this.isLocalSystem = typeof o.isLocalSystem === 'function' ? o.isLocalSystem : null;
this.openState = 'closed'; // closed | opening | open | closing
this.reveal = []; // { o, d, dur, t0 } — the boot stagger
@ -179,6 +200,7 @@ export class ShipWindow extends Phaser.GameObjects.Container {
this._tabShakes = [];
this._kids = []; // every child of the current body repaint
this._hits = [];
this._exitRows = []; // the manifest's rows ({ exit, here }) — one per row
this._glitch = { until: 0, next: 0 };
this._sweep = null;
this._pollNext = 0;
@ -327,6 +349,24 @@ export class ShipWindow extends Phaser.GameObjects.Container {
this.glitchG = s.graphics().setScrollFactor(0);
this.add(this.glitchG);
// The exit CONFIRM (js/ui/ConfirmOverlay.js — the map console's SET
// DESTINATION dialog, one component three screens deep). A CHILD of this
// window at depth 30: it paints over the plate, the feed and the glitch
// layer, fades out with the close tween and dies with destroy(). Its scrim
// covers the whole screen, which is also what makes the console inert while
// the dialog is up (the scrim's own press IS the cancel).
const dcfg = config.section('shipScreen.cargo.detail.setDestination', {});
this.dialog = new ConfirmOverlay(
this.scene,
geo.plateX + geo.plateW / 2,
geo.plateY + geo.plateH / 2,
Math.max(280, Math.min(Number(dcfg.width) || 520, geo.plateW - 30)),
Number(dcfg.height) || 176,
{ areaW: geo.W, areaH: geo.H }, // scrim = the whole console
);
this.dialog.setDepth(30);
this.add(this.dialog);
this._paintPlate();
this._paintAll(null); // the empty state until the first snapshot
}
@ -731,6 +771,7 @@ export class ShipWindow extends Phaser.GameObjects.Container {
}
this._kids = [];
this._hits = [];
this._exitRows = [];
if (this.bodyCont.list) this.bodyCont.list.length = 0;
const s = snap ?? this._snap ?? {};
if (this._activeTab === 'cargo') this._buildCargoBody(s);
@ -1209,7 +1250,8 @@ export class ShipWindow extends Phaser.GameObjects.Container {
}
/** A held good's manifest — the product summary + the charted exits (the
* same scoutGood() report the planet shop's scout panel renders). */
* same scoutGood() report the planet shop's scout panel renders). Each row
* is a tap surface: it asks SET DESTINATION through _askSetDestination. */
_paintManifest(bay, cfg, snap) {
const { plateX, plateY, plateW, plateH } = this.geo;
const x = plateX + 18;
@ -1267,25 +1309,88 @@ export class ShipWindow extends Phaser.GameObjects.Container {
const maxProfit = Math.max(1, Number(rep?.maxProfit) || 0, ...rows.map((e) => Math.abs(Number(e.profit) || 0)));
const pb = cfg.profitBar ?? null;
const nameW = w - 210;
const hover = cfg.exitHover ?? {};
const hotFill = col(hover.fill ?? '#0c2740');
rows.forEach((e, i) => {
const ry = ety + 30 + i * 46;
// The local market is in the charted list too (0 hops) and can never be
// a destination — pointing the ship at itself, so it reads HERE and has
// no tap surface (the shop scout's row, minus that dead end).
const here = e.systemId ? safe(() => !!this.isLocalSystem?.(e.systemId)) : false;
// The row record (the shop scout's convention — dev/tests can drive a
// row without a real pointer: ShipWindow._exitRows[i].onTap()).
const rec = { exit: e, here, onTap: null, hit: null };
this._exitRows[i] = rec;
const accent = i === 0 ? C.amber : 0x1b3a5a;
const p = Number(e.profit) || 0;
// The plate is the row's TAP SURFACE (v4 only hit-tests a graphics object
// that actually renders, so the hover state repaints this same object —
// see button()).
const rg = this.gfx();
panel(rg, x, ry, w, 40, { notch: 6, fill: 0x04070f, fillAlpha: 0.7, stroke: i === 0 ? C.amber : 0x1b3a5a, strokeAlpha: i === 0 ? 0.8 : 0.7 });
let cue = null;
const paint = (on) => {
rg.clear();
panel(rg, x, ry, w, 40, {
notch: 6,
fill: on ? hotFill : 0x04070f,
fillAlpha: on ? (hover.fillAlpha ?? 0.85) : 0.7,
stroke: on ? C.neon : accent,
strokeAlpha: on ? 0.95 : (i === 0 ? 0.8 : 0.7),
glow: on ? C.neon : undefined,
});
if (pb) {
const rw = Math.max(60, nameW);
rg.fillStyle(col(pb.track ?? '#14324f'), 0.9);
rg.fillRect(x + 38, ry + 33, rw, 3);
rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95);
rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3);
}
if (cue) cue.setAlpha(on ? 1 : 0);
};
paint(false);
this.txt(x + 12, ry + 6, `${i + 1}`, { color: i === 0 ? C.amber : C.faint, size: 14, head: true, bold: true });
this.txt(x + 38, ry + 4, String(e.objectName ?? e.systemName), { color: C.ink, size: 12, ls: 1.2, bold: true, wrap: nameW });
const sub = [String(e.clsLabel ?? '').toUpperCase(), String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?')].filter(Boolean).join(' · ');
const meta = here
? String(cfg.hereTag ?? 'HERE')
: String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?');
const sub = [String(e.clsLabel ?? '').toUpperCase(), meta].filter(Boolean).join(' · ');
if (sub) this.txt(x + 38, ry + 19, sub, { color: C.faint, size: 8.5, ls: 1.6 });
const p = Number(e.profit) || 0;
if (pb) {
const rw = Math.max(60, nameW);
rg.fillStyle(col(pb.track ?? '#14324f'), 0.9);
rg.fillRect(x + 38, ry + 33, rw, 3);
rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95);
rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3);
}
this.txt(x + w - 14, ry + 3, `${e.price}`, { color: C.amber, size: 13, bold: true, origin: [1, 0] });
this.txt(x + w - 14, ry + 20, `${p >= 0 ? '+' : '−'}${Math.abs(p)} / UNIT`, { color: p > 0 ? C.green : C.red, size: 9, ls: 1.4, origin: [1, 0] });
if (here) return; // no tap surface at all — HERE is not a destination
// the hover cue in the row's own voice (what pressing it does)
cue = this.txt(x + w - 14, ry + 31, String(cfg.exitCue ?? 'TAP TO SET DESTINATION').toUpperCase(), {
color: C.neon, size: 7.5, ls: 1.8, origin: [1, 0],
}).setAlpha(0);
const tap = () => {
if (!this.isOpen || this.isDialogOpen) return; // the dialog owns the screen
this.sfx('ui_click');
this._askSetDestination(e);
};
rec.onTap = tap;
const area = new Phaser.Geom.Rectangle(x, ry, w, 40);
rec.hit = area; // the row's screen rect (dev probes / hit tests)
rg.setInteractive({
useHandCursor: true,
hitArea: area,
hitAreaCallback: (a, px, py) => a.contains(px, py),
});
rg.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
paint(true);
});
rg.on('pointerout', () => paint(false));
rg.on('pointerdown', tap);
this._hits.push(rg);
});
// the one line that makes the rows discoverable (data → exitHint)
if (rows.some((e) => !safe(() => !!this.isLocalSystem?.(e.systemId)))) {
const hy = Math.min(plateY + plateH - 58, ety + 34 + rows.length * 46);
this.txt(x, hy, String(cfg.exitHint ?? 'TAP AN EXIT TO SET IT AS YOUR DESTINATION').toUpperCase(), {
color: C.faint, size: 8.5, ls: 2,
});
}
// back to the grid
const bw = 200;
@ -1304,6 +1409,53 @@ export class ShipWindow extends Phaser.GameObjects.Container {
}
}
/**
* A BEST-EXIT row was tapped — the SET DESTINATION confirm, the planet
* shop's own contract (js/ui/shop/ScoutPanel.js → SurfaceScene
* .askSetDestination) in the ship console: the pick is confirmed on the
* window-hosted dialog (this.dialog, depth 30 — above the plate, the feed
* and the glitch layer, so it can never be hidden by the console it belongs
* to) and CONFIRM hands the exit's system + world to the scene's
* onSetDestination seam — GameScene.setDestination, the map's own target
* model. CANCEL / the scrim / ESC leave the manifest exactly as it was.
*/
_askSetDestination(e) {
if (!e || typeof e.systemId !== 'string' || !this.dialog) return;
const cfg = config.section('shipScreen.cargo.detail.setDestination', {});
const sys = String(e.systemName ?? e.systemId).toUpperCase();
const obj = String(e.objectName ?? '').toUpperCase();
const p = Number(e.profit) || 0;
const map = {
system: sys,
object: obj || sys,
class: e.clsLabel ? `${String(e.clsLabel).toUpperCase()} · ` : '',
price: Number(e.price) || 0,
sign: p >= 0 ? '+' : '−',
profit: Math.abs(p),
hops: Number.isFinite(e.hops) ? e.hops : '?',
};
this.dialog.show({
title: String(cfg.title ?? 'SET DESTINATION'),
body: [
tpl(cfg.line1 ?? 'DESTINATION — {object} · {system}', map),
Number.isFinite(e.hops)
? tpl(cfg.line2 ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT · {hops} HOPS', map)
: tpl(cfg.line2NoHops ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT', map),
],
accent: col(cfg.accent ?? '#00e5ff', C.neon),
confirmLabel: String(cfg.confirmLabel ?? 'SET DESTINATION'),
cancelLabel: String(cfg.cancelLabel ?? 'CANCEL'),
onConfirm: () => safe(() => this.onSetDestination?.(e.systemId, e.objectId ?? null)),
time: this.scene.time.now,
});
}
/** The confirm is up — it owns the screen (the scrim's press is the cancel).
* The scene reads this for ESC (dialog first, then the console). */
get isDialogOpen() {
return !!(this.dialog && this.dialog.isOpen);
}
// ------------------------------------------------------------ repaint
/** Repaint the whole console from a snapshot (the tab body + the strip). */
_paintAll(snap) {
@ -1351,6 +1503,9 @@ export class ShipWindow extends Phaser.GameObjects.Container {
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
this.openState = 'closing';
// An open confirm goes with the window (the map console's rule) — left
// up, its scrim would still own every click while the console is gone.
if (this.dialog && this.dialog.isOpen) this.dialog.cancel();
this.video?.pause?.();
this.sfx('ui_close');
this.scene.tweens.add({
@ -1499,6 +1654,9 @@ export class ShipWindow extends Phaser.GameObjects.Container {
// the REC dot breathes
this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004)));
// the exit confirm, while it is up (its split-open animation + buttons)
if (this.dialog) this.dialog.update(time);
// poll the scene — repaint on any real change (a hull hit, a level-up,
// a buy/sell, ore mined)
if (time >= this._pollNext) {