371 lines
16 KiB
JavaScript
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);
|