Name scout exits by their world, not just the system

- TradeScout now resolves each exit's specific object (planet of that class, deep-space station, or home world) and exposes objectId/objectName so the destination points at the route's final stop rather than a bare system.
- SurfaceScene dialog and toast show "WORLD · SYSTEM" and pass the object id to GameScene.setDestination; null falls back to system level.
- ScoutPanel exit rows display the world name + hops instead of the class label, drop the per-section tap hint and footer (they collided with the last row in a short panel), and rename "SIGHT ON" to "SOUGHT ON".
- Save/restore now carries ship.credits and ship.cargo across jumps; GameScene restores both and refreshes the credits HUD. Saves tests cover capture, prepare, and legacy records without these fields.
- trade-scout tests assert every exit has objectId/objectName and that resolved ids are real objects of the exit class; scout-dest test verifies the confirmed destination names a real world.
- Add dev/scout-overlap probe (html + mjs) to screenshot the scout panel and numerically report any overlapping text boxes.
- Ship lava-planet-03 art and lava-land-03 video assets.
This commit is contained in:
Brian Fertig 2026-09-12 20:53:16 -06:00
parent 7549c91df1
commit b6523808fa
12 changed files with 319 additions and 51 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

View File

@ -141,9 +141,8 @@
"ease": "Cubic.Out"
},
"scout": {
"_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns) — EXCEPT the best-exit rows: tapping one offers the SET DESTINATION popup (setDestination below; the scene's ConfirmOverlay, same dialog as the map's). exitHint is the tap affordance on the section label. Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.",
"_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns) — EXCEPT the best-exit rows: tapping one offers the SET DESTINATION popup (the destination names the exit's WORLD — its planet of that class, the station, or the home world — not just the system; each exit carries objectId/objectName; setDestination below; the scene's ConfirmOverlay, same dialog as the map's). Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.",
"topN": 3,
"exitHint": "TAP A SYSTEM TO SET DESTINATION",
"setDestination": { "width": 520, "height": 176 },
"revealMs": 140,
"pad": 18,
@ -197,7 +196,6 @@
"#c8d4e8",
"#d09058"
],
"empty": "No charted exit yet — every CAPITAL buys everything. Chart a world that wants this good (or a capital) and it will rank here.",
"footer": "TAP ANYWHERE TO RETURN TO CARGO"
"empty": "No charted exit yet — every CAPITAL buys everything. Chart a world that wants this good (or a capital) and it will rank here."
}
}

View File

@ -41,6 +41,7 @@ const {
} = await import(file('save/SaveData.js'));
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
const { Discovery } = await import(file('galaxy/Discovery.js'));
const { CargoHold } = await import(file('economy/CargoHold.js'));
let failures = 0;
const check = (label, cond) => {
@ -53,11 +54,14 @@ const SEED = 'SAVETEST';
const galaxy = Galaxy.create(SEED, { systemCount: 8 });
const system = galaxy.byId.get(galaxy.currentSystemId); // the starting system
const fakeScene = () => ({
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
galaxy,
systemRecord: { name: system.name },
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 },
const fakeScene = () => {
const cargo = new CargoHold(4);
cargo.add(['frost-diamond', 'iron-ore']);
return {
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
galaxy,
systemRecord: { name: system.name },
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37, credits: 1250, cargo },
discovery: new Discovery(540),
depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field
tetherField: {
@ -67,7 +71,8 @@ const fakeScene = () => ({
],
},
playTimeMs: 123456,
});
};
};
const makeRec = (over = {}) => ({
app: 'orbit',
@ -210,6 +215,9 @@ const makeStorage = (fail = false) => {
check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name);
check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72);
check('capture: the hold is carried (ship.minerals)', rec.ship.minerals === 37);
check('capture: the credit ledger is carried (ship.credits)', rec.ship.credits === 1250);
check('capture: the trade hold is carried (ship.cargo, fill order)',
JSON.stringify(rec.ship.cargo) === JSON.stringify(['frost-diamond', 'iron-ore']));
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
check('capture: playtime carried', rec.playTimeMs === 123456);
@ -234,6 +242,9 @@ const makeStorage = (fail = false) => {
check('prepare: the live state is staged under the pending key',
staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456);
check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37);
check('prepare: the credit ledger rides along (ship.credits)', staged.ship.credits === 1250);
check('prepare: the trade hold rides along (ship.cargo)',
JSON.stringify(staged.ship.cargo) === JSON.stringify(['frost-diamond', 'iron-ore']));
check('prepare: the comms ledger is restored (a Set, same ids)',
reg.get('commsPlayed') instanceof Set
&& reg.get('commsPlayed').has('oac-intro')
@ -246,6 +257,9 @@ const makeStorage = (fail = false) => {
prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals
check('prepare: a legacy record (no minerals) still loads, field absent',
regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined);
check('prepare: a legacy record (no credits/cargo) still loads, fields absent',
regLegacy.get(PENDING_RESTORE_KEY).ship.credits === undefined
&& regLegacy.get(PENDING_RESTORE_KEY).ship.cargo === undefined);
check('prepare: a legacy record (no depletedClusters) stages an empty set',
regLegacy.get('depletedClusters') instanceof Set && regLegacy.get('depletedClusters').size === 0);

View File

@ -8,8 +8,10 @@
* - a tapped exit row offers the exit to the SCENE (the panel does not
* close itself — propagation stops)
* - confirming SETS the destination on the (sleeping) GameScene:
* destination.systemId = the exit, objectId null — and toasts it on
* the surface (the game scene's toast would sit frozen while sleeping)
* destination.systemId = the exit, destination.objectId = the exit's
* WORLD (its planet of that class / station / home world — the
* route's final stop) — and toasts it on the surface (the game
* scene's toast would sit frozen while sleeping)
* - the LOCAL system exit is rejected with a note (no dialog, no
* destination change — pointing the ship at itself bounces)
* - CANCEL and ESC leave the destination untouched
@ -159,8 +161,9 @@ window.game = game;
step('dialog-title', (await waitFor(() => dlg.title.text === 'SET DESTINATION')) && dlg.title.text === 'SET DESTINATION',
{ title: dlg.title.text });
const name = String(remoteExit.systemName ?? remoteExit.systemId).toUpperCase();
step('dialog-body', dlg.bodyTexts[0].text === `DESTINATION — ${name}`,
{ body0: dlg.bodyTexts[0].text, want: `DESTINATION — ${name}` });
const obj = remoteExit.objectName ? String(remoteExit.objectName).toUpperCase() : null;
step('dialog-body', dlg.bodyTexts[0].text === `DESTINATION — ${obj ? `${obj} · ${name}` : name}`,
{ body0: dlg.bodyTexts[0].text, want: `DESTINATION — ${obj ? `${obj} · ${name}` : name}` });
step('dialog-body-price', /SELLS \d+ CR\/UNIT/.test(dlg.bodyTexts[1].text ?? ''), { body1: dlg.bodyTexts[1].text });
// The scene staged the pending pick (exit + route hops) before showing.
@ -171,8 +174,17 @@ window.game = game;
dlg.fireConfirm();
if (!(await dialogHidden())) throw new Error('dialog never closed after confirm');
step('confirmed-closed', dlg.state === 'hidden');
step('destination-set', gs.destination?.systemId === remoteExit.systemId && gs.destination?.objectId === null,
{ destination: gs.destination });
step('destination-set', gs.destination?.systemId === remoteExit.systemId
&& gs.destination?.objectId === (remoteExit.objectId ?? null),
{ destination: gs.destination, wantObject: remoteExit.objectId ?? null });
if (remoteExit.objectId) {
// The destination must name a REAL object of the exit's world — a
// planet (discoveryId = its name) or the deep-space station.
const content = gs.galaxy.ensureContent(remoteExit.systemId);
const world = (content.planets ?? []).find((p) => p.name === remoteExit.objectId)
?? (content.settlements ?? []).find((s) => s.id === remoteExit.objectId);
step('destination-names-a-world', !!world, { objectId: remoteExit.objectId, world: world?.name ?? world?.id });
}
step('surface-toast', /DESTINATION SET/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
// 8 — CANCEL: the same row again → popup; CANCEL leaves everything as-is.

32
dev/scout-overlap.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev scout overlap probe</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<base href="../" />
<style>
/* Same typefaces as index.html (the probe page doesn't include it), so
screenshots show the real Ethnocentric/Centauri canvas text. */
@font-face {
font-family: 'Ethnocentric';
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
font-weight: normal; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Centauri';
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
font-weight: normal; font-style: normal; font-display: swap;
}
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/scout-overlap.mjs"></script>
</body>
</html>

128
dev/scout-overlap.mjs Normal file
View File

@ -0,0 +1,128 @@
/**
* Visual probe for a screenshot + numeric overlap report (headless brave,
* via dev/cdp-shot.mjs): boots the game, lands, opens the shop, opens the
* trade scout on a BUY row that has remote exits, and STOPS THERE (the
* scout panel is the shot's subject — no exit tapped, no dialog). It
* measures every text box in the panel (local coords) and reports any
* pairs whose bounds intersect, so text overlap is verifiable numerically
* and visually.
*
* /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank &
* python3 -m http.server 8129
* node dev/cdp-shot.mjs http://127.0.0.1:8129/dev/scout-overlap.html \
* /tmp/scout-overlap.png 'window.__SCOUT_OVERLAP__ && true' 90000
*/
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 { SurfaceScene } from '../js/scenes/SurfaceScene.js';
import { scoutGood } from '../js/economy/TradeScout.js';
// A fixed galaxy seed (GameScene.ensureGalaxy honors it) so before/after
// runs of this probe compare the SAME system names / world names.
globalThis.__ORBIT_DEV_SEED = 'scout-overlap-probe';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
(async () => {
let gs = null;
for (let i = 0; i < 300; i++) {
gs = game.scene?.getScene('GameScene') ?? null;
if (gs && gs.ship) break;
await sleep(100);
}
if (!gs || !gs.ship) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no GameScene' }; return; }
// A known ledger + cargo (something to sell), and the run has ENTERED
// every system — the scout then has remote exits to offer.
const goods = config.get('goods.goods', []);
gs.ship.credits = 10000;
gs.ship.cargo.add([goods[0].id]);
const sysId = gs.systemRecord.id;
for (const id of gs.galaxy.byId.keys()) gs.visitedSystems.add(id);
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no SurfaceScene' }; return; }
for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250);
ss.startSurface();
for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100);
await sleep(900); // the planet HUD decode
ss.toggleShop();
await sleep(200);
if (!ss.shopView) ss.buildShopView();
const sv = ss.shopView;
if (!sv) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no shopView' }; return; }
sv.setTab('trade');
await sleep(900); // the slide-in
// Open the scout on a BUY row that has a REMOTE profitable exit — the
// fullest panel state (chips + 3 exit rows). Do NOT tap an exit row.
const visited = [...gs.visitedSystems];
let row = null, exits = null;
for (const r of sv.buyList.rows) {
const d = scoutGood(r.id, { galaxy: gs.galaxy, systemId: sysId, cls: sv.cls, buyUnit: r.price, visited, topN: 3 });
if (!d) continue;
const rem = d.exits.find((e) => e.systemId !== sysId && e.profit > 0);
if (rem) { row = r; exits = d.exits; break; }
}
if (!row || !exits) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no remote exit to open' }; return; }
sv._onRowTap(row);
await sleep(400);
const panel = sv._scout;
if (!panel) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no scout panel' }; return; }
// Collect every text box in the panel (container-local coords). In this
// Giedi build the panel's own `_content` array is the flat list of
// per-show objects (text/graphics/chips). Convert origin-anchored x/y to
// the true top-left (left = x - origin.x*width) so right-/center-aligned
// text (price, profit, footer) measures correctly.
const texts = [];
const isText = (o) => o && o.text != null && typeof o.width === 'number';
const take = (o) => {
if (isText(o)) {
const ox = o.originX ?? o.origin?.x ?? 0, oy = o.originY ?? o.origin?.y ?? 0;
texts.push({
text: String(o.text).slice(0, 60),
left: Math.round(o.x - ox * o.width), top: Math.round(o.y - oy * o.height),
w: Math.round(o.width), h: Math.round(o.height),
});
}
else for (const c of o?.list ?? o?.children?.list ?? []) take(c); // containers (chips)
};
for (const o of panel._content ?? []) take(o);
texts.sort((a, b) => (a.top - b.top) || (a.left - b.left));
// Pairwise overlap of the true text boxes (ignore sub-pixel kissing).
const overlaps = [];
for (let i = 0; i < texts.length; i++) {
for (let j = i + 1; j < texts.length; j++) {
const a = texts[i], b = texts[j];
const ix = Math.min(a.left + a.w, b.left + b.w) - Math.max(a.left, b.left);
const iy = Math.min(a.top + a.h, b.top + b.h) - Math.max(a.top, b.top);
if (ix > 2 && iy > 2) overlaps.push({ a: a.text, b: b.text, ix: Math.round(ix), iy: Math.round(iy) });
}
}
window.__SCOUT_OVERLAP__ = {
pass: overlaps.length === 0,
panel: { width: panel.width, height: panel.height, rows: panel._exitRows?.length },
good: row?.id,
exits: exits.map((e) => ({ sys: e.systemName, world: e.objectName, cls: e.cls, price: e.price, profit: e.profit, hops: e.hops })),
texts,
overlaps,
};
console.info('[scout-overlap] texts=' + texts.length + ' overlaps=' + overlaps.length);
})().catch((err) => { window.__SCOUT_OVERLAP__ = { pass: false, error: String(err && err.message || err) }; });

View File

@ -115,6 +115,43 @@ const row = stock.find((s) => sellsHere(s.id, classOf(sysId).find((c) => c !== '
check('a charted CAPITAL always ranks as an exit', d.exits.some((e) => e.systemId === capId && g.economy.classBySystem.get(capId).has('capital')));
}
// ---------------------------------------------------------------------------
// 3b. Exit OBJECTS — each exit names the WORLD that pays the price
// ---------------------------------------------------------------------------
{
const visited = [...g.byId.keys()]; // everything charted — every exit class shows
let exitsChecked = 0, resolved = 0, unresolved = 0, bad = 0;
for (const good of goods) {
const d = scoutGood(good.id, { galaxy: g, systemId: sysId, cls, buyUnit: 1, visited });
for (const e of d.exits) {
exitsChecked += 1;
if (typeof e.objectId !== 'string' && e.objectId !== null) bad += 1;
if (typeof e.objectName !== 'string' && e.objectName !== null) bad += 1;
if (e.objectId == null) { unresolved += 1; continue; }
resolved += 1;
const content = g.ensureContent(e.systemId);
// The exit's WORLD must be a REAL object of the exit's class (the
// discovery id the route's final stop matches on: planets carry
// discoveryId = the planet NAME, stations the settlement id).
let ok = false;
if (e.cls === 'station') {
const st = (content.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation');
ok = !!st && e.objectId === st.id && e.objectName === (st.name ?? st.id);
} else if (e.cls === 'capital') {
const p = (content.planets ?? []).find((x) => typeof x?.class === 'string' && x.class.startsWith('capital'));
ok = !!p && e.objectId === p.name && e.objectName === p.name;
} else {
const p = (content.planets ?? []).find((x) => x?.class === e.cls);
ok = !!p && e.objectId === p.name && e.objectName === p.name;
}
if (!ok) bad += 1;
}
}
check(`every exit carries objectId/objectName fields (${exitsChecked} exits, all systems charted)`, exitsChecked > 0 && bad === 0, { exitsChecked, bad });
check('most exits resolve a WORLD (not just the system)', resolved > 0 && resolved >= unresolved, { resolved, unresolved });
check('resolved objectIds are real objects of the exit class', bad === 0, { resolved, bad });
}
// ---------------------------------------------------------------------------
// 4. Determinism — same inputs ⇒ identical report
// ---------------------------------------------------------------------------

View File

@ -48,6 +48,41 @@ export function sellsHere(goodId, cls) {
return cls === 'capital' || E.wants(goodId, cls);
}
/**
* The SPECIFIC OBJECT of a destination system that actually pays the exit
* class's price — the route's FINAL stop (the compass points at it, it
* wears the route pulse, and REACHED fires when the ship lands on it):
* · a planet class (terran/arid/rocky/gas/ice/lava) → a planet of that
* class (discovery id = the planet's NAME, unique in the system);
* · 'capital' → the capital world (class capital*);
* · 'station' → the deep-space station (a station shop IS the station —
* the only 'station'-class object in the system).
* The home world is special: it exists only in the STARTING system and is
* not in content.planets (the central body, discovery id 'home') — the
* home system's 'terran' class resolves to it. null when the content holds
* no such object (e.g. the capital pass replaced the world's class after
* the class roll) — the destination falls back to system level (objectId →
* null).
*/
function objectForClass(cls, content, isHome = false, homeCls = null) {
if (!content) return null;
if (isHome && homeCls && cls === homeCls) {
return { objectId: 'home', objectName: content.homeName ?? null };
}
const planets = Array.isArray(content.planets) ? content.planets : [];
if (cls === 'station') {
const st = (Array.isArray(content.settlements) ? content.settlements : [])
.find((s) => s?.kind === 'deepSpaceStation');
return st ? { objectId: st.id, objectName: st.name ?? st.id } : null;
}
const p = !cls
? null
: cls === 'capital'
? planets.find((x) => typeof x?.class === 'string' && x.class.startsWith('capital'))
: planets.find((x) => x?.class === cls);
return p ? { objectId: p.name, objectName: p.name } : null;
}
/**
* The scout report for one good at one shop.
*
@ -64,7 +99,7 @@ export function sellsHere(goodId, cls) {
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
* localBuy:number, localSell:number|null,
* producer:Array<{cls,label,economy}>, demand:Array<{cls,label,economy}>,
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops}>,
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops,objectId,objectName}>,
* bestExit:object|null, maxProfit:number, charted:number, topN:number
* } | null} — null only for an unknown good id.
*/
@ -92,6 +127,8 @@ export function scoutGood(goodId, o = {}) {
if (systemId) visited.add(systemId); // we are here — it's charted by definition
const classBySystem = galaxy?.economy?.classBySystem ?? null;
const homeSystemId = galaxy?.homeSystemId ?? null;
const homeCls = config.get('planets.homePlanet', 'terran');
const exits = [];
for (const sysId of visited) {
const rec = galaxy?.byId?.get?.(sysId);
@ -106,11 +143,18 @@ export function scoutGood(goodId, o = {}) {
}
if (!best) continue;
const hops = galaxy?.economy?.producerHops?.[goodId]?.[sysId];
// The WORLD this exit pays at — the destination names the planet (or
// the station / the home world), not just the system: setting it as
// the destination makes the route's final stop that object (see
// objectForClass; null → the system-level fallback).
const obj = objectForClass(best.cls, galaxy?.ensureContent?.(sysId) ?? null, sysId === homeSystemId, homeCls);
exits.push({
...best,
clsLabel: classInfo(best.cls).label,
profit: best.price - localBuy,
hops: Number.isFinite(hops) ? hops : null,
objectId: obj?.objectId ?? null,
objectName: obj?.objectName ?? null,
});
}
exits.sort((a, b) => (b.price - a.price) || String(a.systemName).localeCompare(String(b.systemName)));

View File

@ -2593,6 +2593,11 @@ export class GameScene extends Phaser.Scene {
y: arrival.y,
heading: arrival.heading,
minerals: rec.ship?.minerals ?? 0,
// The credit ledger + trade-goods hold ride the jump too (the ship
// keeps them — only the position + the system change hands). Without
// these the restore below would re-seed 0 credits and an empty hold.
credits: rec.ship?.credits ?? 0,
cargo: rec.ship?.cargo ?? [],
};
rec.tethers = []; // the destination's native set re-forms in create()
prepareLoad(this.registry, rec);
@ -5037,11 +5042,12 @@ export class GameScene extends Phaser.Scene {
// Capacity stays the ship's stat (4, or an upgraded hold).
if (this.ship.cargo) this.ship.cargo.set(r.ship.cargo ?? []);
}
// The corner readout (upper right) was built with the FRESH hold (0)
// earlier in create() — push the restored value through it. No-op
// The corner readouts (upper right) were built with the FRESH hold (0)
// earlier in create() — push the restored values through them. No-op
// when the save held nothing (set() skips unchanged values — no
// count-up, no flash), so mineral-less saves stay quiet.
this.refreshMineralHud();
this.refreshCreditsHud(); // same seam — the ledger line was set to the fresh 0 at build
if (Array.isArray(r.tethers) && r.tethers.length > 0) {
for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);
for (const t of r.tethers) {

View File

@ -481,9 +481,11 @@ export class SurfaceScene extends Phaser.Scene {
* → ShopView): the small "SET DESTINATION" popup — the same confirm the
* map's SET DESTINATION uses (js/ui/ConfirmOverlay.js), centered over the
* shop screen. Confirming records the destination on the sleeping
* GameScene (its stable seam — systemId + null object; the player flies
* the route after take-off) and toasts it here on the surface (the game
* scene's own toast would sit frozen on a sleeping scene).
* GameScene (its stable seam — the exit's system + its WORLD's object:
* the planet of the exit's class, the station, or the home world —
* null → the system-level fallback; the route's final stop is that
* object) and toasts it here on the surface (the game scene's own toast
* would sit frozen on a sleeping scene).
*/
askSetDestination(exit) {
if (!exit || typeof exit.systemId !== 'string') return;
@ -499,6 +501,7 @@ export class SurfaceScene extends Phaser.Scene {
return;
}
const name = String(exit.systemName ?? exit.systemId).toUpperCase();
const obj = exit.objectName ? String(exit.objectName).toUpperCase() : null;
const cls = exit.clsLabel ? `${exit.clsLabel} · ` : '';
const sign = exit.profit >= 0 ? '+' : '−';
const plan = planRoute(gs.galaxy, gs.systemRecord?.id, exit.systemId);
@ -506,7 +509,7 @@ export class SurfaceScene extends Phaser.Scene {
this._ensureSetDestDialog().show({
title: 'SET DESTINATION',
body: [
`DESTINATION — ${name}`,
`DESTINATION — ${obj ? `${obj} · ${name}` : name}`,
`${cls}SELLS ${exit.price} CR/UNIT · ${sign}${Math.abs(exit.profit)}/UNIT`,
],
accent: toCss(themeColor('neon', 0x00e5ff)),
@ -515,8 +518,9 @@ export class SurfaceScene extends Phaser.Scene {
const d = this._setDest;
this._setDest = null;
if (!d) return;
gs.setDestination(d.exit.systemId, null); // the map's seam (objectId → null)
const label = `DESTINATION SET — ${String(d.exit.systemName ?? d.exit.systemId).toUpperCase()}` + (d.hops == null ? '' : ` · ${d.hops} HOP(S)`);
gs.setDestination(d.exit.systemId, d.exit.objectId ?? null); // the map's seam (the exit's world, or null → system level)
const dest = String(d.exit.objectName ?? d.exit.systemName ?? d.exit.systemId).toUpperCase();
const label = `DESTINATION SET — ${dest}` + (d.hops == null ? '' : ` · ${d.hops} HOP(S)`);
this.consoleNote(label, { glyph: '◆', glyphColor: toCss(config.get('gates.route.compassColor', '#ff8c1a')) });
},
time: this.time.now,

View File

@ -10,13 +10,11 @@
* │ The lifeblood… │
* │ ────────────────────────── │
* │ GROWN ON Gas Giant │
* │ SIGHT ON Rocky · Ice … │
* │ SOUGHT ON Rocky · Ice … │
* │ ────────────────────────── │
* │ BEST EXITS — CHARTED │
* │ ① New Concord Gas 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
* │ BEST EXITS │
* │ ① New Concord 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
* │ ② … │ (the scene's ConfirmOverlay)
* │ ────────────────────────── │
* │ TAP ANYWHERE TO CLOSE │
* └────────────────────────────┘
*
* The DATA (which planets want the good, the top-N charted exits + profit)
@ -218,14 +216,7 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
// --- BEST EXITS — the charted systems ranked by sell price ------------
this._divider(pad, y, W - pad * 2);
y += 20;
label(y, `Best exits — charted (${d.exits.length ? 'top ' + d.exits.length : 'none yet'})`);
// the tap affordance, on the section label's own line (right side) —
// the rows are the actionable part of the report
const hint = String(s.exitHint ?? '').toUpperCase();
if (hint && d.exits.length > 0) {
const ht = add(text(W - pad, y + 3, hint, { fontSize: '9px', color: sec.color ?? '#3d4c74', align: 'right' }));
ht?.setOrigin?.(1, 0);
}
label(y, 'Best Exits');
y += 24;
const er = s.exitRow ?? {};
@ -243,10 +234,12 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
// rank medal
const rank = add(text(pad + 4, y + 8, String(i + 1), { fontSize: '15px', color: ranks[i] ?? '#c8d4e8', fontFamily: fontStack('header') }));
rank?.setOrigin?.(0, 0);
// system name + its class
// system name + the WORLD it pays at (its name + hops — the class
// label is dropped: the world name is what the destination points at)
const nm2 = add(text(nameX, y + 4, e.systemName, { fontSize: `${er.fontSize ?? 13}px`, color: er.color ?? '#eaf6ff' }));
nm2?.setOrigin?.(0, 0);
const cl = add(text(nameX, y + 20, `${e.clsLabel}${e.hops != null ? ` · ${e.hops} HOPS` : ''}`, { fontSize: '10px', color: '#7d92c4' }));
const parts = [e.objectName, e.hops != null ? `${e.hops} HOPS` : null].filter(Boolean);
const cl = add(text(nameX, y + 20, parts.join(' · '), { fontSize: '10px', color: '#7d92c4' }));
cl?.setOrigin?.(0, 0);
// price + profit (right block)
const pe = s.exitPrice ?? {};
@ -269,12 +262,14 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
add(bar);
}
}
// the row's TAP SURFACE — "SET DESTINATION" for this system. Built
// during show() (AFTER the panel's tap layer was made in the
// constructor), so it wins the pointer over "tap anywhere = close";
// stopPropagation keeps that layer from swallowing the tap. The
// handler is a plain function on the row record (dev/tests can fire
// it without a real pointer — ScoutPanel._exitRows[i].onTap()).
// the row's TAP SURFACE — "SET DESTINATION" for this exit's WORLD
// (its planet — or station / the home world — the route's final
// stop). Built during show() (AFTER the panel's tap layer was made
// in the constructor), so it wins the pointer over "tap anywhere
// = close"; stopPropagation keeps that layer from swallowing the
// tap. The handler is a plain function on the row record
// (dev/tests can fire it without a real pointer —
// ScoutPanel._exitRows[i].onTap()).
const hit = this.scene.add?.graphics?.();
if (hit) {
const hx = pad - 6, hy = y - 4, hw = W - pad * 2 + 12, hh = rowH;
@ -296,11 +291,9 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
});
}
// --- footer hint -------------------------------------------------------
const foot = add(text(W / 2, this.height - 14, s.footer ?? 'TAP ANYWHERE TO RETURN TO CARGO', { fontSize: '10px', color: '#3d4c74', align: 'center' }));
foot?.setOrigin?.(0.5, 1);
foot?.setWordWrap?.(true);
foot?.setWordWrapWidth?.(W - pad * 2);
// (no footer hint — the shell's top-right "TAP TO CLOSE" already says
// it, and the line collided with the last exit row in a short panel;
// less text = less overlap in this box)
return this;
}