orbit/js/economy/Economy.js

453 lines
19 KiB
JavaScript
Raw 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.

/**
* The trade economy — a pure, deterministic price + stock engine.
*
* MODEL (full version in docs/ECONOMY.md §5, tuning in data/economy.json):
*
* For a good at a place there are TWO FIXED prices in credits, decided once
* at generation and NEVER dependent on where the player got the good or what
* is in their hold:
*
* BUY price (you pay the shop) — cheap where the place's class PRODUCES
* the good (its home region), standard
* where a hub resells it.
* SELL price (the shop pays you) — a premium where the class WANTS the
* good, growing with the hops between
* this system and the good's home region.
*
* Distance is enforced by REGIONAL AVAILABILITY (goods only cheap in their
* home region), not a live per-trade multiplier. Credits are the go-between:
* buying spends them, selling earns them, no good-for-good barter.
*
* Everything here is a pure function of (seed, place, catalog) — the same
* galaxy seed always yields the same shops, so lazy === eager and saves never
* store a shop's stock (see data/economy.json → restock).
*
* The two layers:
* 1. buildEconomy(galaxy) — the generation pass (called once at roster build,
* after the jump network + faction pass). Produces `galaxy.economy`:
* classBySystem — Map systemId → Set of economic classes present
* producerHops — Map goodId → { systemId → hops-to-nearest-producer }
* Both are pure functions of the seed, so they're lazy-safe and save-free.
* 2. buyPrice / sellPrice / shopFor — the per-shop queries the UI and tests
* call with (goodId, class, systemId, galaxy).
*/
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { rollSystemComposition, settlementDensity } from '../galaxy/SystemGenerator.js';
// ---------------------------------------------------------------------------
// Catalog access (data/goods.json, via the config singleton)
// ---------------------------------------------------------------------------
let _goods = null; // cache (config is loaded once)
const goodsList = () => (_goods ??= config.get('goods.goods', []));
const goodById = (id) => goodsList().find((g) => g.id === id) ?? null;
const econ = () => config.section('economy', {});
/**
* The goods spritesheet (data/goods.json → sheet): the Phaser texture key
* GameScene.preload loads it under (128×128 icon frames — 12 across, see
* goods.json → sheet._comment for the cell math). One key shared by every
* consumer so the shop UI and anything else that draws a good's icon
* reference the same texture.
*/
export const GOODS_TEXTURE_KEY = 'goods_icons';
/**
* The sprite frame index for a good (the shop UI draws
* `scene.textures.get(GOODS_TEXTURE_KEY).get(goodFrame(id))` — Phaser frame
* number = sheet frame N). A good's `icon` field in data/goods.json is the
* authoritative index (stamped = the good's array position, i.e. the sheet
* was authored in goods.json order); it falls back to that position when
* `icon` is missing/null, and to 0 for an unknown id (a blank-ish frame
* beats a crash).
*
* @param {string} goodId
* @returns {number} the sheet frame index (0..113 today)
*/
export function goodFrame(goodId) {
const list = goodsList();
const i = list.findIndex((g) => g.id === goodId);
if (i < 0) return 0;
const n = Number(list[i].icon);
return Number.isInteger(n) && n >= 0 ? n : i;
}
/** True when the sheet's texture is actually available this scene. */
export function goodsTextureReady(scene) {
return !!(scene?.textures?.exists?.(GOODS_TEXTURE_KEY));
}
/**
* The economic class of a settlement — the single key the catalog prices off.
* planet → its own class (terran/arid/rocky/gas/ice/lava), or 'capital'
* for a capital world (class capital*).
* station → 'station' (a deepSpaceStation trades; a waypoint does not).
* @returns {'terran'|'arid'|'rocky'|'gas'|'ice'|'lava'|'station'|'capital'|null}
*/
export function economicClass(planet = null, settlement = null) {
if (planet && typeof planet.class === 'string') {
return planet.class.startsWith('capital') ? 'capital' : planet.class;
}
if (settlement && typeof settlement.kind === 'string') {
return settlement.kind === 'deepSpaceStation' ? 'station' : null;
}
return null;
}
/** True if a class PRODUCES the good (it's on sale cheap there). */
export function produces(goodId, cls) {
const g = goodById(goodId);
return !!(g && cls && Array.isArray(g.producer) && g.producer.includes(cls));
}
/** True if a class WANTS the good (it buys it back at a premium). */
export function wants(goodId, cls) {
const g = goodById(goodId);
return !!(g && cls && Array.isArray(g.demand) && g.demand.includes(cls));
}
// ---------------------------------------------------------------------------
// Deterministic per-shop wobble (±economy.pricing.jitter), so no two shops are
// identical and no value looks "formulated." Same (good, class, system) ⇒ same
// jitter for both the buy and the sell price of that good at that shop.
// ---------------------------------------------------------------------------
function jitter(goodId, cls, systemId, seed) {
const j = Number(econ().pricing?.jitter ?? 0);
if (!(j > 0) || !seed) return 1;
const r = Rng.derive(seed, 'economy', 'jitter', String(goodId), String(systemId), String(cls)).next();
return 1 + (r * 2 - 1) * j;
}
/** Hops from a system to the nearest system that produces the good (0 = local). */
function hopsToProducer(goodId, systemId, galaxy) {
const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId];
return Number.isFinite(m) ? m : 0;
}
// ---------------------------------------------------------------------------
// FIXED PRICES (the two credit numbers per good + shop)
// ---------------------------------------------------------------------------
/**
* The BUY price — what the player pays for one unit at this shop. Cheap where
* the class produces the good (producerDiscount), standard where a hub resells
* it. Fixed per (good, class, system); independent of the player's inventory.
*/
export function buyPrice(goodId, cls, systemId, galaxy) {
const g = goodById(goodId);
if (!g || !cls) return 0;
const p = econ().pricing ?? {};
const prod = produces(goodId, cls) ? (1 - (p.producerDiscount ?? 0)) : 1;
return Math.max(1, Math.round((Number(g.baseValue) || 0) * prod * jitter(goodId, cls, systemId, galaxy?.seed)));
}
/**
* The SELL price — what the shop pays the player for one unit. A premium where
* the class wants the good (demandBonusPerClass), growing with the hops
* between this system and the good's home region (distancePerHop, capped).
* Fixed per (good, class, system); independent of where the player bought it.
*/
export function sellPrice(goodId, cls, systemId, galaxy) {
const g = goodById(goodId);
if (!g || !cls) return 0;
const p = econ().pricing ?? {};
const demand = wants(goodId, cls) ? 1 + (p.demandBonusPerClass ?? 0) : 1;
const hops = Math.min(hopsToProducer(goodId, systemId, galaxy), p.distanceCapHops ?? 6);
const dist = 1 + (p.distancePerHop ?? 0) * hops;
return Math.max(1, Math.round((Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed)));
}
// ---------------------------------------------------------------------------
// THE SHOP FLOOR — goods on sale (buy list) + goods the shop buys back (sell list)
// ---------------------------------------------------------------------------
/** Seeded weighted pick of `n` ids from `items` (by each good's `weight`). */
function seededSelect(items, n, seed, cls, systemId) {
const rng = Rng.derive(seed, 'economy', 'shop', String(cls), String(systemId));
const pool = items.slice();
const out = [];
const w = (g) => Math.max(1, Number(g?.weight) || 3);
for (let i = 0; i < n && pool.length; i++) {
const total = pool.reduce((s, g) => s + w(g), 0);
let r = rng.next() * total;
let idx = 0;
for (let k = 0; k < pool.length; k++) { r -= w(pool[k]); if (r <= 0) { idx = k; break; } }
out.push(pool[idx].id);
pool.splice(idx, 1);
}
return out;
}
/**
* The full shop for a settlement class at a system:
* buy — goods on SALE (what you can buy), 8–16 entries, each { id, price, produced }
* · plain world: its produced goods (abundant, cheap)
* · station: produced (≤ half) + a seeded selection of resold goods (variety)
* · capital: produced (≤ half) + a seeded selection of premium goods (tier 3–4)
* · the class's SIGNATURE GEM is ALWAYS on the floor (the world's
* "jewel" — a guaranteed premium good + a guaranteed local buy)
* sell — goods the shop BUYS BACK (what you can sell), each { id, price }
* · plain world / station: the goods the class wants
* · capital: EVERYTHING (the universal sink)
* Pure in (cls, systemId, galaxy) — deterministic and re-derivable.
*/
export function shopFor(cls, systemId, galaxy) {
if (!cls) return { buy: [], sell: [] };
const shop = econ().shop ?? {};
const kind = cls === 'capital' ? 'capital' : cls === 'station' ? 'deepSpaceStation' : null;
const sk = shop.stockByKind?.[kind] ?? {};
const maxStock = sk.max ?? shop.maxStock ?? 16;
const minStock = sk.min ?? shop.minStock ?? 8;
const goods = goodsList();
const seed = galaxy?.seed;
const core = goods.filter((g) => produces(g.id, cls));
const isHub = cls === 'station' || cls === 'capital';
let buyIds;
if (isHub) {
// Hubs: at most half the floor is their own produce (the rest is their
// reseller role — variety for stations, premium for capitals).
const coreCap = Math.max(1, Math.ceil(maxStock / 2));
const coreIds = seededSelect(core, coreCap, seed, cls, systemId);
const other = goods.filter((g) => !produces(g.id, cls));
const premium = cls === 'capital'
? other.filter((g) => (Number(g.tier) || 1) >= 3)
: other.filter((g) => (Number(g.tier) || 1) <= 3);
const fill = seededSelect(premium.length ? premium : other, maxStock - coreIds.length, seed, cls + '-resold', systemId);
buyIds = [...coreIds, ...fill];
} else {
buyIds = core.slice(0, maxStock).map((g) => g.id);
}
// Guarantee the floor reaches minStock when the catalog allows it.
if (buyIds.length < minStock) {
const have = new Set(buyIds);
for (const g of goods) {
if (buyIds.length >= minStock) break;
if (!have.has(g.id)) { buyIds.push(g.id); have.add(g.id); }
}
}
// THE WORLD'S JEWEL (goods.json gemstones, §4/§6): the class's signature
// gem is ALWAYS on the shelf — a guaranteed premium good (tier 3–4) and a
// guaranteed local buy in every shop, whatever the seeded floor picked.
// It's a produced good of `cls`, so it prices at the producer discount;
// swap it into the least-important slot (a hub's resold filler, or an
// extra local good on a plain world) keeping the 8–16 floor size + no dups.
const gem = goods.find(
(g) => g.category === 'gemstone' && Array.isArray(g.producer) && g.producer.includes(cls),
);
if (gem && !buyIds.includes(gem.id)) {
if (buyIds.length < maxStock) buyIds.push(gem.id);
else buyIds[buyIds.length - 1] = gem.id;
}
const buy = buyIds.map((id) => ({
id,
name: goodById(id)?.name ?? id,
price: buyPrice(id, cls, systemId, galaxy),
produced: produces(id, cls),
}));
const sell = (cls === 'capital' ? goods : goods.filter((g) => wants(g.id, cls)))
.map((g) => ({ id: g.id, name: g.name, price: sellPrice(g.id, cls, systemId, galaxy) }));
return { buy, sell };
}
// ---------------------------------------------------------------------------
// THE TRADE PREVIEW — pure, framework-free (the shop UI's calculator)
// ---------------------------------------------------------------------------
/**
* A good id → N repeated ids (the buy/sell side is quantity-based, the hold
* is bay-based: `boughtUnits` of a good fill that many bays).
*/
function repeat(id, qty) {
const n = Math.max(0, Math.floor(Number(qty) || 0));
return Array.from({ length: n }, () => id);
}
/**
* Apply a settled trade to a bay list: remove the sold goods (frees bays),
* then add the bought goods (fills bays). Order matters — selling first is
* what lets "sell 2 to make room to buy 3" land in one confirm even from a
* full hold. Pure (returns a new array).
*/
export function resultingCargo(cargo, sell = [], buy = []) {
const out = (Array.isArray(cargo) ? cargo : []).filter((id) => typeof id === 'string' && id);
for (const s of sell) {
for (const _ of repeat(s.id, s.qty)) {
const i = out.indexOf(s.id);
if (i < 0) break; // nothing left of this good aboard
out.splice(i, 1);
}
}
for (const b of buy) out.push(...repeat(b.id, b.qty));
return out;
}
/**
* The SHOP UI's preview: given the goods the player wants to SELL (out of
* their hold) and BUY (from the shop's stock), compute the two sides, the net
* credit change, the resulting hold, and whether the trade is legal.
*
* sellMap = { goodId: qty } — goods to sell (qty clamped to what's aboard)
* buyMap = { goodId: qty } — goods to buy (into the hold)
*
* Pricing is the FIXED per-(good, shop) price from buyPrice/sellPrice above —
* independent of where the good came from. The two caps:
* · credits — creditsAfter must be ≥ 0 (you can't spend into the red);
* · cargo — the END hold (aboard − sold + bought) must fit the unlocked
* bays (capacity). Selling to free room for a buy is allowed
* (the net end-state is what's checked, not the peak).
* Pure in its inputs — the UI calls it on every selection change; a test can
* call it with no scene at all.
*
* @returns {{
* sell: Array<{id,name,qty,unit,total}>, buy: Array<{id,name,qty,unit,total}>,
* sellTotal:number, buyTotal:number, net:number, creditsAfter:number,
* sellUnits:number, buyUnits:number, cargoAfter:number, cargo: string[],
* valid:boolean, errors: string[]
* }}
*/
export function tradePreview(o) {
const cls = o.cls;
const systemId = o.systemId;
const galaxy = o.galaxy;
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
const sellMap = o.sellMap ?? {};
const buyMap = o.buyMap ?? {};
// Per-good BUY-price overrides (the BUYBACK tab passes the sale price back
// into the buy side — "buy it back for what you got for it", a neutral undo
// so sell→buyback can't net-profit into an infinite-credits loop).
const buyOverrides = o.buyOverrides ?? {};
const credits = Math.max(0, Number(o.credits) || 0);
const capacity = Math.max(0, Math.floor(Number(o.capacity) || 0));
const sell = [];
let sellTotal = 0;
let sellUnits = 0;
for (const [id, rawQty] of Object.entries(sellMap)) {
if (typeof id !== 'string' || !id) continue;
const have = cargo.filter((c) => c === id).length;
const qty = Math.min(Math.max(0, Math.floor(Number(rawQty) || 0)), have);
if (qty <= 0) continue;
const unit = sellPrice(id, cls, systemId, galaxy);
const total = unit * qty;
sell.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
sellTotal += total;
sellUnits += qty;
}
const buy = [];
let buyTotal = 0;
let buyUnits = 0;
for (const [id, rawQty] of Object.entries(buyMap)) {
if (typeof id !== 'string' || !id) continue;
const qty = Math.max(0, Math.floor(Number(rawQty) || 0));
if (qty <= 0) continue;
const unit = (id in buyOverrides) ? Math.max(0, Number(buyOverrides[id]) || 0) : buyPrice(id, cls, systemId, galaxy);
const total = unit * qty;
buy.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
buyTotal += total;
buyUnits += qty;
}
const net = sellTotal - buyTotal;
const creditsAfter = credits + net;
const cargoList = resultingCargo(cargo, sell, buy);
const cargoAfter = cargoList.length;
const errors = [];
if (creditsAfter < 0) errors.push('insufficient-credits');
if (cargoAfter > capacity) errors.push('cargo-full');
const valid = errors.length === 0;
return {
sell, buy, sellTotal, buyTotal, net, creditsAfter,
sellUnits, buyUnits, cargoAfter, cargo: cargoList,
valid, errors,
};
}
// ---------------------------------------------------------------------------
// THE GENERATION PASS — galaxy.economy (classBySystem + producerHops)
// ---------------------------------------------------------------------------
/** Undirected adjacency from the jump network (tree edges run both ways). */
function adjacency(galaxy) {
const adj = new Map();
const add = (a, b) => {
if (!adj.has(a)) adj.set(a, new Set());
if (!adj.has(b)) adj.set(b, new Set());
if (a !== b) { adj.get(a).add(b); adj.get(b).add(a); }
};
const gates = galaxy.jumpNetwork?.gates;
if (gates) for (const [id, targets] of gates) for (const t of targets) add(id, t);
for (const id of galaxy.byId.keys()) if (!adj.has(id)) adj.set(id, new Set());
return adj;
}
/** Multi-source BFS: hops from every system to the nearest of `sources`. */
function multiSourceBFS(sources, adj) {
const dist = {};
const queue = [];
for (const s of new Set(sources)) { if (adj.has(s) || !adj.size) { dist[s] = 0; queue.push(s); } }
let head = 0;
while (head < queue.length) {
const cur = queue[head++];
for (const nb of adj.get(cur) ?? []) {
if (dist[nb] === undefined) { dist[nb] = dist[cur] + 1; queue.push(nb); }
}
}
return dist;
}
/**
* The ECONOMY PASS — the galaxy-level trade map, computed once at roster build
* (after the jump network + faction pass, before any content generation). A
* pure function of the seed:
* classBySystem — Map systemId → Set of economic classes (the planet classes
* from the SAME roll the content generator uses, + the home
* world's class at home, + 'station' if it has a deep-space
* station, + 'capital' if it's a designated capital).
* producerHops — Map goodId → { systemId → hops to the nearest producer }.
* The distance term for sell prices: the further a system
* is from a good's home region, the higher the good sells.
* Stashed on `galaxy.economy` and read by buyPrice/sellPrice/shopFor.
*/
export function buildEconomy(galaxy) {
const typeDefs = galaxy.typeDefs ?? config.get('systems.types', {});
const homeCls = config.get('planets.homePlanet', 'terran');
const classBySystem = new Map();
for (const [id, record] of galaxy.byId) {
const isHome = id === galaxy.homeSystemId;
const type = typeDefs[record.type] ?? {};
const spec = type.attributes?.settlements ?? {};
const attr = type.attributes ?? {};
const density = settlementDensity(galaxy, record);
const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr);
const set = new Set(comp.classes);
if (isHome) set.add(homeCls); // the home world (a terran body by rule)
if (comp.deepSpace) set.add('station');
if (record.capital) set.add('capital');
classBySystem.set(id, set);
}
const adj = adjacency(galaxy);
const producerHops = {};
for (const g of goodsList()) {
const producers = [];
for (const [id, set] of classBySystem) {
if (Array.isArray(g.producer) && g.producer.some((c) => set.has(c))) producers.push(id);
}
producerHops[g.id] = multiSourceBFS(producers, adj);
}
galaxy.economy = { classBySystem, producerHops };
return galaxy.economy;
}