orbit/js/economy/Economy.js

756 lines
34 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 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<number|null>} 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;
}