756 lines
34 KiB
JavaScript
756 lines
34 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|