orbit/dev/economy.test.mjs

242 lines
12 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', []);
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 good', goods.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 good has at least one producing system (the loop is possible).
const unproducible = goods.filter((x) => !goods.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
check('every 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 EVERYTHING (universal sink)', cshop.sell.length === goods.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);
}
// ---------------------------------------------------------------------------
// 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 ✔');