/** * TradeScout test (dev tool — run headless with the Phaser loader): * * node dev/trade-scout.test.mjs * * The PURE strategy layer behind the shop's goods inspector (tap a BUY row's * box → the cargo grid is swapped for the row's strategy report): * * - THE REPORT: identity (name/category/tier/flavor) + local prices + * producer ("GROWN ON") + demand ("SIGHT ON") classes with labels. * - CHARTED EXITS: only systems the run has ENTERED count; the current * system always counts (we're here); a system is an exit only if it * actually BUYS the good (wants it, or is a capital); ranked by its * BEST class's sell price, top-N kept. * - PROFIT: exit price − the shop's local buy price (the row's price); * maxProfit drives the panel's bars. * - CAPITALS: a visited capital is always an exit (the universal sink). * - DETERMINISM: same inputs ⇒ identical report. */ process.env.NODE_ENV = 'dev'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const fs = await import('node:fs'); const dataDir = join(__dirname, '../data'); const configData = {}; for (const f of fs.readdirSync(dataDir)) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); } config.init(configData); const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href); const { scoutGood, goodCatalog, sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const g = Galaxy.create('orbit-trade-scout-test'); const goods = config.get('goods.goods', []); const classOf = (sysId) => [...g.economy.classBySystem.get(sysId)]; // A real shop to scout from: the home system (always charted, terran world). const sysId = g.homeSystemId; const cls = 'terran'; const stock = E.shopFor(cls, sysId, g).buy; const row = stock.find((s) => sellsHere(s.id, classOf(sysId).find((c) => c !== 'capital')) ) ?? stock[0]; // --------------------------------------------------------------------------- // 1. The report — identity + local prices + class info // --------------------------------------------------------------------------- { const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [sysId] }); check('report exists for a stock good', !!d); check('identity: id + name', d.id === row.id && d.name.length > 0); check('identity: flavor from the catalog', d.flavor === (goodCatalog(row.id).flavor ?? '')); check('identity: category + tier from the catalog', d.category === goodCatalog(row.id).category && d.tier === goodCatalog(row.id).tier); check('localBuy = the row price (the profit baseline)', d.localBuy === row.price); const wantsHere = sellsHere(row.id, cls); check(`localSell ${wantsHere ? 'priced (this class wants it)' : 'null (this class does not want it)'}`, wantsHere ? (d.localSell != null && d.localSell > 0) : d.localSell == null); check('producer classes listed with labels', d.producer.every((c) => c.cls && c.label.length > 0)); check('demand classes listed with labels', d.demand.every((c) => c.cls && c.label.length > 0)); check('unknown good → null report', scoutGood('no-such-good', { galaxy: g, systemId: sysId, cls }) == null); } // --------------------------------------------------------------------------- // 2. CharteD exits — only visited systems, only buyers, ranked, top-N // --------------------------------------------------------------------------- { // Pick a demand class of this good and find a charted system carrying it. const wantCls = (goodCatalog(row.id).demand ?? [])[0]; const exitSys = wantCls ? [...g.byId.keys()].find((id) => g.economy.classBySystem.get(id).has(wantCls)) : null; const visited = [sysId, exitSys].filter(Boolean); const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited, topN: 3 }); check('exits are a list of { systemId, systemName, cls, price, profit, hops }', d.exits.every((e) => e.systemId && e.systemName && e.cls && Number.isFinite(e.price) && Number.isFinite(e.profit) && e.clsLabel)); check('every exit is a CHARTED (visited) system', d.exits.every((e) => visited.includes(e.systemId))); check('no exit for an UNCHARTED system', (() => { const others = [...g.byId.keys()].filter((id) => !visited.includes(id)); const d2 = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [sysId] }); return d2.exits.every((e) => visited.includes(e.systemId)); })()); check('ranked by sell price (high → low)', d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price)); check('top-N honored (≤ 3)', d.exits.length <= 3); if (exitSys) { const wantsIt = E.wants(row.id, wantCls); check(`the charted ${wantCls} system ${wantsIt ? 'IS' : 'is not (capital-only)'} in the exits`, d.exits.some((e) => e.systemId === exitSys) === wantsIt); } check('profit = exit price − local buy', d.exits.every((e) => e.profit === e.price - row.price)); check('maxProfit = the best exit profit', d.maxProfit === Math.max(0, ...d.exits.map((e) => e.profit))); check('bestExit = the first ranked exit', d.bestExit == null ? d.exits.length === 0 : d.bestExit === d.exits[0]); } // --------------------------------------------------------------------------- // 3. Capitals are always an exit (the universal sink) // --------------------------------------------------------------------------- { const capId = [...g.byId.keys()].find((id) => g.economy.classBySystem.get(id).has('capital')); const visited = [sysId, capId].filter(Boolean); const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited }); check('a charted CAPITAL always ranks as an exit', d.exits.some((e) => e.systemId === capId && g.economy.classBySystem.get(capId).has('capital'))); } // --------------------------------------------------------------------------- // 3b. Exit OBJECTS — each exit names the WORLD that pays the price // --------------------------------------------------------------------------- { const visited = [...g.byId.keys()]; // everything charted — every exit class shows let exitsChecked = 0, resolved = 0, unresolved = 0, bad = 0; for (const good of goods) { const d = scoutGood(good.id, { galaxy: g, systemId: sysId, cls, buyUnit: 1, visited }); for (const e of d.exits) { exitsChecked += 1; if (typeof e.objectId !== 'string' && e.objectId !== null) bad += 1; if (typeof e.objectName !== 'string' && e.objectName !== null) bad += 1; if (e.objectId == null) { unresolved += 1; continue; } resolved += 1; const content = g.ensureContent(e.systemId); // The exit's WORLD must be a REAL object of the exit's class (the // discovery id the route's final stop matches on: planets carry // discoveryId = the planet NAME, stations the settlement id). let ok = false; if (e.cls === 'station') { const st = (content.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation'); ok = !!st && e.objectId === st.id && e.objectName === (st.name ?? st.id); } else if (e.cls === 'capital') { const p = (content.planets ?? []).find((x) => typeof x?.class === 'string' && x.class.startsWith('capital')); ok = !!p && e.objectId === p.name && e.objectName === p.name; } else if (e.systemId === g.homeSystemId && e.cls === config.get('planets.homePlanet', 'terran')) { // The HOME WORLD — the central body, NOT in content.planets // (discovery id 'home' — objectForClass's special case): // the home system's terran exit resolves to it. ok = e.objectId === 'home' && e.objectName === (content.homeName ?? 'home'); } else { const p = (content.planets ?? []).find((x) => x?.class === e.cls); ok = !!p && e.objectId === p.name && e.objectName === p.name; } if (!ok) bad += 1; } } check(`every exit carries objectId/objectName fields (${exitsChecked} exits, all systems charted)`, exitsChecked > 0 && bad === 0, { exitsChecked, bad }); check('most exits resolve a WORLD (not just the system)', resolved > 0 && resolved >= unresolved, { resolved, unresolved }); check('resolved objectIds are real objects of the exit class', bad === 0, { resolved, bad }); } // --------------------------------------------------------------------------- // 4. Determinism — same inputs ⇒ identical report // --------------------------------------------------------------------------- { const o = { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [...g.byId.keys()].slice(0, 5) }; const a = JSON.stringify(scoutGood(row.id, { ...o })); const b = JSON.stringify(scoutGood(row.id, { ...o })); check('same inputs ⇒ identical report', a === b); } // --------------------------------------------------------------------------- // 5. A good that is produced locally sells ABOVE its local buy (the loop) // --------------------------------------------------------------------------- { // Pick a good this class PRODUCES (in the stock) — somewhere far it must // sell for more than it costs here (hop term), when that far system is // charted. const produced = stock.find((s) => g.economy.classBySystem.get(sysId).has(cls) && E.produces(s.id, cls)); if (produced) { const far = [...g.byId.keys()].find((id) => { const c = classOf(id); return c.some((x) => sellsHere(produced.id, x)) && (g.economy.producerHops[produced.id][id] ?? 0) > 1; }); if (far) { const d = scoutGood(produced.id, { galaxy: g, systemId: sysId, cls, buyUnit: produced.price, visited: [sysId, far] }); const exit = d.exits.find((e) => e.systemId === far); check(`the far charted exit sells ${produced.id} above its local buy (the loop)`, !!exit && exit.price > produced.price); } else { check('far exit found (skipped — no suitable system)', true); // informational } } else { check('a locally produced stock good exists (skipped)', true); // informational } } console.log(failures === 0 ? '\nAll TradeScout checks passed ✔' : `\n${failures} FAILURES`); process.exit(failures === 0 ? 0 : 1);