driver: runTapTargets 加 safeOnly(规避/推理族只点 safe===true、确定性避负到 win;安全放置族缺省关、零回归)。题面: saolei.json targets 加 safe x-ray 观测位(勿渲染)+ safeOnly + 断言 result==win。重生成 saolei: flash 一次过九门(revealed 0→71 / all_cleared / win-latch),自产 safe 正确→坐实缺口在题面非模型。回归 tictactoe(X)/invaders(win)全绿。沉淀 L1brief §九 + game-e2e/cheap-model skill 二分法。零碰 L0(boot-game-host/契约/门面)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
563 lines
34 KiB
JavaScript
563 lines
34 KiB
JavaScript
/**
|
||
* play.cdp.cjs —— W-G1 通用「CDP 真玩取证驱动」(模型无关,所有生成游戏共用一份)
|
||
* owner:W-G1 lane | cwd 必须 = game-runtime | 复用 test/harness/browser-evidence.cjs
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【职责】把一个 __GameBundle iife 装进 P1 宿主页、经 CDP 驱动本机 Chrome 真玩几步,产出四件套证据,
|
||
* 并以「九道假绿守卫」判定是否【真·可玩】——"能生成代码 ≠ 能玩",必须过真玩门才算 pass。
|
||
* 真玩驱动:spec.driver 存在=【适配性真玩】(读 state 自动接球,解盲打固定坐标打不动技巧游戏的假阴性);否则走 spec.inputs 固定序列。
|
||
*
|
||
* 【九道假绿守卫(全过才 PASS)】
|
||
* A 装载:__genBooted===true ∧ __gameHostEngineInitFired===true ∧ 无 bootError(白屏/装载失败拦截)。
|
||
* B 未捕获:__genInternals.uncaught 为空 ∧ 无 __gameHostBootError(运行期报错拦截)。
|
||
* C 掌帧:__engine.snapshot().frame 在 500ms 内增量 ∈ [20,45](活着且非失控/非单步假推进;@60fps 理论~30)。
|
||
* D 真渲染:#game-engine canvas readback「有色像素(maxCh>80) > 阈值」(非白屏/非全黑)。
|
||
* E 活性:真玩输入前后两张快照 FNV-1a 哈希【不同】(画面真在动/真在响应,非冻屏)。
|
||
* F 真接线:__engineCalls 含期望 call-ID(spec.expectedEngineCallPrefixes 任一前缀命中;证真经 ctx.getEngine() 调引擎/插件库,非自绘伪装)。
|
||
* G 输入有效:同 URL 同 seed 起【无输入】对照实例推进到同帧号,整帧哈希【不同】 ⇒ 输入真改变游戏走向(隔离「靠自走动画蒙混过 E」)。
|
||
* H 机制进展 + latch 终态(逐游戏·确定性·可选):spec.assertAfterPlay[] 机制断言全过(真有进展、非空壳)+ spec.expectLatch 时真玩到 phase='gameover' 且【驻留】(官方闭环锚)。
|
||
* 依赖游戏经【可选 _forensicsView()】导出可观测状态(host.state() 透传 → 页面 __gameState);未声明=老游戏→SKIP(向后兼容)。
|
||
* I 控制跟手(逐游戏·确定性·可选):spec.controlCheck 声明时,连点目标 x 验玩家控制体平滑逼近(逮「一格一跳」/卡死/不跟手);依赖游戏导出控制体位置(如 paddle.x)。未声明→SKIP。
|
||
*
|
||
* 【用法】node games/_wg1-gen/_shared/play.cdp.cjs <gameId> [--base=http://localhost:4320] [--cdp=http://localhost:9222] [--spec=<spec.json>]
|
||
* spec.json(缺省读 games/_wg1-gen/<gameId>/play-spec.json,缺失则用极简默认):
|
||
* { "inputs":[{"t":"tap","x":195,"y":600},{"t":"key","code":"ArrowLeft","downMs":300},{"t":"wait","ms":400}],
|
||
* "expectedEngineCallPrefixes":["particles.spawnEmitter","audio.synth"] }
|
||
* 产物:games/_wg1-gen/<gameId>/evidence/{verdict.json, first-paint.png, after-play.png};退出码 0=PASS / 1=FAIL。
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('node:path');
|
||
const http = require('node:http');
|
||
const fs = require('node:fs');
|
||
// 复用纯计算口径 + CDP 会话类 + 像素抓取/截图(test/harness/browser-evidence.cjs)。
|
||
const H = require(path.resolve(__dirname, '../../../test/harness/browser-evidence.cjs'));
|
||
const { CdpSession, captureImageData, saveScreenshot, fnv1a32 } = H;
|
||
// CDP 需 ws(仅本驱动用;cwd=game-runtime 已 npm i ws)。
|
||
const WebSocket = require('ws');
|
||
|
||
/** 纯 Promise 延时(不走被拦的 sleep 命令)。 */
|
||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/** 极简 HTTP(PUT 建 target / GET 列表)。browser-evidence 的 httpJson 未导出,此处复刻。 */
|
||
function httpJson(method, urlStr) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(urlStr);
|
||
const req = http.request(
|
||
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
|
||
(res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
|
||
try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(new Error('CDP HTTP 非 JSON:' + b.slice(0, 200))); } }); }
|
||
);
|
||
req.on('error', reject);
|
||
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** 建 target → 连 WS → 启用域 → 设固定取证视口(390×844/DPR2/touch)→ 导航。返回 CdpSession。 */
|
||
async function connect(url, cdpHttp) {
|
||
// Chrome 111+ 必须 PUT /json/new?<url>(GET 不行)。
|
||
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
|
||
const wsUrl = created.webSocketDebuggerUrl;
|
||
if (!wsUrl) throw new Error('建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
|
||
const ws = await new Promise((resolve, reject) => {
|
||
const s = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
|
||
s.on('open', () => resolve(s)); s.on('error', reject);
|
||
});
|
||
const cdp = new CdpSession(ws, created.id);
|
||
await cdp.send('Page.enable');
|
||
await cdp.send('Runtime.enable');
|
||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
|
||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {}
|
||
await cdp.send('Page.navigate', { url });
|
||
return cdp;
|
||
}
|
||
|
||
/** 守卫A:轮询等装载完成(__genBooted ∧ 引擎接管 __gameHostEngineInitFired);拿到任一 bootError 即抛。 */
|
||
async function waitBoot(cdp, timeoutMs) {
|
||
const deadline = Date.now() + (timeoutMs || 30000);
|
||
await delay(800); // 固定 settle,让脚本起步
|
||
while (Date.now() < deadline) {
|
||
const st = await cdp.evaluate(
|
||
'({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, ' +
|
||
'err: (window.__genBootError || window.__gameHostBootError || null) })'
|
||
);
|
||
if (st && st.err) throw new Error('装载失败(守卫A):' + st.err);
|
||
if (st && st.booted && st.eng) return;
|
||
await delay(300);
|
||
}
|
||
// 超时前再读一次错因,给确切信号。
|
||
const last = await cdp.evaluate('({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, err: (window.__genBootError||window.__gameHostBootError||null) })').catch(() => null);
|
||
throw new Error('装载超时(守卫A):' + JSON.stringify(last));
|
||
}
|
||
|
||
/** 守卫C:测 500ms 内引擎帧增量。读 window.__engine.snapshot().frame(real 通道)。 */
|
||
async function frameDelta(cdp, ms) {
|
||
const read = '(function(){ var e=window.__engine; if(!e) return null; var s=(typeof e.snapshot==="function")?e.snapshot():e; return (s&&typeof s.frame==="number")?s.frame:null; })()';
|
||
const f0 = await cdp.evaluate(read);
|
||
await delay(ms);
|
||
const f1 = await cdp.evaluate(read);
|
||
return { f0, f1, delta: (f0 == null || f1 == null) ? null : (f1 - f0) };
|
||
}
|
||
|
||
/** 推进(对照)实例到目标帧号(real 引擎自走,轮询 __engine.frame)。返回到达的帧号(-1=超时)。 */
|
||
async function advanceToFrame(cdp, targetFrame, timeoutMs) {
|
||
const read = '(function(){var e=window.__engine;var s=(e&&typeof e.snapshot==="function")?e.snapshot():e;return (s&&typeof s.frame==="number")?s.frame:0;})()';
|
||
const deadline = Date.now() + (timeoutMs || 15000);
|
||
while (Date.now() < deadline) {
|
||
const f = await cdp.evaluate(read);
|
||
if (f >= targetFrame) return f;
|
||
await delay(100);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/** 守卫D:#game-engine readback,统计「有色像素(maxCh>80)」数 + 全屏最亮通道值。 */
|
||
async function brightPixels(cdp, selector) {
|
||
const img = await captureImageData(cdp, selector);
|
||
const d = img.data; let bright = 0, maxCh = 0;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] === 0) continue;
|
||
const m = Math.max(d[i], d[i + 1], d[i + 2]);
|
||
if (m > maxCh) maxCh = m;
|
||
if (m > 80) bright++;
|
||
}
|
||
return { bright, maxCh, hash: fnv1a32(d), width: img.width, height: img.height };
|
||
}
|
||
|
||
/** 轻量采样:只取 #game-engine 的 FNV 哈希(守卫E 多采样用,捕捉瞬时闪烁/命中态)。 */
|
||
async function sampleHash(cdp, selector) {
|
||
const img = await captureImageData(cdp, selector);
|
||
return fnv1a32(img.data);
|
||
}
|
||
|
||
/** 逻辑坐标 → 页面 client 坐标(镜像 ref toClient:用画布自身逻辑尺寸 c.width/dpr 算缩放,不硬编码 390/844)。 */
|
||
async function mapToClient(cdp, xLogical, yLogical) {
|
||
const m = await cdp.evaluate(
|
||
'(function(){ var c=document.querySelector("#game-engine")||document.querySelector("#game"); if(!c) return null;' +
|
||
' var r=c.getBoundingClientRect(); var dpr=window.devicePixelRatio||1;' +
|
||
' return { l:r.left, t:r.top, sx:r.width/(c.width/dpr), sy:r.height/(c.height/dpr) }; })()'
|
||
);
|
||
if (!m) throw new Error('mapToClient:找不到 #game-engine/#game');
|
||
return { x: m.l + xLogical * m.sx, y: m.t + yLogical * m.sy };
|
||
}
|
||
|
||
/** 真触摸点按(touchStart→touchEnd 背靠背 + 裸 {x,y} 点;镜像 ref realTapAt 证可用范式)。 */
|
||
async function tap(cdp, xLogical, yLogical) {
|
||
const c = await mapToClient(cdp, xLogical, yLogical);
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: c.x, y: c.y }] });
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await delay(120); // 等真事件经浏览器→输入桥→引擎下一拍消化
|
||
}
|
||
|
||
/** 真键按(keyDown→downMs→keyUp)。code=DOM code(如 ArrowLeft/Space/KeyA)。 */
|
||
async function key(cdp, code, downMs) {
|
||
const map = { ArrowLeft: 37, ArrowRight: 39, ArrowUp: 38, ArrowDown: 40, Space: 32, Enter: 13 };
|
||
const keyCode = map[code] || 0;
|
||
const k = code === 'Space' ? ' ' : code; // 方向键 DOM code===key,直接传;原对 Arrow 发 undefined 致 event.key 空、键盘游戏读 e.key 拿空被忽略(压测批诊断纠)
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||
await delay(downMs || 200);
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||
}
|
||
|
||
/** 读游戏可观测状态(可测性红线:游戏经 _forensicsView().state 导出 → host.state() 透传 → 页面 __gameState 别名)。读不到返回 null。 */
|
||
async function readGameState(cdp) {
|
||
return await cdp.evaluate(
|
||
'(function(){ try {' +
|
||
' var s = (typeof window.__gameState === "function") ? window.__gameState()' +
|
||
' : (window.__genHost && typeof window.__genHost.state === "function") ? window.__genHost.state() : null;' +
|
||
' return s; } catch (e) { return null; } })()'
|
||
);
|
||
}
|
||
|
||
/** 判一条机制断言(s0=玩前态 / s1=玩后态)。op:increased/decreased/changed/>/>=/</<=/==/in。返回逐条明细。 */
|
||
function checkAssertion(a, s0, s1) {
|
||
const p = a.path;
|
||
const v0 = s0 ? s0[p] : undefined;
|
||
const v1 = s1 ? s1[p] : undefined;
|
||
const num = (x) => (typeof x === 'number' ? x : Number(x));
|
||
let pass = false;
|
||
switch (a.op) {
|
||
case 'increased': pass = num(v1) > num(v0); break; // 玩后 > 玩前(如分数上升)
|
||
case 'decreased': pass = num(v1) < num(v0); break; // 玩后 < 玩前(如剩余砖数下降)
|
||
case 'changed': pass = v1 !== v0; break; // 任意变化
|
||
case '>': pass = num(v1) > num(a.value); break;
|
||
case '>=': pass = num(v1) >= num(a.value); break;
|
||
case '<': pass = num(v1) < num(a.value); break;
|
||
case '<=': pass = num(v1) <= num(a.value); break;
|
||
case '==': pass = v1 === a.value; break;
|
||
case 'in': case 'reached': pass = Array.isArray(a.value) && a.value.indexOf(v1) >= 0; break; // 取值落在允许集(如 status∈[playing,win,lose])
|
||
default: pass = false;
|
||
}
|
||
return { path: p, op: a.op, before: v0, after: v1, value: a.value, pass, why: a.why || '' };
|
||
}
|
||
|
||
/** 读嵌套路径(如 'ball.x' / 'paddle.x');任一层缺失返回 undefined。 */
|
||
function getPath(obj, p) {
|
||
if (!obj || !p) return undefined;
|
||
return String(p).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||
}
|
||
|
||
/** 适配性真玩驱动:读 state 自适应出招,解「盲打固定坐标打不动技巧游戏」的假阴性。按 driver.type 分发,缺省=paddle-intercept(向后兼容)。 */
|
||
async function runDriver(cdp, driver, hashes) {
|
||
if (driver.type === 'tap-targets') return runTapTargets(cdp, driver, hashes);
|
||
if (driver.type === 'flap-to-gap') return runFlapToGap(cdp, driver, hashes);
|
||
if (driver.type === 'seek-x') return runSeekX(cdp, driver, hashes);
|
||
if (driver.type === 'tap-pairs') return runTapPairs(cdp, driver, hashes);
|
||
if (driver.type === 'key-cycle') return runKeyCycle(cdp, driver, hashes);
|
||
return runPaddleIntercept(cdp, driver, hashes);
|
||
}
|
||
|
||
/** type='tap-targets':读 state().<targetsPath> 的离散可点目标 {x,y,occupied[,safe]},每步点一个未占用目标,到 gameover 即停(交 latch 校验)。
|
||
* 复用于井字棋/扫雷/翻牌/打地鼠/Simon/见缝插针/节奏等「点击离散目标」类游戏。
|
||
* 【家族二分】安全放置族(井字棋/invaders…任一未占用目标都是合法推进招)= 盲点首个;
|
||
* 规避/推理族(扫雷…部分目标点了立即致负)须配 driver.safeOnly=true:仅点 safe===true 目标、确定性避负把核心循环跑透。 */
|
||
async function runTapTargets(cdp, driver, hashes) {
|
||
const targetsPath = driver.targetsPath || 'targets';
|
||
const steps = driver.steps || 12;
|
||
const stepMs = driver.stepMs != null ? driver.stepMs : 320;
|
||
let drove = 0, cyc = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 终局即停(交 latch 校验)
|
||
const targets = getPath(s, targetsPath);
|
||
if (Array.isArray(targets) && targets.length) {
|
||
let t;
|
||
if (driver.safeOnly === true) {
|
||
// 规避/推理族(扫雷):仅点 safe===true 的目标,确定性避负 → 把核心循环(洪水填充/邻数揭示/win 判定)跑透;
|
||
// 无安全目标(核心应已揭完→win)即停,交 latch 校验 win 终态;绝不回退点不安全目标(否则又踩雷、核心零暴露)。
|
||
t = targets.find((g) => g && g.occupied !== true && g.safe === true && typeof g.x === 'number');
|
||
if (!t) break;
|
||
} else {
|
||
// 安全放置族(井字棋/invaders…):任一未占用目标都是合法推进招,盲点首个;全占用/未导出 occupied → 按序轮点。
|
||
t = targets.find((g) => g && g.occupied !== true && typeof g.x === 'number');
|
||
if (!t) { t = targets[cyc % targets.length]; }
|
||
}
|
||
cyc++;
|
||
if (t && typeof t.x === 'number') { await tap(cdp, Math.max(4, Math.min(386, t.x)), Math.max(4, Math.min(840, t.y))); drove++; }
|
||
else { await delay(stepMs); }
|
||
} else { await delay(stepMs); } // 没导出 targets → 空转(H/G 门会判红线)
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** type='flap-to-gap':读控制体 y 与目标缝隙中心 y——体在缝下方(y 更大=更低)就拍翅上升,否则任重力下落。驱动 Flappy 类穿缝。 */
|
||
async function runFlapToGap(cdp, driver, hashes) {
|
||
const yPath = driver.entityPath || 'bird.y';
|
||
const gapPath = driver.gapPath || 'nextGap.centerY';
|
||
const tapX = driver.tapX != null ? driver.tapX : 195;
|
||
const steps = driver.steps || 60;
|
||
const stepMs = driver.stepMs != null ? driver.stepMs : 110;
|
||
const margin = driver.margin != null ? driver.margin : 16;
|
||
let drove = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 已结束(撞死/通关)→停,交 latch 校验
|
||
const by = getPath(s, yPath);
|
||
let gy = getPath(s, gapPath);
|
||
if (typeof gy !== 'number') gy = 422; // 无缝隙信息→维持中线高度
|
||
if (typeof by === 'number' && by > gy + margin) { await tap(cdp, tapX, 420); drove++; } // 偏低→拍翅上升
|
||
else { await delay(20); } // 偏高/已对齐→不拍,让重力下落
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** type='seek-x':读控制体 x 与下一平台 x——点屏左/右半把体水平移向平台,驱动 Doodle 类踩台上升。 */
|
||
async function runSeekX(cdp, driver, hashes) {
|
||
const xPath = driver.entityPath || 'bird.x';
|
||
const targetPath = driver.targetPath || 'nextPlatform.x';
|
||
const leftX = driver.leftX != null ? driver.leftX : 70;
|
||
const rightX = driver.rightX != null ? driver.rightX : 320;
|
||
const steps = driver.steps || 60;
|
||
const stepMs = driver.stepMs != null ? driver.stepMs : 120;
|
||
const dead = driver.deadzone != null ? driver.deadzone : 18;
|
||
let drove = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 已结束(掉落)→停,交 latch 校验
|
||
const bx = getPath(s, xPath);
|
||
const tx = getPath(s, targetPath);
|
||
if (typeof bx === 'number' && typeof tx === 'number') {
|
||
if (tx < bx - dead) { await tap(cdp, leftX, 420); drove++; } // 平台在左→点左半左移
|
||
else if (tx > bx + dead) { await tap(cdp, rightX, 420); drove++; } // 平台在右→点右半右移
|
||
else { await delay(20); } // 已对齐→不动
|
||
} else { await delay(stepMs); }
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** 触屏拖拽:touchStart(from) → 多帧 touchMove 插值到 to → touchEnd。受控面只给原始 pointer,swipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */
|
||
async function drag(cdp, from, to, ms) {
|
||
const f = from || { x: 195, y: 600 }, t = to || { x: 195, y: 700 };
|
||
const n = 8, dur = ms || 400;
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f.x, y: f.y }] });
|
||
for (let i = 1; i <= n; i++) {
|
||
const k = i / n, x = f.x + (t.x - f.x) * k, y = f.y + (t.y - f.y) * k;
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] });
|
||
await delay(Math.max(8, Math.floor(dur / n)));
|
||
}
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await delay(120);
|
||
}
|
||
|
||
/** type='tap-pairs':读 state().<targetsPath> 离散目标,每步点【相邻两个】(点A→点B=交换),到 gameover 即停。
|
||
* 驱动 Match-3 类「交换相邻消除」——盲态轮换交换,验便宜模型能否产可消三连(多半暴露需「读盘找可消」的更强 driver/swipe 门面)。 */
|
||
async function runTapPairs(cdp, driver, hashes) {
|
||
const targetsPath = driver.targetsPath || 'targets';
|
||
const steps = driver.steps || 30;
|
||
const stepMs = driver.stepMs != null ? driver.stepMs : 280;
|
||
let drove = 0, k = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 终局即停(交 latch 校验)
|
||
const targets = getPath(s, targetsPath);
|
||
if (Array.isArray(targets) && targets.length >= 2) {
|
||
const a = targets[k % targets.length], b = targets[(k + 1) % targets.length];
|
||
k++;
|
||
if (a && b && typeof a.x === 'number' && typeof b.x === 'number') {
|
||
await tap(cdp, a.x, a.y); await delay(90); await tap(cdp, b.x, b.y); drove++;
|
||
} else { await delay(stepMs); }
|
||
} else { await delay(stepMs); }
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** type='key-cycle':循环按下 driver.keys 列表里的键,到 gameover 即停。驱动 Tetris/2048/Asteroids 等纯按键游戏(盲态铺满/合并/射击→暴露上限)。 */
|
||
async function runKeyCycle(cdp, driver, hashes) {
|
||
const keys = (driver.keys && driver.keys.length) ? driver.keys : ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'];
|
||
const steps = driver.steps || 40;
|
||
const stepMs = driver.stepMs != null ? driver.stepMs : 200;
|
||
let drove = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 终局即停(交 latch 校验)
|
||
await key(cdp, keys[i % keys.length], driver.downMs || 60); drove++;
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** type='paddle-intercept':每步读球 x、tap 到球正下方把挡板移过去接(真玩到清砖);到 gameover 即停。 */
|
||
async function runPaddleIntercept(cdp, driver, hashes) {
|
||
const ballPath = driver.ballPath || 'ball.x';
|
||
const paddleY = driver.paddleY != null ? driver.paddleY : 800;
|
||
const steps = driver.steps || 60;
|
||
const stepMs = driver.stepMs || 130;
|
||
let drove = 0;
|
||
for (let i = 0; i < steps; i++) {
|
||
const s = await readGameState(cdp);
|
||
if (s && s.phase === 'gameover') break; // 已结束,停(剩交给 latch 校验)
|
||
const bx = getPath(s, ballPath);
|
||
if (typeof bx === 'number') { await tap(cdp, Math.max(8, Math.min(382, bx)), paddleY); drove++; }
|
||
else { await delay(stepMs); } // 读不到球位=游戏没导出位置,空转(H 门会判红线)
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(stepMs);
|
||
}
|
||
return { drove, steps };
|
||
}
|
||
|
||
/** 控制跟手校验(守卫I):连点目标 x,读玩家控制体位置是否平滑逼近(逮「一格一跳」/卡死/不跟手)。 */
|
||
async function runControlCheck(cdp, cc) {
|
||
const ppath = cc.paddlePath || 'paddle.x';
|
||
const paddleY = cc.paddleY != null ? cc.paddleY : 800;
|
||
const taps = cc.tapsPerTarget || 6;
|
||
const moveMin = cc.moveMin != null ? cc.moveMin : 25;
|
||
const tol = cc.tolerance != null ? cc.tolerance : 70;
|
||
const results = [];
|
||
for (const tx of (cc.tapXs || [50, 340])) {
|
||
const before = getPath(await readGameState(cdp), ppath);
|
||
for (let k = 0; k < taps; k++) { await tap(cdp, tx, paddleY); await delay(120); }
|
||
const after = getPath(await readGameState(cdp), ppath);
|
||
const moved = (typeof before === 'number' && typeof after === 'number') ? (after - before) : null;
|
||
const dist = (typeof after === 'number') ? Math.abs(after - tx) : null;
|
||
// 跟手 = 朝 tx 移动了可观距离(|moved|≥moveMin) 且 最终落在容差内(dist≤tol);一格一跳=moved 太小→判否。
|
||
const ok = moved != null && dist != null && Math.abs(moved) >= moveMin && dist <= tol;
|
||
results.push({ tapX: tx, before, after, moved, distToTarget: dist, pass: ok });
|
||
}
|
||
return { pass: results.length > 0 && results.every((r) => r.pass), paddlePath: ppath, results };
|
||
}
|
||
|
||
async function main() {
|
||
const argv = process.argv.slice(2);
|
||
const gameId = argv[0];
|
||
if (!gameId) { console.error('用法:node play.cdp.cjs <gameId> [--base=] [--cdp=] [--spec=]'); process.exit(2); }
|
||
const getArg = (k, def) => { const a = argv.find((x) => x.startsWith(k + '=')); return a ? a.split('=').slice(1).join('=') : def; };
|
||
const base = getArg('--base', 'http://localhost:4320');
|
||
const cdpHttp = getArg('--cdp', 'http://localhost:9222');
|
||
const gameDir = path.resolve('games/_wg1-gen', gameId);
|
||
const evDir = path.join(gameDir, 'evidence');
|
||
fs.mkdirSync(evDir, { recursive: true });
|
||
|
||
// 读 play-spec(缺省极简:无输入,仅验活性/掌帧/渲染/接线)。
|
||
const specPath = getArg('--spec', path.join(gameDir, 'play-spec.json'));
|
||
let spec = { inputs: [], expectedEngineCallPrefixes: [] };
|
||
try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (_) { /* 用默认 */ }
|
||
|
||
const url = `${base}/games/_wg1-gen/${gameId}/index.html`;
|
||
const verdict = { gameId, url, ts: null, guards: {}, pass: false, notes: [] };
|
||
let cdp = null;
|
||
try {
|
||
cdp = await connect(url, cdpHttp);
|
||
// 守卫A:装载 + 引擎接管。
|
||
await waitBoot(cdp, 30000);
|
||
verdict.guards.A_boot = { pass: true };
|
||
|
||
// 首帧截图 + 渲染快照(守卫D 初值 + 守卫E t0)。
|
||
await saveScreenshot(cdp, path.join(evDir, 'first-paint.png'));
|
||
const px0 = await brightPixels(cdp, '#game-engine');
|
||
// 守卫H 玩前态:真玩输入前读一次可观测状态(基线,供机制断言对照)。
|
||
const state0 = await readGameState(cdp);
|
||
|
||
// 守卫C:掌帧增量。
|
||
const fd = await frameDelta(cdp, 500);
|
||
verdict.guards.C_frame = { pass: fd.delta != null && fd.delta >= 20 && fd.delta <= 45, ...fd };
|
||
|
||
// 守卫I:控制跟手(声明 controlCheck 才启用)—— 读玩家控制体位置,验「点哪、挡板平滑跟到哪」,逮一格一跳/卡死/不跟手。
|
||
if (spec.controlCheck) {
|
||
verdict.guards.I_control = await runControlCheck(cdp, spec.controlCheck);
|
||
} else {
|
||
verdict.guards.I_control = { pass: true, skipped: '未声明 controlCheck(向后兼容)' };
|
||
}
|
||
|
||
// 真玩:有 driver 走【适配性驱动】(读 state 自动接球,真玩技巧游戏,解盲打假阴性);否则走固定输入序列。每步采帧哈希供守卫E。
|
||
const hashes = [px0.hash];
|
||
if (spec.driver) {
|
||
await runDriver(cdp, spec.driver, hashes);
|
||
} else {
|
||
for (const ev of (spec.inputs || [])) {
|
||
if (ev.t === 'tap') await tap(cdp, ev.x, ev.y);
|
||
else if (ev.t === 'key') await key(cdp, ev.code, ev.downMs);
|
||
else if (ev.t === 'drag') await drag(cdp, ev.from, ev.to, ev.ms);
|
||
else if (ev.t === 'wait') await delay(ev.ms || 200);
|
||
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
|
||
await delay(50);
|
||
}
|
||
}
|
||
await delay(250);
|
||
|
||
// 守卫D:真渲染(输入后再测,取较优)。
|
||
const px1 = await brightPixels(cdp, '#game-engine');
|
||
hashes.push(px1.hash);
|
||
const bright = Math.max(px0.bright, px1.bright), maxCh = Math.max(px0.maxCh, px1.maxCh);
|
||
verdict.guards.D_render = { pass: bright > 50 && maxCh > 80, bright, maxCh };
|
||
|
||
// 守卫E:活性 —— 整段真玩出现 ≥2 个不同画面态(真在动/真在响应);全程恒同一帧=冻屏判否。
|
||
const distinct = new Set(hashes);
|
||
verdict.guards.E_live = { pass: distinct.size >= 2, distinctStates: distinct.size, samples: hashes.length };
|
||
|
||
// 守卫B:未捕获错误。
|
||
const unc = await cdp.evaluate('(function(){ var n=window.__genInternals; var u=(n&&n.uncaught)?n.uncaught:[]; return u.slice(0,10); })()');
|
||
verdict.guards.B_uncaught = { pass: Array.isArray(unc) && unc.length === 0, uncaught: unc };
|
||
|
||
// 守卫F:真接线(在页面内扫【全量】__engineCalls,避免 math.lerp 每帧刷屏把后续 gameplay 调用挤出 200 切片)。
|
||
const prefixes = spec.expectedEngineCallPrefixes || [];
|
||
const fw = await cdp.evaluate(
|
||
'(function(){ var c=window.__engineCalls||[]; var pre=' + JSON.stringify(prefixes) + ';' +
|
||
' var hit=pre.length? c.some(function(x){return pre.some(function(p){return String(x).indexOf(p)===0;});}) : c.length>0;' +
|
||
' var nonLerp=c.filter(function(x){return String(x).indexOf("math.lerp")!==0;});' +
|
||
' return { hit:hit, total:c.length, sample:(nonLerp.length?nonLerp:c).slice(0,12) }; })()'
|
||
);
|
||
verdict.guards.F_wiring = { pass: !!(fw && fw.hit), callCount: (fw && fw.total) || 0, sample: (fw && fw.sample) || [], expected: prefixes };
|
||
|
||
// 守卫H 玩后态:真玩输入后读一次可观测状态(与 state0 对照,判机制是否真有进展)。
|
||
const state1 = await readGameState(cdp);
|
||
|
||
await saveScreenshot(cdp, path.join(evDir, 'after-play.png'));
|
||
|
||
// 守卫G:输入有效性(确定性 A/B 对照,隔离「靠自走动画蒙混过 E」的假绿)。
|
||
// 同 URL 同 seed 再开一个【无输入】对照实例,推进到与输入实例【同一帧号】,比对整帧哈希:
|
||
// 不同 ⇒ 输入真改变了游戏走向(挡板位置/Simon 轮次/打地鼠分数都会持续到该帧);相同 ⇒ 输入无效(判否)。
|
||
const hasInput = !!spec.driver || (spec.inputs || []).some((e) => e.t === 'tap' || e.t === 'key');
|
||
if (hasInput) {
|
||
let ctrl = null;
|
||
try {
|
||
const endFrame = await cdp.evaluate('(function(){var e=window.__engine;var s=(e&&typeof e.snapshot==="function")?e.snapshot():e;return (s&&typeof s.frame==="number")?s.frame:0;})()');
|
||
ctrl = await connect(url, cdpHttp);
|
||
await waitBoot(ctrl, 30000);
|
||
const reached = await advanceToFrame(ctrl, endFrame, 20000);
|
||
const ch = await brightPixels(ctrl, '#game-engine');
|
||
verdict.guards.G_input = { pass: px1.hash !== ch.hash, inputHash: px1.hash, controlHash: ch.hash, atFrame: endFrame, ctrlFrame: reached };
|
||
} catch (e2) {
|
||
verdict.guards.G_input = { pass: false, err: String((e2 && e2.message) || e2) };
|
||
} finally {
|
||
if (ctrl) ctrl.close();
|
||
}
|
||
} else {
|
||
verdict.guards.G_input = { pass: true, skipped: '无 tap/key 输入' };
|
||
}
|
||
|
||
// 守卫H:机制进展 + P0 latch 终态(逐游戏确定性)—— 隔离「七门全过但空心」漂亮空壳 + 验官方 latch 终态契约(闭环锚)。
|
||
// spec.assertAfterPlay[] = 进展断言(remaining↓ 等,证非空心);spec.expectLatch=true = 须真玩到 phase='gameover' 且【驻留】。
|
||
// 二者皆未声明=老游戏→SKIP(向后兼容);声明了却读不到 state(未实现 _forensicsView)=可测性红线违约→判否。
|
||
const asserts = spec.assertAfterPlay || [];
|
||
const expectLatch = spec.expectLatch === true;
|
||
if (asserts.length === 0 && !expectLatch) {
|
||
verdict.guards.H_progress = { pass: true, skipped: '未声明 assertAfterPlay/expectLatch(向后兼容)' };
|
||
} else if (!state1 || state1.phase === 'booting') {
|
||
verdict.guards.H_progress = { pass: false, err: '声明了机制门但游戏未经 _forensicsView() 导出可观测状态(可测性红线未满足)', state0, state1 };
|
||
} else {
|
||
const checks = asserts.map((a) => checkAssertion(a, state0, state1));
|
||
let latch = null;
|
||
if (expectLatch) {
|
||
// 停止输入后轮询等进入 gameover(无失败态/永不结束=终态不可达=判否);最多 ~4.2s。
|
||
let phaseNow = state1.phase;
|
||
for (let i = 0; i < 14 && phaseNow !== 'gameover'; i++) { await delay(300); const s = await readGameState(cdp); phaseNow = s ? s.phase : null; }
|
||
if (phaseNow !== 'gameover') {
|
||
latch = { pass: false, reason: '真玩未到达 gameover 终态(终态不可达/无失败态/play-spec 未驱动到结束)', phaseNow };
|
||
} else {
|
||
// latch 驻留校验:再等 600ms,phase 必须仍 'gameover'(瞬时终态/自动重开=违约,宿主 500ms 轮询会漏读)。
|
||
await delay(600);
|
||
const s2 = await readGameState(cdp);
|
||
const dwell = !!(s2 && s2.phase === 'gameover');
|
||
latch = { pass: dwell, dwelledMs: 600, after: s2 ? s2.phase : null, reason: dwell ? '' : 'gameover 未驻留(下帧自动重开/复位=latch 违约)' };
|
||
}
|
||
}
|
||
const progressPass = checks.every((c) => c.pass);
|
||
const latchPass = !expectLatch || !!(latch && latch.pass);
|
||
verdict.guards.H_progress = { pass: progressPass && latchPass, checks, latch, state0, state1 };
|
||
}
|
||
|
||
verdict.pass = ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'E_live', 'F_wiring', 'G_input', 'H_progress', 'I_control']
|
||
.every((g) => verdict.guards[g] && verdict.guards[g].pass);
|
||
} catch (e) {
|
||
verdict.error = String((e && e.message) || e);
|
||
verdict.guards.A_boot = verdict.guards.A_boot || { pass: false, err: verdict.error };
|
||
} finally {
|
||
if (cdp) cdp.close();
|
||
}
|
||
|
||
fs.writeFileSync(path.join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2));
|
||
// 控制台四件套摘要。
|
||
const g = verdict.guards;
|
||
console.log(`\n=== W-G1 真玩判定 [${gameId}] ${verdict.pass ? '✅ PASS' : '❌ FAIL'} ===`);
|
||
const line = (k, ok, extra) => console.log(` ${ok ? '✅' : '❌'} ${k} ${extra || ''}`);
|
||
if (g.A_boot) line('A 装载', g.A_boot.pass, g.A_boot.err || '');
|
||
if (g.B_uncaught) line('B 未捕获', g.B_uncaught.pass, `uncaught=${(g.B_uncaught.uncaught || []).length}`);
|
||
if (g.C_frame) line('C 掌帧', g.C_frame.pass, `Δframe=${g.C_frame.delta} (期望[20,45])`);
|
||
if (g.D_render) line('D 真渲染', g.D_render.pass, `bright=${g.D_render.bright} maxCh=${g.D_render.maxCh}`);
|
||
if (g.E_live) line('E 活性', g.E_live.pass, `distinct=${g.E_live.distinctStates}/${g.E_live.samples} 帧态`);
|
||
if (g.F_wiring) line('F 真接线', g.F_wiring.pass, `calls=${g.F_wiring.callCount} sample=${JSON.stringify((g.F_wiring.sample || []).slice(0, 4))}`);
|
||
if (g.G_input) line('G 输入有效', g.G_input.pass, g.G_input.skipped || (g.G_input.err ? g.G_input.err : `输入态${g.G_input.inputHash}≠对照${g.G_input.controlHash}@f${g.G_input.atFrame}`));
|
||
if (g.H_progress) line('H 机制+latch', g.H_progress.pass, g.H_progress.skipped || g.H_progress.err ||
|
||
((g.H_progress.checks || []).map((c) => `${c.path}:${JSON.stringify(c.before)}→${JSON.stringify(c.after)}[${c.op}${c.pass ? '✓' : '✗'}]`).join(' ')
|
||
+ (g.H_progress.latch ? ` latch[${g.H_progress.latch.pass ? '✓驻留' : '✗' + (g.H_progress.latch.reason || '')}]` : '')));
|
||
if (g.I_control) line('I 控制跟手', g.I_control.pass, g.I_control.skipped ||
|
||
(g.I_control.results || []).map((r) => `tap${r.tapX}:move${r.moved == null ? '?' : Math.round(r.moved)}→dist${r.distToTarget == null ? '?' : Math.round(r.distToTarget)}[${r.pass ? '✓' : '✗'}]`).join(' '));
|
||
if (verdict.error) console.log(' ⚠️ error:', verdict.error);
|
||
console.log(` evidence → ${path.relative(process.cwd(), evDir)}/`);
|
||
process.exit(verdict.pass ? 0 : 1);
|
||
}
|
||
|
||
main();
|