lili 9fe50a4fee fix(amodel-gen): 治便宜档生成幻觉 + 补 play-spec 链路(九门基线剩两层根因)
便宜档九门基线诊断后,治创始人选的剩两层:
- 层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>
2026-06-25 23:15:42 -07:00

261 lines
12 KiB
JavaScript
Raw 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.

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