/** * Band invariant audit (dev diagnostic — run with Node, no browser): * * node dev/economy-bands-audit.mjs * ECON_BANDS=0.75,1.15,1.85,2.75,3.75 node dev/economy-bands-audit.mjs * * The three invariants every distance-band set must keep (the ones * dev/economy.test.mjs pins, re-run against an A/B band override — * ECON_BANDS applies the factors to the existing minHops ladder, in * memory, before anything is built): * * 1. ANTI-LOOP: no shop pays more than it charges for a good on its * OWN floor (sellPrice ≤ buyPrice, every system × class × floor row). * 2. MONOTONE: farther demanders pay ≥ nearer demanders (±jitter-safe: * each band gap must beat the ±10% jitter on both ends). * 3. TRIP-PAYS: a far demander (≥ 4 hops) pays MORE than the origin's * buy price — even at the worst jitter. * Plus a printout of the band gaps vs the jitter envelope. */ 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)); 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')); } if (process.env.ECON_BANDS) { const factors = process.env.ECON_BANDS.split(',').map((x) => Number(x.trim())); const bands = configData.economy?.pricing?.distanceBands ?? []; for (let i = 0; i < bands.length && i < factors.length; i++) bands[i].factor = factors[i]; } 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); const bands = config.get('economy.pricing.distanceBands', []); const jitter = Number(config.get('economy.pricing.jitter', 0.1)); const demandBonus = Number(config.get('economy.pricing.demandBonus', 0.1)); const originDiscount = Number(config.get('economy.pricing.originDiscount', 0.5)); const buyFactor = 1 - originDiscount; console.log(`bands: ${bands.map((b) => `${b.minHops}+h ×${b.factor}`).join(' ')} (buy ×${buyFactor}, jitter ±${jitter * 100}%, demand +${demandBonus * 100}%)`); // Band-gap vs jitter envelope: for "farther ≥ nearer" to hold for ALL // (good, place) pairs, the band ratio must beat the worst jitter ratio. let gapOk = true; for (let i = 1; i < bands.length; i++) { const ratio = bands[i].factor / bands[i - 1].factor; const envelope = (1 + jitter) / (1 - jitter); if (ratio < envelope) gapOk = false; console.log(` gap ×${bands[i - 1].factor} → ×${bands[i].factor}: ratio ${ratio.toFixed(2)} vs jitter envelope ${envelope.toFixed(2)} ${ratio >= envelope ? '✔' : '✘ TOO NARROW'}`); } console.log(` (jitter-safe monotonicity: ${gapOk ? 'OK' : 'AT RISK — 70%-pass tests may still hold, but some far { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; for (const SEED of ['orbit-economy-test', 'ABC-123']) { const g = Galaxy.create(SEED); const tradeable = config.get('goods.goods', []).filter((x) => x.tradeable !== false); const base = (id) => tradeable.find((x) => x.id === id)?.baseValue ?? 0; // 1. ANTI-LOOP — sweep every system × class × floor row. let floors = 0, 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(`[${SEED}] no shop is a positive same-shop spread (${floors} floors, ${loops} loops)`, floors > 0 && loops === 0); // 2. MONOTONE — farther demanders pay ≥ nearer (the test's 70% bar). let passed = 0, total = 0; for (const good of tradeable) { 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], far = demanders[demanders.length - 1]; if (far.h <= near.h + 1) continue; const nearCls = [...g.economy.classBySystem.get(near.id)].find((c) => good.demand.includes(c)); const farCls = [...g.economy.classBySystem.get(far.id)].find((c) => good.demand.includes(c)); total++; if (E.sellPrice(good.id, farCls, far.id, g) >= E.sellPrice(good.id, nearCls, near.id, g)) passed++; } check(`[${SEED}] distance term: farther demanders pay ≥ nearer (${passed}/${total}, bar ≥70%)`, total > 0 && passed >= total * 0.7); // 3. TRIP-PAYS — far demander (≥ 4 hops) pays MORE than the origin buy, // worst-case jitter included (90% sell jitter vs 110% buy jitter). let ok = 0, checked = 0; for (const good of tradeable) { const origin = g.economy.origins[good.id]; if (!origin) continue; 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[0]; const farCls = [...far[1]].find((c) => good.demand.includes(c)); const b = base(good.id); const band4 = bands.filter((x) => x.minHops <= 4).pop().factor; const worstSell = b * band4 * (1 + demandBonus) * (1 - jitter); const worstBuy = b * buyFactor * (1 + jitter); checked++; if (worstSell > worstBuy) ok++; } check(`[${SEED}] ≥4-hop demander ALWAYS pays more than origin buy (worst-jitter, ${ok}/${checked})`, checked > 0 && ok === checked); // 1-hop viability: is the FIRST jump still profitable at worst jitter? const band1 = bands[0].factor; const oneHopWorst = band1 * (1 - jitter) > buyFactor * (1 + jitter); check(`[${SEED}] 1-hop sale (×${band1}) beats origin buy (×${buyFactor}) even at worst jitter`, oneHopWorst); } if (failures) { console.error(`\n${failures} invariant(s) FAILED`); process.exit(1); } console.log('\nAll band invariants hold ✔');