orbit/dev/scout-dest.mjs

222 lines
11 KiB
JavaScript

/**
* Dev-only TRADE SCOUT → SET DESTINATION test: boots the game, lands on the
* home world, opens the buy/sell screen, taps a BUY row's box (the trade
* scout), then taps a BEST-EXIT row and drives the "SET DESTINATION" popup
* end-to-end (ScoutPanel → ShopView → SurfaceScene.askSetDestination →
* ConfirmOverlay → GameScene.setDestination):
*
* - a tapped exit row offers the exit to the SCENE (the panel does not
* close itself — propagation stops)
* - confirming SETS the destination on the (sleeping) GameScene:
* destination.systemId = the exit, destination.objectId = the exit's
* WORLD (its planet of that class / station / home world — the
* route's final stop) — and toasts it on the surface (the game
* scene's toast would sit frozen while sleeping)
* - the LOCAL system exit is rejected with a note (no dialog, no
* destination change — pointing the ship at itself bounces)
* - CANCEL and ESC leave the destination untouched
*
* python3 -m http.server 8125
* node dev/cdp-firefox.mjs http://127.0.0.1:8125/dev/scout-dest.html \
* 'return window.__SCOUT_DEST_TEST__;' 90000
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
import { scoutGood } from '../js/economy/TradeScout.js';
import { planRoute } from '../js/galaxy/Route.js';
const fail = (msg, extra = {}) => {
window.__SCOUT_DEST_TEST__ = { pass: false, error: String(msg), ...extra };
console.error('[scout-dest] FAIL:', msg, extra);
};
const step = (name, cond, extra = {}) => {
if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 400)}`);
window.__STEPS__ = (window.__STEPS__ ?? []).concat(name);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitFor = async (fn, timeoutMs = 4000) => {
const t0 = Date.now();
for (;;) {
if (fn()) return true;
if (Date.now() - t0 > timeoutMs) return false;
await sleep(50);
}
};
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
(async () => {
// 1 — the flight scene is live (v4 boots asynchronously).
let gs = null;
for (let i = 0; i < 300; i++) {
gs = game.scene?.getScene('GameScene') ?? null;
if (gs && gs.ship) break;
await sleep(100);
}
if (!gs || !gs.ship) throw new Error('GameScene never came up');
step('boot', true);
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// 2 — a known ledger + cargo (something to sell).
const A = config.get('goods.goods', [])[0].id;
gs.ship.credits = 10000;
gs.ship.minerals = 37;
gs.ship.cargo.add([A]);
step('ledger-seeded', true);
// 3 — the surface stage on the home world (skip the clip).
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(300);
const ss = game.scene.getScene('SurfaceScene');
if (!ss) throw new Error('SurfaceScene never registered');
for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250);
ss.startSurface();
for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100);
if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')');
step('surface-stage', true);
// 4 — the run has ENTERED (charted) every system, so the scout has
// remote exits to offer (visitedSystems is the scout's "charted" set).
const sysId = gs.systemRecord.id;
for (const id of gs.galaxy.byId.keys()) gs.visitedSystems.add(id);
step('charted-everywhere', gs.visitedSystems.size > 1, { visited: gs.visitedSystems.size });
// 5 — open the shop the real way; headless (no shop clip) falls back to
// the direct build (same UI path).
ss.toggleShop();
await sleep(200);
if (!ss.shopView) ss.buildShopView();
if (!ss.shopView) throw new Error('no shopView');
const sv = ss.shopView;
sv.setTab('trade');
await sleep(600); // the slide-in
step('shop-open', true, { rows: sv.buyList.rows.length });
// The scout's data, computed the same way the panel does — so we can
// pick a good with a REMOTE profitable exit and one the LOCAL system
// buys (its exit row is the reject case).
const visited = [...gs.visitedSystems];
const reportFor = (row) => scoutGood(row.id, {
galaxy: gs.galaxy, systemId: sysId, cls: sv.cls,
buyUnit: row.price, visited, topN: 3,
});
const rows = sv.buyList.rows;
let remoteRow = null, remoteExit = null, bestProfit = -Infinity;
let localRow = null, localExit = null;
for (const row of rows) {
const d = reportFor(row);
if (!d) continue;
const rem = d.exits.find((e) => e.systemId !== sysId && e.profit > 0);
if (rem && rem.profit > bestProfit) { remoteRow = row; remoteExit = rem; bestProfit = rem.profit; }
const loc = d.exits.find((e) => e.systemId === sysId);
if (loc && !localRow) { localRow = row; localExit = loc; }
}
step('scout-data', !!remoteRow && !!remoteExit, { remote: remoteExit?.systemId, profit: bestProfit });
const tapRow = (scout, exit) => {
const i = scout._exitRows.findIndex((r) => r.exit.systemId === exit.systemId);
if (i < 0) throw new Error('no exit row for ' + exit.systemId);
scout._exitRows[i].onTap({ stopPropagation() {} });
};
const dialogShown = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'shown');
const dialogHidden = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'hidden');
// 6 — REJECT: the LOCAL system's exit row → a note, NO dialog, no
// destination change (pointing the ship at itself bounces).
if (localRow) {
sv._onRowTap(localRow);
step('local-scout-open', sv._scoutOpen === localRow.id);
tapRow(sv._scout, localExit);
await sleep(150);
step('local-rejected', ss._setDestDialog == null, { dialog: ss._setDestDialog && ss._setDestDialog.state });
step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
step('local-dest-untouched', gs.destination == null);
sv._onRowTap(null); // the scout closes, cargo grid back
} else {
// Fallback (no local-buying good in stock): the scene API rejects it.
ss.askSetDestination({ systemId: sysId, systemName: 'LOCAL', price: 1, profit: 0, clsLabel: 'X' });
await sleep(150);
step('local-rejected', ss._setDestDialog == null);
step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
}
// 7 — CONFIRM: a REMOTE exit row → the popup; CONFIRM sets the
// destination on the GameScene and toasts it on the surface.
sv._onRowTap(remoteRow);
step('remote-scout-open', sv._scoutOpen === remoteRow.id && sv._scout.report.id === remoteRow.id);
step('exit-rows-built', sv._scout._exitRows.length === sv._scout.report.exits.length,
{ rows: sv._scout._exitRows.length, exits: sv._scout.report.exits.length });
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('SET DESTINATION dialog never opened');
const dlg = ss._setDestDialog;
step('dialog-open', dlg.isOpen, { state: dlg.state });
step('dialog-title', (await waitFor(() => dlg.title.text === 'SET DESTINATION')) && dlg.title.text === 'SET DESTINATION',
{ title: dlg.title.text });
const name = String(remoteExit.systemName ?? remoteExit.systemId).toUpperCase();
const obj = remoteExit.objectName ? String(remoteExit.objectName).toUpperCase() : null;
step('dialog-body', dlg.bodyTexts[0].text === `DESTINATION — ${obj ? `${obj} · ${name}` : name}`,
{ body0: dlg.bodyTexts[0].text, want: `DESTINATION — ${obj ? `${obj} · ${name}` : name}` });
step('dialog-body-price', /SELLS \d+ CR\/UNIT/.test(dlg.bodyTexts[1].text ?? ''), { body1: dlg.bodyTexts[1].text });
// The scene staged the pending pick (exit + route hops) before showing.
const wantHops = planRoute(gs.galaxy, sysId, remoteExit.systemId)?.hops ?? null;
step('pending-staged', ss._setDest?.exit?.systemId === remoteExit.systemId, { setDest: !!ss._setDest });
if (wantHops != null) step('hops-computed', ss._setDest?.hops === wantHops, { got: ss._setDest?.hops, want: wantHops });
dlg.fireConfirm();
if (!(await dialogHidden())) throw new Error('dialog never closed after confirm');
step('confirmed-closed', dlg.state === 'hidden');
step('destination-set', gs.destination?.systemId === remoteExit.systemId
&& gs.destination?.objectId === (remoteExit.objectId ?? null),
{ destination: gs.destination, wantObject: remoteExit.objectId ?? null });
// The scout's run is a CARGO delivery (kind 'cargo') — the compass mark
// reads BROWN (GameScene._routeMarkColor), not the route's orange.
step('destination-kind-cargo', gs.destination?.kind === 'cargo', { kind: gs.destination?.kind });
if (remoteExit.objectId) {
// The destination must name a REAL object of the exit's world — a
// planet (discoveryId = its name) or the deep-space station.
const content = gs.galaxy.ensureContent(remoteExit.systemId);
const world = (content.planets ?? []).find((p) => p.name === remoteExit.objectId)
?? (content.settlements ?? []).find((s) => s.id === remoteExit.objectId);
step('destination-names-a-world', !!world, { objectId: remoteExit.objectId, world: world?.name ?? world?.id });
}
step('surface-toast', /DESTINATION SET/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text });
// 8 — CANCEL: the same row again → popup; CANCEL leaves everything as-is.
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('dialog never reopened');
ss._setDestDialog.cancel();
if (!(await dialogHidden())) throw new Error('dialog never closed after cancel');
step('cancel-kept', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination });
// 9 — ESC: the same row a third time → popup; ESC cancels (the esc
// chain: … → set-destination → save → …) without touching the rest.
tapRow(sv._scout, remoteExit);
if (!(await dialogShown())) throw new Error('dialog never reopened (esc case)');
const saveWas = ss.savePanel ?? null;
ss.escAction();
if (!(await dialogHidden())) throw new Error('ESC did not close the dialog');
step('esc-cancelled', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination });
step('esc-scope', ss.savePanel === saveWas, { savePanel: ss.savePanel, was: saveWas });
// 10 — teardown is clean.
ss.leaveShop();
await sleep(150);
step('leave-shop', ss.shopView === null && ss.shopMode === false, { view: !!ss.shopView, mode: ss.shopMode });
window.__SCOUT_DEST_TEST__ = { pass: true, steps: window.__STEPS__ ?? [] };
console.info('[scout-dest] PASS:', (window.__STEPS__ ?? []).join(' → '));
})().catch((err) => fail(String(err && err.message || err)));