orbit/dev/economics-report.mjs

181 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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