orbit/dev/economy.test.mjs

356 lines
20 KiB
JavaScript
Raw Permalink 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 MODEL v2 in
* docs/ECONOMY.md §5 (ONE WORLD, ONE GOOD):
*
* - THE PASS RAN: galaxy.economy has classBySystem (every system), a
* STRICT 1:1 signature/origin assignment (every settled system has
* exactly one signature good; the catalog's remainder is the hub
* pool), + producerHops for every good.
* - DETERMINISM: same seed ⇒ identical prices + floors; a different
* seed ⇒ a different economy (pure function of the seed, lazy-safe,
* save-free).
* - THE ORIGIN: a good is on sale ONLY at its origin (its world's
* signature, the daily batch) or — hub-pool goods — at hubs; off-
* origin the buy price is 0. Sells are a fixed positive function.
* - THE LOOP: the origin sells at ~half base (the discount); a far
* demander (≥ 4 hops) pays MORE — the trip is the reward, distance
* the dominant term.
* - DISTANCE: the further from the origin, the higher the sell price
* (the banded term, monotone).
* - SHOP FLOOR: a plain world's floor is EXACTLY its one signature;
* hubs carry the hub-pool selection (wide, variety) + their own
* signature; a capital buys back EVERYTHING (universal sink).
* - THE ANTI-LOOP INVARIANT: no shop pays more than it charges for any
* good on its own floor (sellPrice ≤ buyPrice, every system × class).
*/
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);
// MODEL v2 — ONE WORLD, ONE GOOD: the origin assignment is a strict
// 1:1 (a settled system ↔ a signature good); the catalog's remainder is
// the hub pool (resold by stations/capitals). The quest cargo is exempt
// (nothing produces the package — the quest's handoff delivers it).
const sigs = g.economy.signatures;
const origins = g.economy.origins;
const sigSystems = Object.keys(sigs);
check('every settled system has a signature good (≥ 60 of ~90 systems)', sigSystems.length >= 60);
check('every signature good has exactly ONE origin (1:1)',
tradeable.every((x) => !(x.id in origins) || (origins[x.id] && sigs[origins[x.id]] === x.id)));
const assigned = new Set(Object.values(sigs));
const poolSet = new Set(g.economy.hubPool);
check('the hub pool is the catalog remainder (disjoint + complete)',
tradeable.every((x) => assigned.has(x.id) !== poolSet.has(x.id)) &&
assigned.size + poolSet.size === tradeable.length);
// Thematic fit: the origin system hosts a producing class for its
// signature (the assignment prefers fit; a fallback only where the
// catalog leaves a good with no fitting system).
let fit = 0, unfit = 0;
for (const [sys, gid] of Object.entries(sigs)) {
const good = goods.find((x) => x.id === gid);
const set = g.economy.classBySystem.get(sys);
if (good && Array.isArray(good.producer) && good.producer.some((c) => set.has(c))) fit++;
else unfit++;
}
check(`signatures are thematically placed (${fit} fit / ${unfit} fallback)`, fit >= sigSystems.length - 15 && unfit <= 15);
}
// ---------------------------------------------------------------------------
// 2. Determinism (pure function of the seed) + fixed prices
// ---------------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
// THE ORIGIN (model v2) — a good is on sale ONLY at its origin (its
// world's signature, the daily batch) or, for hub-pool goods, at hubs.
// Everywhere else the buy price is 0 (the good isn't there).
const sigSys = Object.keys(g.economy.signatures)[0];
const sigGood = g.economy.signatures[sigSys];
const sigCls = [...g.economy.classBySystem.get(sigSys)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const buyA = E.buyPrice(sigGood, sigCls, sigSys, g);
check('buyPrice at the origin is positive + deterministic', buyA > 0 && E.buyPrice(sigGood, sigCls, sigSys, g2) === buyA);
// Off-origin (a non-hub world): the good is simply NOT on sale.
const foreign = Object.keys(g.economy.signatures).find((s) => s !== sigSys &&
![...g.economy.classBySystem.get(s)].some((c) => c === 'station' || c === 'capital'));
const fCls = foreign ? ([...g.economy.classBySystem.get(foreign)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky') : null;
check('buyPrice is 0 off-origin (the good is not on sale there)', !!foreign && E.buyPrice(sigGood, fCls, foreign, g) === 0);
// The SELL side is a fixed positive function of (good, class, system) —
// the market is always a sink (the anti-loop cap only lowers it).
const sellA = E.sellPrice(sigGood, sigCls, sigSys, g);
check('sellPrice is fixed + deterministic', sellA > 0 && E.sellPrice(sigGood, sigCls, sigSys, g2) === sellA);
check('shopFor is deterministic', JSON.stringify(E.shopFor(sigCls, sigSys, g)) === JSON.stringify(E.shopFor(sigCls, sigSys, g2)));
check('prices do not depend on call order (lazy-safe)', E.buyPrice(sigGood, sigCls, sigSys, g) === buyA);
const other = Galaxy.create('orbit-economy-other-seed');
const otherSigSys = Object.keys(other.economy.signatures)[0];
const otherSigGood = other.economy.signatures[otherSigSys];
const oCls = [...other.economy.classBySystem.get(otherSigSys)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
check('a different seed yields a different trade map',
JSON.stringify(E.shopFor(sigCls, sigSys, g)) !== JSON.stringify(E.shopFor(oCls, otherSigSys, other)) || sigGood !== otherSigGood);
}
// ---------------------------------------------------------------------------
// 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 tradeable) {
// THE ORIGIN (model v2) — buy the signature cheap at its ONE origin
// system (the daily batch; the only cheap source in the galaxy).
const origin = g.economy.origins[good.id];
if (!origin) continue; // hub-pool goods are bought at hubs (no origin)
const oCls = [...g.economy.classBySystem.get(origin)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const buy = E.buyPrice(good.id, oCls, origin, g);
// A system that WANTS it, ≥ 4 hops from the origin (the long haul).
const far = [...g.economy.classBySystem.entries()].find(
([id, s]) => id !== origin && good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id]?.[id] ?? 0) >= 4,
);
if (!far) continue;
const [farId] = far;
const farSet = far[1];
const sellCls = [...farSet].find((c) => good.demand.includes(c));
const sell = E.sellPrice(good.id, sellCls, farId, g);
checked++;
// The distance term dominates: a far demander (≥ 4 hops) pays MORE than
// the origin's half-price, even before the demand bonus.
if (sell > buy) ok++;
}
check(`carry-far beats the origin buy (≥ 4 hops: ${ok}/${checked})`, checked >= 40 && ok / checked >= 0.9);
// THE BASELINE — the origin's sale is the galaxy's cheapest source of the
// good (baseValue × (1 − originDiscount) ± jitter), so the trip's profit
// is measured from a real discount, not a formula.
const sigGood2 = g.economy.signatures[Object.keys(g.economy.signatures)[0]];
const sigSys2 = g.economy.origins[sigGood2];
const oCls2 = [...g.economy.classBySystem.get(sigSys2)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const b2 = base(sigGood2);
const originPrice = E.buyPrice(sigGood2, oCls2, sigSys2, g);
check(`the origin sells at ~half base (origin ${originPrice} / base ${b2})`, b2 > 0 && originPrice < b2 && originPrice >= b2 * 0.35);
}
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
{
// A PLAIN WORLD (model v2) — the floor is EXACTLY its signature good:
// one world, one good (the identity of the place).
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);
const sig = g.economy.signatures[rockySys];
check('plain-world floor is EXACTLY its signature good (one world, one good)', shop.buy.length === 1 && shop.buy[0].id === sig);
check('the signature row is priced at the origin (the discount)', shop.buy[0].price < (goods.find((x) => x.id === sig)?.baseValue ?? Infinity));
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);
// HUBS — the hub pool's resellers: wide floors (variety), standard price.
const stationSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const sshop = E.shopFor('station', stationSys, g);
check('station floor is 10–17 goods (hub-pool selection + own signature)', sshop.buy.length >= 10 && sshop.buy.length <= 17);
check('a station carries hub-pool resales (variety)', sshop.buy.some((b) => !b.produced));
if (g.economy.signatures[stationSys]) {
const row = sshop.buy.find((b) => b.id === g.economy.signatures[stationSys]);
check('the hub\u2019s own signature rides its floor (produced)', !!row && row.produced === true);
}
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 12–17 goods (hub-pool selection + own signature)', cshop.buy.length >= 12 && cshop.buy.length <= 17);
// ONE WORLD, ONE GOOD — the sweep: every plain world's floor is exactly
// its one signature, and the signature's origin is that system (the
// strict 1:1 the economy's identity rests on).
let gemWorlds = 0;
let gemMissing = 0;
for (const [id, set] of g.economy.classBySystem) {
if (set.has('station') || set.has('capital')) continue; // hubs: pool + own signature
const wCls = [...set].find((c) => c !== 'station' && c !== 'capital');
if (!wCls) continue;
gemWorlds++;
const s = E.shopFor(wCls, id, g);
if (s.buy.length !== 1 || s.buy[0].id !== g.economy.signatures[id] || g.economy.origins[g.economy.signatures[id]] !== id) gemMissing++;
}
check(`every world's floor is exactly its one signature (${gemWorlds - gemMissing}/${gemWorlds} worlds)`, gemWorlds > 0 && gemMissing === 0);
// THE ANTI-LOOP INVARIANT (the exploit's tombstone) — no shop, for any
// good on its OWN floor, pays more than it charges: sellPrice ≤ buyPrice
// at the same shop (the anti-loop cap). Sweep every system × class.
let floors = 0;
let loops = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
floors++;
for (const b of E.shopFor(cls, id, g).buy) {
if (E.sellPrice(b.id, cls, id, g) > b.price + 1e-6) loops++;
}
}
}
check(`no shop is a positive same-shop spread (${floors} floors, ${loops} loops)`, floors > 0 && loops === 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 ✔');