222 lines
11 KiB
JavaScript
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)));
|