213 lines
11 KiB
JavaScript
213 lines
11 KiB
JavaScript
// dev/escort-browser.test.mjs — the ESCORT contract in a REAL BROWSER
|
||
// (Playwright + the vendored Phaser). Run:
|
||
//
|
||
// node dev/server.mjs 8123 & # the static dev server
|
||
// node dev/escort-browser.test.mjs # default http://127.0.0.1:8123
|
||
//
|
||
// What this pins (the part the headless seam tests can't): the scene
|
||
// BOOTS with the escort's code live (the transport sheet preloads, the
|
||
// escort's lane director stands, the transport config resolves), and the
|
||
// escort's SCENE-side law runs in the real Phaser runtime without a thrown
|
||
// error — the accept stands the route, the rendezvous beacon paints, and
|
||
// the board's escort row renders with its title / issuer / body.
|
||
//
|
||
// This is the "it runs in the browser" half of the escort's verification;
|
||
// the "it behaves per contract" half is dev/escort.test.mjs (the model)
|
||
// + dev/escort-flow.test.mjs (the scene flow).
|
||
|
||
const PORT = process.env.ORBIT_PORT || '8123';
|
||
const BASE = `http://127.0.0.1:${PORT}`;
|
||
const { pathToFileURL } = await import('node:url');
|
||
const { chromium } = await import(pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href);
|
||
|
||
const failures = [];
|
||
const ok = (cond, msg) => { if (!cond) failures.push(msg); console.log(` ${cond ? '✓' : '✗ FAIL'} ${msg}`); };
|
||
const check = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { console.log(` ✗ FAIL ${msg}`); failures.push(msg); } };
|
||
|
||
console.log(`== escort in the browser — ${BASE} ==`);
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
|
||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
|
||
});
|
||
const page = await browser.newPage();
|
||
|
||
const consoleErrors = [];
|
||
const missingResources = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||
page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`));
|
||
page.on('response', (r) => { if (r.status() >= 400) missingResources.push(`${r.status()} ${r.url()}`); });
|
||
|
||
await page.goto(`${BASE}/index.html`, { waitUntil: 'load' });
|
||
|
||
// Wait for the game to boot and the menu to stand.
|
||
await page.waitForFunction(() => !!(window.game && window.game.scene && window.game.scene.getScene && window.game.scene.getScene('MenuScene')), null, { timeout: 20000 });
|
||
console.log(' ✓ the game booted; the menu stands');
|
||
|
||
// Wait for the menu to be FULLY dressed (overlay + title built, not mid-decode).
|
||
await page.waitForFunction(() => {
|
||
const m = window.game?.scene?.getScene?.('MenuScene');
|
||
return !!(m && m.overlay && m.title && m.seedValue && !m.dead);
|
||
}, null, { timeout: 20000 });
|
||
console.log(' ✓ the menu is dressed (overlay + title + seed ready)');
|
||
|
||
// Start a NEW GAME (the menu's own door) → GameScene.
|
||
await page.evaluate(() => {
|
||
const m = window.game.scene.getScene('MenuScene');
|
||
if (!m.seedValue) m.seedValue = 'ESCORT01'; // the seed panel may still be decoding
|
||
m.startNewGame();
|
||
});
|
||
|
||
// Wait for GameScene to be active and the escort's seams to stand.
|
||
try {
|
||
await page.waitForFunction(() => {
|
||
const g = window.game;
|
||
if (!g || !g.scene || !g.scene.getScene) return false;
|
||
const s = g.scene.getScene('GameScene');
|
||
if (!s) return false;
|
||
try { if (!s.isActive()) return false; } catch { /* isActive optional */ }
|
||
return !!(s.escortDirector && s.combat);
|
||
}, null, { timeout: 40000 });
|
||
} catch (e) {
|
||
const diag = await page.evaluate(() => {
|
||
const g = window.game;
|
||
if (!g || !g.scene) return { game: !!g };
|
||
const s = g.scene.getScene('GameScene');
|
||
return {
|
||
sceneKeys: g.scene.getScenes ? g.scene.getScenes(true).map((x) => x.sys?.key ?? x.key) : null,
|
||
gs: s ? { isActive: (() => { try { return s.isActive(); } catch { return 'n/a'; } })(), hasEscortDirector: !!s.escortDirector, hasCombat: !!s.combat, hasShip: !!s.ship, createRan: !!s.systemRecord } : 'no GameScene',
|
||
};
|
||
});
|
||
console.log(' DIAGNOSTIC', JSON.stringify(diag));
|
||
if (consoleErrors.length) console.log(' CONSOLE ERRORS\n ' + consoleErrors.slice(0, 8).join('\n '));
|
||
if (missingResources.length) console.log(' MISSING RESOURCES\n ' + missingResources.slice(0, 8).join('\n '));
|
||
await browser.close();
|
||
process.exit(1);
|
||
}
|
||
console.log(' ✓ GameScene is active; the escort director + combat system stand');
|
||
|
||
// ---- 1. THE TRANSPORT SHEET preloads -----------------------------------
|
||
const tex = await page.evaluate(() => {
|
||
const s = window.game.scene.getScene('GameScene');
|
||
const key = '__enemy_ship_ships-transport';
|
||
const t = s.textures.get(key);
|
||
return { exists: s.textures.exists(key), frameWidth: t.realWidth ?? null, frameHeight: t.realHeight ?? null, sourceSize: t.getSourceImage() ? { w: t.getSourceImage().width, h: t.getSourceImage().height } : null };
|
||
});
|
||
ok(tex.exists, `the transport spritesheet preloaded (key __enemy_ship_ships-transport)`);
|
||
ok(tex.sourceSize && tex.sourceSize.w === 1800 && tex.sourceSize.h === 640, `…the sheet is 1800×640 (got ${tex.sourceSize ? tex.sourceSize.w + '×' + tex.sourceSize.h : 'none'})`);
|
||
|
||
// ---- 2. THE ESCORT'S RUNTIME SEAMS stand (the scene's doors) -------------
|
||
const seams = await page.evaluate(() => {
|
||
const s = window.game.scene.getScene('GameScene');
|
||
return {
|
||
spawnTransport: typeof s.combat?.spawnTransport === 'function',
|
||
spawnAlly: typeof s.combat?.spawnAlly === 'function',
|
||
escortDirector: !!s.escortDirector && typeof s.escortDirector.update === 'function',
|
||
updateEscort: typeof s.updateEscort === 'function',
|
||
establishEscort: typeof s._establishEscort === 'function',
|
||
drawMarkers: typeof s._drawEscortMarkers === 'function',
|
||
resetForLanding: typeof s._escortResetForLanding === 'function',
|
||
compassTarget: typeof s.escortCompassTarget === 'function',
|
||
};
|
||
});
|
||
ok(seams.spawnTransport, 'combat.spawnTransport stands (the transport seam)');
|
||
ok(seams.spawnAlly, 'combat.spawnAlly stands (the escort fighters seam)');
|
||
ok(seams.escortDirector, 'the escort lane director stands (update())');
|
||
ok(seams.updateEscort && seams.establishEscort, 'the escort scene law stands (updateEscort / _establishEscort)');
|
||
ok(seams.drawMarkers && seams.resetForLanding && seams.compassTarget, 'the escort markers / landing reset / compass stand');
|
||
|
||
// ---- 3 + 4. THE BOARD renders an escort row AND the scene law runs ------
|
||
// (level raised above the escort's minLevel gate, so the row stands ACCEPTED;
|
||
// a neutral system is found by scanning its worlds for an escort slot — the
|
||
// scene's board seam is bound to the current system, so we point it there)
|
||
const boardAndLaw = await page.evaluate(() => {
|
||
const s = window.game.scene.getScene('GameScene');
|
||
const prior = { systemRecord: s.systemRecord, systemContent: s.systemContent, level: s.combat?.xp?.level };
|
||
const isNeutral = (rec) => {
|
||
const f = rec.faction;
|
||
return !(typeof f === 'string' && f !== '' && f.toLowerCase() !== 'neutral');
|
||
};
|
||
try {
|
||
// The escort's minLevel gate (level 3): the player stands at 5.
|
||
if (s.combat?.xp) s.combat.xp.level = 5;
|
||
// Find a NEUTRAL system whose board stands an escort row (scan its worlds).
|
||
let found = null;
|
||
outer:
|
||
for (const rec of s.galaxy.records ?? []) {
|
||
if (!isNeutral(rec)) continue;
|
||
let content = null;
|
||
try { content = s.galaxy.ensureContent(rec.id); } catch { continue; }
|
||
s.systemRecord = rec;
|
||
s.systemContent = content;
|
||
const worlds = [null, ...(content.planets ?? []).map((p) => p.name), ...(content.settlements ?? []).filter((x) => x?.anchor?.type === 'space').map((x) => x.name)];
|
||
for (const wname of worlds) {
|
||
let posts = [];
|
||
try { posts = s.sideQuestPostings(wname, false) ?? []; } catch { continue; }
|
||
const e = posts.find((p) => p.category === 'escort');
|
||
if (e) { found = { rec, wname, posts, escort: e }; break outer; }
|
||
}
|
||
}
|
||
if (!found) return { error: 'no neutral board stands an escort row (across systems/worlds)' };
|
||
const { rec, wname, escort } = found;
|
||
// ACCEPT + ESTABLISH + paint the rendezvous beacon (the real runtime).
|
||
s.sideQuestState.accept(escort.id);
|
||
s.sideQuestState.setActive(escort.id);
|
||
s._establishEscort(escort.id, escort);
|
||
const route = s.sideQuestState.routeFor(escort.id);
|
||
if (!route || route.kind !== 'escort') return { error: 'route not established', route };
|
||
s._drawEscortMarkers();
|
||
return {
|
||
ok: true,
|
||
sysName: rec.name,
|
||
world: wname ?? 'p1',
|
||
board: { total: found.posts.length, title: escort.title, issuer: escort.issuer, description: escort.description, credits: escort.reward?.credits },
|
||
law: {
|
||
jobId: escort.id, kind: route.kind, phase: route.escort?.phase,
|
||
origin: route.escort?.originSystemId, dest: route.escort?.destSystemId, hops: route.escort?.hops,
|
||
waypoints: route.waypoints?.length, transportMaxHull: route.escort?.transportMaxHull,
|
||
escortCount: route.escort?.escortCount, credits: route.credits, xp: route.xp,
|
||
},
|
||
};
|
||
} catch (e) {
|
||
return { error: String(e?.message ?? e), stack: e?.stack };
|
||
} finally {
|
||
s.systemRecord = prior.systemRecord;
|
||
s.systemContent = prior.systemContent;
|
||
if (prior.level != null && s.combat?.xp) s.combat.xp.level = prior.level;
|
||
}
|
||
});
|
||
|
||
if (boardAndLaw.error) {
|
||
ok(false, `board + scene law — ${boardAndLaw.error}`);
|
||
} else {
|
||
const b = boardAndLaw.board;
|
||
ok(!!b.title && b.title.length > 0, `the neutral board stands an ESCORT row (system ${boardAndLaw.sysName}, world ${boardAndLaw.world}, ${b.total} postings): "${b.title}"`);
|
||
ok(!!b.issuer && b.issuer.length > 0, `…the issuer renders: "${b.issuer}"`);
|
||
ok(!!b.description && b.description.length > 40, `…the body renders (${b.description ? b.description.length : 0} chars)`);
|
||
ok((b.credits ?? 0) >= 5, `…the reward meets the floor (${b.credits} CR)`);
|
||
const L = boardAndLaw.law;
|
||
ok(L.kind === 'escort', `the route is typed escort (${L.jobId})`);
|
||
ok(L.phase === 'rendezvous', `…standing at its RENDEZVOUS (${L.phase})`);
|
||
ok(L.dest && L.dest.length > 0, `…aimed at a destination (${L.dest}, ${L.hops} hop${L.hops === 1 ? '' : 's'})`);
|
||
ok((L.waypoints ?? 0) >= 1, `…with a rendezvous waypoint standing (${L.waypoints})`);
|
||
ok(L.transportMaxHull >= 1000, `…a transport at full standing armor (hull ${L.transportMaxHull})`);
|
||
ok(L.escortCount >= 1 && L.escortCount <= 2, `…${L.escortCount} escort fighter(s)`);
|
||
ok(L.credits >= 5 && L.xp > 0, `…payout fixed (${L.credits} CR · +${L.xp} XP)`);
|
||
}
|
||
|
||
// ---- 5. NO uncaught errors / missing resources from the escort's code --
|
||
const escortErrors = consoleErrors.filter((t) => /escort|transport|convoy/i.test(t));
|
||
check(escortErrors.length === 0, `no console errors from the escort/transport code${escortErrors.length ? ` — ${escortErrors[0]}` : ''}`);
|
||
check(missingResources.length === 0, `no missing resources on boot${missingResources.length ? ` — ${missingResources[0]}` : ''}`);
|
||
|
||
await page.close();
|
||
await browser.close();
|
||
|
||
if (failures.length) {
|
||
console.log(`\nescort-browser: FAIL — ${failures.length} failure(s)`);
|
||
for (const f of failures) console.log(` ✗ ${f}`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\nescort-browser: OK — the scene boots with the escort live, the sheet preloads, the board renders, the scene law runs clean');
|
||
process.exit(0);
|