/** * 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, }; }