便宜档九门基线诊断后,治创始人选的剩两层:
- 层1 生成幻觉:M3 对插件参数形状/返回值幻觉(drawButton 返 void 误赋值、
sceneFsm.define 单对象批量),check 门只查方法名存在 → 带病过 check+build+smoke。修:
· prompt.mjs + littlejs-game-dev.md 补两条 footgun 正反例(drawButton 返 void、
命中用 pointInRect;define 逐场景调)
· tools.mjs check 加形状静态门(拦 drawButton 返回值赋值 + define 单对象批量)
- 层2 链路假绿:stage 不产 play-spec → 裸跑 driven=false → E/H 降 advisory → 假绿。修:
· tools.mjs ensurePlaySpec 据 _forensicsView 形态自动推 driver(有 targets→tap-targets/否则 key-cycle)
· play.mjs smokeBoot 抓 _forensicsView().state();gen.mjs done 门 smoke 绿后调用
验证(vf-* 干净重跑):
- check 形状门对 base1/4/5 好游戏零误伤、对 base2/3 幻觉精准命中
- vf-mole 打地鼠(base2 同类):卡menu/E·G·H挂 → 九门全过、进 play(drawButton 幻觉治好)
- vf-2048 合成(base3 同类):define 已写对(幻觉治好);仍卡 menu = 新发现 key-cycle driver
缺起局点击(menu 用 tap、play 用 key)= 第三层 driver bug、非本两层,下一步小修
- 两款 playspec 自动产、driven=true(假绿终结)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
/**
|
||
* 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-<id>/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/<gameId>/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 <id> [port] [cdp] —— spike 信封真玩(__gameBooted)
|
||
// node play.mjs smoke <gameId> [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 <gameId> [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 <id> [port] [cdp] | smoke <gameId> [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);
|
||
});
|
||
}
|
||
}
|