198 lines
8.8 KiB
JavaScript
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,
|
|
};
|
|
}
|