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": {
|
||||
"_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,
|
||||
"hubPriceFactor": 1.00,
|
||||
"demandBonus": 0.10,
|
||||
"distanceBands": [
|
||||
{ "minHops": 0, "factor": 1.0 },
|
||||
{ "minHops": 2, "factor": 1.8 },
|
||||
{ "minHops": 4, "factor": 3.2 },
|
||||
{ "minHops": 7, "factor": 5.0 },
|
||||
{ "minHops": 11, "factor": 7.0 }
|
||||
{ "minHops": 0, "factor": 0.75 },
|
||||
{ "minHops": 2, "factor": 1.15 },
|
||||
{ "minHops": 4, "factor": 1.85 },
|
||||
{ "minHops": 7, "factor": 2.75 },
|
||||
{ "minHops": 11, "factor": 3.75 }
|
||||
],
|
||||
"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+ |
|
||||
|---|---|---|---|---|---|
|
||||
| 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
|
||||
with the largest `minHops ≤ hops` wins.
|
||||
- **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
|
||||
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,
|
||||
pays `66 × 1.8 × 1.1 ≈ 131` → **≈ 100/unit profit on a 2-hop run.** Seven hops
|
||||
out: `66 × 5.0 × 1.1 ≈ 363` → **≈ 330/unit** — same hold-space, three times the
|
||||
pays `66 × 1.15 × 1.1 ≈ 84` → **≈ 53/unit profit on a 2-hop run.** Seven hops
|
||||
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
|
||||
station) nets ~0 per unit (the anti-loop cap) and is limited to 6 units a day —
|
||||
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).
|
||||
Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
|
||||
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
|
||||
ceiling). Planet type contributes at most +10% (demand bonus).
|
||||
(×0.75 / ×1.15 / ×1.85 / ×2.75 / ×3.75 — the halved-margin curve,
|
||||
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
|
||||
wall-clock day (1h); sell side unlimited. Same-system trading is bounded
|
||||
pocket change, never infinite money.
|
||||
|
|
|
|||
Loading…
Reference in New Issue