453 lines
19 KiB
JavaScript
453 lines
19 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|