/** * 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);