orbit/dev/escort-browser.test.mjs

213 lines
11 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.

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