Add set-destination from trade scout, shop HUD, and green sell selection

- Trade scout best-exit rows now open a "SET DESTINATION" confirm dialog
  (SurfaceScene.askSetDestination + ConfirmOverlay), confirming sets the
  GameScene destination; local-system exits are rejected with a note.
- Surface scene builds a corner credits/minerals readout over the shop clip,
  refreshed after trades and torn down on leave.
- Sell-bay selection restyled: green stroke + tinted fill + upper-left
  checkmark badge (data/shopui.json slot config), replacing the cyan ring.
- Add lava/capital-neutral music, videos, and landing data entries; remove
  tresle.png in favor of capital-neutral-surface.png.
- New dev probes: scout-dest, shop-hud, shop-select-shot, map-dest-shot,
  cdp-cast; extend shop-ui.test.mjs for selection visuals and exit taps.
This commit is contained in:
Brian Fertig 2026-09-12 19:12:16 -06:00
parent 141cbdb156
commit 7549c91df1
30 changed files with 1302 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

BIN
assets/music/lava-01.mp3 Normal file

Binary file not shown.

BIN
assets/music/lava-02.mp3 Normal file

Binary file not shown.

BIN
assets/music/lava-03.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -67,13 +67,13 @@
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-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" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.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" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "ice-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" },
{ "land": "capital-land-neutral.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.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" },
{ "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }

View File

@ -23,7 +23,14 @@
"8": "assets/music/rocky-03.mp3",
"9": "assets/music/ice-01.mp3",
"10": "assets/music/ice-02.mp3",
"11": "assets/music/ice-03.mp3"
"11": "assets/music/ice-03.mp3",
"12": "assets/music/lava-01.mp3",
"13": "assets/music/lava-02.mp3",
"14": "assets/music/lava-03.mp3",
"15": "assets/music/lava-01.mp3",
"16": "assets/music/lava-02.mp3",
"17": "assets/music/lava-03.mp3",
"18": "assets/music/capital-neutral-01.mp3"
},
"_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.",
"stationFrames": {}

View File

@ -29,10 +29,19 @@
"centerW": 16
},
"slot": {
"_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).",
"_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `selBgColor`/`selBgAlpha` the slightly lifted green-tinted fill a checked bay gets, and `check` the green CHECKMARK badge in the checked bay's upper-left corner (a second tap reverts all of it). `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (green = queued/sell, cyan = info, magenta = bad).",
"bgColor": "#0e1728",
"border": "#1d2e4c",
"selected": "#00e5ff",
"selected": "#7ce8a4",
"selBgColor": "#152a22",
"selBgAlpha": 0.95,
"check": {
"size": 11,
"inset": 7,
"color": "#7ce8a4",
"badgeBg": "#0a1410",
"badgeAlpha": 0.85
},
"lockAlpha": 0.3,
"iconScale": 0.62,
"name": {
@ -132,8 +141,10 @@
"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). 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 (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.",
"topN": 3,
"exitHint": "TAP A SYSTEM TO SET DESTINATION",
"setDestination": { "width": 520, "height": 176 },
"revealMs": 140,
"pad": 18,
"title": {

70
dev/cdp-cast.mjs Normal file
View File

@ -0,0 +1,70 @@
// Dev-only: CDP screencast while the scout-dest dialog is open — checks
// whether ANY live frame paints the dialog plate (vs. the stale-capture
// hypothesis). Usage:
// node dev/cdp-cast.mjs <url> <dir> '<readyExpr>' [ms]
import { mkdir, writeFile } from 'node:fs/promises';
const [url, dir, readyExpr, ms] = process.argv.slice(2);
const CDP = process.env.CDP_PORT ? `127.0.0.1:${CDP_PORT}` : '127.0.0.1:9333';
const dur = Number(ms ?? 4000);
let target = await (await fetch(`http://${CDP}/json/list`)).json()
.then((ts) => ts.find((t) => t.type === 'page'));
if (!target) {
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
}
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
const frames = [];
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = (e) => rej(new Error('ws')); });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
} else if (msg.method === 'Page.screencastFrame') {
frames.push(msg.params.data);
send('Page.screencastFrameAck', { sessionId: msg.params.sessionId });
}
};
await send('Page.enable');
await send('Runtime.enable');
await send('Network.enable');
await send('Network.setCacheDisabled', { cacheDisabled: true });
await send('Page.navigate', { url: 'about:blank' });
await new Promise((r) => setTimeout(r, 300));
await send('Page.navigate', { url });
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
return r.result?.value;
};
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const v = await evaluate(readyExpr).catch(() => null);
if (v) break;
await new Promise((r) => setTimeout(r, 200));
}
await send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
await new Promise((r) => setTimeout(r, dur));
await send('Page.stopScreencast');
await mkdir(dir, { recursive: true });
let i = 0;
for (const data of frames) {
await writeFile(`${dir}/frame-${String(i).padStart(3, '0')}.png`, Buffer.from(data, 'base64'));
i++;
}
console.log(`captured ${i} frames in ${dir}`);
ws.close();

29
dev/map-dest-shot.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev map set-destination shot</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. -->
<base href="../" />
<style>
@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/map-dest-shot.mjs"></script>
</body>
</html>

42
dev/map-dest-shot.mjs Normal file
View File

@ -0,0 +1,42 @@
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';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
window.__STEP__ = 'booting';
(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.__SHOT_READY__ = { error: 'no GameScene' }; return; }
window.__STEP__ = 'gs ok, mapWindow=' + !!gs.mapWindow;
if (!gs.mapWindow) { window.__SHOT_READY__ = { error: 'no mapWindow' }; return; }
gs.mapWindow.open();
window.__STEP__ = 'opened';
await sleep(600);
try {
if (typeof gs.mapWindow.setMode === 'function') gs.mapWindow.setMode('system');
else gs.mapWindow._mode = 'system';
window.__STEP__ = 'mode set';
} catch (e) { window.__SHOT_READY__ = { error: 'setMode: ' + e.message }; return; }
await sleep(1500);
try {
gs.mapWindow._askObject({ label: 'XANTHOS', id: 'xantos', x: 0, y: 0, r: 4 });
window.__STEP__ = 'asked, dlg=' + gs.mapWindow.dialog.state;
} catch (e) { window.__SHOT_READY__ = { error: 'ask: ' + e.message }; return; }
const dlg = gs.mapWindow.dialog;
for (let i = 0; i < 80 && dlg.state !== 'shown'; i++) await sleep(100);
window.__SHOT_READY__ = { ok: dlg.state === 'shown', state: dlg.state };
})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; });

32
dev/scout-dest-shot.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev scout set-destination shot</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-dest-shot.mjs"></script>
</body>
</html>

97
dev/scout-dest-shot.mjs Normal file
View File

@ -0,0 +1,97 @@
/**
* Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs):
* boots the game, lands, opens the shop, opens the trade scout on a BUY
* row, and taps a REMOTE best-exit row — the "SET DESTINATION" popup sits
* open over the shop screen (the shot's subject). The exit rows are
* tappable (hand cursor) and the section label carries the hint.
*
* /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank &
* python3 -m http.server 8128
* node dev/cdp-shot.mjs http://127.0.0.1:8128/dev/scout-dest-shot.html \
* /tmp/scout-dest.png 'window.__SHOT_READY__ && 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';
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.__SHOT_READY__ = { 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.__SHOT_READY__ = { 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.__SHOT_READY__ = { error: 'no shopView' }; return; }
sv.setTab('trade');
await sleep(900); // the slide-in
// Pick a BUY row with a REMOTE profitable exit, open its scout, and tap
// that exit row — the SET DESTINATION popup opens and STAYS open (the
// shot's subject).
const visited = [...gs.visitedSystems];
let row = null, exit = null, best = -Infinity;
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 && rem.profit > best) { row = r; exit = rem; best = rem.profit; }
}
if (!row || !exit) { window.__SHOT_READY__ = { error: 'no remote exit to offer' }; return; }
sv._onRowTap(row);
await sleep(300);
const i = sv._scout._exitRows.findIndex((x) => x.exit.systemId === exit.systemId);
if (i < 0) { window.__SHOT_READY__ = { error: 'no exit row for the picked exit' }; return; }
sv._scout._exitRows[i].onTap({ stopPropagation() {} });
// Wait for the open animation to land (title decoded, buttons up).
const dlg = ss._setDestDialog;
if (!dlg) { window.__SHOT_READY__ = { error: 'no dialog' }; return; }
const t0 = Date.now();
while (dlg.state !== 'shown' && Date.now() - t0 < 5000) await sleep(50);
await sleep(700); // the title decode + button flicker settle
window.__SHOT_READY__ = {
state: dlg.state,
title: dlg.title.text,
body: dlg.bodyTexts.map((t) => t.text),
exit: { systemId: exit.systemId, systemName: exit.systemName, price: exit.price, profit: exit.profit },
hops: ss._setDest?.hops,
};
})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; });

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

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev scout set-destination test</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-dest.mjs"></script>
</body>
</html>

202
dev/scout-dest.mjs Normal file
View File

@ -0,0 +1,202 @@
/**
* Dev-only TRADE SCOUT → SET DESTINATION test: boots the game, lands on the
* home world, opens the buy/sell screen, taps a BUY row's box (the trade
* scout), then taps a BEST-EXIT row and drives the "SET DESTINATION" popup
* end-to-end (ScoutPanel → ShopView → SurfaceScene.askSetDestination →
* ConfirmOverlay → GameScene.setDestination):
*
* - 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)
* - 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
*
* python3 -m http.server 8125
* node dev/cdp-firefox.mjs http://127.0.0.1:8125/dev/scout-dest.html \
* 'return window.__SCOUT_DEST_TEST__;' 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';
import { planRoute } from '../js/galaxy/Route.js';
const fail = (msg, extra = {}) => {
window.__SCOUT_DEST_TEST__ = { pass: false, error: String(msg), ...extra };
console.error('[scout-dest] FAIL:', msg, extra);
};
const step = (name, cond, extra = {}) => {
if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 400)}`);
window.__STEPS__ = (window.__STEPS__ ?? []).concat(name);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitFor = async (fn, timeoutMs = 4000) => {
const t0 = Date.now();
for (;;) {
if (fn()) return true;
if (Date.now() - t0 > timeoutMs) return false;
await sleep(50);
}
};
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 () => {
// 1 — the flight scene is live (v4 boots asynchronously).
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) throw new Error('GameScene never came up');
step('boot', true);
// 2 — a known ledger + cargo (something to sell).
const A = config.get('goods.goods', [])[0].id;
gs.ship.credits = 10000;
gs.ship.minerals = 37;
gs.ship.cargo.add([A]);
step('ledger-seeded', true);
// 3 — the surface stage on the home world (skip the clip).
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) throw new Error('SurfaceScene never registered');
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);
if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')');
step('surface-stage', true);
// 4 — the run has ENTERED (charted) every system, so the scout has
// remote exits to offer (visitedSystems is the scout's "charted" set).
const sysId = gs.systemRecord.id;
for (const id of gs.galaxy.byId.keys()) gs.visitedSystems.add(id);
step('charted-everywhere', gs.visitedSystems.size > 1, { visited: gs.visitedSystems.size });
// 5 — open the shop the real way; headless (no shop clip) falls back to
// the direct build (same UI path).
ss.toggleShop();
await sleep(200);
if (!ss.shopView) ss.buildShopView();
if (!ss.shopView) throw new Error('no shopView');
const sv = ss.shopView;
sv.setTab('trade');
await sleep(600); // the slide-in
step('shop-open', true, { rows: sv.buyList.rows.length });
// The scout's data, computed the same way the panel does — so we can
// pick a good with a REMOTE profitable exit and one the LOCAL system
// buys (its exit row is the reject case).
const visited = [...gs.visitedSystems];
const reportFor = (row) => scoutGood(row.id, {
galaxy: gs.galaxy, systemId: sysId, cls: sv.cls,
buyUnit: row.price, visited, topN: 3,
});
const rows = sv.buyList.rows;
let remoteRow = null, remoteExit = null, bestProfit = -Infinity;
let localRow = null, localExit = null;
for (const row of rows) {
const d = reportFor(row);
if (!d) continue;
const rem = d.exits.find((e) => e.systemId !== sysId && e.profit > 0);
if (rem && rem.profit > bestProfit) { remoteRow = row; remoteExit = rem; bestProfit = rem.profit; }
const loc = d.exits.find((e) => e.systemId === sysId);
if (loc && !localRow) { localRow = row; localExit = loc; }
}
step('scout-data', !!remoteRow && !!remoteExit, { remote: remoteExit?.systemId, profit: bestProfit });
const tapRow = (scout, exit) => {
const i = scout._exitRows.findIndex((r) => r.exit.systemId === exit.systemId);
if (i < 0) throw new Error('no exit row for ' + exit.systemId);
scout._exitRows[i].onTap({ stopPropagation() {} });
};
const dialogShown = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'shown');
const dialogHidden = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'hidden');
// 6 — REJECT: the LOCAL system's exit row → a note, NO dialog, no
// destination change (pointing the ship at itself bounces).
if (localRow) {
sv._onRowTap(localRow);
step('local-scout-open', sv._scoutOpen === localRow.id);
tapRow(sv._scout, localExit);
await sleep(150);
step('local-rejected', ss._setDestDialog == null, { dialog: ss._setDestDialog && ss._setDestDialog.state });
step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
step('local-dest-untouched', gs.destination == null);
sv._onRowTap(null); // the scout closes, cargo grid back
} else {
// Fallback (no local-buying good in stock): the scene API rejects it.
ss.askSetDestination({ systemId: sysId, systemName: 'LOCAL', price: 1, profit: 0, clsLabel: 'X' });
await sleep(150);
step('local-rejected', ss._setDestDialog == null);
step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
}
// 7 — CONFIRM: a REMOTE exit row → the popup; CONFIRM sets the
// destination on the GameScene and toasts it on the surface.
sv._onRowTap(remoteRow);
step('remote-scout-open', sv._scoutOpen === remoteRow.id && sv._scout.report.id === remoteRow.id);
step('exit-rows-built', sv._scout._exitRows.length === sv._scout.report.exits.length,
{ rows: sv._scout._exitRows.length, exits: sv._scout.report.exits.length });
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('SET DESTINATION dialog never opened');
const dlg = ss._setDestDialog;
step('dialog-open', dlg.isOpen, { state: dlg.state });
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}` });
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.
const wantHops = planRoute(gs.galaxy, sysId, remoteExit.systemId)?.hops ?? null;
step('pending-staged', ss._setDest?.exit?.systemId === remoteExit.systemId, { setDest: !!ss._setDest });
if (wantHops != null) step('hops-computed', ss._setDest?.hops === wantHops, { got: ss._setDest?.hops, want: wantHops });
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('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.
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('dialog never reopened');
ss._setDestDialog.cancel();
if (!(await dialogHidden())) throw new Error('dialog never closed after cancel');
step('cancel-kept', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination });
// 9 — ESC: the same row a third time → popup; ESC cancels (the esc
// chain: … → set-destination → save → …) without touching the rest.
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('dialog never reopened (esc case)');
const saveWas = ss.savePanel ?? null;
ss.escAction();
if (!(await dialogHidden())) throw new Error('ESC did not close the dialog');
step('esc-cancelled', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination });
step('esc-scope', ss.savePanel === saveWas, { savePanel: ss.savePanel, was: saveWas });
// 10 — teardown is clean.
ss.leaveShop();
await sleep(150);
step('leave-shop', ss.shopView === null && ss.shopMode === false, { view: !!ss.shopView, mode: ss.shopMode });
window.__SCOUT_DEST_TEST__ = { pass: true, steps: window.__STEPS__ ?? [] };
console.info('[scout-dest] PASS:', (window.__STEPS__ ?? []).join(' → '));
})().catch((err) => fail(String(err && err.message || err)));

32
dev/shop-hud-shot.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev shop hud shot</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/shop-hud-shot.mjs"></script>
</body>
</html>

67
dev/shop-hud-shot.mjs Normal file
View File

@ -0,0 +1,67 @@
/**
* Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs): boots the game, lands, opens the shop, reveals the
* buy/sell panel — then sets window.__SHOT_READY__ so the driver captures
* the moment the trade screen (and its upper-right credits/minerals
* readout) is on screen.
*
* /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank &
* node dev/server.mjs 8127
* node dev/cdp-shot.mjs http://127.0.0.1:8127/dev/shop-hud-shot.html \
* /tmp/shop-hud.png 'window.__SHOT_READY__ && (r = window.__SHOT_READY__, r) && 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';
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.__SHOT_READY__ = { error: 'no GameScene' }; return; }
// A known ledger + cargo so the SELL grid has filled bays.
const goods = config.get('goods.goods', []);
gs.ship.credits = 10000;
gs.ship.minerals = 37;
gs.ship.cargo.add([goods[0].id, goods[1].id]);
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) { window.__SHOT_READY__ = { 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.__SHOT_READY__ = { error: 'no shopView' }; return; }
sv.setTab('trade');
await sleep(900); // the slide-in + the HUD count-up
window.__SHOT_READY__ = {
hud: !!ss.shopHud,
credits: ss.shopHud?.creditsHud?.label?.text ?? null,
minerals: ss.shopHud?.mineralHud?.label?.text ?? null,
creditsPos: ss.shopHud ? { x: Math.round(ss.shopHud.creditsHud.label.x), y: Math.round(ss.shopHud.creditsHud.label.y) } : null,
mineralsPos: ss.shopHud ? { x: Math.round(ss.shopHud.mineralHud.label.x), y: Math.round(ss.shopHud.mineralHud.label.y) } : null,
};
})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; });

32
dev/shop-hud.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev shop hud test</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/shop-hud.mjs"></script>
</body>
</html>

159
dev/shop-hud.mjs Normal file
View File

@ -0,0 +1,159 @@
/**
* Dev-only SHOP HUD test: boots the game, lands on the home world, forces
* the surface stage, opens the buy/sell screen, and checks the corner
* readout (SurfaceScene.buildShopHud) — the main game's own credits +
* minerals pair, rebuilt over the shop clip in the upper right:
*
* - the HUD is built with the shop view (credits line + minerals line
* + hold bar, upper right, the GameScene recipe)
* - the credits line reads the ship's ledger and sits at the corner
* margin; the minerals line stacks below it (topOffset recipe)
* - a confirmed trade moves the credits readout (count-up settles on
* the new ledger)
* - leaving the shop tears the readout down with the shop view
*
* python3 -m http.server 8125
* node dev/cdp-firefox.mjs http://127.0.0.1:8125/dev/shop-hud.html \
* 'return window.__SHOP_HUD_TEST__;' 60000
*/
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';
const fail = (msg, extra = {}) => {
window.__SHOP_HUD_TEST__ = { pass: false, error: String(msg), ...extra };
console.error('[shop-hud] FAIL:', msg, extra);
};
const step = (name, cond, extra = {}) => {
if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 300)}`);
window.__STEPS__ = (window.__STEPS__ ?? []).concat(name);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitFor = async (fn, timeoutMs = 3000) => {
const t0 = Date.now();
for (;;) {
if (fn()) return true;
if (Date.now() - t0 > timeoutMs) return false;
await sleep(50);
}
};
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 () => {
// 1 — the flight scene is live (v4 boots asynchronously).
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) throw new Error('GameScene never came up');
step('boot', true, { frame: gs.planet.sheetFrame });
// 2 — a known ledger + cargo (something to sell).
const goods = config.get('goods.goods', []);
const A = goods[0].id;
gs.ship.credits = 10000;
gs.ship.minerals = 37;
gs.ship.cargo.add([A]);
step('ledger-seeded', gs.ship.credits === 10000 && gs.ship.minerals === 37);
// 3 — the real entry point + the surface stage (skip the clip).
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) throw new Error('SurfaceScene never registered');
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);
if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')');
step('surface-stage', true);
// 4 — open the shop the real way; headless (no shop clip) falls back to
// the direct build (same UI path — the HUD is part of buildShopView).
ss.toggleShop();
await sleep(200);
if (!ss.shopView) ss.buildShopView();
if (!ss.shopView) throw new Error('no shopView');
step('shop-open', true, { shopMode: ss.shopMode });
// 5 — THE HUD: the corner readout is up with the shop view.
const hud = ss.shopHud;
step('hud-built', !!hud && !!hud.creditsHud && !!hud.mineralHud,
{ hud: !!hud, credits: !!hud?.creditsHud, minerals: !!hud?.mineralHud });
const W = game.scale.width; // 1280
const padC = Number(config.get('creditshud.pad', 16));
const padM = Number(config.get('mineralhud.pad', 16));
// The CREDITS line: the ship's ledger, right-anchored at the corner margin
// (origin 1,0 — x is the RIGHT edge, y the top). The count-up is a ~420 ms
// tween — poll until it settles on the seeded ledger.
const cl = hud.creditsHud.label;
const wantC = `CREDITS ${gs.ship.credits.toLocaleString('en-US')}`;
await waitFor(() => cl.text === wantC);
step('credits-line',
cl.text === wantC
&& Math.abs(cl.x - (W - padC)) < 1 && Math.abs(cl.y - padC) < 1,
{ text: cl.text, x: cl.x, y: cl.y, right: W - padC });
// The MINERALS line: stacks BELOW the credits line (topOffset recipe),
// right-anchored the same way — n / CAP like the main game's corner.
const ml = hud.mineralHud.label;
const expectM = `MINERALS ${gs.ship.minerals} / ${gs.ship.stats.mineralStorage}`;
step('minerals-line',
ml.text === expectM
&& Math.abs(ml.x - (W - padM)) < 1,
{ text: ml.text, want: expectM, x: ml.x, y: ml.y });
step('minerals-below-credits', ml.y > cl.y, { min: ml.y, cred: cl.y });
// The hold BAR: below its label, inside the screen.
const mh = hud.mineralHud;
step('minerals-bar',
Number.isFinite(mh.barX) && Number.isFinite(mh.barY)
&& mh.barY > ml.y && mh.barX + mh.barW <= W,
{ barX: mh.barX, barY: mh.barY, barW: mh.barW });
// Rendered ABOVE the shop clip (the video is depth 10; the HUD is 30).
step('hud-above-clip', (cl.depth ?? 0) >= 30, { depth: cl.depth });
// 6 — A TRADE: sell the bay, confirm → the credits line moves (the
// count-up settles on the new ledger; the minerals line is untouched).
const sv = ss.shopView;
sv.setTab('trade');
await sleep(600); // the slide-in
sv.sellGrid.toggleBay(0);
const pv = sv.preview();
const expectCredits = gs.ship.credits + pv.sellTotal;
step('trade-confirmed', sv.confirm() === true, { sell: pv.sellTotal });
step('ledger-moved', gs.ship.credits === expectCredits,
{ credits: gs.ship.credits, want: expectCredits });
// The count-up is a ~420 ms tween — poll until it settles on the new ledger.
const wantC2 = `CREDITS ${expectCredits.toLocaleString('en-US')}`;
await waitFor(() => cl.text === wantC2);
step('hud-follows-trade', cl.text === wantC2,
{ text: cl.text, want: expectCredits });
step('minerals-untouched', ml.text === expectM, { text: ml.text });
// 7 — LEAVE SHOP: the readout dies with the shop view.
ss.leaveShop();
await sleep(100);
step('hud-torn-down', ss.shopHud === null && ss.shopView === null,
{ hud: ss.shopHud, view: ss.shopView });
const kids = ss.sys.displayList?.getChildren?.() ?? [];
step('labels-destroyed',
!kids.includes(cl) && !kids.includes(ml),
{ clIn: kids.includes(cl), mlIn: kids.includes(ml) });
window.__SHOP_HUD_TEST__ = { pass: true, steps: window.__STEPS__ ?? [] };
console.info('[shop-hud] PASS:', (window.__STEPS__ ?? []).join(' → '));
})().catch((err) => fail(String(err && err.message || err)));

32
dev/shop-select-shot.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev shop select shot</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/shop-select-shot.mjs"></script>
</body>
</html>

99
dev/shop-select-shot.mjs Normal file
View File

@ -0,0 +1,99 @@
/**
* Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs):
* boots the game, lands, opens the shop, reveals the buy/sell panel, and
* CHECKS ONE CARGO BAY — bay 0 marked to sell (green stroke + tinted fill
* + the green checkmark in its upper-left corner), bay 1 left unchecked so
* the shot shows both states side by side. On ready it sets
* window.__SHOT_READY__ with the checked state + a record of what the
* checked bay drew (border/fill colors + the corner-check strokes).
*
* /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank &
* python3 -m http.server 8128
* node dev/cdp-shot.mjs http://127.0.0.1:8128/dev/shop-select-shot.html \
* /tmp/shop-select.png 'window.__SHOT_READY__ && 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';
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.__SHOT_READY__ = { error: 'no GameScene' }; return; }
// A known ledger + cargo: TWO goods → bay 0 checked, bay 1 left unchecked
// so the shot shows the selection look next to an idle bay.
const goods = config.get('goods.goods', []);
gs.ship.credits = 10000;
gs.ship.cargo.add([goods[0].id, goods[1].id]);
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) { window.__SHOT_READY__ = { 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.__SHOT_READY__ = { error: 'no shopView' }; return; }
sv.setTab('trade');
await sleep(900); // the slide-in
// CHECK bay 0 (queue one unit for sale) — the selection look is what
// this shot is for.
sv.sellGrid.toggleBay(0);
await sleep(300);
// Diagnostics: record what the (re)render of the CHECKED state draws on
// the bay's plate (fill + border) and its sel graphics (the corner
// check). Un-check first, then re-check, so the LAST recorded calls are
// the checked look.
let diag = null;
const cell = sv.sellGrid.cells[0];
if (cell?.plate && cell?.sel) {
diag = { plate: [], sel: [] };
const rec = (o, k, sink) => {
const orig = o[k];
o[k] = (...a) => {
sink.push({ k, a: a.map((v) => (typeof v === 'number' ? Math.round(v) : v)) });
return orig.apply(o, a); // record AND keep drawing
};
};
for (const k of ['fillStyle', 'lineStyle']) { rec(cell.plate, k, diag.plate); rec(cell.sel, k, diag.sel); }
rec(cell.sel, 'lineBetween', diag.sel);
rec(cell.sel, 'fillCircle', diag.sel);
sv.sellGrid.toggleBay(0); // un-check (idle render)
sv.sellGrid.toggleBay(0); // re-check → the checked render (what we want)
}
window.__SHOT_READY__ = {
checked: sv.sellGrid.getSellMap(),
diag,
slot: {
selected: config.get('shopui.slot.selected'),
selBgColor: config.get('shopui.slot.selBgColor'),
check: config.get('shopui.slot.check'),
},
};
})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; });

View File

@ -318,6 +318,68 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
}
console.log('INTERACTION: OK');
// ===========================================================================
console.log('\n== SELECT VISUAL — checked bay: green stroke + tint + corner check; uncheck reverts ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
const grid = view.sellGrid;
const cell = grid.cells[0];
const slotCfg = config.get('shopui.slot', {});
const hexInt = (h) => {
const m = String(h).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : null;
};
const GREEN = hexInt(slotCfg.selected ?? '#7ce8a4');
const BG = hexInt(slotCfg.bgColor ?? '#0e1728');
const BORDER = hexInt(slotCfg.border ?? '#1d2e4c');
const SELBG = hexInt(slotCfg.selBgColor ?? BG);
assert.ok(GREEN != null && BG != null && SELBG != null, 'the slot config carries the selection colors');
assert.notEqual(SELBG, BG, 'the selected fill is a distinct (tinted) shade');
// Record the draw calls on the cell's plate + check graphics.
const wrap = (o) => {
const calls = { fill: [], line: [], between: [] };
if (!o) return calls;
if (typeof o.fillStyle === 'function') o.fillStyle = (...a) => { calls.fill.push(a); return o; };
if (typeof o.lineStyle === 'function') o.lineStyle = (...a) => { calls.line.push(a); return o; };
if (typeof o.lineBetween === 'function') o.lineBetween = (...a) => { calls.between.push(a); return o; };
return calls;
};
// CHECK the bay → GREEN stroke + tinted fill on the plate, and the
// corner check (green strokes in the upper-left) on the cell's sel graphics.
const plate = wrap(cell.plate);
const sel = wrap(cell.sel);
grid.toggleBay(0);
assert.deepEqual(grid.getSellMap(), { [A]: 1 }, 'bay 0 is checked (1 unit queued)');
assert.ok(plate.line.length >= 1, 'the plate was re-styled on the check');
assert.equal(plate.line[0][1], GREEN, `checked bay strokes the SELECTION GREEN (got 0x${(plate.line[0]?.[1] ?? 0).toString(16)}, want 0x${GREEN.toString(16)})`);
assert.ok(plate.line[0][0] >= 2, `… a heavier stroke than the idle border (width ${plate.line[0][0]})`);
assert.equal(plate.fill[0][0], SELBG, `… and the fill is the selected tint (got 0x${(plate.fill[0]?.[0] ?? 0).toString(16)}, want 0x${SELBG.toString(16)})`);
assert.ok(sel.between.length >= 2, `the corner check is drawn (≥ 2 strokes, got ${sel.between.length})`);
assert.ok(sel.line.some((l) => l[1] === GREEN), `… in the selection green (line styles: ${JSON.stringify(sel.line)})`);
// The check sits in the cell's UPPER-LEFT quadrant (up + left of centre).
const { x, y } = grid._cellXY(0);
for (const b of sel.between) {
assert.ok(b[0] < x && b[1] < y && b[2] < x && b[3] < y,
`check stroke (${b.map((v) => Math.round(v)).join(',')}) is up-left of the cell centre (${x},${y})`);
}
// UNCHECK the bay → the plate reverts to the idle border/fill and the
// check is cleared (nothing new drawn on the sel graphics).
const plate2 = wrap(cell.plate);
const sel2 = wrap(cell.sel);
grid.toggleBay(0);
assert.deepEqual(grid.getSellMap(), {}, 'bay 0 is checked off again');
assert.equal(plate2.line[0][1], BORDER, `unchecked bay strokes the IDLE border (got 0x${(plate2.line[0]?.[1] ?? 0).toString(16)}, want 0x${BORDER.toString(16)})`);
assert.equal(plate2.line[0][0], 1, `… the thin idle border again (width ${plate2.line[0][0]})`);
assert.equal(plate2.fill[0][0], BG, `… and the plain idle fill (got 0x${(plate2.fill[0]?.[0] ?? 0).toString(16)}, want 0x${BG.toString(16)})`);
assert.equal(sel2.between.length, 0, 'the corner check is gone (cleared, no new strokes)');
}
console.log('SELECT VISUAL: OK');
// ===========================================================================
console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==');
{
@ -758,4 +820,45 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re
}
console.log('TRADE SCOUT: OK');
console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scene is offered the exit ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view, scene } = makeView(ship);
const calls = [];
scene.askSetDestination = (exit) => { calls.push(exit); };
const row = view.buyList.rows[0];
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
const d = view._scout.report;
assert.ok(d.exits.length > 0, 'the report has charted exits to tap');
// One tappable record per exit row, in rank order.
assert.equal(view._scout._exitRows.length, d.exits.length, 'one tappable record per exit row');
assert.ok(view._scout._exitRows.every((r, i) => r.exit.systemId === d.exits[i].systemId), '… rows keep the report\'s rank order');
// TAP ROW 1 → the scene is offered THAT exit, and the panel does NOT close.
const best = d.exits[0];
let stopped = false;
view._scout._exitRows[0].onTap({ stopPropagation() { stopped = true; } });
assert.equal(calls.length, 1, 'the scene was offered the exit');
assert.equal(calls[0].systemId, best.systemId, '… for the tapped (best) system');
assert.equal(stopped, true, '… propagation stopped (the panel tap-layer is not fired)');
assert.equal(view._scoutOpen, row.id, '… and the scout stays open (no close)');
// TAP ROW 2 (when there is one) → the scene is offered THAT row\'s exit.
if (d.exits.length > 1) {
view._scout._exitRows[1].onTap({});
assert.equal(calls.length, 2, 'second row tap → second offer');
assert.equal(calls[1].systemId, d.exits[1].systemId, '… for that row\'s system');
}
// Re-showing the report rebuilds the rows (no stale tap surfaces).
view._onRowTap(null); // close
view._onRowTap(row); // reopen → show() runs again
assert.equal(view._scout._exitRows.length, view._scout.report.exits.length, 'rows rebuilt for the fresh report');
}
console.log('SET DESTINATION (panel → scene): OK');
console.log('\nAll shop-UI checks passed \u2714');

View File

@ -11,7 +11,11 @@ import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { ConfirmOverlay } from '../ui/ConfirmOverlay.js';
import { planRoute } from '../galaxy/Route.js';
import { ShopView } from '../ui/shop/ShopView.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import * as Economy from '../economy/Economy.js';
import { buildDefs } from '../research/ResearchModel.js';
@ -104,8 +108,12 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
this.shopView = null; // the buy/sell UI over the clip (js/ui/shop/ShopView.js); null when out of the shop
this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair
this.noteG = null; // console-note glyphs (consoleNote)
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
this._setDestDialog = null; // the shop's "SET DESTINATION" confirm (ConfirmOverlay, container-hosted)
this._setDestHost = null; // the centered container hosting that dialog (SavePanel pattern)
this._setDest = null; // the pending { exit, hops } behind that dialog
}
/** Pick this world's clips (data/landing.json) and queue them. */
@ -357,6 +365,9 @@ export class SurfaceScene extends Phaser.Scene {
* Buyback state resets with it), and destroyed by leaveShop(). It reads the
* paused GameScene for the ledger (ship.credits / ship.cargo) + the economy
* (galaxy, this system) and the shop's economic class (this world's nature).
* The screen also carries the main game's corner readout (upper right —
* buildShopHud), so the player sees the same credits/minerals lines they
* have in flight while trading.
*/
buildShopView() {
if (this.shopView) return this.shopView; // already up this visit
@ -373,9 +384,43 @@ export class SurfaceScene extends Phaser.Scene {
anchor: this.shopAnchor(),
topY: this.shopTopY(),
});
this.buildShopHud(); // the upper-right ledger (credits + minerals), this scene
return this.shopView;
}
/**
* The buy/sell screen's CORNER READOUT — the main game's own pair
* (js/ui/CreditsHud.js + js/ui/MineralHud.js) built in THIS scene, so it
* renders over the shop clip: CREDITS on top, MINERALS + the hold bar
* stacked below (upper right, the same recipe as GameScene's corner —
* same data/*.json config, same count-up). It reads the very ship the
* shop trades against and dies with the shop view (leaveShop/shutdown).
*/
buildShopHud() {
if (this.shopHud) return this.shopHud;
const ship = this.gameScene?.ship;
if (!ship) return null;
const creditsHud = new CreditsHud(this);
creditsHud.set(ship.credits);
const mineralHud = new MineralHud(this, { topOffset: creditsHud.height + creditsHud.gap });
mineralHud.set(ship.minerals, ship.stats?.mineralStorage);
this.shopHud = { creditsHud, mineralHud };
return this.shopHud;
}
/**
* Mirror the ship's ledger into the corner readout (after a trade —
* ShopView.refreshHud — or any other change while the shop is up).
* No-op when the shop (and its HUD) is not on screen.
*/
refreshShopHud() {
const hud = this.shopHud;
const ship = this.gameScene?.ship;
if (!hud || !ship) return;
hud.creditsHud?.set?.(ship.credits);
hud.mineralHud?.set?.(ship.minerals, ship.stats?.mineralStorage);
}
/**
* The trade panel's top edge — just under the surface HUD's planet text
* (the name + the type/tether sub-line), with a little padding. The HUD is
@ -420,6 +465,96 @@ export class SurfaceScene extends Phaser.Scene {
if (!this.shopView) return;
this.shopView.destroy?.();
this.shopView = null;
this.destroyShopHud(); // the corner readout belongs to the shop visit
}
/** Tear the corner readout down (leaving the shop / scene shutdown). */
destroyShopHud() {
if (!this.shopHud) return;
this.shopHud.creditsHud?.destroy?.();
this.shopHud.mineralHud?.destroy?.();
this.shopHud = null;
}
/**
* A BEST-EXIT row on the trade scout was tapped (ScoutPanel → onExitTap
* → 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).
*/
askSetDestination(exit) {
if (!exit || typeof exit.systemId !== 'string') return;
const gs = this.gameScene;
if (!gs || typeof gs.setDestination !== 'function') {
this.consoleNote('NO NAV LINK — SET IT FROM THE MAP', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) });
return;
}
// The local system is always in the exit list (charted, 0 hops) —
// pointing the ship at itself just bounces back, so say so instead.
if (gs.systemRecord?.id === exit.systemId) {
this.consoleNote('ALREADY AT THAT SYSTEM', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) });
return;
}
const name = String(exit.systemName ?? exit.systemId).toUpperCase();
const cls = exit.clsLabel ? `${exit.clsLabel} · ` : '';
const sign = exit.profit >= 0 ? '+' : '−';
const plan = planRoute(gs.galaxy, gs.systemRecord?.id, exit.systemId);
this._setDest = { exit, hops: plan?.hops ?? null };
this._ensureSetDestDialog().show({
title: 'SET DESTINATION',
body: [
`DESTINATION — ${name}`,
`${cls}SELLS ${exit.price} CR/UNIT · ${sign}${Math.abs(exit.profit)}/UNIT`,
],
accent: toCss(themeColor('neon', 0x00e5ff)),
confirmLabel: 'SET DESTINATION',
onConfirm: () => {
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)`);
this.consoleNote(label, { glyph: '◆', glyphColor: toCss(config.get('gates.route.compassColor', '#ff8c1a')) });
},
time: this.time.now,
});
}
/** The shared confirm dialog (built once, sized like the map's). */
_ensureSetDestDialog() {
if (this._setDestDialog) return this._setDestDialog;
const ds = config.get('shopui.scout.setDestination', {});
const w = Number(ds?.width) > 0 ? Number(ds.width) : 520;
const h = Number(ds?.height) > 0 ? Number(ds.height) : 176;
// Host it the way SavePanel/MapWindow host their confirm dialogs:
// `new ConfirmOverlay(scene, 0, 0, …)` inside a centered container
// (`new Container` + `add.existing` — not `scene.add.container`, which
// this v4 build doesn't put on the display list).
// Note: in the headless-Brave probe the plate Graphics doesn't paint in
// the SurfaceScene context (the same happens to SavePanel's own confirm
// there); the dialog renders correctly in a real browser — see
// dev/map-dest-shot.* (the identical component over the map, painted
// fully in headless).
this._setDestHost = new Phaser.GameObjects.Container(
this, this.scale.width / 2, this.scale.height / 2,
);
this.add.existing(this._setDestHost);
// Above EVERYTHING in the surface scene — the shop's panels live in
// ShopView's root container at depth 100 (js/ui/shop/ShopView.js),
// above the deck (50), console note (60), SavePanel (70), MapWindow
// (80). 110 puts the dialog + its scrim over all of it.
this._setDestHost.setDepth(110);
this._setDestDialog = new ConfirmOverlay(
this,
0, 0,
w, h,
{ areaW: this.scale.width, areaH: this.scale.height },
);
this._setDestHost.add(this._setDestDialog);
return this._setDestDialog;
}
/**
@ -855,7 +990,7 @@ export class SurfaceScene extends Phaser.Scene {
this.questWindow?.refresh();
}
/** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */
/** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → sub-bar. */
escAction() {
if (this.buildWindow?.isOpen) {
this.buildWindow.close();
@ -865,6 +1000,10 @@ export class SurfaceScene extends Phaser.Scene {
this.questWindow.close();
return;
}
if (this._setDestDialog?.isOpen) {
this._setDestDialog.cancel();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
@ -1106,6 +1245,7 @@ export class SurfaceScene extends Phaser.Scene {
this.actionBar?.update(time, delta);
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
this._setDestDialog?.update(time); // the SET DESTINATION confirm
this.updateHud(time); // the upper-left name/type decode (surface stage only)
// The build console: tick the single in-progress build (one at a
@ -1144,6 +1284,12 @@ export class SurfaceScene extends Phaser.Scene {
this.noteG = null;
}
this.destroyHud();
this.destroyShopHud();
this._setDestDialog?.destroy?.();
this._setDestDialog = null;
this._setDestHost?.destroy?.();
this._setDestHost = null;
this._setDest = null;
this.buildWindow?.destroy();
this.buildWindow = null;
this.actionBar?.destroy();

View File

@ -13,8 +13,8 @@
* │ SIGHT ON Rocky · Ice … │
* │ ────────────────────────── │
* │ BEST EXITS — CHARTED │
* │ ① New Concord Gas 41 cr │
* │ ② … │
* │ ① New Concord Gas 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
* │ ② … │ (the scene's ConfirmOverlay)
* │ ────────────────────────── │
* │ TAP ANYWHERE TO CLOSE │
* └────────────────────────────┘
@ -22,7 +22,9 @@
* The DATA (which planets want the good, the top-N charted exits + profit)
* is computed by js/economy/TradeScout.js (pure). This component only
* renders that report in the panel's w×h box and reports taps back to the
* ShopView (tap anywhere = close — the ShopView restores the cargo grid).
* ShopView (tap anywhere = close — the ShopView restores the cargo grid;
* tap a BEST-EXIT row = onExitTap(exit) — the scene offers to SET it as
* the destination).
*
* Headless-safe: the Phaser rendering is best-effort (guarded); the layout
* math is fixed offsets so a stub scene still constructs.
@ -35,8 +37,10 @@ import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
export class ScoutPanel extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { width, height, onClose } (ShopView sizes it to the
* SELL grid's box; onClose fires on any tap of the panel).
* @param {object} o — { width, height, onClose, onExitTap } (ShopView sizes
* it to the SELL grid's box; onClose fires on any tap of the panel;
* onExitTap(exit) fires when a BEST-EXIT row is tapped — the scene
* offers the "Set Destination" popup for that system).
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
@ -46,10 +50,12 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
this.height = Math.max(240, Number(o.height) || 480);
this.pad = s.pad ?? 18;
this.onClose = typeof o.onClose === 'function' ? o.onClose : null;
this.onExitTap = typeof o.onExitTap === 'function' ? o.onExitTap : null;
this._objs = []; // shell objects (plate, title, tap layer)
this._content = []; // per-show content (rebuilt by show())
this._data = null; // the last scoutGood() report
this._exitRows = []; // per-show best-exit rows { exit, hit, onTap }
this._buildShell();
this._buildTapLayer(); // LAST — the tap rect sits on top
@ -121,6 +127,7 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
if (!d || typeof d.id !== 'string') return this;
this._data = d;
this._destroyContent();
this._exitRows = [];
const s = config.section('shopui.scout', {});
const W = this.width;
const pad = this.pad;
@ -212,6 +219,13 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
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);
}
y += 24;
const er = s.exitRow ?? {};
@ -255,6 +269,29 @@ 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()).
const hit = this.scene.add?.graphics?.();
if (hit) {
const hx = pad - 6, hy = y - 4, hw = W - pad * 2 + 12, hh = rowH;
const onTap = (p) => { p?.stopPropagation?.(); this.onExitTap?.(e); };
if (typeof hit.setInteractive === 'function' && typeof hit.on === 'function') {
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(hx, hy, hw, hh) : null;
hit.setInteractive({
useHandCursor: true,
hitArea: area,
hitAreaCallback: (pp, px, py) => (area ? Rect.Contains(pp, px, py) : true),
});
hit.on('pointerdown', onTap);
}
this._exitRows.push({ exit: e, hit, onTap });
add(hit);
}
y += rowH;
});
}

View File

@ -494,11 +494,14 @@ export class ShopView extends Phaser.GameObjects.Container {
this._syncPreview();
// The scout panel: sized to the SELL grid's box (it replaces the cargo
// area), added LAST so it paints above the grid while open.
// area), added LAST so it paints above the grid while open. A BEST-EXIT
// row's tap goes to the scene (the "SET DESTINATION" popup —
// SurfaceScene.askSetDestination); anything else closes the panel.
const scout = new ScoutPanel(this.scene, {
width: sellGrid.width,
height: sellGrid.height,
onClose: () => this._closeScout(),
onExitTap: (exit) => this.scene?.askSetDestination?.(exit),
});
scout.setPosition?.(leftX, this.topY);
this._scout = scout;
@ -674,7 +677,12 @@ export class ShopView extends Phaser.GameObjects.Container {
}
refreshHud() {
// The GAME scene's HUD (the main-flight corner readout) — and the
// SURFACE scene's HUD (this screen's own upper-right pair, which is the
// one the player actually sees over the shop clip — the GameScene is
// sleeping beneath us, so its HUD update is belt-and-braces).
this.gameScene?.refreshCreditsHud?.();
this.scene?.refreshShopHud?.();
}
// ---------------------------------------------------------------------

View File

@ -7,8 +7,8 @@
* │ │icon││icon│ │ filled bay → good icon
* │ └────┘└────┘ │ empty bay → dim plate
* │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room)
* │ │ ││lock│ │ checked → cyan selection ring (1 unit queued)
* │ └────┘└────┘ │
* │ │ ✓ ││lock│ │ checked → GREEN stroke + tinted fill + a green ✓ in
* │ └────┘└────┘ │ the upper-left corner (1 unit queued)
* └────────────┘
*
* A BAY is the unit (not a good): each filled bay holds 1 unit of its good
@ -16,7 +16,10 @@
* live (filled with a good, or empty); the rest are LOCKED — the hold's
* upgrade room (the ship can't carry there yet). Hovering a cell reveals
* the good's name + sell price in a shared tooltip (icon-only cells — no
* text over the icon).
* text over the icon). A CHECKED bay is marked three ways (all revert
* together when it's checked off again): the green border stroke, a
* slightly lifted green-tinted fill, and the green checkmark badge in the
* upper-left corner (data/shopui.json → slot: selected, selBg, check).
*
* (The BUY side is no longer a grid — the shop's stock is now a scrollable
* list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side
@ -158,7 +161,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
const plate = this.scene.add?.graphics?.();
if (plate) { cell.plate = plate; cell.objects.push(plate); }
// selection ring (sell) — cyan highlight when the bay is checked
// corner check (sell) — the green checkmark badge when the bay is
// queued (added AFTER the icon so it paints over it — see _buildCells)
if (this.side === 'sell') {
const sel = this.scene.add?.graphics?.();
if (sel) { cell.sel = sel; cell.objects.push(sel); }
@ -190,9 +194,9 @@ export class TradeGrid extends Phaser.GameObjects.Container {
this.cells.push(cell);
this.add?.(cell.plate);
if (cell.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
if (cell.label) this.add?.(cell.label);
if (cell.sel) this.add?.(cell.sel); // the corner check — LAST, above the icon
}
}
@ -333,17 +337,29 @@ export class TradeGrid extends Phaser.GameObjects.Container {
const usable = !locked && !!goodId;
// plate border (selected sell bays get the cyan ring)
// plate — base fill + border. A QUEUED bay (checked to sell) is marked
// here: the GREEN stroke + a slightly lifted green-tinted fill (the
// corner check is drawn below); unchecking re-renders the idle look.
const selected = usable && this.sellSel.has(i);
if (cell.plate && typeof cell.plate.clear === 'function') {
const selected = this.sellSel.has(i);
cell.plate.clear?.();
if (typeof cell.plate.fillStyle === 'function') {
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3));
cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#00e5ff') : (sl.border ?? '#1d2e4c')), 255);
cell.plate.fillStyle(
_hex(selected ? (sl.selBgColor ?? sl.bgColor ?? '#0e1728') : (sl.bgColor ?? '#0e1728')),
usable ? (selected ? (sl.selBgAlpha ?? 0.95) : 0.92) : (sl.lockAlpha ?? 0.3));
cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#7ce8a4') : (sl.border ?? '#1d2e4c')), 255);
}
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
}
// the corner check (upper-left) — the selected bay's green checkmark
// badge, drawn on cell.sel (added after the icon → above it); cleared
// when the bay is checked off again (the render is pure state).
if (cell.sel && typeof cell.sel.clear === 'function') {
cell.sel.clear?.();
if (selected) this._drawCheck(cell.sel, x, y, sl);
}
// icon — the cell's only content for a usable bay (the name+price live
// in the hover tooltip). Centered.
if (cell.icon) {
@ -370,6 +386,31 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return list.find((g) => g.id === id)?.name ?? id;
}
/**
* The checked bay's corner mark (UPPER-LEFT): a small dark badge so the
* green check reads over any icon corner, then the green check itself
* (two strokes — short arm down to the vertex, long arm up). Sized from
* data/shopui.json → slot.check; drawn on the cell's `sel` graphics.
* Headless-safe: a fake scene without fillCircle just skips the badge.
*/
_drawCheck(g, x, y, sl) {
if (!g || typeof g.fillStyle !== 'function' || typeof g.lineStyle !== 'function' || typeof g.lineBetween !== 'function') return;
const chk = sl.check ?? {};
const r = Math.max(6, Number(chk.size) || 11); // the badge radius
const inset = Math.max(4, Number(chk.inset) || 7); // the badge's corner inset
const cx = x - this.slot / 2 + inset + r; // badge centre (upper-left)
const cy = y - this.slot / 2 + inset + r;
if (chk.badgeBg != null && typeof g.fillCircle === 'function') {
g.fillStyle(_hex(chk.badgeBg), chk.badgeAlpha != null ? Number(chk.badgeAlpha) : 0.85);
g.fillCircle(cx, cy, r);
}
// the check — sized to the badge, green (the selection color)
const s = r * 0.62;
g.lineStyle?.(Math.max(2, Math.round(r * 0.3)), _hex(chk.color ?? sl.selected ?? '#7ce8a4'), 1);
g.lineBetween(cx - s * 0.9, cy + s * 0.05, cx - s * 0.25, cy + s * 0.62);
g.lineBetween(cx - s * 0.25, cy + s * 0.62, cx + s * 0.95, cy - s * 0.55);
}
// --- HOVER TOOLTIP (name + price, revealed per cell) ------------------
/**