/** * 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)); } /** * Is this good TRADEABLE — part of the shop economy at all? The catalog's * quest cargo (data/goods.json → tradeable: false — 'Special Delivery''s * special-package, the run's story parcel) is the exception: it rides in * the hold, but no shop touches it — shopFor keeps it off every floor and * buy-back list, and the SELL grid refuses to queue a bay of it * (js/ui/shop/TradeGrid.js → toggleBay). Unknown ids are tradeable-false * too (a good that isn't in the catalog can't be a good). */ export function isTradeable(goodId) { const g = goodById(goodId); return !!g && g.tradeable !== false; } // --------------------------------------------------------------------------- // 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; // THE SHOP FLOOR's catalog — tradeable goods only. The quest cargo // (tradeable: false, data/goods.json → special-package) is the one good // the economy does not price: it never stocks, never resells, never // buy-backs — not on the capital's "EVERYTHING" floor either (the // package is the run's story, not a commodity; it reaches the hold via // the quest's handoff, GameScene._handoffSpecialPackage). const goods = goodsList().filter((g) => isTradeable(g.id)); 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; }