orbit/js/economy/TradeScout.js

184 lines
8.0 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 SPECIFIC OBJECT of a destination system that actually pays the exit
* class's price — the route's FINAL stop (the compass points at it, it
* wears the route pulse, and REACHED fires when the ship lands on it):
* · a planet class (terran/arid/rocky/gas/ice/lava) → a planet of that
* class (discovery id = the planet's NAME, unique in the system);
* · 'capital' → the capital world (class capital*);
* · 'station' → the deep-space station (a station shop IS the station —
* the only 'station'-class object in the system).
* The home world is special: it exists only in the STARTING system and is
* not in content.planets (the central body, discovery id 'home') — the
* home system's 'terran' class resolves to it. null when the content holds
* no such object (e.g. the capital pass replaced the world's class after
* the class roll) — the destination falls back to system level (objectId →
* null).
*/
function objectForClass(cls, content, isHome = false, homeCls = null) {
if (!content) return null;
if (isHome && homeCls && cls === homeCls) {
return { objectId: 'home', objectName: content.homeName ?? null };
}
const planets = Array.isArray(content.planets) ? content.planets : [];
if (cls === 'station') {
const st = (Array.isArray(content.settlements) ? content.settlements : [])
.find((s) => s?.kind === 'deepSpaceStation');
return st ? { objectId: st.id, objectName: st.name ?? st.id } : null;
}
const p = !cls
? null
: cls === 'capital'
? planets.find((x) => typeof x?.class === 'string' && x.class.startsWith('capital'))
: planets.find((x) => x?.class === cls);
return p ? { objectId: p.name, objectName: p.name } : null;
}
/**
* 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,objectId,objectName}>,
* 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 homeSystemId = galaxy?.homeSystemId ?? null;
const homeCls = config.get('planets.homePlanet', 'terran');
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];
// The WORLD this exit pays at — the destination names the planet (or
// the station / the home world), not just the system: setting it as
// the destination makes the route's final stop that object (see
// objectForClass; null → the system-level fallback).
const obj = objectForClass(best.cls, galaxy?.ensureContent?.(sysId) ?? null, sysId === homeSystemId, homeCls);
exits.push({
...best,
clsLabel: classInfo(best.cls).label,
profit: best.price - localBuy,
hops: Number.isFinite(hops) ? hops : null,
objectId: obj?.objectId ?? null,
objectName: obj?.objectName ?? 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,
};
}