292 lines
16 KiB
JavaScript
292 lines
16 KiB
JavaScript
/**
|
||
* 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);
|
||
// The delivery (the 'Special Delivery''s step 3 — the package SOLD on
|
||
// the home world): the catalog's one non-commodity prices at its flat
|
||
// sellFor (zero credits), not the fixed per-shop price — and that
|
||
// price is the delivery, not a reward (data/goods.json → sellFor).
|
||
check('isQuestCargo() flags the package (and only the package)',
|
||
E.isQuestCargo('special-package') === true && E.isQuestCargo(tradeable[0]?.id ?? 'fresh-produce') === false && E.isQuestCargo('no-such-good') === false);
|
||
check('questCargoSellPrice(): the parcel sells for zero (the delivery)',
|
||
E.questCargoSellPrice('special-package') === 0 && E.questCargoSellPrice(tradeable[0]?.id ?? 'fresh-produce') === 0);
|
||
{
|
||
const pv = E.tradePreview({
|
||
credits: 50, cls: 'industrial', systemId: 'sys-a',
|
||
buyMap: {}, sellMap: { 'special-package': 1 },
|
||
cargo: ['special-package'],
|
||
});
|
||
check('tradePreview: the parcel sells at its flat delivery price (0 cr, not the shop price)',
|
||
pv.valid === true && pv.sell.length === 1 && pv.sell[0].id === 'special-package' && pv.sell[0].unit === 0 && pv.sellTotal === 0 && pv.net === 0,
|
||
`preview ${JSON.stringify(pv)}`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 ✔');
|