/** * Economics report (dev diagnostic — run with Node, no browser): * * node dev/economics-report.mjs [seed …] * node dev/economics-report.mjs → uses 'orbit-economy-test' * * Answers: "on an average product, how many credits are earned at 0 hops, * 1 hop, 2 hops, …?" by driving the REAL engine (js/economy/Economy.js) * against a real generated galaxy (js/galaxy/Galaxy.js). * * Method — for EVERY signature good (its origin O, its origin BUY price), * every shop-hosting destination system S is scored: the best SELL price * the player can realize at S (max over S's economic classes — the sell * grid takes any tradeable good anywhere, the demand class only adds the * +10%). Profit = bestSell − originBuy. Aggregated by: * · hop distance O→S (the distance bands) * · destination zone (near / middle / far — data/galaxy.json zones) * · demand hit (does ANY class at S want the good? +10%) * Plus: per-day (6-unit daily batch) and per-trip (16-bay hold) numbers, * the average product (mean base value), and the top concrete routes. */ 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); const SEEDS = process.argv.slice(2).length ? process.argv.slice(2) : ['orbit-economy-test']; // A/B band override (design experiments): ECON_BANDS=0.75,1.15,… applies new // factors to the existing minHops ladder (same order as economy.json), // patched in-memory BEFORE config.init — nothing on disk is touched. 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]; } const DAILY_BATCH = Number(config.get('economy.stock.dailyBatch', 6)); const FULL_HOLD = Number(config.get('economy.cargo.maxSlots', 16)); const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0); const fmt = (n) => (Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')); function analyze(seed) { const g = Galaxy.create(seed); const goods = config.get('goods.goods', []).filter((x) => x.tradeable !== false); const byId = new Map(goods.map((x) => [x.id, x])); const zones = config.get('galaxy.distribution.zones', []); const zoneOf = (rec) => { const d = Number(rec?.d) || 0; for (const z of zones) if (d >= z.d[0] && d < z.d[1]) return z.name; return zones.length ? zones[zones.length - 1].name : '?'; }; const sigs = g.economy.signatures; // origin systemId → goodId // Destinations = systems that host a shop (≥1 economic class). const dests = [...g.byId.keys()].filter((s) => g.economy.classBySystem.get(s).size > 0); const perHop = new Map(); // hops → rows const byZone = new Map(); // zone → rows const wantHit = { hit: [], miss: [] }; const bases = []; for (const [O, gid] of Object.entries(sigs)) { const good = byId.get(gid); bases.push(good.baseValue); const clsO = [...g.economy.classBySystem.get(O)][0]; const buy = E.buyPrice(gid, clsO, O, g); for (const S of dests) { const d = g.economy.producerHops[gid]?.[S] ?? 0; let best = 0; for (const cls of g.economy.classBySystem.get(S)) { const p = E.sellPrice(gid, cls, S, g); if (p > best) best = p; } const row = { profit: best - buy, sell: best, buy, base: good.baseValue, d, zone: zoneOf(g.byId.get(S)), goodName: good.name, originName: g.byId.get(O)?.name ?? O, destName: g.byId.get(S)?.name ?? S, }; if (!perHop.has(d)) perHop.set(d, []); perHop.get(d).push(row); (byZone.get(row.zone) ?? byZone.set(row.zone, []).get(row.zone)).push(row); (E.wants(gid, [...g.economy.classBySystem.get(S)].find((c) => E.wants(gid, c))) ? wantHit.hit : wantHit.miss).push(row); } } const avgBase = mean(bases); console.log(`\n=== seed "${seed}" — ${Object.keys(sigs).length} origins, ${goods.length} tradeable goods, ${dests.length} shop systems ===`); const liveBands = config.get('economy.pricing.distanceBands', []).map((b) => `${b.minHops}+h:×${b.factor}`).join(' '); console.log(`bands: ${liveBands} (origin buy = ×${(1 - Number(config.get('economy.pricing.originDiscount', 0.5))).toFixed(2)})`); console.log(`average product: base value ${fmt(avgBase)} cr (median ${fmt([...bases].sort((a, b) => a - b)[Math.floor(bases.length / 2)])}) — range ${fmt(Math.min(...bases))}–${fmt(Math.max(...bases))}`); const header = (label, extra = '') => console.log(`\n${label}${extra}\nhops | markets | avg sell | avg profit/unit | vs base | per day (6u) | per trip (16u)`); const line = (h, rows) => { const n = rows.length; const sell = mean(rows.map((r) => r.sell)); const prof = mean(rows.map((r) => r.profit)); const buy = mean(rows.map((r) => r.buy)); console.log( `${String(h).padStart(4)} | ${fmt(n).padStart(8)} | ${fmt(sell).padStart(8)} | ${fmt(prof).padStart(15)} | ${(prof / avgBase).toFixed(2).padStart(5)}× | ${fmt(prof * DAILY_BATCH).padStart(11)} | ${fmt(prof * FULL_HOLD).padStart(10)}`, ); void buy; }; header('PROFIT BY HOP (every origin × every destination at that distance)'); const hopLines = []; for (const h of [...perHop.keys()].sort((a, b) => a - b)) { line(h, perHop.get(h)); hopLines.push({ h, n: perHop.get(h).length, prof: mean(perHop.get(h).map((r) => r.profit)) }); } // Band rollup (the actual pricing bands from economy.json) const bands = config.get('economy.pricing.distanceBands', []); header('PROFIT BY BAND (as banded in data/economy.json)', ' — the step the trip pays in'); const bandEnd = [0, 1, 3, 6, 10, 99]; for (let i = 0; i < bands.length; i++) { const lo = bands[i].minHops; const hi = i + 1 < bands.length ? bands[i + 1].minHops - 1 : 99; const rows = [...perHop.entries()].filter(([h]) => h >= lo && h <= hi).flatMap(([, r]) => r); const f = bands[i].factor; const n = rows.length; const sell = mean(rows.map((r) => r.sell)); const prof = mean(rows.map((r) => r.profit)); console.log( `${String(lo).padStart(2)}–${String(hi).padStart(2)} (×${f}) | ${fmt(n).padStart(8)} | ${fmt(sell).padStart(8)} | ${fmt(prof).padStart(15)} | ${(prof / avgBase).toFixed(2).padStart(5)}× | ${fmt(prof * DAILY_BATCH).padStart(11)} | ${fmt(prof * FULL_HOLD).padStart(10)}`, ); void bandEnd; } header('PROFIT BY DESTINATION ZONE (near / middle / far — diagonal from home)'); for (const [z, rows] of [...byZone.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { const prof = mean(rows.map((r) => r.profit)); const avgH = mean(rows.map((r) => r.d)); console.log(`${z.padEnd(7)} | ${fmt(rows.length).padStart(8)} | (avg ${avgH.toFixed(1)} hops) avg profit/unit ${fmt(prof)} cr = ${(prof / avgBase).toFixed(2)}× base, ${fmt(prof * FULL_HOLD)} cr per full 16-bay trip`); } header('DEMAND BONUS EFFECT (+10% when the destination class wants the good)'); for (const [label, rows] of [['wants it', wantHit.hit], ['does not', wantHit.miss]]) { const prof = mean(rows.map((r) => r.profit)); console.log(`${label.padEnd(10)} | ${fmt(rows.length).padStart(8)} markets | avg profit/unit ${fmt(prof)} cr (${(prof / avgBase).toFixed(2)}× base)`); } // Like-for-like: within one band (×3.2, hops 4–6), want vs not-want const bandRows = (arr) => arr.filter((r) => r.d >= 4 && r.d <= 6); const hitBand = mean(bandRows(wantHit.hit).map((r) => r.profit)); const missBand = mean(bandRows(wantHit.miss).map((r) => r.profit)); console.log(` at fixed distance (×3.2 band): wants ${fmt(hitBand)} cr vs does-not ${fmt(missBand)} cr → the bonus itself is +${((hitBand / missBand - 1) * 100).toFixed(0)}%`); console.log(`(share of origin×destination pairs where SOME class at the destination wants the good: ${((wantHit.hit.length / (wantHit.hit.length + wantHit.miss.length)) * 100).toFixed(0)}%)`); // Concrete routes — the single best trade at each hop distance console.log('\nBEST TRADE AT EACH HOP (one example route, per unit)'); for (const h of [...perHop.keys()].sort((a, b) => a - b)) { const rows = perHop.get(h); const r = rows.slice().sort((a, b) => b.profit - a.profit)[0]; console.log(` ${h} hop${h === 1 ? '' : 's'} ${r.goodName} (${r.base} base, ${r.zone} zone) — ${r.originName} → ${r.destName}: buy ${r.buy}, sell ${r.sell}, profit ${r.profit}/unit (${(r.profit / r.base).toFixed(2)}× base)`); } const worst = [...perHop.values()].flat().filter((r) => r.d >= 2).sort((a, b) => a.profit - b.profit)[0]; if (worst) console.log(` weakest ≥2-hop trade: ${worst.goodName} — buy ${worst.buy}, sell ${worst.sell}, profit ${worst.profit}/unit`); return { g, sigs, byId, perHop, byZone, avgBase }; } for (const seed of SEEDS) analyze(seed);