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:
parent
7549c91df1
commit
b6523808fa
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) }; });
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue