orbit/dev/economy.test.mjs

274 lines
14 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.

/**
* Economy test (dev tool, run with Node — no browser needed):
*
* node dev/economy.test.mjs
*
* Drives the real trade engine (js/economy/Economy.js) against a real
* generated galaxy (js/galaxy/Galaxy.js) and the real catalog + tuning
* (data/goods.json, data/economy.json). Asserts the model in
* docs/ECONOMY.md §5:
*
* - THE PASS RAN: galaxy.economy has classBySystem (every system) + a
* producerHops map for every good; the home system carries terran
* (the home world) + its composition classes.
* - DETERMINISM: same seed ⇒ identical prices + shop floors; a different
* seed ⇒ a different economy (the trade map is a pure function of the
* seed, lazy-safe and save-free).
* - FIXED PRICES: buyPrice/sellPrice are stable for (good, class, system)
* — independent of where the player got the good or what's in their hold.
* - THE LOOP: carrying a good far from its home region sells for MORE than
* it cost to buy at the source (buy cheap where produced, dear where
* wanted + far) — the trip is the reward.
* - DISTANCE: the further a system is from a good's producer, the higher
* the good sells there (the hop term, capped).
* - SHOP FLOOR: 8–16 goods; locally produced goods present and dominant;
* the class's wanted goods in the buy-back list; a station carries
* resold goods (variety); a capital buys back EVERYTHING (universal sink).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const SEED = 'orbit-economy-test';
const g = Galaxy.create(SEED);
const goods = config.get('goods.goods', []);
// The catalog's quest cargo (tradeable: false — the 'Special Delivery'
// package, data/goods.json) is the one good the economy does not price:
// the invariants below that assume a good is a commodity (a producer map,
// a buy-back, a floor slot) run over the TRADEABLE subset, and a dedicated
// section (§5b) pins the package's exclusion from every shop in the galaxy.
const tradeable = goods.filter((x) => x.tradeable !== false);
const clsOf = (sysId) => [...g.economy.classBySystem.get(sysId)];
const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.classBySystem.get(sysId).has(c));
// ---------------------------------------------------------------------------
// 1. The economy pass ran and is complete
// ---------------------------------------------------------------------------
{
check('galaxy.economy exists', !!g.economy);
check('classBySystem covers every system', g.economy.classBySystem.size === g.byId.size);
check('producerHops has an entry for every tradeable good', tradeable.every((x) => g.economy.producerHops[x.id] && Object.keys(g.economy.producerHops[x.id]).length > 0));
const home = clsOf(g.homeSystemId);
check('home system carries terran (the home world)', home.includes('terran'));
check('home system carries its composition classes (gas + rocky)', home.includes('gas') && home.includes('rocky'));
// Every economic class in the catalog is present somewhere in the galaxy.
const present = new Set();
for (const set of g.economy.classBySystem.values()) for (const c of set) present.add(c);
const wantClasses = ['terran', 'arid', 'rocky', 'gas', 'ice', 'lava', 'station', 'capital'];
const missing = wantClasses.filter((c) => !present.has(c));
check(`every catalog class exists in the galaxy (missing: ${missing.join(', ') || 'none'})`, missing.length === 0);
// Every TRADEABLE good has at least one producing system (the loop is
// possible) — the quest cargo is exempt: nothing produces the package,
// it arrives by the quest's handoff, not by the trade map.
const unproducible = tradeable.filter((x) => !tradeable.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
check('every tradeable good is produced somewhere', unproducible.length === 0);
}
// ---------------------------------------------------------------------------
// 2. Determinism (pure function of the seed) + fixed prices
// ---------------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
const sysA = g.byId.keys().next().value;
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const goodId = goods[0].id;
check('buyPrice is fixed + deterministic', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g2) && E.buyPrice(goodId, 'rocky', rockySys, g) > 0);
check('sellPrice is fixed + deterministic', E.sellPrice(goodId, 'rocky', rockySys, g) === E.sellPrice(goodId, 'rocky', rockySys, g2) && E.sellPrice(goodId, 'rocky', rockySys, g) > 0);
check('shopFor is deterministic', JSON.stringify(E.shopFor('rocky', rockySys, g)) === JSON.stringify(E.shopFor('rocky', rockySys, g2)));
check('prices do not depend on call order (lazy-safe)', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g));
void sysA;
}
{
const other = Galaxy.create('orbit-economy-other-seed');
const rockyA = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const rockyB = [...other.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const goodId = goods[0].id;
check('a different seed yields a different trade map', JSON.stringify(E.shopFor('rocky', rockyA, g)) !== JSON.stringify(E.shopFor('rocky', rockyB, other)));
}
// ---------------------------------------------------------------------------
// 3. The loop: buy cheap at the source, sell dear far away (trip = reward)
// ---------------------------------------------------------------------------
{
const base = (id) => (goods.find((x) => x.id === id) || {}).baseValue || 0;
let checked = 0;
let ok = 0;
for (const good of goods) {
// A system that PRODUCES it (buy it cheap there).
const prod = [...g.economy.classBySystem.entries()].find(([, s]) => good.producer.some((c) => s.has(c)));
if (!prod) continue;
const [prodId] = prod;
const buyCls = classFor(prodId, good, good.producer);
// A system that WANTS it, ≥ 3 hops from the nearest producer.
const far = [...g.economy.classBySystem.entries()].find(
([id, s]) => good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id][id] ?? 0) >= 3,
);
if (!far) continue;
const [farId] = far;
const sellCls = classFor(farId, good, good.demand);
const buy = E.buyPrice(good.id, buyCls, prodId, g);
const sell = E.sellPrice(good.id, sellCls, farId, g);
checked++;
if (sell > buy && sell > base(good.id) * 0.9) ok++;
}
check(`carry-far beats buy-local on a meaningful share of goods (${ok}/${checked})`, checked > 0 && ok / checked >= 0.6);
}
// ---------------------------------------------------------------------------
// 4. Distance: the further from the producer, the higher the sell price
// ---------------------------------------------------------------------------
{
// Pick a good with several producers/demanders, then two demanders at
// different hop distances and assert the farther one pays more.
let found = false;
let passed = 0;
let total = 0;
for (const good of goods) {
const hops = g.economy.producerHops[good.id] ?? {};
const demanders = [...g.economy.classBySystem.entries()]
.filter(([, s]) => good.demand.some((c) => s.has(c)))
.map(([id]) => ({ id, h: hops[id] ?? 99 }))
.sort((a, b) => a.h - b.h);
if (demanders.length < 2) continue;
const near = demanders[0];
const far = demanders[demanders.length - 1];
if (far.h <= near.h + 1) continue; // need a real distance gap
const nearCls = classFor(near.id, good, good.demand);
const farCls = classFor(far.id, good, good.demand);
const nearPrice = E.sellPrice(good.id, nearCls, near.id, g);
const farPrice = E.sellPrice(good.id, farCls, far.id, g);
total++;
if (farPrice >= nearPrice) passed++;
found = true;
}
check(`distance term: farther demanders pay ≥ nearer ones (${passed}/${total})`, found && passed >= total * 0.7);
}
// ---------------------------------------------------------------------------
// 5. The shop floor (8–16, local-dominant, buy-back, hubs)
// ---------------------------------------------------------------------------
{
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky') && !s.has('station') && !s.has('capital'))[0];
const shop = E.shopFor('rocky', rockySys, g);
check('plain-world floor is 8–16 goods', shop.buy.length >= 8 && shop.buy.length <= 16);
check('locally produced goods are on the floor', shop.buy.some((b) => b.produced));
check('more than a third of the floor is locally produced', shop.buy.filter((b) => b.produced).length >= shop.buy.length / 3);
check('the buy-back list is non-empty (it wants things)', shop.sell.length > 0);
// No good appears on the floor twice.
const ids = shop.buy.map((b) => b.id);
check('no duplicate goods on the floor', new Set(ids).size === ids.length);
const stationSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const sshop = E.shopFor('station', stationSys, g);
check('station floor is 8–16 goods', sshop.buy.length >= 8 && sshop.buy.length <= 16);
check('a station carries resold goods (variety)', sshop.buy.some((b) => !b.produced));
const capSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('capital'))[0];
const cshop = E.shopFor('capital', capSys, g);
check('a capital buys back EVERY tradeable good (universal sink)', cshop.sell.length === tradeable.length);
check('a capital floor is 8–16 goods', cshop.buy.length >= 8 && cshop.buy.length <= 16);
// THE WORLD'S JEWEL (§4/§6): every shop carries ITS class's signature gem —
// a guaranteed premium good + a guaranteed local buy in every shop. Sweep
// every system of every class (a gem that's only present by catalog luck
// would fail here: the floor must always include the class's gemstone).
let gemWorlds = 0;
let gemMissing = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
const gem = goods.find((x) => x.category === 'gemstone' && Array.isArray(x.producer) && x.producer.includes(cls));
if (!gem) continue;
gemWorlds++;
const shop = E.shopFor(cls, id, g);
const row = shop.buy.find((b) => b.id === gem.id);
// the jewel is a premium good (tier 3–4) + a local buy (produced).
if (!row || row.produced !== true || (Number(gem.tier) || 0) < 3) gemMissing++;
}
}
check(`every shop carries its class's signature gem (${gemWorlds - gemMissing}/${gemWorlds} worlds)`, gemWorlds > 0 && gemMissing === 0);
}
// ---------------------------------------------------------------------------
// 5b. The quest cargo — the 'Special Delivery' package (non-tradeable)
// ---------------------------------------------------------------------------
{
const pkg = goods.find((x) => x.id === 'special-package');
check('the catalog carries the Special Package (sheet frame 114)', !!pkg && pkg.name === 'Special Package' && pkg.icon === 114);
check('isTradeable() says NO for the package', E.isTradeable('special-package') === false);
check('isTradeable() says YES for an ordinary good', E.isTradeable(tradeable[0]?.id ?? 'fresh-produce') === true);
check('isTradeable() is false for an unknown id', E.isTradeable('no-such-good') === false);
// The package never stocks and never buy-backs — on ANY class of ANY
// system (sweep the whole galaxy, not just one floor: the capital's
// "EVERYTHING" floor is the one that would leak it).
let worlds = 0;
let leaked = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
worlds++;
const shop = E.shopFor(cls, id, g);
if (shop.buy.some((b) => b.id === 'special-package') || shop.sell.some((s) => s.id === 'special-package')) leaked++;
}
}
check(`no shop in the galaxy carries the Special Package (${worlds} floors swept)`, worlds > 0 && leaked === 0);
}
// ---------------------------------------------------------------------------
// 6. Credits start at 0 (the config the Ship reads)
// ---------------------------------------------------------------------------
{
check('the player starts at 0 credits', config.get('economy.credits.startingCredits', 0) === 0);
}
// ---------------------------------------------------------------------------
// 7. The goods sheet icon mapping (frame N = goods[N], 12×128 grid)
// ---------------------------------------------------------------------------
{
const sheet = config.section('goods', {}).sheet ?? {};
check('sheet config: 128×128 frames, 12 across, 10 down', sheet.frameWidth === 128 && sheet.frameHeight === 128 && sheet.cols === 12 && sheet.rows === 10);
check('sheet texture points at assets/images/goods.png', sheet.texture === 'assets/images/goods.png');
const icons = goods.map((x) => x.icon);
check('every good has a stamped icon frame', goods.every((x) => Number.isInteger(x.icon) && x.icon >= 0));
check('icon frames are unique (no two goods share a cell)', new Set(icons).size === icons.length);
check('icon frames fit the 120-cell sheet', icons.every((n) => n < sheet.cols * sheet.rows));
check('goodFrame() resolves id → stamped frame', goods.every((x) => E.goodFrame(x.id) === x.icon));
check('goodFrame() is safe on unknown ids (blank frame, no crash)', E.goodFrame('a-never-defined-good') === 0 && E.goodFrame('fresh-produce') === 0);
}
// ---------------------------------------------------------------------------
console.log('');
if (failures) {
console.error(`${failures} check(s) FAILED`);
process.exit(1);
}
console.log('All economy checks passed ✔');