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