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