orbit/js/economy/TradeScout.js

198 lines
8.8 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 MADE (model v2 — the ONE ORIGIN system: the single world
* that sells it cheap in a daily batch; hub-pool goods name their
* reseller role — stations/capitals) — producer classes shown as the
* secondary "made of" hint
* · 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 (the banded distance price — the
* dominant factor).
*
* 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,
* origin:{ systemId:string|null, systemName:string|null, hub:boolean } | 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;
// THE ORIGIN (model v2) — the ONE system that makes this good (the world
// whose shop sells it cheap, daily batch). Hub-pool goods have none —
// they ride the station/capital reseller network.
const originSys = E.originOf(goodId, galaxy);
const origin = originSys
? { systemId: originSys, systemName: galaxy?.byId?.get?.(originSys)?.name ?? originSys, hub: false }
: { systemId: null, systemName: null, hub: true };
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,
origin,
producer,
demand,
exits: top,
bestExit: top[0] ?? null,
maxProfit,
charted: visited.size,
topN,
};
}