Halve trade margins: distance bands -> x0.75/x1.15/x1.85/x2.75/x3.75
f' = 0.25 + f/2 on every band (x1.0/1.8/3.2/5.0/7.0): with the origin buy fixed at x0.5, per-unit profit lands at exactly ~half the original in every band (30->17, 67->35, 130->66, 218->110, 382->192 cr for the average product) while distance stays the dominant term and the curve's shape is preserved. Data-only change (economy.json); docs/ECONOMY.md table + example updated. All invariants re-verified (anti-loop, monotone distance, far pays more than buy, 1-hop viable) and the economy/cargo/shop-UI/scout suites pass. dev/economics-report.mjs gains an ECON_BANDS=... A/B override and dev/economy-bands-audit.mjs sweeps the band invariants on any band set.
This commit is contained in:
parent
15eaf26c03
commit
4b8ddae8d9
|
|
@ -10,16 +10,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"_comment": "Multipliers are dimensionless and multiply baseValue. ORIGIN sale (the good's home world, batch-limited) = baseValue × (1 − originDiscount) × jitter — the only cheap source in the galaxy. HUB resale (station/capital, hub-pool goods only) = baseValue × hubPriceFactor × jitter — standard, never below origin. SELL price (the shop pays you) = baseValue × distanceBand(min(hops to origin, band)) × (1 + demandBonus if the class wants it) × jitter, CAPPED at the shop's own sale price for that good (anti-loop). `distanceBands` are ascending minHops → factor; the band with the largest minHops ≤ hops wins (no cap — the jump tree's diameter ~17 hops is the natural ceiling). `jitter` is a per-good, per-place seeded wobble (±%) so no two shops feel formulated.",
|
"_comment": "Multipliers are dimensionless and multiply baseValue. ORIGIN sale (the good's home world, batch-limited) = baseValue × (1 − originDiscount) × jitter — the only cheap source in the galaxy. HUB resale (station/capital, hub-pool goods only) = baseValue × hubPriceFactor × jitter — standard, never below origin. SELL price (the shop pays you) = baseValue × distanceBand(min(hops to origin, band)) × (1 + demandBonus if the class wants it) × jitter, CAPPED at the shop's own sale price for that good (anti-loop). `distanceBands` are ascending minHops → factor; the band with the largest minHops ≤ hops wins (no cap — the jump tree's diameter ~17 hops is the natural ceiling). The CURRENT factors are the HALVED-MARGIN curve (f′ = 0.25 + f/2 applied to the original ×1.0 / ×1.8 / ×3.2 / ×5.0 / ×7.0): with the origin buy at ×0.5, profit per unit ≈ (factor − 0.5) × base — ≈ 0.25× / 0.65× / 1.35× / 2.25× / 3.25× of base before the demand bonus — so every band nets exactly half the original margin while distance stays the dominant term. `jitter` is a per-good, per-place seeded wobble (±%) so no two shops feel formulated.",
|
||||||
"originDiscount": 0.50,
|
"originDiscount": 0.50,
|
||||||
"hubPriceFactor": 1.00,
|
"hubPriceFactor": 1.00,
|
||||||
"demandBonus": 0.10,
|
"demandBonus": 0.10,
|
||||||
"distanceBands": [
|
"distanceBands": [
|
||||||
{ "minHops": 0, "factor": 1.0 },
|
{ "minHops": 0, "factor": 0.75 },
|
||||||
{ "minHops": 2, "factor": 1.8 },
|
{ "minHops": 2, "factor": 1.15 },
|
||||||
{ "minHops": 4, "factor": 3.2 },
|
{ "minHops": 4, "factor": 1.85 },
|
||||||
{ "minHops": 7, "factor": 5.0 },
|
{ "minHops": 7, "factor": 2.75 },
|
||||||
{ "minHops": 11, "factor": 7.0 }
|
{ "minHops": 11, "factor": 3.75 }
|
||||||
],
|
],
|
||||||
"jitter": 0.10
|
"jitter": 0.10
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* 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 ✔');
|
||||||
|
|
@ -171,8 +171,14 @@ sell(g, C, S) = baseValue(g)
|
||||||
|
|
||||||
| hops from origin | 0 | 2 | 4 | 7 | 11+ |
|
| hops from origin | 0 | 2 | 4 | 7 | 11+ |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| factor | ×1.0 | ×1.8 | ×3.2 | ×5.0 | ×7.0 |
|
| factor | ×0.75 | ×1.15 | ×1.85 | ×2.75 | ×3.75 |
|
||||||
|
| profit/unit (≈) | 0 → 0.25× | 0.65× | 1.35× | 2.25× | 3.25× |
|
||||||
|
|
||||||
|
Profit is in base-value multiples (≈ (factor − 0.5) × base, before the
|
||||||
|
+10% demand bonus and jitter); 0 hops nets ~0 — the anti-loop cap.
|
||||||
|
HALVED-MARGIN curve (f′ = 0.25 + f/2 on the original ×1.0 / ×1.8 / ×3.2 /
|
||||||
|
×5.0 / ×7.0): every band's per-unit profit is half the original, distance
|
||||||
|
stays the dominant term.
|
||||||
The jump tree's diameter (~17 hops) is the natural ceiling. No cap: the band
|
The jump tree's diameter (~17 hops) is the natural ceiling. No cap: the band
|
||||||
with the largest `minHops ≤ hops` wins.
|
with the largest `minHops ≤ hops` wins.
|
||||||
- **demandBonus** = +10% when the class wants the good (`g.demand` contains `C`)
|
- **demandBonus** = +10% when the class wants the good (`g.demand` contains `C`)
|
||||||
|
|
@ -211,8 +217,8 @@ location; none changes based on your inventory.
|
||||||
**Concrete example (real numbers, test seed):** `Cryo Serum` (base 66) is
|
**Concrete example (real numbers, test seed):** `Cryo Serum` (base 66) is
|
||||||
Keth's signature (an ice world — thematic fit). At Keth it sells for
|
Keth's signature (an ice world — thematic fit). At Keth it sells for
|
||||||
`66 × 0.5 ≈ 31` (6 units a day). A rocky world 2 hops out, which wants cryo,
|
`66 × 0.5 ≈ 31` (6 units a day). A rocky world 2 hops out, which wants cryo,
|
||||||
pays `66 × 1.8 × 1.1 ≈ 131` → **≈ 100/unit profit on a 2-hop run.** Seven hops
|
pays `66 × 1.15 × 1.1 ≈ 84` → **≈ 53/unit profit on a 2-hop run.** Seven hops
|
||||||
out: `66 × 5.0 × 1.1 ≈ 363` → **≈ 330/unit** — same hold-space, three times the
|
out: `66 × 2.75 × 1.1 ≈ 200` → **≈ 169/unit** — same hold-space, three times the
|
||||||
money, because it was carried further. The same-system flip (Keth world → Keth
|
money, because it was carried further. The same-system flip (Keth world → Keth
|
||||||
station) nets ~0 per unit (the anti-loop cap) and is limited to 6 units a day —
|
station) nets ~0 per unit (the anti-loop cap) and is limited to 6 units a day —
|
||||||
pocket change by design.
|
pocket change by design.
|
||||||
|
|
@ -325,8 +331,9 @@ memory — and it only ever *refills* in the player's favor.
|
||||||
system; every settled system sells exactly one product (its signature).
|
system; every settled system sells exactly one product (its signature).
|
||||||
Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
|
Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
|
||||||
2. **Distance is the dominant profit factor** — banded hops from the origin
|
2. **Distance is the dominant profit factor** — banded hops from the origin
|
||||||
(×1.0 / ×1.8 / ×3.2 / ×5.0 / ×7.0), no cap (the tree's diameter is the
|
(×0.75 / ×1.15 / ×1.85 / ×2.75 / ×3.75 — the halved-margin curve,
|
||||||
ceiling). Planet type contributes at most +10% (demand bonus).
|
f′ = 0.25 + f/2), no cap (the tree's diameter is the ceiling).
|
||||||
|
Planet type contributes at most +10% (demand bonus).
|
||||||
3. **The daily batch bounds the local loop** — 6 units per origin per
|
3. **The daily batch bounds the local loop** — 6 units per origin per
|
||||||
wall-clock day (1h); sell side unlimited. Same-system trading is bounded
|
wall-clock day (1h); sell side unlimited. Same-system trading is bounded
|
||||||
pocket change, never infinite money.
|
pocket change, never infinite money.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue