/** * The trade economy — a pure, deterministic price engine (model v2). * * MODEL — ONE WORLD, ONE GOOD (full version in docs/ECONOMY.md §5, tuning in * data/economy.json): * * Scarcity first: every tradeable good has exactly ONE ORIGIN system — the * single world whose shop sells it, cheap, in a DAILY BATCH (js/economy/ * Stock.js; the only stateful piece of the economy). The seeded strict * assignment ('origin-assign' pass below) gives every system exactly one * SIGNATURE good (a world's floor is that one product) and leaves the * catalog's remainder as the HUB POOL (resold by stations/capitals at * standard price; its distance is measured to the NEAREST HUB). * * 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) — the ORIGIN world's batch price * (baseValue × (1 − originDiscount)), or * the hub's standard resale price * (baseValue × hubPriceFactor) for * hub-pool goods at stations/capitals. * Everywhere else the good is simply * NOT on sale — you carry it. * SELL price (the shop pays you) — baseValue × distanceBand(hops to * origin) × (1 + demandBonus if the * class wants it), CAPPED at the shop's * own sale price for the good (no shop * may ever be a positive same-shop * spread — the anti-loop rule). * * The BANDED DISTANCE term is the dominant factor in profit (it has no * cap — the jump tree's ~17-hop diameter is the ceiling); planet type * (demand) is the secondary modifier. 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 floor or prices (see data/economy.json → restock). The one * saved number per origin system is its day's remaining batch. * * 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 * signatures — Map systemId → goodId (the system's ONE signature * good — the strict 1:1 origin assignment) * origins — Map goodId → systemId (the good's ONE origin system; * the catalog's remainder — the HUB POOL — has none) * hubPool — array of good ids resold by stations/capitals * hubSystems — array of system ids that host a hub (station/capital) * producerHops — Map goodId → { systemId → hops to the good's origin * (signatures) or nearest hub (hub pool) } * All pure functions of the seed, so 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 — the only free-space kind). * @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; } /** * Does the catalog's class list mark `cls` as a natural producer of the good? * CATALOG-LEVEL (data/goods.json → producer) — model v2 prices off the good's * assigned ORIGIN SYSTEM (originOf), not its producer class; this is used by * the origin assignment's compatibility (thematic fit) and by reports. */ 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)); } /** * The good's ONE ORIGIN system (the strict 1:1 assignment — the only shop in * the galaxy that sells it cheap). null = the good is in the HUB POOL * (resold by stations/capitals, distance to the nearest hub). */ export function originOf(goodId, galaxy) { const o = galaxy?.economy?.origins?.[goodId]; return typeof o === 'string' ? o : null; } /** The system's ONE signature good (a world's entire floor). */ export function signatureOf(systemId, galaxy) { const s = galaxy?.economy?.signatures?.[systemId]; return typeof s === 'string' ? s : null; } /** Is the good part of the HUB POOL (resold by stations/capitals)? */ export function isHubPool(goodId, galaxy) { const hp = galaxy?.economy?.hubPool; if (!Array.isArray(hp)) return true; // no economy pass ran → hubs carry it return !originOf(goodId, galaxy) && hp.includes(goodId); } /** Is `cls` a HUB class (station/capital — a reseller, not a producer)? */ export function isHubClass(cls) { return cls === 'station' || cls === 'capital'; } /** * 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, and no shop's FLOOR touches it — shopFor keeps it off every * buy and buy-back list. The ONE sale it has is the delivery: the HOME * WORLD's shop takes it for zero credits (the grid's home-only saleable * gate — js/ui/shop/TradeGrid.js → _sellable, the flat price — * questCargoSellPrice below, the good's `sellFor`). 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; } /** * Is this good the run's QUEST CARGO — in the catalog but excluded from * the shop economy (data/goods.json → tradeable: false — 'Special * Delivery''s special-package)? It's the one good the economy doesn't * price (no floor stocks it, no shop buys it back): its ONLY sale is the * delivery — the home world takes it for its flat `sellFor` (zero credits * — the parcel in Carol's hands IS the quest's step 3, and the shop's * Confirm hands the sale to the ledger: ShopView.confirm → * GameScene.onQuestPackageSold). Unknown ids are quest-cargo-false too * (a good that isn't in the catalog is no one's cargo). */ export function isQuestCargo(goodId) { const g = goodById(goodId); return !!g && g.tradeable === false; } /** * The QUEST CARGO's flat sale price (credits) — the delivery, not the * per-shop price: sellPrice's demand/hops math is for commodities, and the * parcel's sale is the run's story (the 'Special Delivery''s step 3 — the * home world takes it for nothing). It's the good's `sellFor` * (data/goods.json, zero for the package); 0 for anything that isn't * quest cargo (those price via sellPrice, not here). */ export function questCargoSellPrice(goodId) { const g = goodById(goodId); if (!g || g.tradeable !== false) return 0; return Math.max(0, Math.floor(Number(g.sellFor) || 0)); } // --------------------------------------------------------------------------- // 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 good's origin (signatures) or nearest hub (hub pool); 0 = local. */ function hopsToProducer(goodId, systemId, galaxy) { const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId]; return Number.isFinite(m) ? m : 0; } /** * The BANDED DISTANCE factor (data/economy.json → pricing.distanceBands — * ascending minHops → factor, the largest minHops ≤ hops wins; NO cap — the * jump tree's diameter is the ceiling). This is the dominant term in the * sell price: distance is the biggest factor in profit. */ export function distanceFactor(hops, bands) { const list = Array.isArray(bands) ? bands : (econ().pricing?.distanceBands ?? []); const h = Math.max(0, Math.floor(Number(hops) || 0)); let f = 1; for (const b of list) { const min = Number(b?.minHops); if (Number.isFinite(min) && h >= min) f = Number(b?.factor) || 1; } return f; } // --------------------------------------------------------------------------- // FIXED PRICES (the two credit numbers per good + shop) // --------------------------------------------------------------------------- /** * The BUY price — what the player pays for one unit at this shop (0 = not on * sale here). Model v2: the good's ORIGIN world sells it at the batch price * (originDiscount — the only cheap source in the galaxy; the batch itself is * time-gated — js/economy/Stock.js); a HUB (station/capital) resells HUB- * POOL goods at standard (hubPriceFactor — never below origin). Everything * else, everywhere, is simply not on sale — you carry 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 base = Number(g.baseValue) || 0; const j = jitter(goodId, cls, systemId, galaxy?.seed); // 1) THE ORIGIN — the good's home system sells it cheap (any shop there: // the world, or the capital when it sits in the origin system). if (systemId != null && originOf(goodId, galaxy) === systemId) { return Math.max(1, Math.round(base * (1 - (p.originDiscount ?? 0.5)) * j)); } // 2) HUBS resell the HUB POOL at standard — a convenience source, never a // cheap one (origin is always strictly cheaper). if (isHubClass(cls) && isHubPool(goodId, galaxy)) { return Math.max(1, Math.round(base * (p.hubPriceFactor ?? 1) * j)); } return 0; } /** * The SELL price — what the shop pays the player for one unit. Model v2: * baseValue × distanceBand(hops to origin) × (1 + demandBonus if the class * wants it) — CAPPED at the shop's own sale price for the good (the * anti-loop rule: no shop may ever be a positive same-shop spread, so a * reseller's buyback of its own stock is always neutral). * 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.demandBonus ?? 0.1) : 1; const hops = hopsToProducer(goodId, systemId, galaxy); const dist = distanceFactor(hops); let price = (Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed); const own = buyPrice(goodId, cls, systemId, galaxy); if (own > 0) price = Math.min(price, own); // anti-loop cap return Math.max(1, Math.round(price)); } // --------------------------------------------------------------------------- // 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 (model v2 — ONE WORLD, * ONE GOOD): * buy — what the shop SELLS: * · world: EXACTLY ONE product — the system's signature good * (its origin's batch price; the batch's remaining units * are time-gated — js/economy/Stock.js, the shop UI shows * the count / sold-out state) * · station / capital: a seeded selection from the HUB POOL at * standard price (variety for stations, the wider floor * for capitals) + the system's own signature good at the * origin price (when this system IS its origin) * sell — what the shop BUYS BACK: * · world / station: the goods the class WANTS (planet type — the * secondary price modifier), at the banded distance price * · capital: EVERYTHING (the universal sink — the long-haul exit) * · always CAPPED at the shop's own sale price for a good (no shop * is ever a positive same-shop spread) * Pure in (cls, systemId, galaxy) — deterministic and re-derivable. */ export function shopFor(cls, systemId, galaxy) { if (!cls) return { buy: [], sell: [] }; const shop = econ().shop ?? {}; // 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; let buyIds; if (isHubClass(cls)) { // HUBS: a seeded selection from the HUB POOL (the catalog's remainder — // resellers, standard price), sized by kind (capital widest). const kind = cls === 'capital' ? 'capital' : 'deepSpaceStation'; const sk = shop.stockByKind?.[kind] ?? {}; const maxStock = sk.max ?? shop.maxStock ?? 16; const minStock = sk.min ?? shop.minStock ?? 8; const pool = (galaxy?.economy?.hubPool ?? []).map((id) => goodById(id)).filter(Boolean); const fill = seededSelect(pool, Math.min(maxStock, pool.length), seed, cls, systemId); buyIds = [...fill]; // 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); } } } } else { // WORLDS: the floor is the system's ONE signature good — pure identity // ("this is the world that makes this"). A world with no signature // (no economy pass ran) gets an empty floor. const sig = signatureOf(systemId, galaxy); buyIds = sig ? [sig] : []; } // The system's OWN signature good rides EVERY shop's floor at the origin // price (a world sells it as its one product; a capital/station in the // origin system resells its home product too — batch-limited like the // world's, since it's the same day's batch at the same source). const sig = signatureOf(systemId, galaxy); if (sig && !buyIds.includes(sig)) buyIds.push(sig); const buy = buyIds.map((id) => ({ id, name: goodById(id)?.name ?? id, price: buyPrice(id, cls, systemId, galaxy), produced: originOf(id, galaxy) === systemId, })); 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 three 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); * · stock — stockCaps = { goodId: unitsLeft } — the shop's REMAINING * BATCH for batch-limited goods (the origin's daily stock — * js/economy/Stock.js; the UI caps the stepper at the same * number, this is the backstop). A buyback (buyOverrides) never * draws on the batch — it returns what the shop JUST bought * from the player. * 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 ?? {}; // Per-good STOCK caps (the origin's remaining daily batch — unlimited when // the good isn't batch-limited here; a buyback never consumes the batch). const stockCaps = (o.stockCaps && typeof o.stockCaps === 'object') ? o.stockCaps : {}; 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; // Quest cargo (tradeable:false — the 'Special Delivery' package) is the // catalog's one non-commodity: where it is saleable (the home world // only — the SELL grid's gate is the world check) it takes its flat // delivery price (the good's `sellFor` — zero credits), not the fixed // per-shop price (which would be a reward the quest doesn't pay). const unit = isQuestCargo(id) ? questCargoSellPrice(id) : 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'); // THE BATCH CAP — a trade buy can't exceed the shop's remaining daily stock. // A BUYBACK (an overridden buy — the shop returning what it JUST bought // from the player) is structurally exempt: it never draws on the batch. for (const b of buy) { if (b.id in buyOverrides) continue; if (b.id in stockCaps && b.qty > (stockCaps[b.id] ?? 0)) errors.push('insufficient-stock'); } const valid = errors.length === 0; return { sell, buy, sellTotal, buyTotal, net, creditsAfter, sellUnits, buyUnits, cargoAfter, cargo: cargoList, valid, errors, }; } /** * The SELL side's PROFIT — the XP a confirmed trade earns (1 XP per 1 credit * earned ABOVE each good's original purchase price). `sell` is tradePreview's * sell array (each { id, unit, qty }); `costsFor(goodId, n)` returns the * per-unit PURCHASE costs of the n bays of that good the sale removes (FIFO — * the same bays CargoHold.remove() takes; a null entry = no cost on file). * * Each bay contributes max(0, unit − cost): selling at or below cost yields * nothing (no negative XP, no loss offset), and a bay with no cost on file * (the quest package's handoff; a save predating the cost record) contributes * nothing — no basis, no profit claimed. The total is floored to whole * credits (XP is an integer) and clamped to ≥ 0. Pure — the caller * (ShopView) grants it through the scene's XP pipe (source 'trade'). * * @param {Array<{id:string, unit:number, qty:number}>} sell — tradePreview's sell lines * @param {(goodId: string, n: number) => Array} costsFor — the sale's per-bay cost basis * @returns {number} the sale's profit in whole credits (≥ 0) — the XP to grant */ export function tradeProfitXp(sell, costsFor) { if (!Array.isArray(sell)) return 0; const basis = (id, n) => { const arr = (typeof costsFor === 'function') ? costsFor(id, n) : null; return Array.isArray(arr) ? arr : []; }; let profit = 0; for (const s of sell) { if (!s || typeof s !== 'object') continue; const unit = Math.max(0, Number(s.unit) || 0); const qty = Math.max(0, Math.floor(Number(s.qty) || 0)); if (qty <= 0) continue; const costs = basis(s.id, qty); for (let i = 0; i < qty; i++) { const c = costs[i]; if (c == null || !Number.isFinite(c)) continue; // no cost on file → no basis const bay = unit - c; if (bay > 0) profit += bay; // at/below cost earns nothing } } return Math.max(0, Math.floor(profit)); } // --------------------------------------------------------------------------- // 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 ORIGIN ASSIGNMENT (model v2 — the 'origin-assign' pass, the model's * core): the strict 1:1 matching behind ONE WORLD, ONE GOOD. * * · every tradeable good gets exactly ONE origin system (or none — it * stays in the HUB POOL, resold by stations/capitals); * · every system gets exactly ONE signature good (a world's floor); * · the catalog's remainder (goods ≥ systems — 114 vs 90) is the HUB POOL. * * Seeded + greedy (most-constrained system first — fewest thematically * compatible goods first, so the rare combinations land, never a filler), * with a zone→tier fit (far systems, d→1, take the high tiers) and a seeded * tie-break. Compatibility = the good's `producer` class list vs the * system's classes — planet type is the SECONDARY fit signal (a lava world * rarely grows kelp; a terran homeworld rarely exports obsidian ingots). * Pure in (seed, version, catalog, galaxy) — the same seed, always the same * map. */ export function assignOrigins(galaxy, classBySystem) { const seed = galaxy.seed; const version = econ().version ?? 2; const goods = goodsList().filter((g) => isTradeable(g.id)); // BARREN systems (jump-gate-only — no planets, no stations, no shop) can't // host a signature (there's no world to sell it from) — they stay // signature-less by design; the systems that DO settle all participate. const systems = [...galaxy.byId.keys()].filter((s) => (classBySystem.get(s)?.size ?? 0) > 0); const compat = new Map(); // systemId → [goodIds it can plausibly make] for (const s of systems) { const classes = classBySystem.get(s) ?? new Set(); compat.set(s, goods.filter((g) => (g.producer ?? []).some((p) => classes.has(p))).map((g) => g.id)); } const rng = Rng.derive(seed, 'economy', `origin-assign-v${version}`); // Most-constrained first (fewest compatible goods); the localeCompare // tie-break keeps the order fully deterministic. const order = systems.slice().sort((a, b) => (compat.get(a).length - compat.get(b).length) || a.localeCompare(b)); const tierOf = (id) => Number(goodById(id)?.tier) || 1; const dOf = (s) => { const rec = galaxy.byId.get(s); return Number.isFinite(rec?.d) ? rec.d : 0.5; }; const remaining = new Set(goods.map((g) => g.id)); const signatures = {}; // systemId → goodId const origins = {}; // goodId → systemId let fallbackCount = 0; /** Score a candidate: zone→tier fit (near wants tier 1, far wants tier 4) * + seeded jitter — pick the best. Deterministic (strict > keeps the * first max on ties). */ const pick = (cands, s) => { let best = null; for (const id of cands) { if (!remaining.has(id)) continue; const fit = 1 - Math.abs(tierOf(id) - (1 + dOf(s) * 3)) / 3; const sc = fit + rng.next() * 0.5; if (!best || sc > best.sc) best = { id, sc }; } return best ? best.id : null; }; for (const s of order) { let id = pick(compat.get(s) ?? [], s); if (!id) { // THEMATIC FIT IMPOSSIBLE (or exhausted) — assign ANY remaining good: // strictness always wins (every good needs an owner), the hub pool // stays intact, and the thematics report counts it. id = pick([...remaining], s); fallbackCount += 1; } if (!id) break; // more systems than goods — cannot happen (114 > 90) remaining.delete(id); signatures[s] = id; origins[id] = s; } return { signatures, origins, hubPool: [...remaining], fallbackCount }; } /** * 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). * signatures — Map systemId → goodId (the system's ONE signature good). * origins — Map goodId → systemId (the good's ONE origin system). * hubPool — the catalog's remainder (resold by stations/capitals). * hubSystems — the systems that host a hub (station/capital). * producerHops — Map goodId → { systemId → hops to the good's origin * (signatures) or the NEAREST HUB (hub pool) }. * The distance the banded sell price is measured over: * the further a system is from a good's source, the higher * the good sells (the dominant profit factor). * 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(); const hubSystems = []; 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); // The SAME planet classes the content generator draws (the galaxy-deck // deal stamp, data/systems.json → planetClassBalance — null when the // balance is off, in which case the per-system weighted draws apply). const stamped = galaxy.planetClasses instanceof Map ? galaxy.planetClasses.get(id) : null; const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr, stamped); const set = new Set(comp.classes); if (isHome) set.add(homeCls); // the home world (a terran body by rule) if ((comp.stations ?? 0) > 0) set.add('station'); if (record.capital) set.add('capital'); classBySystem.set(id, set); if (set.has('station') || set.has('capital')) hubSystems.push(id); } // THE ORIGIN ASSIGNMENT — the strict 1:1 match (the model's core). const { signatures, origins, hubPool } = assignOrigins(galaxy, classBySystem); const adj = adjacency(galaxy); const producerHops = {}; for (const g of goodsList()) { if (!isTradeable(g.id)) { producerHops[g.id] = {}; continue; } // quest cargo: no price, no hops // Signatures: hops to THEIR origin system (the single source); hub-pool // goods: hops to the NEAREST HUB (their reseller — that is the distance // a player's trip is measured over when they carry one off a station). const sources = origins[g.id] ? [origins[g.id]] : hubSystems; producerHops[g.id] = multiSourceBFS(sources, adj); } galaxy.economy = { classBySystem, signatures, origins, hubPool, hubSystems, producerHops }; return galaxy.economy; }