199 lines
10 KiB
JavaScript
199 lines
10 KiB
JavaScript
/**
|
||
* 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);
|