orbit/dev/trade-scout.test.mjs

199 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);