orbit/dev/ship-cargo-dest.test.mjs

371 lines
16 KiB
JavaScript

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