orbit/js/economy/TradeScout.js

140 lines
5.7 KiB
JavaScript

/**
* TradeScout — the pure strategy layer behind the shop's goods inspector.
*
* When the player taps a BUY-list row (outside the stepper), the shop swaps
* the cargo grid for a SCOUT PANEL (js/ui/shop/ScoutPanel.js) answering the
* trader's questions:
*
* · WHAT is this good (name, category, tier, flavor)?
* · WHERE is it GROWN (the producer classes — cheap to buy there)?
* · WHO WANTS it (the demand classes — the premium sell exits)?
* · WHERE does it sell BEST — the top-N CHARTED (visited) systems ranked
* by the SELL price they'd pay, each with the per-unit PROFIT vs. the
* player's local buy price here.
*
* Everything is a PURE function of (catalog, galaxy, the run's visited set)
* — the same deterministic pricing Economy.js feeds the shop — so it's
* testable in Node (dev/trade-scout.test.mjs) and needs no scene.
*
* const scout = scoutGood('refined-fuel', {
* galaxy, systemId, cls, // this shop (Economy.economicClass)
* buyUnit, // this row's buy price (the baseline)
* visited, // the systems the run has entered
* topN: 3,
* });
*/
import { config } from '../config/Config.js';
import * as E from './Economy.js';
/** One economic class with its player-facing label + economy blurb. */
function classInfo(cls) {
const info = config.get('goods.classes', {})[cls] ?? {};
return { cls, label: String(info.label ?? cls).toUpperCase(), economy: String(info.economy ?? '') };
}
/** The full catalog entry for a good id (null when unknown). */
export function goodCatalog(id) {
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id) ?? null;
}
/**
* Does a shop of class `cls` BUY BACK the good (is it on that shop's sell
* side)? Capitals buy everything (the universal sink); everyone else buys
* back exactly the goods their class WANTS (Economy.shopFor's rule).
*/
export function sellsHere(goodId, cls) {
if (!cls) return false;
return cls === 'capital' || E.wants(goodId, cls);
}
/**
* The scout report for one good at one shop.
*
* @param {string} goodId — the catalog id (a BUY row's good).
* @param {object} o —
* galaxy — the generated galaxy (Economy's classBySystem + prices).
* systemId — THIS system (always counts as charted — the player is here).
* cls — this shop's economic class.
* buyUnit — the row's local BUY price (the profit baseline; defaults to
* Economy.buyPrice for (good, cls, system)).
* visited — the systems the run has ENTERED (GameScene.visitedSystems).
* topN — how many charted exits to rank (default 3).
* @returns {{
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
* localBuy:number, localSell:number|null,
* producer:Array<{cls,label,economy}>, demand:Array<{cls,label,economy}>,
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops}>,
* bestExit:object|null, maxProfit:number, charted:number, topN:number
* } | null} — null only for an unknown good id.
*/
export function scoutGood(goodId, o = {}) {
const g = goodCatalog(goodId);
if (!g) return null;
const galaxy = o.galaxy ?? null;
const systemId = o.systemId ?? null;
const cls = o.cls ?? null;
const localBuy = Number.isFinite(o.buyUnit) ? Math.max(0, Math.round(o.buyUnit)) : E.buyPrice(goodId, cls, systemId, galaxy);
const localSell = sellsHere(goodId, cls) ? E.sellPrice(goodId, cls, systemId, galaxy) : null;
const producer = (Array.isArray(g.producer) ? g.producer : []).map(classInfo);
const demand = (Array.isArray(g.demand) ? g.demand : []).map(classInfo);
// --- THE CHARTED EXITS ------------------------------------------------
// Every system the run has entered (the current one always counts) that
// actually BUYS the good (wants it, or is a capital) is a possible exit,
// priced at its BEST class's sell price. Ranked high→low, top-N kept,
// each with the per-unit profit vs. the local buy price + the hop
// distance to the nearest producer (the engine's distance term — the
// "far = dear" story in one number).
const visited = new Set();
for (const s of o.visited ?? []) if (typeof s === 'string' && s) visited.add(s);
if (systemId) visited.add(systemId); // we are here — it's charted by definition
const classBySystem = galaxy?.economy?.classBySystem ?? null;
const exits = [];
for (const sysId of visited) {
const rec = galaxy?.byId?.get?.(sysId);
if (!rec) continue;
const classes = classBySystem?.get?.(sysId);
if (!classes) continue;
let best = null;
for (const c of classes) {
if (!sellsHere(goodId, c)) continue;
const price = E.sellPrice(goodId, c, sysId, galaxy);
if (!best || price > best.price) best = { systemId: sysId, systemName: rec.name ?? sysId, cls: c, price };
}
if (!best) continue;
const hops = galaxy?.economy?.producerHops?.[goodId]?.[sysId];
exits.push({
...best,
clsLabel: classInfo(best.cls).label,
profit: best.price - localBuy,
hops: Number.isFinite(hops) ? hops : null,
});
}
exits.sort((a, b) => (b.price - a.price) || String(a.systemName).localeCompare(String(b.systemName)));
const topN = Math.max(1, Math.floor(Number(o.topN) || 3));
const top = exits.slice(0, topN);
const maxProfit = top.reduce((m, e) => Math.max(m, e.profit), 0);
return {
id: g.id,
name: String(g.name ?? g.id),
flavor: String(g.flavor ?? ''),
category: g.category ?? null,
tier: Number.isFinite(g.tier) ? g.tier : null,
baseValue: Number.isFinite(g.baseValue) ? g.baseValue : null,
localBuy,
localSell,
producer,
demand,
exits: top,
bestExit: top[0] ?? null,
maxProfit,
charted: visited.size,
topN,
};
}