181 lines
9.5 KiB
JavaScript
181 lines
9.5 KiB
JavaScript
/**
|
||
* 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);
|