/** * play.mjs — 循环外·权威门:serve + headless Chrome boot + 帧推进 + 截图 + 一次交互 = 能跑能玩 * owner:amodel-gen spike | 消费方:gen.mjs(agent done 后跑) / CLI 独立验证 * * 【bar = 能跑能玩(非九门)】 * 能跑 = booted(无 boot error)+ 真引擎掌帧推进(frameCount 前进); * 能玩 = do(start)+tap 后状态有 phase(非 booting),且截图存证(供人眼最终确认); * 截图落 games/amgen-/evidence/{first-paint,after-play}.png + verdict.json。 * * 【CDP】Node22 全局 WebSocket + fetch,无需 ws 包。Chrome --headless=new --remote-debugging-port。 * 注:本文件是 harness 侧(非游戏代码),允许用 Date.now/setTimeout(红线只约束游戏 src)。 */ 'use strict'; import { spawn } from 'node:child_process'; import { writeFileSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { GAME_RUNTIME, gameDir } from './tools.mjs'; const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 起静态服务器(game-runtime 根)。 */ function startServer(port) { return spawn('node', ['test/harness/static-serve.cjs', '.', String(port)], { cwd: GAME_RUNTIME, stdio: 'ignore', }); } /** 起 headless Chrome(端口避开 live;独立 user-data 防串)。 */ function startChrome(cdpPort, userDataDir) { return spawn(CHROME, [ '--headless=new', '--no-sandbox', '--no-zygote', '--disable-gpu', `--remote-debugging-port=${cdpPort}`, '--remote-allow-origins=*', '--autoplay-policy=no-user-gesture-required', `--user-data-dir=${userDataDir}`, 'about:blank', ], { stdio: 'ignore' }); } /** 轮询 CDP /json,拿一个 page target 的 ws 调试 url。 */ async function getWsUrl(cdpPort, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const r = await fetch(`http://localhost:${cdpPort}/json`); const list = await r.json(); const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl); if (page) return page.webSocketDebuggerUrl; } catch { /* chrome 还没起好,继续轮询 */ } await sleep(300); } throw new Error('CDP 未就绪(chrome 起失败?)'); } /** 极简 CDP 客户端(全局 WebSocket)。 */ class CDP { constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); ws.addEventListener('message', (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.id && this.pending.has(msg.id)) { const { resolve: res, reject } = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result); } }); } send(method, params) { const id = ++this.id; return new Promise((res, reject) => { this.pending.set(id, { resolve: res, reject }); this.ws.send(JSON.stringify({ id, method, params: params || {} })); }); } /** 在页面里求值(returnByValue);异常透出。 */ async evalJs(expression, awaitPromise = false) { const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }); if (r.exceptionDetails) { throw new Error('页面 eval 异常: ' + (r.exceptionDetails.text || JSON.stringify(r.exceptionDetails))); } return r.result ? r.result.value : undefined; } } async function connectCDP(wsUrl) { const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('ws 连接失败')), { once: true }); }); return new CDP(ws); } /** * 真玩验证。返回 verdict;截图 + verdict.json 落 evidence/。 * @param {string} id * @param {{port?:number, cdpPort?:number}} [opts] */ export async function play(id, opts = {}) { const port = opts.port || 4320; const cdpPort = opts.cdpPort || 9222; const url = `http://localhost:${port}/games/amgen-${id}/index.html?engine=real&seed=12345`; const evDir = join(gameDir(id), 'evidence'); mkdirSync(evDir, { recursive: true }); const userDataDir = `/tmp/amgen-chrome-${id}`; const verdict = { id, url, pass: false, steps: {}, notes: [] }; const server = startServer(port); const chrome = startChrome(cdpPort, userDataDir); let cdp; try { await sleep(900); // 等 server + chrome 起 const wsUrl = await getWsUrl(cdpPort, 15000); cdp = await connectCDP(wsUrl); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); await cdp.send('Page.navigate', { url }); // 1) 等 boot(__gameBooted=true 或 __gameBootError) let booted = false, bootErr = null; const bootDeadline = Date.now() + 30000; while (Date.now() < bootDeadline) { const st = await cdp.evalJs( '({b: window.__gameBooted, e: window.__gameBootError, ei: window.__gameHostEngineInitFired})', ).catch(() => null); if (st && st.b === true) { booted = true; verdict.steps.engineInit = !!st.ei; break; } if (st && st.e) { bootErr = st.e; break; } await sleep(400); } verdict.steps.boot = { booted, bootErr }; if (!booted) { verdict.notes.push('boot 失败:' + (bootErr || '超时(30s)')); throw new Error('boot 失败'); } // 2) 帧推进(真引擎掌帧) const f0 = await cdp.evalJs('window.__gameHost ? window.__gameHost.frameCount() : -1'); await sleep(1000); const f1 = await cdp.evalJs('window.__gameHost ? window.__gameHost.frameCount() : -1'); verdict.steps.frames = { f0, f1, advanced: typeof f1 === 'number' && f1 > f0 }; // 3) 首屏截图 const shot1 = await cdp.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(evDir, 'first-paint.png'), Buffer.from(shot1.data, 'base64')); // 4) do(start) 进 play + 一次 tap(do 形态两款游戏不一,两种都试) const st0 = await cdp.evalJs('window.__gameHost ? window.__gameHost.state() : null'); await cdp.evalJs(`(()=>{const h=window.__gameHost; if(!h||!h.do) return; try{h.do({type:'start'})}catch(e){} try{h.do('start')}catch(e){}})()`).catch(() => {}); await sleep(300); await cdp.evalJs('window.__gameHost && window.__gameHost.tap && window.__gameHost.tap(195, 500)').catch(() => {}); await sleep(1200); const st1 = await cdp.evalJs('window.__gameHost ? window.__gameHost.state() : null'); verdict.steps.play = { st0, st1 }; // 5) 玩后截图 const shot2 = await cdp.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(evDir, 'after-play.png'), Buffer.from(shot2.data, 'base64')); // 判定:能跑 = booted + 帧推进;phase 进展供"能玩"参考(人眼看截图最终拍板) const phase1 = st1 && st1.phase; verdict.pass = booted && verdict.steps.frames.advanced; verdict.notes.push(`booted=${booted} engineInit=${verdict.steps.engineInit} framesAdvanced=${verdict.steps.frames.advanced}(${f0}→${f1}) phase=${phase1}`); } catch (e) { verdict.error = String((e && e.message) || e); } finally { try { cdp && cdp.ws.close(); } catch {} try { chrome.kill('SIGKILL'); } catch {} try { server.kill('SIGKILL'); } catch {} } writeFileSync(join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2)); return verdict; } /** * smokeBoot —— 轻量同进程"能起来"检(boot + 帧推进,无九门/截图/tap)。 * owner:amodel-gen 批A | 用途:① SAA-mode done 门(治 review HIGH-1:--no-play 后仍在 generate 内挡住 boot 失败) * ② 控制验证(证 SAA 信封 + stage 真通 B1/B2)。读 SAA 信封标记 __genBooted/__genBootError/__genHost(非 spike 的 __gameBooted)。 * @param {string} gameId stage 后的游戏(读 games/_wg1-gen//index.html) * @returns {Promise<{ok,booted,bootErr,framesAdvanced,f0,f1}>} */ export async function smokeBoot(gameId, opts = {}) { const port = opts.port || 4320, cdpPort = opts.cdpPort || 9222; const url = `http://localhost:${port}/games/_wg1-gen/${gameId}/index.html`; const userDataDir = `/tmp/amgen-chrome-smoke-${gameId}`; const out = { gameId, url, ok: false, booted: false, bootErr: null, framesAdvanced: false, f0: -1, f1: -1 }; const server = startServer(port); const chrome = startChrome(cdpPort, userDataDir); let cdp; try { await sleep(900); cdp = await connectCDP(await getWsUrl(cdpPort, 15000)); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); await cdp.send('Page.navigate', { url }); const deadline = Date.now() + 30000; while (Date.now() < deadline) { const st = await cdp.evalJs('({ b: window.__genBooted, e: window.__genBootError })').catch(() => null); if (st && st.b === true) { out.booted = true; break; } if (st && st.e) { out.bootErr = st.e; break; } await sleep(400); } if (out.booted) { out.f0 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1'); await sleep(1000); out.f1 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1'); out.framesAdvanced = typeof out.f1 === 'number' && out.f1 > out.f0; // 抓游戏 _forensicsView().state() 快照(host.state() 透传;index.template 已挂 __genHost.state / __gameState 别名)。 // 用途:done 门据此推断 driver 形态(有 targets 数组=点击类→tap-targets;否则按键类→key-cycle)自动产 play-spec。读不到返 null,不影响 smoke 判定。 out.state = await cdp.evalJs( '(window.__genHost && typeof window.__genHost.state === "function") ? window.__genHost.state() : ' + '((typeof window.__gameState === "function") ? window.__gameState() : null)', ).catch(() => null); } // 抓运行时日志(window.__gameLog:ctx.log 游戏语义 + 插件自带日志)→ 写 staged evidence + 末段进 out。 // 无论 boot 成败都抓(boot 失败时日志最有诊断价值)。让"读日志诊断运行时问题"成立。 try { const logJson = await cdp.evalJs('JSON.stringify(window.__gameLog||[])').catch(() => '[]'); const arr = JSON.parse(logJson || '[]'); out.logCount = arr.length; out.logTail = arr.slice(-12).map((e) => `[${e.scope}:${e.tag}]${e.msg ? ' ' + e.msg : ''}`); const evDir = join(GAME_RUNTIME, 'games', '_wg1-gen', gameId, 'evidence'); mkdirSync(evDir, { recursive: true }); writeFileSync(join(evDir, 'game-log.json'), JSON.stringify(arr, null, 2)); } catch (_) { /* 日志捕获失败不影响 smoke 判定 */ } } catch (e) { out.bootErr = out.bootErr || String((e && e.message) || e); } finally { try { cdp && cdp.ws.close(); } catch {} try { chrome.kill('SIGKILL'); } catch {} try { server.kill('SIGKILL'); } catch {} } out.ok = out.booted && out.framesAdvanced; return out; } // ───────────────────────── CLI ───────────────────────── // 用法:node play.mjs [port] [cdp] —— spike 信封真玩(__gameBooted) // node play.mjs smoke [port] [cdp] —— SAA 信封 smoke-boot(__genBooted) if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2); if (args[0] === 'smoke') { const [, id, port, cdp] = args; if (!id) { console.error('用法:node play.mjs smoke [port] [cdp]'); process.exit(2); } smokeBoot(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined }) .then((o) => { console.log('[smoke]', o.ok ? 'PASS' : 'FAIL', JSON.stringify(o)); process.exit(o.ok ? 0 : 1); }); } else { const [id, port, cdp] = args; if (!id) { console.error('用法:node play.mjs [port] [cdp] | smoke [port] [cdp]'); process.exit(2); } play(id, { port: port ? Number(port) : undefined, cdpPort: cdp ? Number(cdp) : undefined }) .then((v) => { console.log('[play]', v.pass ? 'PASS(能跑)' : 'FAIL', '—', v.notes.join(' | ')); if (v.error) console.log(' error:', v.error); console.log(' evidence:', join(gameDir(id), 'evidence')); process.exit(v.pass ? 0 : 1); }); } }