356 lines
20 KiB
JavaScript
356 lines
20 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 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 ✔');
|