chart n systems (TradeScout ranks exits only
+ * among systems the run has entered — a fresh
+ * run has one, so the list needs this to show)
+ * &pressLocked=1 press the locked SKILL TREE socket
+ * &mutate=1 change cargo/ore AFTER opening (live repaint)
+ * &fight=1 a live combat session with damaged armor
+ *
+ * node dev/server.mjs 8082 # static server
+ * node dev/cdp-shot.mjs \
+ * "http://127.0.0.1:8082/dev/ship-shot.html?tab=cargo&goods=iron-ore,grain-crates&minerals=half" \
+ * SHIP_CARGO.png "window.__SHIP_SHOT ? window.__SHIP_SHOT.ready : null" 30000 \
+ * "document.getElementById('report').style.display='none'; true"
+ *
+ * The report (top-left) lists console errors + the console's paint state.
+ */
+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 data = await ConfigLoader.load();
+config.init(data);
+
+// Capture console errors + uncaught exceptions for the report.
+const errors = [];
+const origErr = console.error.bind(console);
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
+window.addEventListener('error', (e) => errors.push(String(e.message)));
+window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
+
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+// The report lives OUTSIDE the canvas — a DOM the screenshot can
+// always read (headless canvases don't have to cooperate).
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText =
+ 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => {
+ report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n');
+};
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+ for (let i = 0; i < 100; i++) {
+ const s = game.scene.getScene('GameScene');
+ if (s && s.ship && s.shipWindow) return s;
+ await sleep(100);
+ }
+ throw new Error('GameScene never booted');
+}
+
+try {
+ const s = await waitScene();
+ await sleep(800); // let the boot settle (dossier decode, deck flicker)
+
+ const q = new URLSearchParams(location.search);
+ const tab = q.get('tab') || 'status';
+ const level = Math.max(0, Number(q.get('level') ?? 0) || 0);
+ const step = Math.min(1, Math.max(0, Number(q.get('step') ?? 0.4) || 0));
+ const goodsRaw = q.get('goods');
+ const mineralsRaw = q.get('minerals') ?? 'half';
+ const select = q.has('select') ? Number(q.get('select')) : null;
+ const fight = q.get('fight') === '1';
+
+ // ---- the XP ledger (js/combat/XpManager.js is the only ledger) --------
+ // restore() writes { level, xp } with no events; the bar's fine fill comes
+ // from the level's own threshold, so take a data-driven slice of it.
+ const xp = s.combat?.xp;
+ if (xp) {
+ const need = level < xp.maxLevel ? xp.xpForLevel(level + 1) : 0;
+ xp.restore({ level, xp: Math.round(need * step) });
+ }
+
+ // ---- the cargo hold (CargoHold — one good per bay, fill order) --------
+ const wantBays = q.has('bays') ? Number(q.get('bays')) : null;
+ if (wantBays !== null) s.ship.setCargoHold(wantBays);
+ const goods =
+ !goodsRaw || goodsRaw === 'none'
+ ? []
+ : String(goodsRaw)
+ .split(',')
+ .map((g) => g.trim())
+ .filter(Boolean);
+ s.ship.cargo.clear?.();
+ if (goods.length) s.ship.cargo.add(goods);
+
+ // ---- a LIVE combat session (?fight=1) --------------------------------
+ // `CombatSystem.active` is true while a hostile is up, and that is exactly
+ // what flips the plate's armor rows from "nominal maxima" to the session's
+ // real hull/shields — so spawn hostiles through the system's own seam, then
+ // take hits hard enough to break the shields and dent the hull.
+ if (fight && s.combat) {
+ const c = s.combat;
+ const sx = s.ship?.x ?? 0;
+ const sy = s.ship?.y ?? 0;
+ c.spawnEnemy(sx + 520, sy - 260, 901);
+ c.spawnEnemy(sx - 480, sy + 300, 902);
+ c.damagePlayer(Math.round(c.maxShields * 0.55), { bearing: 0 });
+ c.damagePlayer(Math.round(c.maxShields * 0.6 + c.maxHull * 0.3), { bearing: Math.PI });
+ }
+
+ // ---- the mineral load (aggregate — what the Mineral HUD reads) --------
+ const cap = s.ship.stats.mineralStorage;
+ s.ship.setMinerals(
+ mineralsRaw === 'zero' || mineralsRaw === '0'
+ ? 0
+ : mineralsRaw === 'half'
+ ? Math.round(cap * 0.5)
+ : mineralsRaw === 'full'
+ ? cap
+ : Number(mineralsRaw) || 0,
+ );
+ s.ship.credits = Math.max(s.ship.credits, 12_480);
+
+ // ---- the chart (visitedSystems is what TradeScout ranks exits across) ---
+ const known = q.has('known') ? Math.max(0, Number(q.get('known')) || 0) : null;
+ if (known !== null && s.galaxy) {
+ let n = 0;
+ for (const id of s.galaxy.byId.keys()) {
+ if (n >= known) break;
+ s.visitedSystems.add(id);
+ n++;
+ }
+ }
+
+ // The fresh run's opening ACK comm owns the screen (deckAction swallows the
+ // press while it holds) — clear the chain through the hub's own finish path
+ // so the deck is live, exactly like dev/combat-shot.mjs does.
+ const hub = s.comms;
+ if (hub) {
+ if (hub.current) { try { hub._finish(); } catch { /* already ended */ } }
+ if (Array.isArray(hub.queue)) hub.queue.length = 0;
+ hub._setPaused(false);
+ try { hub.box?.close(); } catch { /* no box */ }
+ hub.box = null;
+ // _commInputBlocked() holds the deck for 200 ms after a comm ends —
+ // give that window time to close before the deck press.
+ await sleep(400);
+ }
+
+ // Open the console (the deck SHIP button does exactly this). The press is a
+ // no-op while ANY input owner still holds the deck, and that clearance races
+ // this script — so nudge until the console says it is open (a press while it
+ // is already open would TOGGLE it shut, hence the guard).
+ const win = s.shipWindow;
+ for (let i = 0; i < 12 && !win.isOpen; i++) {
+ s.deckAction('ship');
+ await sleep(350);
+ }
+ await sleep(1500); // the boot reveal + the first paint
+ if (tab !== 'status') {
+ win._setTab(tab);
+ await sleep(700);
+ }
+ if (select !== null) {
+ win._selectedBay = select;
+ win.refresh();
+ await sleep(700);
+ }
+
+ // ---- live refresh while open (?mutate=1) -------------------------------
+ // The console polls the scene's snapshot seam on its own beat; changing the
+ // hold through the run's OWN api (a buy landing in a bay, ore aboard) must
+ // repaint it — no reopen, no manual refresh.
+ let liveUpdate = 'n/a';
+ if (q.get('mutate') === '1' && win) {
+ const before = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
+ s.ship.cargo.add(['iron-ore']);
+ s.ship.minerals = Math.round((s.ship.stats.mineralStorage || 0) * 0.9);
+ await sleep(1200); // longer than the window's poll interval
+ const after = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
+ liveUpdate = `${before !== after ? 'fingerprint changed → repaint' : 'STALE (no repaint)'} · ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays, ${s.ship.minerals} u`;
+ }
+
+ // The locked SKILL TREE socket must shake + toast, never crash.
+ let locked = 'n/a';
+ if (q.get('pressLocked') === '1') {
+ try {
+ const t = win._tabs.find((x) => x.id === 'skills');
+ win._shakeTab(t);
+ win.onLocked?.('skills');
+ locked = 'press ok';
+ } catch (e) {
+ locked = 'press err: ' + e.message;
+ }
+ }
+
+ const snap = s.shipScreenSnapshot?.() ?? null;
+ const lines = [
+ errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
+ `window: ${win?.openState} · tab=${win?._activeTab} · selected=${win?._selectedBay ?? '—'}`,
+ `video: ${win?.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL (clip absent)'}`,
+ snap
+ ? `live hull ${snap.live.hull}/${snap.live.hullMax} · shields ${snap.live.shields}/${snap.live.shieldsMax}${fight ? ' (combat ACTIVE)' : ''}`
+ : 'snapshot: NONE',
+ `level ${snap?.level?.level ?? '?'} (${Math.round((snap?.level?.progress ?? 0) * 100)}% → next) · ledger=${xp ? 'XpManager' : 'none'}`,
+ `cargo ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays (${s.ship.cargo.items.join(', ') || 'empty'}) · locked ${s.ship.maxCargoSlots - s.ship.cargo.capacity}`,
+ `minerals ${s.ship.minerals}/${s.ship.stats.mineralStorage} · credits ${s.ship.credits}`,
+ `locked tab: ${locked}`,
+ `live update: ${liveUpdate}`,
+ ];
+ setReport(lines);
+ window.__SHIP_SHOT = { ready: true, lines, errors };
+ console.info('ship-shot: report painted');
+} catch (err) {
+ errors.push(`FATAL: ${err.message}`);
+ setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
+ window.__SHIP_SHOT = { ready: true, fatal: String(err.message), errors };
+ console.error('ship-shot: fatal', err);
+}
diff --git a/dev/ship-window.test.mjs b/dev/ship-window.test.mjs
new file mode 100644
index 0000000..1633039
--- /dev/null
+++ b/dev/ship-window.test.mjs
@@ -0,0 +1,275 @@
+/**
+ * SHIP console test (dev tool, run with Node — no browser needed):
+ *
+ * node dev/ship-window.test.mjs
+ *
+ * The pure half of the SHIP screen: js/ship/ShipScreenModel.js (the read-only
+ * brain behind js/ui/ShipWindow.js) driven over the REAL data files. The
+ * console's doctrine is that it never invents a number — every value, label,
+ * unit, order and format comes from data/shipScreen.json → status.rows,
+ * resolved against references into data/*.json and the run snapshot:
+ *
+ * 'config:' hardware specs as authored (data/ship.json thrust/maxSpeed,
+ * data/combat.json playerShip shield recharge + player.weapon)
+ * 'ship:' state the run mutates (stats.cargoHold / mineralStorage /
+ * miningSpeed — what research & builds already rewrite —
+ * minerals, credits, cargo.used/items)
+ * 'live:' the live combat session's armor (hull/shields + maxima),
+ * folded by GameScene.shipScreenSnapshot()
+ *
+ * Asserted here:
+ * - EVERY configured readout row resolves to a finite number from a realistic
+ * snapshot, formats without a NaN hole, and its meter stays in 0…1;
+ * - the numbers ARE the data files' numbers (fire rate = 1/cooldown, DPS =
+ * damage × shots/sec, flight feel = ship.thrust / ship.maxSpeed, shield
+ * recharge = playerShield regen + the row's own meta delay …), so the plate
+ * cannot drift from combat;
+ * - deltas already flowing through ship.stats (a cargo-hold upgrade, mined
+ * ore) move the rows with them, and a missing source reads as an em-dash
+ * instead of a fabricated zero;
+ * - the journey's ledger is XpManager alone: over the REAL progression curve
+ * the plate shows the 0→5 timeline below data classAt and CHOOSE YOUR
+ * CLASS exactly at it (and a class on file wins over both);
+ * - the hold summary matches CargoHold (one good per bay, fill order) +
+ * economy.cargo.maxSlots, with minerals aggregate-only;
+ * - TradeScout.scoutGood() prices a held good's exits so the bay manifest has
+ * ranked data to draw (price + profit per hop, bestExit first).
+ */
+import { readdirSync, readFileSync } from 'node:fs';
+import assert from 'node:assert/strict';
+
+// ---------------------------------------------------------------------------
+// Real data → the config singleton (the same shape Config.load() produces)
+// ---------------------------------------------------------------------------
+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')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
+}
+config.init(data);
+
+const M = await import('../js/ship/ShipScreenModel.js');
+const { CargoHold } = await import('../js/economy/CargoHold.js');
+
+let failures = 0;
+const check = (label, cond) => {
+ console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
+ if (!cond) failures++;
+};
+const near = (a, b, eps = 1e-6) => Number.isFinite(a) && Math.abs(a - b) <= eps;
+
+// ---------------------------------------------------------------------------
+// A snapshot in the shape GameScene.shipScreenSnapshot() produces
+// ---------------------------------------------------------------------------
+function snapshot(o = {}) {
+ // The run's invariant: the hold's capacity IS ship.stats.cargoHold (the
+ // cargo upgrades raise both) — a snapshot that disagrees would let the plate
+ // and the grid drift apart, which is exactly what must not happen.
+ const capacity = o.capacity ?? data.ship.stats.cargoHold;
+ const hold = new CargoHold(capacity);
+ if (o.items?.length) hold.add(o.items);
+ // Armor maxima come from the combat system (data/combat.json → playerShip —
+ // note ship.stats.shields is authored 0, so the plate MUST read combat's
+ // numbers; GameScene folds combat.maxHull/maxShields into the snapshot).
+ const hullMax = o.hullMax ?? data.combat.playerShip.hull;
+ const shieldsMax = o.shieldsMax ?? data.combat.playerShip.shields;
+ return {
+ live: {
+ hull: o.hull ?? hullMax,
+ hullMax,
+ shields: o.shields ?? shieldsMax,
+ shieldsMax,
+ },
+ ship: {
+ stats: { ...data.ship.stats, cargoHold: capacity, ...(o.stats || {}) },
+ minerals: o.minerals ?? 0,
+ credits: o.credits ?? 12480,
+ cargo: { used: hold.used, capacity: hold.capacity, items: hold.snapshot() },
+ },
+ level: o.level ?? null,
+ playerClass: o.playerClass ?? null,
+ };
+}
+
+const CFG = config.section('shipScreen', {});
+const rowsCfg = CFG.status.rows;
+const byId = (rows, id) => rows.find((r) => r.id === id);
+
+// --- 1. the plate's rows all resolve from the data files -------------------
+{
+ const snap = snapshot({ capacity: 4, minerals: 125, items: ['fresh-produce', 'grain-crates'] });
+ const rows = M.statRows(rowsCfg, snap);
+ check(`status.rows resolves every configured row (${rows.length})`, rows.length === rowsCfg.length && rows.length > 0);
+ for (const r of rows) {
+ check(` ${r.id} → finite value (${r.text}${r.unit ? ' ' + r.unit : ''})`, M.isResolved(r));
+ check(` ${r.id} label/copy comes from data`, !!r.label && r.label === String(rowsCfg.find((d) => d.id === r.id).label).toUpperCase());
+ if (rowsCfg.find((d) => d.id === r.id).meter) {
+ check(` ${r.id} meter stays in 0…1`, r.frac !== null && r.frac >= 0 && r.frac <= 1);
+ }
+ }
+ // meta lines (the row's small right-hand read) resolve too
+ for (const d of rowsCfg.filter((x) => x.meta)) {
+ const r = byId(rows, d.id);
+ check(` ${d.id} meta resolves (${r.meta})`, /\d/.test(r.meta));
+ }
+}
+
+// --- 2. the values ARE the data files' numbers -----------------------------
+{
+ const snap = snapshot();
+ const rows = M.statRows(rowsCfg, snap);
+ const W = data.combat.player.weapon;
+ const expect = {
+ hull: data.combat.playerShip.hull,
+ shields: data.combat.playerShip.shields,
+ shieldRegen: data.combat.playerShip.shieldRegenRate,
+ accel: data.ship.thrust,
+ maxSpeed: data.ship.maxSpeed,
+ fireRate: 1 / W.cooldown,
+ damage: W.damage,
+ dps: W.damage / W.cooldown,
+ cargoBays: data.ship.stats.cargoHold,
+ mineralHold: data.ship.stats.mineralStorage,
+ miningSpeed: data.ship.stats.miningSpeed,
+ };
+ for (const [id, v] of Object.entries(expect)) {
+ const r = byId(rows, id);
+ check(` ${id} = data value ${v}`, !!r && near(r.value, v));
+ }
+ // the recharge row carries the delay; the gun rows carry shot speed
+ assert.match(byId(rows, 'shieldRegen').meta, new RegExp(String(data.combat.playerShip.shieldRegenDelay)));
+ assert.match(byId(rows, 'dps').meta, new RegExp(String(data.combat.player.weapon.bulletSpeed)));
+ check(' DPS = damage × shots/sec (product of two sources)', near(byId(rows, 'dps').value, W.damage * (1 / W.cooldown)));
+}
+
+// --- 3. live combat vs the nominal (out-of-combat) state -------------------
+{
+ const full = snapshot();
+ const fight = snapshot({ hull: 62, shields: 0, hullMax: 140, shieldsMax: 56 });
+ const a = M.statRows(rowsCfg, full);
+ const b = M.statRows(rowsCfg, fight);
+ check('nominal: current hull reads its max with a full meter', near(byId(a, 'hullNow').frac, 1) && byId(a, 'hullNow').value === byId(a, 'hull').value);
+ check('nominal copy is authored in data (not coded)', byId(a, 'hullNow').nominalLabel === rowsCfg.find((d) => d.id === 'hullNow').nominalLabel);
+ check('combat session: hull reads the session (62/140)', byId(b, 'hullNow').value === 62 && near(byId(b, 'hullNow').frac, 62 / 140));
+ check('combat session: an emptied shield meter floors at 0', near(byId(b, 'shieldNow').frac, 0) && byId(b, 'shieldNow').value === 0);
+}
+
+// --- 4. run deltas + missing sources --------------------------------------
+{
+ const upgraded = snapshot({ capacity: 8, minerals: 240, stats: { cargoHold: 8, miningSpeed: 1.35 } });
+ const rows = M.statRows(rowsCfg, upgraded);
+ check('a cargo-hold upgrade moves the row (ship.stats.cargoHold)', byId(rows, 'cargoBays').value === 8);
+ check('mining research moves the row (ship.stats.miningSpeed)', near(byId(rows, 'miningSpeed').value, 1.35));
+ const ore = snapshot({ minerals: 250 });
+ check('the mineral meter tracks what is aboard', near(byId(M.statRows(rowsCfg, ore), 'mineralHold').frac, Math.min(1, 250 / data.ship.stats.mineralStorage)));
+ // a source that does not exist must never print a number
+ const bare = M.resolveRow({ id: 'ghost', label: 'GHOST', from: 'ship:nope.nothere', format: 'int' }, snapshot());
+ check('a missing source reads as a dash, never a fabricated 0', bare.value === null && bare.text === '—');
+ check('formats: int rounds / 1dp / 2dp', M.formatValue(6.49, 'int') === '6' && M.formatValue(62.5, '1dp') === '62.5' && M.formatValue(1, '2dp') === '1.00');
+}
+
+// --- 5. the journey's ledger is XpManager alone ---------------------------
+{
+ const { XpManager } = await import('../js/combat/XpManager.js');
+ const cfg = CFG.level;
+ const xp = new XpManager();
+ const modes = [];
+ for (let lv = 0; lv <= cfg.classAt + 1; lv++) {
+ // walk the REAL curve: pay xpForLevel(1..lv) through grant()
+ xp.reset();
+ let total = 0;
+ for (let n = 1; n <= lv; n++) total += xp.xpForLevel(n);
+ if (total > 0) xp.grant(total, 'test');
+ assert.equal(xp.stats().level, lv, `xp curve reached level ${lv}`);
+ const j = M.journeyState(xp.stats(), cfg);
+ modes.push(`${j.level}:${j.mode}`);
+ if (lv < cfg.classAt) {
+ check(` level ${lv} → timeline`, j.mode === 'timeline');
+ const cur = j.nodes.filter((n) => n.state === 'current');
+ check(` level ${lv} marks exactly one milestone current`, cur.length === 1 && cur[0].level === lv);
+ check(` level ${lv} fills ${(lv / cfg.to).toFixed(2)} of the rail`, near(j.progress, lv / cfg.to));
+ } else {
+ check(` level ${lv} → CHOOSE YOUR CLASS door`, j.mode === 'choose');
+ }
+ }
+ // a class on file wins over the door; no ledger at all still renders
+ check('class on file → class plate', M.journeyState(xp.stats(), cfg, { name: 'MARAUDER', insignia: 'marauder' }).mode === 'class');
+ const dark = M.journeyState(null, cfg);
+ check('no combat ledger yet → level-0 timeline, no crash', dark.mode === 'timeline' && dark.level === cfg.from && dark.progress === 0);
+ xp.restore({ level: 80 });
+ check('the cap reads MAX (ledger untouched by the console)', M.journeyState(xp.stats(), cfg).isMax === true);
+}
+
+// --- 6. the hold summary matches CargoHold + maxSlots ---------------------
+{
+ const catalog = data.goods.goods ?? data.goods;
+ const items = ['fresh-produce', 'grain-crates', 'fresh-produce'];
+ const sum = M.cargoSummary({ ship: snapshot({ capacity: 4, items, minerals: 125 }).ship }, catalog);
+ check(`the grid follows the hold's fill order (${sum.bays.map((b) => b.state).join(',')})`, sum.bays.map((b) => b.state).join(',') === 'filled,filled,filled,empty');
+ check('duplicate goods occupy one bay each', sum.bays[0].goodId === 'fresh-produce' && sum.bays[2].goodId === 'fresh-produce');
+ check(`the locked upgrade room is reported up to economy.cargo.maxSlots (${sum.lockedBays})`, sum.lockedBays === config.get('economy.cargo.maxSlots', 16) - 4);
+ check('names/icons come from data/goods.json', sum.bays[0].name === catalog.find((g) => g.id === items[0]).name && Number.isInteger(sum.bays[0].icon));
+ check('used/room counts agree with the hold', sum.used === 3 && sum.room === 1 && sum.capacity === 4 && sum.empty === false);
+ check('minerals stay aggregate (aboard vs capacity, no per-mineral model)', sum.minerals === 125 && sum.mineralCapacity === data.ship.stats.mineralStorage && !Array.isArray(sum.mineralLines));
+ const empty = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 4 }, cargo: { used: 0, items: [] }, minerals: 0 } }, catalog);
+ check('an empty hold is an intentional empty plate (not a broken layout)', empty.empty === true && empty.bays.every((b) => b.state !== 'filled'));
+ const none = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 0 }, cargo: { used: 0, items: [] } } }, catalog);
+ check('no bays installed still summarizes without a crash', none.capacity === 0 && none.used === 0 && none.empty === true);
+ // the two edges the plate must never get wrong: a FULL hold and a full
+ // mineral load (the numbers are CargoHold's + MineralHud's, not ours)
+ const full = M.cargoSummary(
+ { ship: { stats: { ...data.ship.stats, cargoHold: 4, mineralStorage: 250 }, cargo: { used: 4, items: ['a', 'b', 'c', 'd'] }, minerals: 250 } },
+ catalog
+ );
+ check(
+ 'a full hold reads full (no room, no negative)',
+ full.used === 4 && full.room === 0 && full.empty === false && full.bays.every((b) => b.state === 'filled') && full.pct >= 1
+ );
+ check('minerals at capacity read 100% and stay aggregate', full.mineralPct === 1 && full.minerals === full.mineralCapacity);
+ const dry = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 4, mineralStorage: 250 }, cargo: { used: 0, items: [] }, minerals: 0 } }, catalog);
+ check('no ore aboard reads 0%', dry.mineralPct === 0 && dry.minerals === 0);
+ const unknown = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 2 }, cargo: { used: 1, items: ['not-a-good'] } } }, catalog);
+ check('an unknown good id still draws its bay (name falls back to the id)', unknown.bays[0].state === 'filled' && unknown.bays[0].name === 'not-a-good');
+}
+
+// --- 7. a held good's exits (the bay manifest's data) ---------------------
+{
+ const { Galaxy } = await import('../js/galaxy/Galaxy.js');
+ const { scoutGood } = await import('../js/economy/TradeScout.js');
+ const g = Galaxy.create('orbit-ship-window-test');
+ const sysId = g.homeSystemId;
+ const goods = data.goods.goods ?? data.goods;
+ const goodId = goods[0].id;
+ const rep = scoutGood(goodId, { galaxy: g, systemId: sysId, buyUnit: goods.find((x) => x.id === goodId).baseValue, topN: CFG.cargo.detail.topN ?? 3 });
+ check(`scout report for ${goodId}: charted=${rep.charted} exits=${rep.exits.length}`, rep && Array.isArray(rep.exits));
+ const profits = rep.exits.map((e) => e.profit);
+ check('exits are ranked by per-unit profit (best first)', profits.every((p, i) => i === 0 || profits[i - 1] >= p));
+ check('the best exit is the first row', rep.exits.length === 0 || rep.bestExit === rep.exits[0]);
+ check(`topN caps the list (${rep.exits.length} ≤ ${CFG.cargo.detail.topN ?? 3})`, rep.exits.length <= (CFG.cargo.detail.topN ?? 3));
+ check('rows carry what the manifest draws (price/hops/system)', rep.exits.every((e) => Number.isFinite(e.price) && Number.isFinite(e.hops) && !!e.systemName));
+
+ // THE BASELINE (data: cargo.detail.baseline). This screen has no shop, so
+ // data picks the reference the exits' profits are measured against — the
+ // catalog value (goods.json → baseValue) or the local buy price. The panel
+ // must never invent one.
+ const baseline = String(CFG.cargo.detail.baseline ?? '');
+ check(`the profit baseline is documented in data ("${baseline}")`, baseline === 'baseValue' || baseline === 'buy');
+ if (baseline === 'baseValue') {
+ const base = goods.find((x) => x.id === goodId).baseValue;
+ check(`profit = exit price − goods.json baseValue (${base}) on every row`, rep.exits.length > 0 && rep.exits.every((e) => e.profit === e.price - base));
+ const other = scoutGood(goodId, { galaxy: g, systemId: sysId, topN: 3 }); // no buyUnit → the local market's own price
+ check('the other documented baseline (Economy buy price) really is a different reference', other.localBuy !== base || rep.exits.length === 0);
+ } else {
+ const local = rep.localBuy;
+ check('profit = exit price − local buy price on every row', rep.exits.length > 0 && rep.exits.every((e) => e.profit === e.price - local));
+ }
+
+ // A bay whose good isn't in the catalog (a save from an older build, a
+ // stripped data file) degrades: cargoSummary keeps the id as the name and
+ // the manifest simply has no report to draw.
+ const ghost = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 1 }, cargo: { used: 1, items: ['no-such-good'] } } }, goods);
+ check('an unparseable good still yields a selectable bay without a report', ghost.bays[0].state === 'filled' && ghost.bays[0].icon === null);
+}
+
+console.log(failures === 0 ? '\nSHIP console model: ALL PASS' : `\n${failures} FAILURE(S)`);
+process.exit(failures ? 1 : 0);
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 56bfe3d..aa6f368 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -41,6 +41,7 @@ import { CombatHud } from '../combat/CombatHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
+import { scoutGood } from '../economy/TradeScout.js';
import * as Stock from '../economy/Stock.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
@@ -57,6 +58,7 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { MapWindow } from '../ui/MapWindow.js';
+import { ShipWindow } from '../ui/ShipWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
@@ -273,6 +275,20 @@ export class GameScene extends Phaser.Scene {
}
}
+ // The ship console's bay camera (data/shipScreen.json → video): a muted
+ // 2:3 loop behind the SHIP window. Same contract — the clip is planned
+ // but not authored yet (assets/videos/ship.mp4 is not in the repo), and a
+ // missing file just leaves the window's procedural NO SIGNAL plate up.
+ if (config.get('shipScreen.enabled', true)) {
+ const shipVideo = String(config.get('shipScreen.video.file') ?? '');
+ if (shipVideo) {
+ const url = /^(https?:)?\/\//.test(shipVideo) || shipVideo.startsWith('assets/')
+ ? shipVideo
+ : `assets/videos/${shipVideo}`;
+ this.load.video(ShipWindow.VIDEO_KEY, url);
+ }
+ }
+
// The build console's feed (data/builds.json → video): a muted 2:3
// loop behind the BUILD window on a planet surface (SurfaceScene's
// BuildWindow reads the shared cache). A missing file just leaves the
@@ -1143,6 +1159,7 @@ export class GameScene extends Phaser.Scene {
(this.savePanel && this.savePanel.isOpen) ||
(this.researchWindow && this.researchWindow.isOpen) ||
(this.mapWindow && this.mapWindow.isOpen) ||
+ (this.shipWindow && this.shipWindow.isOpen) ||
(this.questWindow && this.questWindow.isOpen) ||
(this.commsPanel && this.commsPanel.isOpen) ||
(this.menuSubBar && this.menuSubBar.isOpen) ||
@@ -1348,6 +1365,39 @@ export class GameScene extends Phaser.Scene {
},
),
});
+ // ---- SHIP CONSOLE (the deck's SHIP button) ------------------------
+ // The hull & hardware console (data/shipScreen.json, js/ui/ShipWindow.js,
+ // depth 80): left the bay camera (assets/videos/ship.mp4 — a muted 2:3
+ // loop; until the clip is authored the window's procedural NO SIGNAL
+ // plate stands in), right the SHIP STATUS / SKILL TREE / CARGO HOLDS tabs
+ // over one plate. Ship Status reads the run through the data-driven
+ // source table (data/shipScreen.json → status.rows, resolved by the pure
+ // js/ship/ShipScreenModel.js — ship.stats, the live combat session's
+ // armor, the cannon's cooldown→rate, the hold's bays) and shows the
+ // XP ledger (js/combat/XpManager.js) as a bar + the level 0→5 journey,
+ // which gives way to CHOOSE YOUR CLASS at the class level (the class
+ // layer's seam — onChooseClass). Skill Tree is a STANDBY socket until the
+ // class layer lands: pressing it shakes the tab and toasts. Cargo Holds
+ // reads js/economy/CargoHold.js (one good per bay) + the aggregate
+ // mineral load, and a filled bay opens its manifest with the charted
+ // exits (js/economy/TradeScout.js — the planet shop's own report).
+ this.shipWindow = new ShipWindow(this, {
+ getSnapshot: () => this.shipScreenSnapshot(),
+ getScoutReport: (goodId) => this.shipScreenScoutReport(goodId),
+ onLocked: () =>
+ this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), {
+ glyph: '✕',
+ glyphColor: toCss(themeColor('amber', 0xffc94d)),
+ }),
+ // The level-5 door. Until the class-selection screen exists this is a
+ // voice, never a dead click (the data's own line).
+ onChooseClass: () =>
+ this.consoleToast(String(config.get('shipScreen.level.chooseClass.toast', 'CLASS SELECT OFFLINE')), {
+ glyph: '✕',
+ glyphColor: toCss(themeColor('amber', 0xffc94d)),
+ }),
+ });
+
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
@@ -1626,8 +1676,8 @@ export class GameScene extends Phaser.Scene {
if (this.mapWindow && this.mapWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
- // The quests console (depth 80) — the same full-screen contract.
- if (this.questWindow && this.questWindow.isOpen) return;
+ // The ship console (depth 80) — the same full-screen contract.
+ if (this.shipWindow && this.shipWindow.isOpen) return;
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
// its buttons — the buttons fire on their own pointerdown). ANY
// click OUTSIDE — world, deck, HUD, compass — folds it back down
@@ -2408,6 +2458,7 @@ export class GameScene extends Phaser.Scene {
this.researchWindow?.update(_time); // the console's living details (open state)
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
+ this.shipWindow?.update(_time); // the ship console: reveal, decodes, sweep, telemetry poll
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
// Builds: a build started on a surface keeps running in space if the
@@ -4127,6 +4178,7 @@ export class GameScene extends Phaser.Scene {
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
+ if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.researchWindow?.open();
return;
}
@@ -4139,6 +4191,7 @@ export class GameScene extends Phaser.Scene {
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+ if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.questWindow?.open();
return;
}
@@ -4151,9 +4204,24 @@ export class GameScene extends Phaser.Scene {
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+ if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
this.mapWindow?.open();
return;
}
+ // The ship console (full-screen, depth 80) — the deck's hardware tab.
+ if (id === 'ship') {
+ if (this.shipWindow && this.shipWindow.isOpen) {
+ this.shipWindow.close();
+ return;
+ }
+ if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
+ if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
+ if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+ if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
+ if (this.questWindow && this.questWindow.isOpen) this.questWindow.close();
+ this.shipWindow?.open();
+ return;
+ }
if (id === 'menu') {
this.menuAction();
return;
@@ -5773,6 +5841,82 @@ export class GameScene extends Phaser.Scene {
});
}
+ // ------------------------------------------------------------ ship console
+
+ /**
+ * The SHIP console's snapshot (js/ui/ShipWindow.js polls this while the
+ * deck's SHIP slot is open; js/ship/ShipScreenModel.js resolves every row
+ * of data/shipScreen.json → status.rows against it). Plain data on purpose
+ * — a copy, so the view can never mutate the run:
+ *
+ * live:* the armor AS IT STANDS. Out of combat a ship is never damage
+ * (no persistent hull state exists by design), so the read is
+ * the maxima and the plate's rows carry their 'nominal' tag;
+ * while a fight runs these are the session's own numbers.
+ * ship:* the run's mutable state — stats AFTER upgrades/builds, ore
+ * aboard, credits, and the cargo hold's bays in fill order.
+ * level the XP ledger itself (js/combat/XpManager.js → stats()).
+ */
+ shipScreenSnapshot() {
+ const s = this.ship;
+ if (!s) return null;
+ const c = this.combat;
+ const fighting = !!(c && c.active);
+ // The maxima come from the combat system when it exists — that's where
+ // the per-level armor bump lands (progression.reward.hullPerLevel /
+ // shieldPerLevel), so the plate never shows a stale base number.
+ const hullMax = Math.max(1, Math.round(Number(c?.maxHull ?? s.stats?.hullIntegrity) || 0));
+ const shieldsMax = Math.max(0, Math.round(Number(c?.maxShields ?? s.stats?.shields) || 0));
+ return {
+ live: {
+ hull: fighting ? Math.max(0, Math.round(Number(c.hull) || 0)) : hullMax,
+ hullMax,
+ shields: fighting ? Math.max(0, Math.round(Number(c.shields) || 0)) : shieldsMax,
+ shieldsMax,
+ },
+ ship: {
+ stats: { ...(s.stats ?? {}) },
+ minerals: Math.max(0, Math.round(Number(s.minerals) || 0)),
+ credits: Math.max(0, Math.round(Number(s.credits) || 0)),
+ cargo: {
+ used: Math.max(0, Math.round(Number(s.cargo?.used) || 0)),
+ capacity: Math.max(0, Math.round(Number(s.cargo?.capacity) || 0)),
+ items: Array.isArray(s.cargo?.items) ? [...s.cargo.items] : [],
+ },
+ },
+ level: typeof c?.xp?.stats === 'function' ? c.xp.stats() : null,
+ // The class layer's seam — a chosen class lands on the save when the
+ // class selection screen exists (js/ui/ShipWindow.js then shows the
+ // class plate instead of the CHOOSE YOUR CLASS door).
+ playerClass: this.playerClass ?? null,
+ };
+ }
+
+ /**
+ * A held good's report for the SHIP console's bay manifest — the SAME
+ * scoutGood() the planet shop's scout panel renders (js/ui/shop/ScoutPanel.js),
+ * over the run's chart (galaxy + current system + visitedSystems). There is
+ * no shop under the ship, so the profit baseline is the catalog's own value
+ * (data/goods.json → baseValue — data/shipScreen.json → cargo.detail.baseline
+ * documents the choice); the exits themselves are priced exactly as the
+ * shop prices them.
+ */
+ shipScreenScoutReport(goodId) {
+ if (!goodId || !this.galaxy) return null;
+ const good = (config.get('goods.goods', []) ?? []).find((g) => g?.id === goodId) ?? null;
+ const baseline = String(config.get('shipScreen.cargo.detail.baseline', 'baseValue'));
+ const base = Math.max(0, Math.round(Number(good?.baseValue) || 0));
+ return scoutGood(goodId, {
+ galaxy: this.galaxy,
+ systemId: this.systemRecord?.id ?? null,
+ // no local market → the catalog value is the reference (a non-finite
+ // buyUnit would make TradeScout price against the current system)
+ buyUnit: baseline === 'buy' ? undefined : base,
+ visited: this.visitedSystems,
+ topN: Math.max(1, Math.floor(Number(config.get('shipScreen.cargo.detail.topN', 3)) || 3)),
+ });
+ }
+
// ------------------------------------------------------------ map console
/**
@@ -6927,6 +7071,11 @@ export class GameScene extends Phaser.Scene {
this.questWindow.close();
return;
}
+ // The ship console (depth 80) — the same contract.
+ if (this.shipWindow && this.shipWindow.isOpen) {
+ this.shipWindow.close();
+ return;
+ }
// The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close();
@@ -7207,6 +7356,7 @@ export class GameScene extends Phaser.Scene {
this.commsPanel?.destroy();
this.researchWindow?.destroy(); // the console (video + UI)
this.mapWindow?.destroy(); // the map console (video + chart canvas)
+ this.shipWindow?.destroy(); // the ship console (video + telemetry plate)
this.creditsHud?.destroy();
this.mineralHud?.destroy();
this.mining?.destroy();
diff --git a/js/ship/ShipScreenModel.js b/js/ship/ShipScreenModel.js
new file mode 100644
index 0000000..11e40f0
--- /dev/null
+++ b/js/ship/ShipScreenModel.js
@@ -0,0 +1,241 @@
+/**
+ * ShipScreenModel — the SHIP console's read-only brain (data/shipScreen.json,
+ * rendered by js/ui/ShipWindow.js). Pure: no Phaser, no scene, no game
+ * objects, so dev/ship-window.test.mjs drives it in bare Node with a plain
+ * snapshot object.
+ *
+ * The console never invents a number. Every value on the Ship Status plate is
+ * resolved from a path written in data/shipScreen.json → status.rows[*].from:
+ *
+ * 'config:' a data file (config.get) — the hardware specs
+ * that live in data/ship.json / data/combat.json.
+ * 'ship:' a field of the ship snapshot (stats.cargoHold,
+ * minerals, credits, cargo.used …) — state the run
+ * mutates (upgrades, ore, buys/sells).
+ * 'live:' the LIVE combat session's armor (hull / hullMax /
+ * shields / shieldsMax), folded by GameScene: while
+ * a fight runs these are the session's values, and
+ * with no fight they read the maxima (the ship is
+ * never damaged out of combat — the plate says so
+ * through the row's `nominalLabel`).
+ *
+ * Optional per-row math: op 'reciprocal' (a cooldown → a rate) and, for an
+ * array of sources, op 'product' (damage × shots/sec = DPS). A row's meter is
+ * likewise two paths — fill / max — so bars are data too.
+ */
+import { config } from '../config/Config.js';
+
+// A missing source stays MISSING (null) — never a fabricated 0, which would
+// let a broken path masquerade as a real reading on the plate.
+const num = (v) => {
+ if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+};
+const clamp01 = (v) => Math.min(1, Math.max(0, v));
+
+/**
+ * Resolve one `from` reference against the snapshot.
+ * @param {string} ref 'config:ship.thrust' | 'ship:stats.cargoHold' | 'live:hull'
+ * @param {{ship?: object, live?: object}} snap the console snapshot
+ * @returns {number|null} a finite number, or null when the path is missing
+ */
+export function resolveRef(ref, snap = {}) {
+ const s = String(ref ?? '');
+ const i = s.indexOf(':');
+ if (i < 0) return null;
+ const ns = s.slice(0, i);
+ const path = s.slice(i + 1);
+ let node;
+ if (ns === 'config') node = config.get(path, undefined);
+ else if (ns === 'ship') node = path.split('.').reduce((o, k) => (o == null ? o : o[k]), snap?.ship ?? null);
+ else if (ns === 'live') node = (snap?.live ?? {})[path];
+ else return null;
+ return num(node);
+}
+
+/** The row's value — one source (+ op), or an array of sources + 'product'. */
+export function resolveValue(def, snap) {
+ const list = Array.isArray(def?.from) ? def.from : def?.from ? [{ from: def.from, op: def.op }] : [];
+ if (!list.length) return null;
+ if (def?.op === 'product') {
+ let p = 1;
+ let any = false;
+ for (const part of list) {
+ const v = resolveValue(part, snap);
+ if (v === null) return null;
+ p *= v;
+ any = true;
+ }
+ return any ? p : null;
+ }
+ const v = resolveRef(list[0].from ?? list[0], snap);
+ if (v === null) return null;
+ const op = list[0].op ?? def?.op;
+ if (op === 'reciprocal') return v > 0 ? 1 / v : null;
+ return v;
+}
+
+/** The plate's number formatting (data picks it per row). */
+export function formatValue(v, format) {
+ const n = num(v);
+ if (n === null) return '—';
+ if (format === '1dp') return n.toFixed(1);
+ if (format === '2dp') return n.toFixed(2);
+ return String(Math.round(n));
+}
+
+/**
+ * One readout row, resolved + formatted. `meta` (the row's small right-hand
+ * read — a recharge delay, a shot speed) is resolved the same way.
+ * @returns {{id,label,text,unit,value,frac,meta,note,accent}}
+ */
+export function resolveRow(def, snap) {
+ const v = resolveValue(def, snap);
+ const meter = def?.meter ?? null;
+ let frac = null;
+ if (meter) {
+ const fill = resolveRef(meter.fill ?? meter.of, snap);
+ const max = meter.max ? resolveRef(meter.max, snap) : v;
+ frac = fill !== null && max !== null && max > 0 ? clamp01(fill / max) : null;
+ }
+ const m = def?.meta ?? null;
+ const mv = m ? resolveValue(m, snap) : null;
+ return {
+ id: String(def?.id ?? ''),
+ group: String(def?.group ?? ''),
+ label: String(def?.label ?? '').toUpperCase(),
+ value: v,
+ text: formatValue(v, def?.format),
+ unit: String(def?.unit ?? ''),
+ frac,
+ note: def?.note ? String(def.note) : '',
+ accent: def?.accent ?? null,
+ // the row's own DANGER line (data picks it; damage meters declare one, an
+ // empty cargo bay is not an alarm). null = the row never reads hot.
+ hotBelow: Number.isFinite(def?.hotBelow) ? Number(def.hotBelow) : null,
+ meta: m && mv !== null ? `${String(m.label ?? '').toUpperCase()} ${formatValue(mv, m.format)}${m.unit ? ' ' + m.unit : ''}` : '',
+ nominalLabel: def?.nominalLabel ? String(def.nominalLabel) : '',
+ };
+}
+
+/** Every configured row (data order), grouped by status.groups. */
+export function statRows(rows, snap) {
+ const defs = Array.isArray(rows) ? rows : [];
+ return defs.filter((d) => d && d.id).map((d) => resolveRow(d, snap));
+}
+
+/** The rows of one group, with the group's label (status.groups). */
+export function statGroups(rows, snap, groups) {
+ const all = statRows(rows, snap);
+ const gs = Array.isArray(groups) && groups.length ? groups : [...new Set(all.map((r) => r.group))].map((g) => ({ id: g, label: g }));
+ return gs
+ .map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: all.filter((r) => r.group === String(g.id)) }))
+ .filter((g) => g.rows.length);
+}
+
+/** A row resolves (or explicitly reads a dash — never a NaN hole in the plate). */
+export function isResolved(row) {
+ return row && row.value !== null && Number.isFinite(row.value);
+}
+
+/**
+ * The COMBAT RECORD's journey state (data/shipScreen.json → level). The
+ * ledger itself is js/combat/XpManager.js — stats() hands over {level, xp,
+ * xpForNext, maxLevel, isMax, progress}; this only decides what the plate
+ * shows:
+ * mode 'timeline' the level `from`…`to` milestone journey (below `classAt`)
+ * mode 'choose' the CHOOSE YOUR CLASS door (at/above `classAt`, no class)
+ * mode 'class' a class is on file — its name + insignia slot
+ * @param {object|null} stats XpManager.stats() (null: no combat ledger yet)
+ * @param {object} cfg data/shipScreen.json → level
+ * @param {{name?: string|null, insignia?: string|null}} [playerClass]
+ */
+export function journeyState(stats, cfg = {}, playerClass = null) {
+ const from = Number.isFinite(cfg.from) ? cfg.from : 0;
+ const to = Number.isFinite(cfg.to) ? cfg.to : 5;
+ const classAt = Number.isFinite(cfg.classAt) ? cfg.classAt : 5;
+ const s = stats ?? null;
+ const level = s ? Math.max(0, Math.round(Number(s.level) || 0)) : from;
+ const xp = s ? Math.max(0, Math.round(Number(s.xp) || 0)) : 0;
+ const xpForNext = s ? Math.max(0, Math.round(Number(s.xpForNext) || 0)) : 0;
+ const isMax = !!(s && s.isMax);
+ // the partial step toward the NEXT level (the bar's fine fill)
+ const step = clamp01(Number(s?.progress) || 0);
+ const span = Math.max(1, to - from);
+ const nodes = (Array.isArray(cfg.milestones) ? cfg.milestones : []).map((m) => {
+ const lv = Number(m?.level);
+ return {
+ level: Number.isFinite(lv) ? lv : from,
+ label: String(m?.label ?? `LV ${lv}`).toUpperCase(),
+ note: String(m?.note ?? ''),
+ state: !Number.isFinite(lv) ? 'future' : level > lv ? 'done' : level === lv ? 'current' : 'future',
+ };
+ });
+ const name = typeof playerClass?.name === 'string' && playerClass.name ? playerClass.name : null;
+ return {
+ mode: name ? 'class' : level >= classAt ? 'choose' : 'timeline',
+ from,
+ to,
+ classAt,
+ level,
+ xp,
+ xpForNext,
+ step,
+ isMax,
+ // the journey's overall fill (0…1 across `from`…`to`) — the timeline bar
+ progress: clamp01((level - from + (level >= to ? 0 : step)) / span),
+ nodes,
+ className: name,
+ classInsignia: playerClass?.insignia ?? null,
+ };
+}
+
+/**
+ * The CARGO HOLDS summary (js/economy/CargoHold.js — one good per bay, in
+ * fill order — plus the aggregate mineral load the Mineral HUD reads).
+ * @param {object} snap the console snapshot ({ ship: { stats, minerals,
+ * cargo: { used, items }, credits } })
+ * @param {Array<{id:string,name:string,icon?:number}>} [catalog] goods.json
+ * (icon frame + display name; ShopView's texture is keyed off it)
+ */
+export function cargoSummary(snap = {}, catalog = []) {
+ const hold = snap?.ship?.cargo ?? null;
+ const items = Array.isArray(hold?.items) ? hold.items : [];
+ const capacity = Math.max(0, Math.round(num(snap?.ship?.stats?.cargoHold) ?? 0));
+ const slots = Math.max(1, Math.round(num(config.get('economy.cargo.maxSlots', 16)) || 16));
+ const used = Math.max(0, Math.min(capacity, Math.round(num(hold?.used) ?? items.length)));
+ const byId = new Map((Array.isArray(catalog) ? catalog : []).map((g) => [String(g.id), g]));
+ return {
+ // the bays as drawn: filled first (the held good), then the unlocked-but-
+ // empty bays, then the grid's LOCKED bays (the hold's upgrade room)
+ bays: Array.from({ length: Math.max(used, Math.min(slots, Math.max(capacity, used))) }).map((_, i) => {
+ const id = items[i] != null ? String(items[i]) : null;
+ const g = id ? byId.get(id) ?? null : null;
+ return {
+ bay: i,
+ goodId: id,
+ name: g?.name ?? id ?? '',
+ icon: Number.isFinite(Number(g?.icon)) ? Number(g.icon) : null,
+ state: id ? 'filled' : i < capacity ? 'empty' : 'locked',
+ };
+ }),
+ used,
+ capacity,
+ room: Math.max(0, capacity - used),
+ pct: capacity > 0 ? Math.min(1, used / capacity) : 0,
+ slots,
+ lockedBays: Math.max(0, slots - capacity),
+ empty: used === 0,
+ minerals: Math.max(0, Math.round(num(snap?.ship?.minerals) ?? 0)),
+ mineralCapacity: Math.max(0, Math.round(num(snap?.ship?.stats?.mineralStorage) ?? 0)),
+ // the same fill the Mineral HUD draws (js/ui/MineralHud.js is fed
+ // ship.minerals / stats.mineralStorage — one aggregate, no ore model)
+ mineralPct: Number(num(snap?.ship?.stats?.mineralStorage)) > 0
+ ? Math.min(1, Math.max(0, num(snap?.ship?.minerals) ?? 0) / num(snap?.ship?.stats?.mineralStorage))
+ : 0,
+ credits: Math.max(0, Math.round(num(snap?.ship?.credits) ?? 0)),
+ };
+}
+
+export default { resolveRef, resolveValue, formatValue, resolveRow, statRows, statGroups, isResolved, journeyState, cargoSummary };
diff --git a/js/ui/ShipWindow.js b/js/ui/ShipWindow.js
new file mode 100644
index 0000000..895a067
--- /dev/null
+++ b/js/ui/ShipWindow.js
@@ -0,0 +1,1584 @@
+/**
+ * ShipWindow — the full-screen SHIP SYSTEMS console (the deck's SHIP button,
+ * main deck). Same console as RESEARCH / MAP / QUESTS
+ * (js/ui/ResearchWindow.js, js/ui/MapWindow.js, js/ui/QuestWindow.js):
+ *
+ * ┌────────────────────────────────────────────────────────────────────┐
+ * │ SHIP SYSTEMS ▌ HULL & HARDWARE // LIVE TELEMETRY [✕] │
+ * ├───────────────────────┬────────────────────────────────────────────┤
+ * │ ● BAY CAMERA // LIVE │ [ SHIP STATUS ] [SKILL ▢] [ CARGO HOLDS ] │
+ * │ ┌─────────────────┐ │ ┌──────────────────────────────────────┐ │
+ * │ │ 2:3 video feed │ │ │ ◆ HARDWARE READOUT ARMOR/GUNS… │ │
+ * │ │ (muted, looping) │ │ │ ▸ ARMOR │ │
+ * │ │ + scanlines │ │ │ HULL STRENGTH 100 HP ████ │ │
+ * │ │ + sweep band │ │ │ CURRENT HULL 100 HP NOMINAL│ │
+ * │ └─────────────────┘ │ │ SHIELD RECHARGE 12 /s ARM 3 s │ │
+ * │ ● REC │ │ ▸ FLIGHT / GUNS / HOLD … │ │
+ * │ LV 3 · 240/700 XP │ ├────────────── COMBAT RECORD ──────────┤ │
+ * │ │ │ LEVEL 3 ▓▓▓▓░░░ 240/700 XP │ │
+ * │ │ │ THE JOURNEY // LV 0–5 │ │
+ * │ │ │ SADDLE─SOLO─SCUFFED─◉╌╌○ CHOSEN │ │
+ * └───────────────────────┴────────────────────────────────────────────┘
+ *
+ * Left: the bay camera — assets/videos/ship.mp4, a 2:3 portrait (the same
+ * dimensions as the research / map / quests feeds) that LOOPS MUTED while the
+ * console is open; the procedural NO SIGNAL plate stands in until the clip is
+ * authored (data/shipScreen.json → video). Right: the tab row over ONE plate:
+ *
+ * SHIP STATUS the hardware readout — every number resolved through the
+ * data-driven source table (data/shipScreen.json → status.rows
+ * via js/ship/ShipScreenModel.js): the ship's stats, the live
+ * combat session's hull/shields, the cannon's cooldown→rate and
+ * the hold's bays/minerals — plus the COMBAT RECORD: the XP
+ * bank (js/combat/XpManager.js) as a bar + the level 0→5
+ * journey of milestone nodes. At the class level the journey
+ * gives way to the CHOOSE YOUR CLASS door.
+ * SKILL TREE a STANDBY socket until the class layer lands — press it and
+ * the tab shakes, the scene toasts (the locked plate stays
+ * reachable so the console's final shape is visible).
+ * CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay)
+ * + the aggregate mineral load; a filled bay opens its manifest
+ * with the charted exits (js/economy/TradeScout.js), the same
+ * report the planet shop's scout panel renders.
+ *
+ * The window is a VIEW. Every number comes from the scene's snapshot seam
+ * (GameScene.shipScreenSnapshot — ship.stats, the live combat session, the XP
+ * ledger, the cargo hold) polled while open and repainted on any real change;
+ * labels / units / order / format / copy all live in data/shipScreen.json.
+ *
+ * Cyberpunk dressing in the Map/Quest idiom: cut-corner plates (CyberShape),
+ * decode text (ScrambleDecode), a crawling sweep band, an occasional RGB-split
+ * glitch burst, a breathing REC dot.
+ */
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toCss } from '../utils/Color.js';
+import { fontStack, themeColor } from '../utils/Theme.js';
+import { ScrambleDecode } from '../utils/Decode.js';
+import { playSfxOn } from '../utils/Sfx.js';
+import { CyberShape } from './CyberShape.js';
+import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js';
+import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js';
+
+const HEADER = fontStack('header');
+const BODY = fontStack('body');
+const rand = (a, b) => a + Math.random() * (b - a);
+const clamp01 = (v) => Math.min(1, Math.max(0, v));
+const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(2 - 2 * u, 2) / 2);
+
+/** Theme palette (data/theme.json), resolved once at build time. */
+const C = {
+ ink: themeColor('ink', 0xeaf6ff),
+ dim: themeColor('dim', 0x7d92c4),
+ faint: themeColor('faint', 0x3d4c74),
+ neon: themeColor('neon', 0x00e5ff),
+ neon2: themeColor('neon2', 0xff2d6f),
+ amber: themeColor('amber', 0xffc94d),
+ green: themeColor('green', 0x67e863),
+ red: themeColor('red', 0xff5c77),
+ panel: themeColor('panel', 0x0a1120),
+ bg: themeColor('bg', 0x04060d),
+};
+
+/** The built-in tab row (data/shipScreen.json → tabs overrides it). */
+const DEFAULT_TABS = [
+ { id: 'status', label: 'Ship Status', accent: '#7ce8a4' },
+ { id: 'skills', label: 'Skill Tree', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' },
+ { id: 'cargo', label: 'Cargo Holds', accent: '#00e5ff' },
+];
+
+/** A config accent (CSS string or int) → the int Graphics wants. */
+function col(v, fallback = C.neon) {
+ if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
+ const s = String(v ?? '').trim();
+ if (!s) return fallback;
+ const n = parseInt(s.replace('#', ''), 16);
+ return Number.isFinite(n) ? n >>> 0 : fallback;
+}
+
+/** Fill a cut-corner plate (CyberShape.points is centred — translate to centre). */
+function panel(g, x, y, w, h, o = {}) {
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map((p) => ({ x: p.x + cx, y: p.y + cy }));
+ if (o.fill !== undefined) {
+ g.fillStyle(o.fill, o.fillAlpha ?? 1);
+ g.fillPoints(pts, true);
+ }
+ if (o.stroke !== undefined) {
+ g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
+ g.strokePoints(pts, true);
+ }
+ if (o.glow !== undefined) {
+ g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
+ g.strokePoints(pts, true);
+ }
+}
+
+/** Corner brackets framing a rect (x, y = top-left). */
+function brackets(g, x, y, w, h, o = {}) {
+ const color = o.color ?? C.neon;
+ const alpha = o.alpha ?? 0.7;
+ const len = o.length ?? 14;
+ g.lineStyle(o.lineWidth ?? 2, color, alpha);
+ const x2 = x + w;
+ const y2 = y + h;
+ g.lineBetween(x, y, x + len, y);
+ g.lineBetween(x, y, x, y + len);
+ g.lineBetween(x2, y, x2 - len, y);
+ g.lineBetween(x2, y, x2, y + len);
+ g.lineBetween(x, y2, x + len, y2);
+ g.lineBetween(x, y2, x, y2 - len);
+ g.lineBetween(x2, y2, x2 - len, y2);
+ g.lineBetween(x2, y2, x2, y2 - len);
+}
+
+/** A filled diamond marker (group bullets, the journey's nodes). */
+function diamond(g, x, y, r, color, alpha = 1) {
+ if (!(r > 0)) return;
+ g.fillStyle(color, alpha);
+ g.fillPoints([{ x, y: y - r }, { x: x + r, y }, { x, y: y + r }, { x: x - r, y }], true);
+}
+
+export class ShipWindow extends Phaser.GameObjects.Container {
+ /** The left-panel clip (assets/videos/ship.mp4, loaded by the scene). */
+ static VIDEO_KEY = 'ship_feed';
+
+ /**
+ * @param {Phaser.Scene} scene the owning scene (GameScene)
+ * @param {object} [o]
+ * @param {() => object|null} [o.getSnapshot] — the live console snapshot
+ * (GameScene.shipScreenSnapshot; polled while open, repaint on change)
+ * @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
+ * (the scene toasts — the Skill Tree socket)
+ * @param {() => void} [o.onChooseClass] — the level-5 CHOOSE YOUR CLASS door
+ * was pressed (the class layer's seam; the scene answers it — a toast
+ * until the class selection screen exists)
+ * @param {(goodId: string) => object|null} [o.getScoutReport] — a held
+ * good's trade report (TradeScout.scoutGood over the run's chart), shown
+ * by the bay manifest's BEST EXITS list
+ */
+ constructor(scene, o = {}) {
+ super(scene, 0, 0);
+ // v4 quirk: a directly-constructed GameObject must register itself.
+ this.scene.add.existing(this);
+ this.setScrollFactor(0); // UI — pinned to the screen
+ this.setDepth(80); // above save panel (70), sub-bar (60), deck (50)
+
+ this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null;
+ this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
+ this.onChooseClass = typeof o.onChooseClass === 'function' ? o.onChooseClass : null;
+ this.getScoutReport = typeof o.getScoutReport === 'function' ? o.getScoutReport : null;
+
+ this.openState = 'closed'; // closed | opening | open | closing
+ this.reveal = []; // { o, d, dur, t0 } — the boot stagger
+ this.decodes = []; // { txt, dec } — polled ScrambleDecodes
+ this._activeTab = 'status';
+ this._selectedBay = null; // the bay whose manifest is open (null = grid)
+ this._tabs = [];
+ this._tabShakes = [];
+ this._kids = []; // every child of the current body repaint
+ this._hits = [];
+ this._glitch = { until: 0, next: 0 };
+ this._sweep = null;
+ this._pollNext = 0;
+ this._fp = null; // the last painted fingerprint
+ this._snap = null; // the last snapshot (kept fresh between repaints)
+ this._destroyed = false;
+
+ this._build();
+ this.setVisible(true);
+ this.setAlpha(0); // hidden until open() — built once, shown on demand
+ }
+
+ sfx(name) {
+ playSfxOn(this.scene, name);
+ }
+
+ hasVideo(key) {
+ const c = this.scene.cache?.video;
+ return !!(c && typeof c.has === 'function' && c.has(key));
+ }
+
+ /** Decode text into `txt` starting at `t0` (driven by update()). */
+ decodeTo(txt, str, t0, dur = 520) {
+ this.decodes = this.decodes.filter((d) => d.txt !== txt);
+ if (txt.text === str) return;
+ txt.setText('');
+ this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) });
+ }
+
+ // ------------------------------------------------------------ geometry
+ _measure() {
+ const W = this.scene.scale.width;
+ const H = this.scene.scale.height;
+ const m = 10;
+ const pad = 14;
+ const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m };
+ const titleH = 46;
+ const bodyY = rect.y + titleH + pad;
+ const bodyH = rect.h - titleH - 2 * pad;
+ // Left: the bay camera — 2:3 (data/shipScreen.json → video.aspect), the
+ // same frame as the research / map / quest feeds.
+ const a = config.get('shipScreen.video.aspect', [2, 3]);
+ const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3;
+ const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8);
+ const videoW = Math.max(90, videoH * ar);
+ const leftX = rect.x + pad;
+ const leftW = videoW + 30;
+ // Right: the tab row over ONE plate whose body each tab paints.
+ const rightX = leftX + leftW + pad;
+ const rightW = rect.x + rect.w - pad - rightX;
+ const tabH = 38;
+ const tabGap = 12;
+ const plateY = bodyY + tabH + tabGap;
+ const plateH = Math.max(160, rect.y + rect.h - pad - plateY);
+ return {
+ W, H, rect, titleH, pad,
+ bodyY, bodyH,
+ leftX, leftW, videoW, videoH,
+ rightX, rightW, tabH, tabGap,
+ plateX: rightX, plateY, plateW: rightW, plateH,
+ };
+ }
+
+ _build() {
+ const s = this.scene.add;
+ const geo = (this.geo = this._measure());
+
+ // window background
+ const bg = s.graphics().setScrollFactor(0);
+ panel(bg, geo.rect.x, geo.rect.y, geo.rect.w, geo.rect.h, {
+ notch: 16,
+ fill: 0x04060d,
+ fillAlpha: 0.94,
+ stroke: 0x1b3a5a,
+ strokeAlpha: 0.65,
+ });
+ this.add(bg);
+
+ // title: SHIP SYSTEMS + meta (decoded on open)
+ const titleY = geo.rect.y + 12;
+ const title = String(config.get('shipScreen.title', 'SHIP SYSTEMS'));
+ const titleStyle = { fontFamily: HEADER, fontSize: '19px', fontStyle: 'bold' };
+ this.titleTxt = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.ink) }).setScrollFactor(0);
+ this.add(this.titleTxt);
+ // the glitch's RGB-split partners (the Map/Quest voice)
+ this.titleGhostA = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon) }).setScrollFactor(0).setAlpha(0);
+ this.add(this.titleGhostA);
+ this.titleGhostB = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon2) }).setScrollFactor(0).setAlpha(0);
+ this.add(this.titleGhostB);
+ const tw = Math.max(150, 8 + title.length * 12);
+ this.titleMeta = s
+ .text(geo.rect.x + 18 + tw, titleY + 6, String(config.get('shipScreen.meta', 'HULL & HARDWARE // LIVE TELEMETRY')), {
+ fontFamily: BODY,
+ fontSize: '10px',
+ color: toCss(C.faint),
+ letterSpacing: 2.5,
+ })
+ .setScrollFactor(0);
+ this.add(this.titleMeta);
+
+ // close button — the interactivity rides the VISIBLE plate (v4 needs
+ // willRender() for a hit candidate; a transparent rect is never clicked)
+ const csize = 30;
+ const cx = geo.rect.x + geo.rect.w - csize - 14;
+ const cy = geo.rect.y + 11;
+ this.closeG = s.graphics().setScrollFactor(0);
+ this.add(this.closeG);
+ this._paintClose(false);
+ this.closeTxt = s
+ .text(cx + csize / 2, cy + csize / 2, '×', { fontFamily: BODY, fontSize: '14px', color: toCss(C.ink) })
+ .setOrigin(0.5)
+ .setScrollFactor(0);
+ this.add(this.closeTxt);
+ this.closeG.setInteractive({
+ useHandCursor: true,
+ hitArea: new Phaser.Geom.Rectangle(cx, cy, csize, csize),
+ hitAreaCallback: (area, px, py) => area.contains(px, py),
+ });
+ this.closeG.on('pointerover', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_hover');
+ this._paintClose(true);
+ });
+ this.closeG.on('pointerout', () => this._paintClose(false));
+ this.closeG.on('pointerdown', () => {
+ if (this.isOpen) this.close();
+ });
+
+ // the left feed + the right column
+ this.videoPanel = this._buildVideoPanel();
+ this.add(this.videoPanel);
+ this._buildTabs();
+ this.plateG = s.graphics().setScrollFactor(0);
+ this.add(this.plateG);
+ this.bodyCont = new Phaser.GameObjects.Container(this.scene, 0, 0).setScrollFactor(0);
+ this.add(this.bodyCont);
+
+ // Scanlines live on the FEED ONLY (the tile sprite inside
+ // _buildVideoPanel) — dragging the Map/Quest full-plate veil over the
+ // whole window dimmed the stat readouts and made small tab copy muddy.
+ // `this.scan` stays a valid handle for the shake slot; it simply has no
+ // full-window layer to shove any more (the video panel shakes with it).
+ this.scan = null;
+
+ // the glitch layer (RGB-split slices over the feed + title)
+ this.glitchG = s.graphics().setScrollFactor(0);
+ this.add(this.glitchG);
+
+ this._paintPlate();
+ this._paintAll(null); // the empty state until the first snapshot
+ }
+
+ /** Repaint the close plate (one persistent graphics object — swapping it
+ * out would orphan its handlers). */
+ _paintClose(hot) {
+ const csize = 30;
+ const cx = this.geo.rect.x + this.geo.rect.w - csize - 14;
+ const cy = this.geo.rect.y + 11;
+ const g = this.closeG;
+ g.clear();
+ g.lineStyle(1.5, C.neon, hot ? 0.9 : 0.55);
+ g.strokeRect(cx, cy, csize, csize);
+ g.lineStyle(2, hot ? C.ink : C.dim, 1);
+ g.lineBetween(cx + 9, cy + 9, cx + csize - 9, cy + csize - 9);
+ g.lineBetween(cx + csize - 9, cy + 9, cx + 9, cy + csize - 9);
+ }
+
+ // ------------------------------------------------------------ video panel
+ _buildVideoPanel() {
+ const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
+ const s = this.scene.add;
+ const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY).setScrollFactor(0);
+
+ // header: ● BAY CAMERA // LIVE FEED (data/shipScreen.json → video.tag)
+ this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
+ cont.add(this.recDot);
+ cont.add(
+ s.text(20, 6, String(config.get('shipScreen.video.tag', 'BAY CAMERA // LIVE FEED')), {
+ fontFamily: BODY,
+ fontSize: '10px',
+ color: toCss(C.faint),
+ letterSpacing: 2.5,
+ }).setScrollFactor(0)
+ );
+
+ // frame + the clip
+ const vx = (leftW - videoW) / 2;
+ const vy = 26;
+ const frameG = s.graphics().setScrollFactor(0);
+ brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 });
+ frameG.lineStyle(1, 0x14324f, 0.55);
+ frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2);
+ frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2);
+ cont.add(frameG);
+
+ const vcx = vx + videoW / 2;
+ const vcy = vy + videoH / 2;
+ const vrect = { x: vx, y: vy, w: videoW, h: videoH };
+
+ // The NO SIGNAL plate is ALWAYS the base layer: the clip is planned but
+ // not authored yet (assets/videos/ship.mp4 is not in the repo), and even a
+ // file that 404s still registers its cache key — cache.video.has() cannot
+ // tell the two apart, so the plate is the only reliable fallback. A live
+ // clip simply covers it the moment it decodes.
+ this._paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy);
+ this.video = null;
+ if (this.hasVideo(ShipWindow.VIDEO_KEY)) {
+ const v = s.video(0, 0, ShipWindow.VIDEO_KEY);
+ v.setOrigin(0.5).setScrollFactor(0);
+ // ambience: muted + looping (v4's setVolume only sets el.volume — the
+ // autoplay policy looks at el.muted, so mute it properly)
+ v.setVolume(0);
+ v.setLoop(true);
+ if (v.video) v.video.muted = true;
+ v.setVisible(false); // the plate stands in until 'created'
+ const fit = (vv, iw = 0, ih = 0) => {
+ const el = vv.video;
+ const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544;
+ const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800;
+ vv.setPosition(vcx, vcy);
+ vv.setScale(Math.min(videoW / vw, videoH / vh));
+ };
+ fit(v);
+ v.on('created', (vv, w, h) => {
+ if (vv !== v || v.destroyed) return;
+ fit(vv, w, h);
+ vv.setVisible(true);
+ });
+ v.on('error', () => {
+ // 404 or undecodable — the plate stays up; the console still works.
+ if (v.destroyed) return;
+ if (this.video === v) this.video = null;
+ v.destroy();
+ });
+ cont.add(v);
+ this.video = v;
+ }
+
+ // scanlines over the feed
+ const scanKey = 'shipfeed_scanlines';
+ if (!this.scene.textures.exists(scanKey)) {
+ const c = document.createElement('canvas');
+ c.width = 4;
+ c.height = 8;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = 'rgba(2,6,12,0.5)';
+ ctx.fillRect(0, 0, 4, 3);
+ ctx.fillStyle = 'rgba(120,220,255,0.05)';
+ ctx.fillRect(0, 4, 4, 1);
+ this.scene.textures.addCanvas(scanKey, c);
+ }
+ cont.add(s.tileSprite(vcx, vcy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5));
+
+ // the sweep band (crawls down the feed on a loop)
+ const swKey = 'shipscreen_sweep';
+ if (!this.scene.textures.exists(swKey)) {
+ const c = document.createElement('canvas');
+ c.width = 8;
+ c.height = 64;
+ const ctx = c.getContext('2d');
+ const grad = ctx.createLinearGradient(0, 0, 0, 64);
+ grad.addColorStop(0, 'rgba(127,223,255,0)');
+ grad.addColorStop(0.5, 'rgba(127,223,255,0.5)');
+ grad.addColorStop(1, 'rgba(127,223,255,0)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, 8, 64);
+ this.scene.textures.addCanvas(swKey, c);
+ }
+ this.sweepBand = s
+ .image(vcx, vrect.y - 40, swKey)
+ .setDisplaySize(videoW, 48)
+ .setScrollFactor(0)
+ .setAlpha(0)
+ .setBlendMode(Phaser.BlendModes.ADD);
+ cont.add(this.sweepBand);
+ this.sweepCfg = config.get('shipScreen.sweep', {});
+
+ // bottom status strip — the ship line + the level bar (the quick read)
+ const sy = vy + videoH + 14;
+ const strip = s.graphics().setScrollFactor(0);
+ strip.lineStyle(1, 0x14324f, 0.8);
+ strip.lineBetween(2, sy, leftW - 2, sy);
+ strip.fillStyle(C.neon, 0.7);
+ strip.fillRect(2, sy - 1, 26, 2);
+ cont.add(strip);
+ this.statusTxt = s
+ .text(2, sy + 10, 'SHIP SYSTEMS ONLINE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2 })
+ .setScrollFactor(0);
+ cont.add(this.statusTxt);
+ this.statusBar = s.graphics().setScrollFactor(0);
+ this.statusBarY = sy + 32;
+ this.statusBarW = Math.max(40, leftW - 60);
+ cont.add(this.statusBar);
+
+ this.videoCx = vcx;
+ this.videoCy = vcy;
+ this.videoRect = vrect;
+ return cont;
+ }
+
+ /** The 'NO SIGNAL' plate — a procedural static frame (no stand-in clip),
+ * the ship's own silhouette drawn from the plate's geometry. */
+ _paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy) {
+ const ph = s.graphics().setScrollFactor(0);
+ panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 });
+ for (let i = 0; i < 40; i++) {
+ ph.fillStyle(C.neon, rand(0.02, 0.08));
+ ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1);
+ }
+ const r = Math.min(videoW, videoH) * 0.16;
+ ph.lineStyle(1.5, C.neon, 0.3);
+ ph.strokePoints(
+ [
+ { x: vcx, y: vcy - r * 1.5 },
+ { x: vcx + r, y: vcy + r * 0.6 },
+ { x: vcx + r * 0.35, y: vcy + r * 0.25 },
+ { x: vcx - r * 0.35, y: vcy + r * 0.25 },
+ { x: vcx - r, y: vcy + r * 0.6 },
+ ],
+ true
+ );
+ cont.add(ph);
+ cont.add(
+ s.text(vcx, vcy - 34, 'NO SIGNAL', {
+ fontFamily: HEADER, fontSize: '14px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 5, align: 'center',
+ }).setScrollFactor(0)
+ );
+ cont.add(
+ s.text(vcx, vcy - 14, String(config.get('shipScreen.video.noSignalTag', 'BAY CAMERA OFFLINE')), {
+ fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 3, align: 'center',
+ }).setScrollFactor(0)
+ );
+ }
+
+ // ------------------------------------------------------------ tabs
+ _tabDefs() {
+ const defs = config.get('shipScreen.tabs', DEFAULT_TABS);
+ const arr = Array.isArray(defs) && defs.length ? defs : DEFAULT_TABS;
+ return arr.filter((t) => t && typeof t.id === 'string');
+ }
+
+ _buildTabs() {
+ const s = this.scene.add;
+ const { rightX, bodyY, rightW, tabH } = this.geo;
+ const defs = this._tabDefs();
+ const gap = 10;
+ // fixed-width sockets when they fit; share the width when they don't
+ const tabW = Math.max(96, Math.min(172, (rightW - gap * (defs.length - 1)) / defs.length));
+ let x = rightX + Math.max(0, (rightW - (defs.length * tabW + (defs.length - 1) * gap)) / 2);
+ for (const t of defs) {
+ const id = String(t.id);
+ const label = String(t.label ?? id).toUpperCase();
+ const accent = t.accent ?? null;
+ const standby = t.standby === true;
+ const standbyTag = String(t.standbyTag ?? 'STANDBY').toUpperCase();
+ const g = s.graphics().setScrollFactor(0);
+ const txt = s.text(0, 0, label, { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 2 })
+ .setOrigin(0.5).setScrollFactor(0);
+ const tagTxt = standby
+ ? s.text(0, 0, standbyTag, { fontFamily: BODY, fontSize: '8px', color: toCss(C.faint), letterSpacing: 1.5 })
+ .setOrigin(0.5).setScrollFactor(0)
+ : null;
+ const tab = { id, label, accent, standby, g, txt, tagTxt, x, w: tabW };
+ g.setInteractive({
+ useHandCursor: true,
+ hitArea: new Phaser.Geom.Rectangle(x, bodyY, tabW, tabH),
+ hitAreaCallback: (area, px, py) => area.contains(px, py),
+ });
+ g.on('pointerover', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_hover');
+ this._paintTab(tab, standby ? 'standby' : this._activeTab === id ? 'active' : 'hot');
+ });
+ g.on('pointerout', () => this._paintTab(tab, standby ? 'standby' : this._activeTab === id ? 'idle' : 'idle'));
+ g.on('pointerdown', () => {
+ if (!this.isOpen) return;
+ if (standby) {
+ // the socket is not live — shake it and let the scene toast why
+ this.sfx('ui_click');
+ this._shakeTab(tab);
+ this.onLocked?.(id);
+ return;
+ }
+ this._setTab(id);
+ });
+ this._paintTab(tab, standby ? 'standby' : id === this._activeTab ? 'active' : 'idle');
+ this.add(g);
+ this.add(txt);
+ if (tagTxt) this.add(tagTxt);
+ this._tabs.push(tab);
+ x += tabW + gap;
+ }
+ }
+
+ /** One tab's visual state: 'active' | 'hot' | 'idle' | 'standby'. */
+ _paintTab(tab, mode) {
+ const { g, txt, tagTxt, x, w } = tab;
+ const y = this.geo.bodyY;
+ const h = this.geo.tabH;
+ const accent = col(tab.accent);
+ g.clear();
+ let stroke, strokeA, fillA, ink;
+ if (mode === 'active') {
+ stroke = accent; strokeA = 0.95; fillA = 0.16; ink = C.ink;
+ } else if (mode === 'hot') {
+ stroke = C.neon; strokeA = 0.7; fillA = 0.08; ink = C.ink;
+ } else if (mode === 'standby') {
+ stroke = 0x14324f; strokeA = 0.8; fillA = 0.25; ink = C.faint;
+ } else {
+ stroke = 0x14324f; strokeA = 0.9; fillA = 0.5; ink = C.dim;
+ }
+ panel(g, x, y, w, h, { notch: 8, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
+ if (mode === 'active') {
+ g.fillStyle(accent, 0.9);
+ g.fillRect(x + 10, y + h - 4, w - 20, 2);
+ }
+ if (tab.standby) {
+ g.lineStyle(1, C.faint, 0.9); // the standby square (the socket is not live)
+ g.strokeRect(x + 12, y + h / 2 - 4, 8, 8);
+ }
+ txt.color = toCss(ink);
+ txt.setText(tab.standby ? ' ' + tab.label : tab.label);
+ txt.x = x + w / 2 + (tab.standby ? 6 : 0);
+ txt.y = y + (tagTxt ? h / 2 - 6 : h / 2);
+ if (tagTxt) {
+ tagTxt.x = txt.x;
+ tagTxt.y = y + h / 2 + 9;
+ }
+ }
+
+ _paintTabs() {
+ for (const t of this._tabs) this._paintTab(t, t.standby ? 'standby' : this._activeTab === t.id ? 'active' : 'idle');
+ }
+
+ /** The standby tab's rejected press — a shake (the map's STANDBY tabs do it). */
+ _shakeTab(tab) {
+ this._tabShakes.push({ tab, t0: this.scene.time.now, x: tab.txt.x });
+ }
+
+ /** Switch the live tab (STANDBY sockets never reach here). */
+ _setTab(id) {
+ if (this._activeTab === id) return;
+ this._activeTab = id;
+ this._selectedBay = null; // every tab opens on its own summary
+ this.sfx('ui_click');
+ this._paintTabs();
+ this._buildBody(this._snap);
+ }
+
+ get activeTab() {
+ return this._activeTab;
+ }
+
+ // ------------------------------------------------------------ body plumbing
+ /** The one right-hand plate every tab shares. */
+ _paintPlate() {
+ const { plateX, plateY, plateW, plateH } = this.geo;
+ const g = this.plateG;
+ g.clear();
+ panel(g, plateX, plateY, plateW, plateH, { notch: 10, fill: 0x060b16, fillAlpha: 0.85, stroke: 0x14324f, strokeAlpha: 0.9 });
+ brackets(g, plateX - 4, plateY - 4, plateW + 8, plateH + 8, { length: 12, color: C.neon, alpha: 0.5 });
+ }
+
+ /** A text child of the current body repaint. */
+ txt(x, y, str, o = {}) {
+ const t = this.scene.add
+ .text(x, y, String(str ?? ''), {
+ fontFamily: o.head ? HEADER : BODY,
+ fontSize: `${o.size ?? 11}px`,
+ color: toCss(o.color ?? C.dim),
+ letterSpacing: o.ls ?? 0,
+ align: o.align ?? 'left',
+ wordWrap: o.wrap ? { width: o.wrap, breakWord: true } : undefined,
+ ...(o.bold ? { fontStyle: 'bold' } : {}),
+ })
+ .setScrollFactor(0);
+ // origin accepts a number or [x, y] — right-aligned copy needs the x
+ // anchor alone (a numeric origin would also move the baseline).
+ if (Array.isArray(o.origin)) t.setOrigin(o.origin[0], o.origin[1]);
+ else if (o.origin !== undefined) t.setOrigin(o.origin);
+ this.bodyCont.add(t);
+ this._kids.push(t);
+ return t;
+ }
+
+ /** A graphics child of the current body repaint. */
+ gfx() {
+ const g = this.scene.add.graphics().setScrollFactor(0);
+ this.bodyCont.add(g);
+ this._kids.push(g);
+ return g;
+ }
+
+ /** An icon child (the goods spritesheet — the shop's texture, when loaded). */
+ icon(x, y, frame, size = 40) {
+ if (!goodsTextureReady(this.scene)) return null;
+ const sp = this.scene.add
+ .image(x, y, GOODS_TEXTURE_KEY, goodFrame(frame))
+ .setDisplaySize(size, size)
+ .setScrollFactor(0);
+ this.bodyCont.add(sp);
+ this._kids.push(sp);
+ return sp;
+ }
+
+ /** A button-ish plate with a label; the callback fires on pointerdown. */
+ button(x, y, w, h, label, accent, onPress) {
+ const g = this.gfx();
+ const paint = (hot) => {
+ g.clear();
+ panel(g, x, y, w, h, {
+ notch: 8,
+ fill: col(accent),
+ fillAlpha: hot ? 0.26 : 0.14,
+ stroke: col(accent),
+ strokeAlpha: hot ? 1 : 0.7,
+ glow: hot ? col(accent) : undefined,
+ });
+ };
+ paint(false);
+ const t = this.txt(x + w / 2, y + h / 2, label, { color: C.ink, size: 12, ls: 2.4, align: 'center', origin: 0.5, bold: true });
+ g.setInteractive({
+ useHandCursor: true,
+ hitArea: new Phaser.Geom.Rectangle(x, y, w, h),
+ hitAreaCallback: (area, px, py) => area.contains(px, py),
+ });
+ g.on('pointerover', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_hover');
+ paint(true);
+ });
+ g.on('pointerout', () => paint(false));
+ g.on('pointerdown', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_click');
+ onPress?.();
+ });
+ this._hits.push(g);
+ return { g, t, paint };
+ }
+
+ /** Drop the body and rebuild it (the repaint idiom). */
+ _buildBody(snap) {
+ for (const k of this._kids) {
+ try {
+ k.destroy();
+ } catch {
+ /* already gone */
+ }
+ }
+ this._kids = [];
+ this._hits = [];
+ if (this.bodyCont.list) this.bodyCont.list.length = 0;
+ const s = snap ?? this._snap ?? {};
+ if (this._activeTab === 'cargo') this._buildCargoBody(s);
+ else if (this._activeTab === 'skills') this._buildSkillsBody();
+ else this._buildStatusBody(s);
+ }
+
+ /** A plate headline: ◆ LABEL ─────────── meta */
+ head(x, y, w, label, meta, accent) {
+ const g = this.gfx();
+ diamond(g, x + 5, y + 8, 4, col(accent ?? C.neon), 0.9);
+ g.lineStyle(1, 0x14324f, 0.9);
+ g.lineBetween(x + 16, y + 8, x + w, y + 8);
+ this.txt(x + 16, y - 2, label, { color: C.ink, size: 11, ls: 2.4, bold: true });
+ if (meta) this.txt(x + w, y, meta, { color: C.faint, size: 9, ls: 1.6, align: 'right', origin: 1 });
+ }
+
+ /** A thin fill bar (the meters). */
+ bar(g, x, y, w, h, frac, accent, o = {}) {
+ const f = clamp01(frac ?? 0);
+ g.lineStyle(1, o.stroke ?? 0x1e3a5c, o.strokeAlpha ?? 1);
+ g.strokeRect(x, y, w, h);
+ if (o.track) {
+ g.fillStyle(o.track, 0.18);
+ g.fillRect(x + 1, y + 1, w - 2, h - 2);
+ }
+ g.fillStyle(col(accent), o.alpha ?? 0.75);
+ g.fillRect(x + 1, y + 1, Math.max(0, (w - 2) * f), h - 2);
+ if (f > 0 && f < 1) {
+ // the leading edge reads brighter (the meter is alive)
+ g.fillStyle(C.ink, 0.85);
+ g.fillRect(x + Math.max(0, (w - 2) * f) - 1, y + 1, 2, h - 2);
+ }
+ }
+
+ // ------------------------------------------------------------ SHIP STATUS
+ /** The hardware readout + the combat record. Rows come from the data table
+ * (data/shipScreen.json → status.rows), resolved by the pure model. */
+ _buildStatusBody(snap) {
+ const cfg = config.section('shipScreen.status', {});
+ const { plateX, plateY, plateW, plateH } = this.geo;
+ const x = plateX + 18;
+ const w = plateW - 36;
+ const rows = statRows(cfg.rows, snap);
+
+ // ---- COMBAT RECORD (top: the level bank + the journey) --------------
+ const recH = Math.min(196, Math.max(140, plateH * 0.27));
+ this._paintRecord(snap, x, plateY + 16, w, recH);
+
+ // ---- HARDWARE READOUT (below: every source-table row) --------------
+ const hwY = plateY + 16 + recH + 22;
+ const hwH = Math.max(80, plateY + plateH - 18 - hwY);
+ this.head(x, hwY, w, cfg.hardwareTitle ?? 'HARDWARE READOUT', rows.every((r) => r.value !== null) ? '' : 'PARTIAL TELEMETRY', C.neon);
+ this._paintRowsTable(rows, Array.isArray(cfg.groups) ? cfg.groups : [], x, hwY + 22, w, hwH - 22);
+ }
+
+ /** The readout's rows as TWO columns of stacked group blocks (four
+ * side-by-side columns leave a row no room for "MINERAL STORAGE ··· 250 u"
+ * plus its meter). Each block: the group's rail + name, then its rows —
+ * label over the meter, value right-aligned, meta line beneath. */
+ _paintRowsTable(rows, groups, x, y, w, h) {
+ const gs = (Array.isArray(groups) && groups.length ? groups : [...new Set(rows.map((r) => r.group))].map((g) => ({ id: g, label: String(g).toUpperCase() })))
+ .map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: rows.filter((r) => r.group === String(g.id)) }))
+ .filter((g) => g.rows.length);
+ if (!gs.length) return;
+ const cols = Math.min(2, gs.length);
+ const colGap = Math.round(Math.min(110, Math.max(56, w * 0.09)));
+ const cw = (w - colGap * (cols - 1)) / cols;
+ const headH = 26; // the group's rail line
+ const blockGap = 18;
+ // One column's worth of vertical rhythm: the column's height minus its
+ // heads/gaps, shared by every row it carries (clamped so a tall plate
+ // never stretches the rows into a ghost town and a short one stays legible).
+ const layout = (colBlocks) => {
+ const nRows = colBlocks.reduce((n, g) => n + g.rows.length, 0);
+ const heads = colBlocks.length * headH + Math.max(0, colBlocks.length - 1) * blockGap;
+ // the row rhythm the column's own height affords…
+ let pitch = Math.max(40, Math.min(58, (h - heads) / Math.max(1, nRows)));
+ // …but a short plate wins over the rhythm: never let the last row hang
+ // off the foot (the meta line below the meter has to stay inside too).
+ const need = pitch * nRows + heads;
+ if (need > h) pitch = Math.max(30, (h - heads) / Math.max(1, nRows));
+ return pitch;
+ };
+ // Two columns balanced by ROW COUNT (data order preserved — a group is
+ // never split), so neither column can run past the plate's foot.
+ let blocks = [gs];
+ if (cols === 2) {
+ blocks = [[], []];
+ const tally = [0, 0];
+ for (const g of gs) {
+ const c = tally[0] === tally[1] ? 0 : tally[0] < tally[1] ? 0 : 1;
+ blocks[c].push(g);
+ tally[c] += g.rows.length;
+ }
+ blocks = blocks.filter((b) => b.length);
+ }
+ blocks.forEach((colBlocks, ci) => {
+ const bx = x + ci * (cw + colGap);
+ const pitch = layout(colBlocks);
+ let by = y;
+ colBlocks.forEach((grp, gi) => {
+ // the group's rail + name
+ const rg = this.gfx();
+ rg.lineStyle(1.5, C.neon, 0.45);
+ rg.fillPoints([{ x: bx, y: by + 2 }, { x: bx + 7, y: by + 8 }, { x: bx, y: by + 14 }], true);
+ this.txt(bx + 13, by, grp.label, { color: C.neon, size: 9, ls: 3.4, bold: true });
+ rg.lineStyle(1, 0x14324f, 0.9);
+ rg.lineBetween(bx + 22 + grp.label.length * 7.4, by + 8, bx + cw - 6, by + 8);
+ let ry = by + headH;
+ for (const r of grp.rows) {
+ this._paintStatRow(r, bx, ry, cw - 6);
+ ry += pitch;
+ }
+ by = ry + (gi < colBlocks.length - 1 ? blockGap : 0);
+ });
+ // the column divider (the two blocks should never read as one list)
+ if (ci < blocks.length - 1) {
+ const dv = this.gfx();
+ dv.lineStyle(1, 0x14324f, 0.75);
+ dv.lineBetween(bx + cw + colGap / 2, y + 4, bx + cw + colGap / 2, Math.min(y + h, by));
+ }
+ });
+ }
+
+ /** One readout row: label · value+unit over its meter, meta line beneath. */
+ _paintStatRow(r, gx, ry, cw) {
+ const accent = col(r.accent ?? C.neon, C.neon);
+ // the danger read is the ROW's call (data → hotBelow) — an empty cargo bay
+ // is not an alarm, a hull down to a third is.
+ const hot = r.frac !== null && r.hotBelow !== null && r.frac <= r.hotBelow;
+ // a row at rest (no meter) still reads as a row — the hairline under it
+ const nominal = !!(r.nominalLabel && r.frac !== null && r.frac >= 0.999);
+ this.txt(gx, ry, r.label, { color: C.dim, size: 10, ls: 1.4 });
+ const vs = nominal
+ ? String(r.nominalLabel).toUpperCase()
+ : `${r.text}${r.unit ? ' ' + r.unit : ''}`;
+ this.txt(gx + cw, ry - 2, vs, {
+ color: nominal ? C.green : hot ? C.red : C.ink,
+ size: nominal ? 10 : 13,
+ ls: 1.4,
+ origin: [1, 0],
+ bold: !nominal,
+ });
+ const g = this.gfx();
+ if (r.frac !== null) this.bar(g, gx, ry + 17, cw, 5, r.frac, hot ? C.red : accent, { track: true });
+ else {
+ g.fillStyle(accent, 0.28);
+ g.fillRect(gx, ry + 19, cw, 2);
+ }
+ const sub = r.meta || r.note;
+ if (sub) this.txt(gx, ry + 27, sub, { color: C.faint, size: 8, ls: 1.6 });
+ }
+
+ /** The COMBAT RECORD plate — the XP bank's bar + the level 0→5 journey (or
+ * the CHOOSE YOUR CLASS door at the class level). */
+ _paintRecord(snap, x, y, w, h) {
+ const cfg = config.section('shipScreen.level', {});
+ const j = journeyState(snap?.level, cfg, snap?.playerClass ?? null);
+ const g = this.gfx();
+ panel(g, x - 6, y - 6, w + 12, h, { notch: 10, fill: 0x04070f, fillAlpha: 0.75, stroke: 0x1b3a5a, strokeAlpha: 0.8 });
+ this.head(x, y + 4, w, cfg.title ?? 'COMBAT RECORD', j ? `${cfg.levelWord ?? 'LEVEL'} ${j.level}` : 'NO LEDGER', C.amber);
+
+ if (!j) {
+ this.txt(x + w / 2, y + h * 0.6, String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase(), {
+ color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
+ });
+ return;
+ }
+
+ // the big level read + the XP bar toward the next level
+ const lvWord = String(cfg.levelWord ?? 'LEVEL');
+ this.txt(x + 6, y + 26, lvWord, { color: C.faint, size: 9, ls: 3 });
+ this.txt(x + 6, y + 34, String(j.level), { color: C.ink, size: 34, head: true, bold: true });
+ const lvW = 12 + String(j.level).length * 20;
+ const barX = x + 12 + lvW + 18;
+ const barW = Math.max(120, x + w - 12 - barX);
+ const xpLabel = j.isMax
+ ? String(cfg.maxLabel ?? 'MAX LEVEL').toUpperCase()
+ : `${String(cfg.xpLabel ?? 'XP')} ${j.xp}/${j.xpForNext}`;
+ this.txt(barX, y + 30, xpLabel, { color: C.faint, size: 9, ls: 2 });
+ const bg = this.gfx();
+ this.bar(bg, barX, y + 46, barW, 8, j.isMax ? 1 : j.step, j.isMax ? C.amber : C.neon, { track: true, alpha: 0.85 });
+ // the next-level tick (the bank's own number, not a guess)
+ if (!j.isMax) {
+ this.txt(barX + barW, y + 30, `${String(cfg.nextLabel ?? 'NEXT LV')} ${j.level + 1}`, {
+ color: C.faint, size: 8, ls: 1.6, align: 'right',
+ });
+ }
+
+ const jy = y + 74;
+ if (j.mode === 'timeline') this._paintJourney(j, cfg, x, jy, w, Math.max(60, y + h - 12 - jy));
+ else this._paintClassDoor(j, cfg, x, jy, w);
+ }
+
+ /** The journey's timeline: milestone nodes with the fill between them. */
+ _paintJourney(j, cfg, x, y, w, h) {
+ const title = String(cfg.journeyTitle ?? 'THE JOURNEY // LV {from}–{to}')
+ .replace('{from}', j.from)
+ .replace('{to}', j.to);
+ this.txt(x + 6, y, title, { color: C.dim, size: 9, ls: 2.6 });
+
+ const n = Math.max(1, j.nodes.length);
+ const padX = 26;
+ const lineY = y + 40;
+ const span = Math.max(1, w - padX * 2);
+ const stepX = (n > 1 ? span / (n - 1) : 0);
+ const g = this.gfx();
+ // the rail + the fill up to the current node (the journey's own progress)
+ g.lineStyle(2, 0x1e3a5c, 1);
+ g.lineBetween(x + padX, lineY, x + padX + span, lineY);
+ const fx = x + padX + span * clamp01(j.progress);
+ g.lineStyle(2.5, C.neon, 0.9);
+ g.lineBetween(x + padX, lineY, fx, lineY);
+
+ j.nodes.forEach((node, i) => {
+ const nx = x + padX + stepX * i;
+ const done = node.state === 'done';
+ const current = node.state === 'current';
+ const accent = done ? C.neon : current ? C.amber : C.faint;
+ // the node: a diamond (past), a ring+dot (now), a hollow pip (future)
+ if (current) {
+ diamond(g, nx, lineY, 9, C.amber, 0.25);
+ g.lineStyle(1.5, C.amber, 0.95);
+ g.strokeCircle(nx, lineY, 6.5);
+ g.fillStyle(C.amber, 1);
+ g.fillCircle(nx, lineY, 3);
+ } else if (done) {
+ diamond(g, nx, lineY, 6, C.neon, 0.9);
+ } else {
+ g.lineStyle(1.2, C.faint, 0.85);
+ g.strokeCircle(nx, lineY, 4);
+ }
+ // The milestone's label + note (the copy is data). A centered string at
+ // the rail's two ends would hang off the plate — each line is pulled
+ // inside by half its own measured width.
+ const tuck = (str, size) => {
+ const g2 = this.scene.add.text(0, 0, String(str), {
+ fontFamily: BODY,
+ fontSize: `${size}px`,
+ letterSpacing: 1.6,
+ });
+ const half = g2.width / 2;
+ g2.destroy();
+ return Math.min(Math.max(nx, x + half + 4), x + w - half - 4);
+ };
+ const lx = tuck(node.label, current ? 10 : 9);
+ this.txt(lx, lineY + 12, node.label, {
+ color: current ? C.amber : done ? C.ink : C.faint,
+ size: current ? 10 : 9,
+ ls: 1.6,
+ align: 'center',
+ origin: 0.5,
+ bold: current || done,
+ });
+ if (node.note) {
+ this.txt(tuck(node.note, 8), lineY + 25, node.note, { color: C.faint, size: 8, ls: 1, align: 'center', origin: 0.5 });
+ }
+ this.txt(tuck(`LV ${node.level}`, 7), lineY - 22, `LV ${node.level}`, { color: C.faint, size: 7, ls: 1, align: 'center', origin: 0.5 });
+ });
+ }
+
+ /** The class-level door — the timeline's replacement (level ≥ classAt). */
+ _paintClassDoor(j, cfg, x, y, w) {
+ const door = cfg.chooseClass ?? {};
+ const plate = this.gfx();
+ panel(plate, x + 6, y - 4, w - 12, 92, { notch: 10, fill: C.amber, fillAlpha: 0.05, stroke: C.amber, strokeAlpha: 0.35 });
+ if (j.mode === 'class') {
+ // a class is on file — the insignia + name slot (data-driven; empty seam)
+ this.txt(x + 26, y + 14, String(cfg.classPlate?.label ?? 'CLASS').toUpperCase(), { color: C.faint, size: 9, ls: 3 });
+ this.txt(x + 26, y + 26, j.className, { color: C.ink, size: 20, head: true, bold: true });
+ if (j.classInsignia) this.icon(x + w - 52, y + 34, j.classInsignia, 40);
+ } else {
+ const bw = Math.min(320, w * 0.6);
+ this.button(x + (w - bw) / 2, y + 10, bw, 40, String(door.label ?? 'CHOOSE YOUR CLASS'), C.amber, () => {
+ // the class layer's seam — the scene answers (a toast until the
+ // selection screen exists), so the door is never a dead click
+ if (this.onChooseClass) this.onChooseClass();
+ else this.onLocked?.('skills');
+ });
+ this.txt(x + w / 2, y + 66, String(door.hint ?? '').replace('{classAt}', j.classAt), {
+ color: C.dim, size: 9, ls: 2.4, align: 'center', origin: 0.5,
+ });
+ }
+ }
+
+ // ------------------------------------------------------------ SKILL TREE
+ /** The OFFLINE tab's body — the locked plate that keeps the console's final
+ * structure visible (copy from data/shipScreen.json → skillsPanel). */
+ _buildSkillsBody() {
+ const cfg = config.section('shipScreen.skillsPanel', {});
+ const { plateX, plateY, plateW, plateH } = this.geo;
+ const x = plateX + 18;
+ const w = plateW - 36;
+ this.head(x, plateY + 20, w, 'Skill Interface', 'OFFLINE', C.amber);
+
+ const g = this.gfx();
+ const boxY = plateY + plateH / 2 - 66;
+ panel(g, x, boxY, w, 132, { notch: 10, fill: 0x04070f, fillAlpha: 0.8, stroke: C.amber, strokeAlpha: 0.35 });
+ g.lineStyle(1, C.amber, 0.08); // the locked plate's hatch
+ for (let i = 1; i < 9; i++) {
+ const t = (w / 9) * i;
+ g.lineBetween(x + t, boxY + 4, x + t - 30, boxY + 128);
+ }
+ this.txt(x + w / 2, boxY + 36, String(cfg.headline ?? 'SKILL TREE OFFLINE'), {
+ color: C.amber, size: 15, ls: 4, align: 'center', origin: 0.5, bold: true, head: true,
+ });
+ String(cfg.note ?? '')
+ .split('\n')
+ .forEach((line, i) => {
+ this.txt(x + w / 2, boxY + 68 + i * 16, line, { color: C.dim, size: 10, ls: 1.4, align: 'center', origin: 0.5 });
+ });
+ // the tree's ghost sockets (what lands with the class layer)
+ const rowY = boxY + 152;
+ const n = 5;
+ const cell = (w - 40) / n;
+ for (let i = 0; i < n; i++) {
+ const cx = x + 20 + cell * (i + 0.5);
+ g.lineStyle(1, C.faint, 0.5);
+ g.strokeRect(cx - 30, rowY, 60, 28);
+ this.txt(cx, rowY + 14, i === 0 ? String(config.get('shipScreen.level.classPlate.label', 'CLASS')) : '—', {
+ color: C.faint, size: 9, ls: 1.6, align: 'center', origin: 0.5,
+ });
+ if (i < n - 1) {
+ g.lineStyle(1, C.faint, 0.35);
+ g.lineBetween(cx + 30, rowY + 14, cx + cell - 30, rowY + 14);
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ CARGO HOLDS
+ /** The hold's summary — the bay grid + the mineral aggregate — or the
+ * selected bay's manifest (the shop scout's report). */
+ _buildCargoBody(snap) {
+ const cfg = config.section('shipScreen.cargo', {});
+ const sum = cargoSummary(snap, config.get('goods.goods', []));
+ if (this._selectedBay != null && sum.bays[this._selectedBay]?.goodId) {
+ this._paintManifest(sum.bays[this._selectedBay], cfg.detail ?? {}, snap);
+ return;
+ }
+ const { plateX, plateY, plateW, plateH } = this.geo;
+ const x = plateX + 18;
+ const w = plateW - 36;
+
+ // ---- TRADE BAYS ---------------------------------------------------
+ const meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
+ .replace('{used}', sum.used)
+ .replace('{capacity}', sum.capacity)
+ .replace('{room}', sum.room);
+ this.head(x, plateY + 20, w, cfg.holdTitle ?? 'TRADE BAYS', meta, C.neon);
+
+ // the grid: filled bays first, then empty, then the LOCKED room left by
+ // the hold's upgrades (economy.cargo.maxSlots is the full grid)
+ const top = plateY + 46;
+ const gap = 12;
+ const cols = Math.max(4, Math.min(8, Math.floor((w + gap) / (98 + gap))));
+ const tileW = (w - gap * (cols - 1)) / cols;
+ const gridH = Math.max(120, plateH * 0.5);
+ this._paintBayGrid(sum, cfg, x, top, w, gridH, tileW, gap, cols);
+
+ // the hold's upgrade room (economy.cargo.maxSlots is the full grid) and
+ // the empty-hold line — both read as states, never as a broken plate
+ const rows = Math.ceil(sum.bays.length / cols);
+ const noteY = top + rows * (Math.max(56, Math.min(86, (gridH - gap * 2) / 2)) + gap) + 4;
+ if (sum.empty && cfg.emptyNote) {
+ this.txt(x, noteY, String(cfg.emptyNote).toUpperCase(), { color: C.dim, size: 10, ls: 2.6 });
+ }
+ if (sum.lockedBays > 0) {
+ const ly = sum.empty ? noteY + 20 : noteY;
+ const g2 = this.gfx();
+ // the locked pips themselves (the room, seen at a glance)
+ const pw = 16,
+ pg = 5;
+ const shown = Math.min(sum.lockedBays, Math.floor((w * 0.34) / (pw + pg)));
+ for (let i = 0; i < shown; i++) {
+ g2.lineStyle(1, 0x648fb0, 0.35);
+ g2.strokeRect(x + i * (pw + pg), ly + 1, pw, 9);
+ }
+ const line = String(cfg.lockedRoomMeta ?? '+{locked} LOCKED BAYS · UPGRADE THE HOLD AT HARGUN')
+ .replace('{locked}', sum.lockedBays)
+ .replace('{slots}', sum.slots);
+ this.txt(x + shown * (pw + pg) + 12, ly, line, { color: C.faint, size: 9, ls: 2 });
+ }
+
+ // ---- MINERAL HOLDS (the aggregate — the Mineral HUD's numbers) -----
+ const my = top + gridH + 26;
+ this.head(x, my, w, cfg.mineralsTitle ?? 'MINERAL HOLDS', String(cfg.mineralsMeta ?? '{minerals} / {capacity} u')
+ .replace('{minerals}', sum.minerals)
+ .replace('{capacity}', sum.mineralCapacity), C.neon2);
+ const g = this.gfx();
+ const barY = my + 24;
+ const frac = sum.mineralCapacity > 0 ? sum.minerals / sum.mineralCapacity : 0;
+ this.bar(g, x, barY, w, 12, frac, C.neon2, { track: true, alpha: 0.8 });
+ // the capacity ticks (the hold's quarters)
+ g.lineStyle(1, 0x04070f, 0.9);
+ for (let i = 1; i < 4; i++) g.lineBetween(x + (w / 4) * i, barY + 2, x + (w / 4) * i, barY + 10);
+ this.txt(x, barY + 20, `${Math.round(clamp01(frac) * 100)}%`, { color: frac >= 0.999 ? C.red : C.neon2, size: 10, ls: 2, bold: true });
+ if (sum.minerals === 0) {
+ this.txt(x + w, barY + 20, String(cfg.emptyMineralsNote ?? 'NO ORE ABOARD').toUpperCase(), { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
+ }
+ // the credits line (the hold's other cargo)
+ const cy = Math.min(plateY + plateH - 12, barY + 46);
+ this.txt(x, cy, `${String(cfg.creditsLabel ?? 'CREDITS').toUpperCase()} ${sum.credits}`, { color: C.amber, size: 10, ls: 2.4 });
+ }
+
+ /** The bay grid — filled bays carry the good's icon + name and open its
+ * manifest; empty bays read as room to fill; locked ones as upgrade room. */
+ _paintBayGrid(sum, cfg, x, y, w, h, tileW, gap, cols) {
+ const bays = sum.bays;
+ if (!bays.length) {
+ this.txt(x + w / 2, y + 60, String(cfg.noBaysNote ?? 'NO CARGO BAYS INSTALLED').toUpperCase(), {
+ color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
+ });
+ return;
+ }
+ const tileH = Math.max(56, Math.min(86, (h - gap * 2) / 2));
+ bays.forEach((b, i) => {
+ const bx = x + (i % cols) * (tileW + gap);
+ const by = y + Math.floor(i / cols) * (tileH + gap);
+ if (by + tileH > y + h + tileH) return; // rows beyond the plate's room
+ const filled = b.state === 'filled';
+ const locked = b.state === 'locked';
+ const accent = filled ? C.neon : locked ? C.faint : C.dim;
+ const g = this.gfx();
+ const paint = (hot) => {
+ g.clear();
+ panel(g, bx, by, tileW, tileH, {
+ notch: 6,
+ fill: filled ? col(C.neon) : 0x04070f,
+ fillAlpha: filled ? (hot ? 0.2 : 0.1) : locked ? 0.35 : 0.6,
+ stroke: accent,
+ strokeAlpha: locked ? 0.35 : hot && filled ? 1 : 0.6,
+ lineWidth: 1.2,
+ });
+ };
+ paint(false);
+ this.txt(bx + 7, by + 4, `BAY ${String(i + 1).padStart(2, '0')}`, { color: C.faint, size: 8, ls: 1.4 });
+ if (filled) {
+ this.icon(bx + 26, by + tileH / 2 + 6, b.goodId, 32);
+ const name = this.txt(bx + 46, by + tileH / 2 - 1, b.name, { color: C.ink, size: 9.5, ls: 0.6, wrap: Math.floor((tileW - 56) / 5.6) });
+ name.setOrigin(0, 0.5);
+ this.txt(bx + tileW - 7, by + tileH - 9, String(cfg.manifestTag ?? 'MANIFEST ▸'), { color: C.neon, size: 7, ls: 1.2, origin: [1, 0] });
+ } else if (locked) {
+ g.lineStyle(1, C.faint, 0.4);
+ g.strokeRect(bx + tileW / 2 - 7, by + tileH / 2 - 2, 14, 11);
+ g.beginPath?.();
+ g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 2, bx + tileW / 2 - 4, by + tileH / 2 - 7);
+ g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 7);
+ g.lineBetween(bx + tileW / 2 + 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 2);
+ this.txt(bx + tileW / 2, by + tileH - 14, String(cfg.lockedTag ?? 'LOCKED'), { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
+ } else {
+ diamond(g, bx + tileW / 2, by + tileH / 2, 3, C.dim, 0.5);
+ this.txt(bx + tileW / 2, by + tileH - 14, 'EMPTY', { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
+ }
+ if (!filled) return;
+ // the manifest opens on press (the visible plate carries the input)
+ g.setInteractive({
+ useHandCursor: true,
+ hitArea: new Phaser.Geom.Rectangle(bx, by, tileW, tileH),
+ hitAreaCallback: (area, px, py) => area.contains(px, py),
+ });
+ g.on('pointerover', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_hover');
+ paint(true);
+ });
+ g.on('pointerout', () => paint(false));
+ g.on('pointerdown', () => {
+ if (!this.isOpen) return;
+ this.sfx('ui_click');
+ this._selectedBay = b.bay;
+ this._buildBody(this._snap);
+ });
+ this._hits.push(g);
+ });
+ }
+
+ /** A held good's manifest — the product summary + the charted exits (the
+ * same scoutGood() report the planet shop's scout panel renders). */
+ _paintManifest(bay, cfg, snap) {
+ const { plateX, plateY, plateW, plateH } = this.geo;
+ const x = plateX + 18;
+ const w = plateW - 36;
+ const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId)) : null;
+
+ this.head(x, plateY + 20, w, cfg.title ?? 'BAY MANIFEST', `BAY ${String(bay.bay + 1).padStart(2, '0')}`, C.neon);
+ // the good itself
+ const g = this.gfx();
+ panel(g, x, plateY + 40, w, 86, { notch: 8, fill: col(C.neon), fillAlpha: 0.06, stroke: 0x1b3a5a, strokeAlpha: 0.9 });
+ this.icon(x + 46, plateY + 83, bay.goodId, 56);
+ this.txt(x + 84, plateY + 52, String(rep?.name ?? bay.name), { color: C.ink, size: 17, head: true, bold: true });
+ const chips = [
+ rep?.category ? `${String(cfg.categoryLabel ?? 'CLASS').toUpperCase()} ${String(rep.category).toUpperCase()}` : '',
+ rep?.tier != null ? `${String(cfg.tierLabel ?? 'T')}${rep.tier}` : '',
+ rep?.baseValue != null ? `${String(cfg.valueLabel ?? 'BASE VALUE')} ${rep.baseValue}` : '',
+ ].filter(Boolean);
+ this.txt(x + 84, plateY + 76, chips.join(' · '), { color: C.neon, size: 9, ls: 2 });
+ const flavor = String(rep?.flavor ?? '');
+ this.txt(x + 84, plateY + 92, flavor, { color: C.dim, size: 9.5, ls: 0.4, wrap: Math.floor((w - 110) / 5.4) });
+
+ // the origin / demand line (the trade story in one row)
+ let ly = plateY + 138;
+ // the trade story (ScoutPanel's recipe): MADE IN
+ // + the producer classes, SOUGHT ON the demand classes
+ const origin = rep?.origin?.hub ? String(cfg.hubLabel ?? 'HUB POOL') : rep?.origin?.systemName ?? '—';
+ const producers = (Array.isArray(rep?.producer) ? rep.producer : [])
+ .map((p) => p?.label ?? p?.cls ?? p)
+ .slice(0, 4)
+ .join(' · ');
+ const demand = (rep?.demand ?? []).map((d) => d.label ?? d.cls ?? d).slice(0, 6).join(' · ');
+ // two rows of their own — an origin list long enough to wrap must never
+ // land on top of the exits' header
+ this.txt(x, ly, `${String(cfg.originLabel ?? 'MADE IN').toUpperCase()} ${String(origin).toUpperCase()}`, { color: C.dim, size: 9, ls: 2 });
+ if (demand) {
+ const dLabel = String(cfg.demandLabel ?? 'SOUGHT ON').toUpperCase();
+ this.txt(x + w, ly, `${dLabel} ${String(demand).toUpperCase()}`, { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
+ }
+ const story = [
+ producers ? `${String(cfg.producerLabel ?? 'MADE OF').toUpperCase()} ${String(producers).toUpperCase()}` : '',
+ ].filter(Boolean);
+ if (story.length) this.txt(x, ly + 15, story.join(' · '), { color: C.faint, size: 8.5, ls: 2 });
+
+ // ---- BEST EXITS (the charted markets that pay best) ----------------
+ const ety = ly + (story.length ? 34 : 26);
+ const exits = Array.isArray(rep?.exits) ? rep.exits : [];
+ const topN = Math.max(1, Math.floor(Number(cfg.topN) || 3));
+ this.head(x, ety, w, cfg.exitsTitle ?? 'BEST EXITS', rep ? `${rep.charted} CHARTED` : '', C.amber);
+ if (!exits.length) {
+ this.txt(x + 6, ety + 34, String(cfg.exitsEmpty ?? 'NO CHARTED EXIT YET').toUpperCase(), { color: C.faint, size: 10, ls: 2.4 });
+ }
+ const rows = exits.slice(0, topN);
+ // the margin rail is comparative — ScoutPanel scales it against the
+ // report's own best row (rep.maxProfit), which is what this list ranks
+ const maxProfit = Math.max(1, Number(rep?.maxProfit) || 0, ...rows.map((e) => Math.abs(Number(e.profit) || 0)));
+ const pb = cfg.profitBar ?? null;
+ const nameW = w - 210;
+ rows.forEach((e, i) => {
+ const ry = ety + 30 + i * 46;
+ const rg = this.gfx();
+ panel(rg, x, ry, w, 40, { notch: 6, fill: 0x04070f, fillAlpha: 0.7, stroke: i === 0 ? C.amber : 0x1b3a5a, strokeAlpha: i === 0 ? 0.8 : 0.7 });
+ this.txt(x + 12, ry + 6, `${i + 1}`, { color: i === 0 ? C.amber : C.faint, size: 14, head: true, bold: true });
+ this.txt(x + 38, ry + 4, String(e.objectName ?? e.systemName), { color: C.ink, size: 12, ls: 1.2, bold: true, wrap: nameW });
+ const sub = [String(e.clsLabel ?? '').toUpperCase(), String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?')].filter(Boolean).join(' · ');
+ if (sub) this.txt(x + 38, ry + 19, sub, { color: C.faint, size: 8.5, ls: 1.6 });
+ const p = Number(e.profit) || 0;
+ if (pb) {
+ const rw = Math.max(60, nameW);
+ rg.fillStyle(col(pb.track ?? '#14324f'), 0.9);
+ rg.fillRect(x + 38, ry + 33, rw, 3);
+ rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95);
+ rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3);
+ }
+ this.txt(x + w - 14, ry + 3, `${e.price}`, { color: C.amber, size: 13, bold: true, origin: [1, 0] });
+ this.txt(x + w - 14, ry + 20, `${p >= 0 ? '+' : '−'}${Math.abs(p)} / UNIT`, { color: p > 0 ? C.green : C.red, size: 9, ls: 1.4, origin: [1, 0] });
+ });
+
+ // back to the grid
+ const bw = 200;
+ this.button(x + w - bw, plateY + plateH - 34, bw, 28, String(cfg.backLabel ?? 'BACK TO BAYS'), C.neon, () => {
+ this._selectedBay = null;
+ this.sfx('ui_click');
+ this._buildBody(this._snap);
+ });
+ // the local reference line (what it cost / would pay HERE)
+ if (rep) {
+ const baseKey = cfg.baseline === 'buy' ? 'localBuy' : 'baseValue';
+ const base = rep[baseKey] ?? rep.baseValue ?? 0;
+ this.txt(x, plateY + plateH - 24, `REF ${base} · LOCAL BUY ${rep.localBuy ?? '—'}${rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : ''}`, {
+ color: C.faint, size: 9, ls: 1.8,
+ });
+ }
+ }
+
+ // ------------------------------------------------------------ repaint
+ /** Repaint the whole console from a snapshot (the tab body + the strip). */
+ _paintAll(snap) {
+ this._snap = snap ?? this._snap;
+ this._buildBody(this._snap);
+ this._paintFeedStrip(this._snap);
+ }
+
+ /** The feed's bottom strip — the ship line + the level bar. */
+ _paintFeedStrip(snap) {
+ const g = this.statusBar;
+ g.clear();
+ const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
+ if (!j) {
+ this.statusTxt.setText(String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase());
+ return;
+ }
+ const lvWord = String(config.get('shipScreen.level.levelWord', 'LEVEL'));
+ this.statusTxt.setText(
+ j.isMax
+ ? `${lvWord} ${j.level} · MAX LEVEL`
+ : `${lvWord} ${j.level} · ${j.xp}/${j.xpForNext} XP`.toUpperCase?.() ?? ''
+ );
+ this.bar(g, 0, 6, this.statusBarW, 5, j.isMax ? 1 : clamp01((j.level - j.from + j.step) / Math.max(1, j.to - j.from)), j.isMax ? C.amber : C.neon);
+ }
+
+ // ------------------------------------------------------------ open/close
+ open() {
+ if (this.openState === 'open' || this.openState === 'opening') return;
+ this.openState = 'opening';
+ this.setAlpha(0);
+ // resume() not play(): v4's play() is a no-op after pause() (its
+ // _playCalled flag stays true) — the feed would freeze after reopen.
+ this.video?.resume?.();
+ this.sfx('ui_window');
+ this._startReveal(this.scene.time.now);
+ }
+
+ /** Open straight on a tab (the scene's tutorial / diag seams). */
+ openTab(id) {
+ if (this._tabs.some((t) => t.id === id && !t.standby)) this._activeTab = id;
+ this.open();
+ }
+
+ close() {
+ if (this.openState === 'closed' || this.openState === 'closing') return;
+ this.openState = 'closing';
+ this.video?.pause?.();
+ this.sfx('ui_close');
+ this.scene.tweens.add({
+ targets: this,
+ alpha: 0,
+ duration: 150,
+ ease: 'Power2',
+ onComplete: () => {
+ this.openState = 'closed';
+ this.glitchG.clear();
+ this.titleGhostA.setAlpha(0);
+ this.titleGhostB.setAlpha(0);
+ },
+ });
+ }
+
+ // 'closing' counts as open: the close button flips openState in the SAME
+ // input pass, before the scene's pointerdown guard runs — while the window
+ // is still on screen (fading out) it keeps owning the click.
+ get isOpen() {
+ return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
+ }
+
+ // ------------------------------------------------------------ boot reveal
+ _startReveal(now) {
+ const list = [];
+ const push = (o, d, dur) => list.push({ o, d, dur, t0: now });
+ this.decodeTo(this.titleTxt, String(config.get('shipScreen.title', 'SHIP SYSTEMS')).toUpperCase(), now, 520);
+ push(this, 0, 240);
+ push(this.titleMeta, 140, 300);
+ push(this.closeG, 200, 200);
+ push(this.closeTxt, 200, 200);
+ if (this.videoPanel) push(this.videoPanel, 260, 380);
+ for (const t of this._tabs) {
+ push(t.g, 300, 260);
+ push(t.txt, 300, 260);
+ if (t.tagTxt) push(t.tagTxt, 300, 260);
+ }
+ push(this.plateG, 380, 300);
+ this.reveal = list;
+ // the glitch clock + the sweep clock
+ const g = config.get('shipScreen.glitch', {});
+ const [ia, ib] = g.intervalMs ?? [4200, 9200];
+ this._glitch.next = now + rand(ia, ib);
+ this._glitch.until = 0;
+ this._sweep = { next: now + 1500, active: null };
+ // the first poll lands on the reveal
+ this._pollNext = 0;
+ this._fp = null;
+ this.openState = 'open';
+ }
+
+ _glitchBurst() {
+ const g = config.get('shipScreen.glitch', {});
+ if (g.enabled === false) return;
+ const [da, db] = g.durationMs ?? [110, 260];
+ this._glitch.until = this.scene.time.now + rand(da, db);
+ const [sa, sb] = g.slices ?? [1, 4];
+ const maxOff = g.maxOffset ?? 9;
+ const vr = this.videoRect ?? { x: 0, y: 0, w: 100, h: 100 };
+ const gg = this.glitchG;
+ gg.clear();
+ const n = Math.max(1, Math.round(rand(sa, sb)));
+ for (let i = 0; i < n; i++) {
+ gg.fillStyle(Math.random() < 0.5 ? C.neon : C.neon2, rand(0.06, 0.2));
+ gg.fillRect(vr.x + rand(-maxOff, maxOff), vr.y + rand(0, Math.max(1, vr.h - 6)), vr.w, rand(2, 7));
+ }
+ // the title's RGB split
+ const t = this.titleTxt;
+ if (!t.text) return;
+ const off = rand(2, 5);
+ this.titleGhostA.setText(t.text).setPosition(t.x - off, t.y).setAlpha(0.55);
+ this.titleGhostB.setText(t.text).setPosition(t.x + off, t.y).setAlpha(0.55);
+ }
+
+ // ------------------------------------------------------------ per-frame
+ update(time) {
+ if (!this.isOpen) return;
+
+ // the reveal stagger
+ if (this.reveal.length) {
+ let done = true;
+ for (const r of this.reveal) {
+ const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur));
+ if (u < 1) done = false;
+ r.o.setAlpha(easeIO(u));
+ }
+ if (done) this.reveal = [];
+ }
+
+ // decodes
+ if (this.decodes.length) {
+ for (const d of this.decodes) {
+ if (d.dec.started(time)) d.txt.setText(d.dec.display(time));
+ }
+ this.decodes = this.decodes.filter((d) => !d.dec.finished(time));
+ }
+
+ // the standby tab's rejected shake
+ if (this._tabShakes.length) {
+ const keep = [];
+ for (const sh of this._tabShakes) {
+ const u = (time - sh.t0) / 260;
+ if (u >= 1) {
+ sh.tab.txt.x = sh.x;
+ continue;
+ }
+ sh.tab.txt.x = sh.x + Math.sin(u * 28) * 3 * (1 - u);
+ keep.push(sh);
+ }
+ this._tabShakes = keep;
+ }
+
+ // glitch bursts
+ const g = this._glitch;
+ if (time >= g.next && time > g.until) {
+ this._glitchBurst();
+ const [ia, ib] = config.get('shipScreen.glitch.intervalMs', [4200, 9200]);
+ g.next = time + rand(ia, ib);
+ }
+ if (time > g.until && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0)) {
+ this.titleGhostA.setAlpha(0);
+ this.titleGhostB.setAlpha(0);
+ }
+
+ // the sweep band down the feed
+ if (this._sweep && (this.sweepCfg ?? {}).enabled !== false) {
+ if (this._sweep.active) {
+ const a = this._sweep.active;
+ const u = (time - a.t0) / a.dur;
+ if (u >= 1) {
+ this.sweepBand.setAlpha(0);
+ this._sweep.active = null;
+ } else {
+ this.sweepBand.setPosition(this.videoCx, this.videoRect.y + u * (this.videoRect.h + 80) - 40);
+ this.sweepBand.setAlpha(0.5 * Math.sin(Math.PI * u));
+ }
+ } else if (time >= this._sweep.next) {
+ const [ia, ib] = this.sweepCfg.intervalMs ?? [5200, 9800];
+ const [da, db] = this.sweepCfg.durationMs ?? [1400, 2200];
+ this._sweep.next = time + rand(ia, ib);
+ this._sweep.active = { t0: time, dur: rand(da, db) };
+ }
+ }
+
+ // the REC dot breathes
+ this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004)));
+
+ // poll the scene — repaint on any real change (a hull hit, a level-up,
+ // a buy/sell, ore mined)
+ if (time >= this._pollNext) {
+ this._pollNext = time + 250;
+ const snap = this.getSnapshot ? safe(() => this.getSnapshot()) : null;
+ if (snap) {
+ const fp = this._fpOf(snap);
+ this._snap = snap;
+ if (fp !== this._fp) {
+ this._fp = fp;
+ this._paintAll(snap);
+ }
+ }
+ }
+ }
+
+ /** The repaint fingerprint — every painted number is in here. */
+ _fpOf(snap) {
+ const rows = statRows(config.get('shipScreen.status.rows', []), snap ?? {});
+ const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
+ const c = cargoSummary(snap, config.get('goods.goods', []));
+ return [
+ this._activeTab,
+ this._selectedBay ?? '-',
+ rows.map((r) => `${r.id}=${r.text}`).join(','),
+ j ? `${j.mode}:${j.level}:${j.xp}:${Math.round(j.step * 100)}:${j.className ?? '-'}` : 'none',
+ c.used,
+ sum(c.bays.filter((b) => b.goodId).map((b) => (b.goodId === null ? '' : String(b.goodId).length))),
+ c.minerals,
+ c.credits,
+ ].join('|');
+ }
+
+ /** The scene calls this after anything the console reads so it repaints on
+ * its next poll instead of waiting out the interval. */
+ refresh() {
+ this._pollNext = 0;
+ this._fp = null;
+ }
+
+ destroy() {
+ this._destroyed = true;
+ for (const k of this._kids) {
+ try {
+ k.destroy();
+ } catch {
+ /* already gone */
+ }
+ }
+ this._kids = [];
+ for (const t of this._tabs) {
+ for (const k of [t.g, t.txt, t.tagTxt]) {
+ if (!k) continue;
+ try {
+ k.destroy();
+ } catch {
+ /* already gone */
+ }
+ }
+ }
+ if (this.closeG) {
+ try {
+ this.closeG.destroy();
+ } catch {
+ /* already gone */
+ }
+ }
+ // v4 quirk: there is no removeChildren() — super.destroy(true) recurses
+ // through the display list (the Map/Quest idiom).
+ super.destroy(true);
+ }
+}
+
+const sum = (a) => a.reduce((t, v) => t + Number(v || 0), 0);
+function safe(fn) {
+ try {
+ return fn();
+ } catch {
+ return null;
+ }
+}
+
+export default ShipWindow;