/** * 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 ✔');