lili 38032c72b4 feat(wg1): tap-targets safeOnly oracle driver——扫雷 borderline 翻绿(自产缺口修复)
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>
2026-06-14 20:36:51 -07:00

563 lines
34 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.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();