294 lines
18 KiB
JavaScript
294 lines
18 KiB
JavaScript
/**
|
||
* 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,
|
||
* each bay with its recorded purchase cost — null where none is on
|
||
* file) + 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) —
|
||
* and a recorded purchase cost re-baselines those profits (exit price
|
||
* vs what the player actually paid, not the catalog value).
|
||
*/
|
||
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) {
|
||
if (o.costs) for (let i = 0; i < o.items.length; i++) hold.add(o.items[i], o.costs[i] ?? null);
|
||
else 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(), costs: [...hold.costs] },
|
||
},
|
||
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('a hold with no cost ledger reads all-null costs (old saves load clean)', sum.bays.every((b) => b.cost === null) && sum.totalCost === 0 && sum.costBays === 0);
|
||
const priced = M.cargoSummary({ ship: snapshot({ capacity: 4, items, costs: [30, 21, null], minerals: 125 }).ship }, catalog);
|
||
check('each bay carries its recorded purchase cost (null where there is none)', priced.bays[0].cost === 30 && priced.bays[1].cost === 21 && priced.bays[2].cost === null);
|
||
check('the cost basis sums only the recorded bays (nulls count as 0 — never invented)', priced.totalCost === 51 && priced.costBays === 2);
|
||
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 COST BASIS (the manifest's whole point): a bay whose purchase cost is
|
||
// ON FILE — the credits/unit the player actually paid, recorded by
|
||
// CargoHold at the moment of purchase — re-baselines every exit profit.
|
||
const paid = 37;
|
||
const repPaid = scoutGood(goodId, { galaxy: g, systemId: sysId, buyUnit: paid, topN: CFG.cargo.detail.topN ?? 3 });
|
||
check(`a recorded purchase cost re-baselines every exit profit (price − ${paid})`, repPaid.exits.length > 0 && repPaid.exits.every((e) => e.profit === e.price - paid));
|
||
check('… the paid cost stands where the catalog value would (localBuy = paid)', repPaid.localBuy === paid);
|
||
|
||
// 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);
|