140 lines
5.7 KiB
JavaScript
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,
|
|
};
|
|
}
|