diff --git a/data/economy.json b/data/economy.json index 95ad5b7..ee43b64 100644 --- a/data/economy.json +++ b/data/economy.json @@ -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 }, diff --git a/dev/economics-report.mjs b/dev/economics-report.mjs new file mode 100644 index 0000000..d5a3a4e --- /dev/null +++ b/dev/economics-report.mjs @@ -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); diff --git a/dev/economy-bands-audit.mjs b/dev/economy-bands-audit.mjs new file mode 100644 index 0000000..a9a8dc4 --- /dev/null +++ b/dev/economy-bands-audit.mjs @@ -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 { + 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 ✔'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index 2226797..9ab02a1 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -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.