orbit/dev/ship-window.test.mjs

276 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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:<path>' hardware specs as authored (data/ship.json thrust/maxSpeed,
* data/combat.json playerShip shield recharge + player.weapon)
* 'ship:<path>' state the run mutates (stats.cargoHold / mineralStorage /
* miningSpeed — what research & builds already rewrite —
* minerals, credits, cargo.used/items)
* 'live:<key>' 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);