139 lines
6.3 KiB
JavaScript
139 lines
6.3 KiB
JavaScript
/**
|
||
* 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<near pairs will appear'})`);
|
||
|
||
let failures = 0;
|
||
const check = (label, cond) => {
|
||
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 ✔');
|