zizi d4f16aabff feat(wg1-groupc): 九门 harness 加首局体验门(品类化·additive 派生超集)——3 断言三款真玩全绿+skill 类首反馈 A/B 真判
组C 严谨首反馈:A/B 无输入对照,skill 类升级真判(超上一档诚实化 SKIP):
- 上一档(P1-1)把动画自走类(skill/action,如 breakout)首反馈标 SKIP,因「帧哈希 changed」对动球
  是重言式(球每帧本就在动)非输入因果。本档落地 A/B 无输入对照,把 SKIP 升级为真判——
  动画自走类的输入因果靠「有输入 vs 无输入的差异」鉴别,而非裸帧变。
- 复用 G 门 A/B 范式:三趟 fresh 实例同 seed(缺省 0x1234abcd)+LittleJS 固定步推进到【同一绝对帧】比对:
  A=无输入自走基线 / Ap=无输入纯净探针 / B=派一招首招。real 引擎自由运行(host 不掌 RAF/无法暂停回退),
  三趟落帧有 ≤数帧抖动→动球自走位置略差,故【不】用裸帧哈希等值(过严必假 SKIP),分阶梯判:
    [0] 控制体维度(input-controllable,最干净免动球抖动):spec.controlCheck.paddlePath(如 breakout
        paddle.x)——A 趟控制体停默认位、B 趟点远端边连派 tapsPerTarget 拍被移开;|ctrlB-ctrlA|>moveMin
        =真输入因果 true。这是 prompt「只比 input-controllable 维度」之意。
    [1] 否则 state 级因果(remaining/score/moves/phase A vs B 变)=次强信号,直接 true;
    [2] 否则像素信号/噪声(noise=diff(A,Ap)基线抖动地板,signal=diff(A,B));signal 显著超 noise→true(兜底);
    [3] noise 超顶=非确定性(unseeded random/wall-clock)→不可净判 SKIP(血统债,不假判);
    [4] signal≈noise 且无控制体维度→SKIP(动球抖动可能盖过信号,不假 FAIL)。
- 必须串行跑三趟:headless Chrome 对后台 target rAF 节流/冻结,并发开三个只前台跑(实测 A/Ap frame=-1、
  B=2484 全程跑完=对照失效);串行下每趟=当前唯一活动 target 满速,确定性保同绝对帧自走态可比。
- 纪律:路A 零改契约/prompt/play-spec,只动 play.cdp.cjs(additive,advanceToFrame 加 pollMs 缺省 100 向后兼容,
  G 门逐字不变);静态盘类(placement/avoidance)走原单趟口径不动;首局门 FAIL 不阻断九门;warm-only 标注保留。
- 重验(mini-desktop Chrome/CDP):breakout 首反馈 假 SKIP→真判 (控制体 paddle.x delta=145,A=195/B=50,
  control-body-moved,净判=true);tictactoe/saolei 首反馈仍真(单趟 state变=true);三款九门零回归
  (逐门 base==verify,均 A-I 全 true,base 跑于 pristine ee1b697e);firstPlay.pass 全 true。
- 诚实:breakout A/B 净判靠控制体维度(paddle.x),非裸全帧像素——实测裸全帧 noise≈signal(球每帧动+两趟落帧
  抖动污染),故有控制体路径优先取该维度免污染;无控制体路径的 skill 类退信号/噪声兜底,signal≈noise 即诚实 SKIP。

P2-1 warm-only 局限标注保留:playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;
真机 first-load 阈值校准留 staging。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:38:33 +08:00

1063 lines
73 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=超时)。
* pollMs:轮询间隔(缺省 100ms,G 门沿用)。A/B 首反馈对照传小值(如 16ms)逼近精确落帧——
* 粗轮询(100ms≈6帧)会让 A/A' 落在不同精确帧→动球类自走态有差→污染对照;细轮询使三趟尽量落同一帧,差异才纯归输入。 */
async function advanceToFrame(cdp, targetFrame, timeoutMs, pollMs) {
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(pollMs || 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);
if (driver.type === 'drag-aiming') return runDragAiming(cdp, driver, hashes);
if (driver.type === 'aim-fire') return runAimFire(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 + 竖直速度 vy,做【速度前瞻预测】对齐缝隙中心——预测下一拍仍偏低就拍翅,否则任重力下落。
* 原纯位置反应式对「高位缝/快下落」追不上或过冲(flappy 假阴根因);前瞻=早拍防追不上、晚拍防过冲。驱动 Flappy 类穿缝。 */
async function runFlapToGap(cdp, driver, hashes) {
const yPath = driver.entityPath || 'bird.y';
const vyPath = driver.vyPath || 'bird.vy'; // 竖直速度(y 向下为正→vy>0=下落);缺失则退化为纯位置反应式
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;
const lookahead = driver.lookahead != null ? driver.lookahead : 0.18; // 速度前瞻秒数:预测 lookahead 秒后位置(vy 单位 px/s)
const aimBias = driver.aimBias != null ? driver.aimBias : 24; // 瞄准缝心上方 aimBias px(y 越小越高),留下落余量→落入缝心而非贴下沿
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);
const vy = getPath(s, vyPath);
let gy = getPath(s, gapPath);
if (typeof gy !== 'number') gy = 422; // 无缝隙信息→维持中线高度
const target = gy - aimBias; // 目标点略高于缝心
// 前瞻预测:当前位置 + 速度×lookahead = 下一拍预计位置;预测仍偏低于 target+margin 才拍翅(早拍/不过冲)。
const predicted = (typeof by === 'number')
? by + (typeof vy === 'number' ? vy * lookahead : 0)
: null;
if (predicted != null && predicted > target + 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='drag-aiming':抛射瞄准(愤怒小鸟类)。读 targets[{x,y}] 选最近目标 + `launch{gravity,powerScale}`:
* 游戏暴露物理常数 → 45° 抛【解析反解】拖拽矢量(确定性命中 + 各发 ±力度微抖兜物理离散;asteroids 式『暴露即可测』范式);
* 未暴露 → 退化「力度扫描 powers[] + 上抬补下坠 upLead」逐发兜(确定性尽力,非保证;全 miss=可测性边界,非模型造不出)。 */
async function runDragAiming(cdp, driver, hashes) {
const origin = driver.origin || { x: 80, y: 600 }; // 弹弓发射点(逻辑像素)
const targetsPath = driver.targetsPath || 'targets';
const steps = driver.steps || 6; // 最多发射次数(通常=birdsLeft)
const settleMs = driver.settleMs != null ? driver.settleMs : 1700; // 每发后等飞行+结算
const powers = driver.powers || [70, 95, 120, 150, 180, 210, 240]; // 拖拽距离扫描(力∝距离,常数未知→扫描兜不同射程)
const upLead = driver.upLead != null ? driver.upLead : 60; // 抛物线下坠补偿:瞄准点上抬 px
const dragMs = driver.dragMs != null ? driver.dragMs : 360;
let drove = 0, shot = 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);
// 选最近的活目标(猪/关键砖);无合法目标→停。
let aim = null;
if (Array.isArray(targets) && targets.length) {
for (const t of targets) {
if (!t || typeof t.x !== 'number' || typeof t.y !== 'number') continue;
if (!aim) { aim = t; continue; }
const da = Math.hypot(aim.x - origin.x, aim.y - origin.y);
const db = Math.hypot(t.x - origin.x, t.y - origin.y);
if (db < da) aim = t;
}
}
if (!aim) break;
// 物理感知瞄准:游戏暴露 launch{gravity,powerScale} → 45° 抛解析反解拖拽矢量;否则退化力度扫描。
const launch = getPath(s, driver.launchPath || 'launch');
let to = null;
if (launch && typeof launch.gravity === 'number' && typeof launch.powerScale === 'number' && launch.powerScale > 0) {
const g = launch.gravity, K = launch.powerScale;
const dx = aim.x - origin.x; // 目标在右为正
const dyUp = origin.y - aim.y; // 目标在上为正(屏 y 向下)
const denom = dx - dyUp; // 45° 抛闭式需 dx>dyUp
if (dx > 0 && denom > 0) {
const c = Math.sqrt(0.5 * g * dx * dx / denom); // 45° 各轴分速 vx=vyUp=c(初速 s=c√2)
const jit = [0, 0.06, -0.06, 0.12, -0.12, 0.18][shot % 6]; // 各发 ±力度微抖,兜物理离散/常数微偏
const dragLen = (c / K) * (1 + jit); // 反解每轴拖拽像素(拖拽距×K=初速)
const cand = { x: origin.x - dragLen, y: origin.y + dragLen }; // 左下拖→右上发
if (cand.x >= 4 && cand.y <= 840) to = cand; // 解在屏内才用,否则退扫描
}
}
if (!to) {
// 退化:无 launch 常数 / 解超界 → origin→aim 方向 + 力度扫描(原行为)。
const aimY = aim.y - upLead;
const ddx = aim.x - origin.x, ddy = aimY - origin.y;
const len = Math.hypot(ddx, ddy) || 1;
const power = powers[shot % powers.length];
to = {
x: Math.max(4, Math.min(386, origin.x - (ddx / len) * power)),
y: Math.max(4, Math.min(840, origin.y - (ddy / len) * power)),
};
}
await drag(cdp, origin, to, dragMs);
drove++; shot++;
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(settleMs);
}
return { drove, steps };
}
/** type='aim-fire':射击瞄准(Asteroids 类)。读 ship{x,y,angle} + targets[{x,y}] 选最近目标,旋转使
* |目标方位(atan2) - ship.angle| 收敛→对齐即开火;旋向【自适应】(上拍 |diff| 变大就翻向,免猜角度约定 CW/CCW)。
* 依赖游戏 exportState 暴露目标坐标(可测性红线)——盲射无坐标=可测性边界,暴露后即可确定性瞄准。 */
async function runAimFire(cdp, driver, hashes) {
const shipPath = driver.shipPath || 'ship';
const targetsPath = driver.targetsPath || 'asteroids';
const fireKey = driver.fireKey || 'Space';
const cwKey = driver.cwKey || 'ArrowRight';
const ccwKey = driver.ccwKey || 'ArrowLeft';
const tol = driver.tol != null ? driver.tol : 0.20; // 对齐容差(弧度,~11°)
const steps = driver.steps || 80;
const stepMs = driver.stepMs != null ? driver.stepMs : 130;
const downMs = driver.downMs != null ? driver.downMs : 70;
const norm = (a) => { while (a > Math.PI) a -= 2 * Math.PI; while (a < -Math.PI) a += 2 * Math.PI; return a; };
let drove = 0, lastDiff = null, rotKey = cwKey;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') break; // 终局即停(清光/被撞死)→交 latch 校验
const ship = getPath(s, shipPath);
const targets = getPath(s, targetsPath);
let aim = null;
if (ship && typeof ship.x === 'number' && Array.isArray(targets)) {
for (const t of targets) {
if (!t || typeof t.x !== 'number' || typeof t.y !== 'number') continue;
if (!aim) { aim = t; continue; }
const da = Math.hypot(aim.x - ship.x, aim.y - ship.y);
const db = Math.hypot(t.x - ship.x, t.y - ship.y);
if (db < da) aim = t;
}
}
if (aim && ship && typeof ship.angle === 'number') {
const bearing = Math.atan2(aim.y - ship.y, aim.x - ship.x);
const diff = Math.abs(norm(bearing - ship.angle));
if (diff <= tol) { // 已对齐→开火
await key(cdp, fireKey, downMs); drove++; lastDiff = null;
} else { // 未对齐→旋转(自适应旋向)
if (lastDiff != null && diff > lastDiff) rotKey = (rotKey === cwKey ? ccwKey : cwKey);
await key(cdp, rotKey, downMs); drove++; lastDiff = diff;
}
} else {
// 读不到 ship/targets(游戏未暴露坐标)→退化盲扫(旋转+开火),H 门会判(可测性红线)
await key(cdp, i % 2 ? fireKey : cwKey, downMs); drove++;
}
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
}
return { drove, steps };
}
/** type='paddle-intercept':每步读球 x、tap 到球正下方把挡板移过去接(真玩到清砖);到 gameover 即停。
* drainAfter 声明时:跑满 drainAfter 步(已证进展)后转【弃守排空相位】——挡板停角落不再追球,让球漏光→触发 lose/gameover,
* 验真终态(接球类如 multiball 靠一直接球永不失败→latch 不可达,drain 把真失败态打出来)。 */
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;
const drainAfter = driver.drainAfter != null ? driver.drainAfter : null; // 此步后弃守排空(null=不排空,向后兼容打砖块)
const drainX = driver.drainX != null ? driver.drainX : 10; // 排空相位挡板停泊 x(远离球→球漏掉)
let drove = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') break; // 已结束,停(剩交给 latch 校验)
if (drainAfter != null && i >= drainAfter) {
// 弃守排空相位:挡板停角落、不再追球,等球漏光→失败终态(latch)。
await tap(cdp, drainX, paddleY);
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
continue;
}
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 };
}
/* ════════════════════════════════════════════════════════════════════════════
* 【首局体验门(W-G1 组C · 品类化 · additive 派生超集)】
* 验主干口号「陌生人 10 秒觉得能玩」——3 条断言:① 可玩≤2s ② 首反馈即时 ③ 60s 内品类核心闭环可达。
* v0=路A(零改生成侧):从【现有 play-spec.driver 家族】反推品类,复用 H 门判定,零新生成。
* 设计要点:①② 走【独立 pre-flight CDP 会话】(预热导航量稳态 playableMs + 派一招测首反馈),
* 与九门主流程完全隔离 → 保证八门判定路径逐字不变(纯 additive,不破八门);
* ③ 在主流程对「driver+latch」段加墙钟 + 从 H 门结果派生 coreLoopReached(仅加时间戳/派生字段)。
* ════════════════════════════════════════════════════════════════════════════ */
/** 路A 品类反推:从 driver.type 家族映射品类(零改生成侧,仅读现有 gatespec)。
* tap-targets(无 safeOnly)→placement(放置族) / tap-targets+safeOnly→avoidance(规避族) /
* paddle-intercept 及其它 action 驱动→skill(技巧/action 族) / 无 driver→degraded(退化)。 */
function deriveCategory(spec) {
const d = spec && spec.driver;
if (!d || !d.type) return 'degraded'; // 无 driver=退化(无对应可驱动品类)
if (d.type === 'tap-targets') return d.safeOnly === true ? 'avoidance' : 'placement';
// 其余 driver(paddle-intercept/flap-to-gap/seek-x/key-cycle/aim-fire/drag-aiming/tap-pairs…)= 技巧/action 族
return 'skill';
}
/** 取「该品类合法首招」的目标坐标(镜像各 driver 选靶逻辑取第一招,不改原 driver;读不到→null)。
* placement(放置):首个未占用 targets 目标;avoidance(规避·扫雷):首个 safe===true 且未占用目标(须避首招踩雷秒负);
* skill(技巧):返回 {kind:'skill'} 交调用方按 driver 派一拍(paddle-intercept→点球正下方 / key-cycle→按首键 等)。 */
function pickFirstMove(state, spec, category) {
const d = (spec && spec.driver) || {};
if (category === 'placement' || category === 'avoidance') {
const targets = getPath(state, d.targetsPath || 'targets');
if (!Array.isArray(targets) || !targets.length) return null;
let t;
if (category === 'avoidance') {
// 规避族首招须点 safe 格(避免首招踩雷把"反馈"判定污染成秒负)。
t = targets.find((g) => g && g.occupied !== true && g.safe === true && typeof g.x === 'number');
} else {
t = targets.find((g) => g && g.occupied !== true && typeof g.x === 'number') || targets.find((g) => g && typeof g.x === 'number');
}
if (t && typeof t.x === 'number') return { kind: 'tap', x: Math.max(4, Math.min(386, t.x)), y: Math.max(4, Math.min(840, t.y)) };
return null;
}
if (category === 'skill') {
// 技巧/action 族:按 driver.type 派一拍合法首招(复用各 driver 选靶口径取第一招)。
if (d.type === 'paddle-intercept' || d.type === undefined) {
const bx = getPath(state, d.ballPath || 'ball.x');
const paddleY = d.paddleY != null ? d.paddleY : 800;
if (typeof bx === 'number') return { kind: 'tap', x: Math.max(8, Math.min(382, bx)), y: paddleY };
return { kind: 'tap', x: 195, y: paddleY }; // 读不到球位→点屏中下兜一拍(仍是合法输入)
}
if (d.type === 'key-cycle') { const keys = (d.keys && d.keys.length) ? d.keys : ['ArrowLeft']; return { kind: 'key', code: keys[0], downMs: d.downMs || 60 }; }
if (d.type === 'aim-fire') return { kind: 'key', code: d.fireKey || 'Space', downMs: d.downMs || 70 };
if (d.type === 'flap-to-gap') return { kind: 'tap', x: d.tapX != null ? d.tapX : 195, y: 420 };
if (d.type === 'seek-x') return { kind: 'tap', x: d.rightX != null ? d.rightX : 320, y: 420 };
if (d.type === 'drag-aiming') { const o = d.origin || { x: 80, y: 600 }; return { kind: 'drag', from: o, to: { x: o.x - 120, y: o.y + 120 }, ms: d.dragMs || 360 }; }
if (d.type === 'tap-pairs') { const tg = getPath(state, d.targetsPath || 'targets'); if (Array.isArray(tg) && tg[0] && typeof tg[0].x === 'number') return { kind: 'tap', x: tg[0].x, y: tg[0].y }; return null; }
return { kind: 'tap', x: 195, y: 600 }; // 未知 skill driver→中屏兜一拍
}
return null; // degraded:无可驱动首招
}
/** 派一招首招(kind=tap/key/drag)。 */
async function dispatchFirstMove(cdp, mv) {
if (!mv) return false;
if (mv.kind === 'tap') { await tap(cdp, mv.x, mv.y); return true; }
if (mv.kind === 'key') { await key(cdp, mv.code, mv.downMs); return true; }
if (mv.kind === 'drag') { await drag(cdp, mv.from, mv.to, mv.ms); return true; }
return false;
}
/** 读引擎当前帧号(real 通道 __engine.snapshot().frame;读不到返 0)。复用 G 门同一帧号口径。 */
async function readEngineFrame(cdp) {
return 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;})()');
}
/** 两帧 RGBA 像素差异计数(逐像素 maxCh 差 >24 计一处变;尺寸不一致返 -1)。用于 A/B 信号/噪声度量。 */
function diffPixels(a, b) {
if (!a || !b || !a.length || a.length !== b.length) return -1;
let n = 0;
for (let i = 0; i < a.length; i += 4) {
const da = Math.max(Math.abs(a[i] - b[i]), Math.abs(a[i + 1] - b[i + 1]), Math.abs(a[i + 2] - b[i + 2]), Math.abs(a[i + 3] - b[i + 3]));
if (da > 24) n++; // 容 readback 抖动,>24 才算真变
}
return n;
}
/** 一趟首反馈对照:fresh navigate → boot → 读 boot 帧 → 若 mv 给了招则立刻派一招(B 趟)/不派(A 趟) →
* 推进到【绝对目标帧 targetFrame】落帧瞬间原子采 RGBA 像素 + state → 关会话。返 {bootFrame, frame, img, state}。
* ★ 必须【串行】单实例跑(open→advance→close,一次只一个活动 target):headless Chrome 对【后台 target】rAF 节流/冻结,
* 若同时开三个 target 并发推进,只有前台那个自由跑、另俩 frame 卡在 boot(实测 A/A'=-1、B=2484 全程) → 对照彻底失效。
* 串行下每趟都是当前唯一活动 target=前台 rAF 满速;确定性(同 seed+固定步)保各趟推进到同一绝对帧的自走态可比。
* @param {Object|null} mv 首招(null=A/A' 不派);@param {number} [repeat] 该招重复次数(控制体类挡板需多拍逼近,
* 取 spec.controlCheck.tapsPerTarget;每拍间留 1 帧消化,推进到目标帧前派完) —— 仍属「一次首招交互」(玩家朝一侧拖/连点)。 */
async function feedbackTrip(connectFn, url, cdpHttp, mv, targetFrame, timeoutMs, pollMs, repeat) {
const trip = await connectFn(url, cdpHttp);
try {
await waitBoot(trip, 30000);
const bootFrame = await readEngineFrame(trip);
if (mv) { // B 趟 boot 后立刻派招(控制体类连派 repeat 拍逼近);A 趟 mv=null 不派(纯自走)
const n = Math.max(1, repeat || 1);
for (let i = 0; i < n; i++) { await dispatchFirstMove(trip, mv); }
}
const reached = await advanceToFrame(trip, targetFrame, timeoutMs, pollMs);
let img = null; try { img = await captureImageData(trip, '#game-engine'); } catch (_) {}
const state = await readGameState(trip);
return { bootFrame, frame: reached, img, state };
} finally {
try { trip.close(); } catch (_) {}
}
}
/** A/B 无输入对照·首反馈严谨判(复用 G 门「同帧号比对」范式,自由运行引擎下用【信号/噪声】净判动球类):
* ★【串行】跑三趟 fresh 实例(各 open→boot→[派招]→推进→采→close,一次只一个活动 target)——
* headless Chrome 对后台 target rAF 节流/冻结,并发开三个只前台那个跑(实测 A/A' frame=-1、B=2484 全程跑完=对照失效);串行可避。
* A(无输入自走基线) / A'(无输入纯净探针) / B(派一招首招),其中 B boot 后立刻派一招。
* ★ 三趟推进到【同一绝对目标帧 target】(同 seed 缺省 0x1234abcd + LittleJS 固定步,frame N=自 init 第 N 拍,跨 fresh 实例可比;
* G 门 atFrame=943 对照已实证此性质)。但 real 引擎自由运行(host 不掌 RAF,无法暂停/回退,见 boot-game-host real 通道),
* 三趟落帧有 ≤数帧抖动 → 动球自走位置略有差(噪声)。故【不】用裸帧哈希等值(过严→动球类必假 SKIP),改【信号/噪声】度量:
* noise = diffPixels(A, A') —— 两趟【无输入】基线抖动(同帧号目标下的落帧抖动+引擎噪声),即「可净判的噪声地板」。
* signal = diffPixels(A, B) —— 输入趟 vs 无输入基线的差异(=输入因果 + 同等抖动)。
* ★ 判定阶梯(优先取「免动球抖动污染」的因果维度):
* ⓪ 控制体维度(input-controllable):若 spec.controlCheck.paddlePath 给了控制体位置路径(如 breakout paddle.x)——
* A 趟无输入→控制体停默认位、B 趟点首招→控制体被移;|ctrlB-ctrlA| 显著(>moveMin) → true(挡板动 vs 不动=真输入因果,
* 只比该维度=【免球位/粒子抖动污染】,最干净);≈0 → false(输入没动控制体=坏输入,真 FAIL)。这是 prompt「只比 input-controllable 维度」之意。
* ① 否则 state 级因果(remaining/score/moves/phase 变,A vs B)=次强信号,直接 true(无歧义);
* ② 否则像素级:signal 显著超 noise(signal ≥ noise×3 ∧ signal ≥ MIN_SIGNAL) ∧ 噪声地板有界(noise ≤ NOISE_CEILING) → true;
* ——但动球类裸全帧像素易被「球每帧本在动+两趟落帧抖动」污染(实测 breakout noise≈signal),故仅作【无控制体路径时】的兜底,有路径优先取⓪。
* ③ noise 超顶(NOISE_CEILING,两趟无输入已大幅分叉)=非确定性(unseeded random/wall-clock 物理) → 不可净判,SKIP(血统债);
* ④ 否则(signal≈noise,输入未显著超基线,且无控制体维度可裁) → SKIP(像素不可净判,标血统债;不假 FAIL——可能只是动球抖动盖过信号)。
* 返回 {clean(可净判?), feedback(true/false/null), noise, signal, ctrlDelta, stateChanged, ...}。任一趟 boot/推进异常→抛,由调用方兜成 SKIP。
* @param {Object} spec play-spec(读 controlCheck.paddlePath/moveMin 取控制体维度)。
* @param {number} fbFrames 反馈窗折算帧数(目标=boot 帧 + fbFrames,确保推进段够长容输入显现)。 */
async function measureFirstFeedbackAB(connectFn, url, cdpHttp, mv, spec, fbFrames) {
// 信号/噪声门限:动球类一招挡板移位上百像素,远超 ≤数帧球位抖动(几十像素内);取保守倍率+绝对地板+噪声顶。
const SIGNAL_RATIO = 3; // signal 须 ≥ noise×3 才认显著(隔离纯抖动)
const MIN_SIGNAL = 200; // 像素绝对地板(挡板 70px 宽×移位→数百像素;低于此视为无实质输入效果)
const NOISE_CEILING = 6000; // 噪声顶(两趟无输入差异超此=非确定性,不可净判)
const win = Math.max(6, fbFrames);
{
// ★ 串行三趟(每趟 open→boot→[派招]→推进→采→close,避免并发后台 target rAF 冻结)。
// 1) 探一趟 boot 帧以定【绝对目标帧 target】(留 30 帧余量,保后续趟 boot 帧 < target 不被秒越过)。
const tmp = await connectFn(url, cdpHttp);
let firstBoot = 0;
try { await waitBoot(tmp, 30000); firstBoot = await readEngineFrame(tmp); } finally { try { tmp.close(); } catch (_) {} }
const target = firstBoot + win + 30; // 绝对目标帧(参照 boot + 窗帧 + 余量)
// 控制体类首招【因果探针强化】:若 spec.controlCheck 给了控制体(paddle.x)+tapXs,B 趟改点【离默认位最远的 tapX】并连派 tapsPerTarget 拍——
// 原 mv 点 ball-x 可能 ≈ 控制体默认位(实测 ball≈198≈paddle195→Δ仅3)→无法证因果;改点远端边(如 x=50)保控制体被大幅移开,因果信号干净。
// 仍属「一次该品类合法首招」(玩家朝一侧拖/连点把挡板移到边)。
const cc = spec && spec.controlCheck;
let bMove = mv, bRepeat = 1;
if (cc && cc.paddlePath && Array.isArray(cc.tapXs) && cc.tapXs.length && cc.paddleY != null) {
const farX = cc.tapXs.reduce((a, b) => (Math.abs(b - 195) > Math.abs(a - 195) ? b : a), cc.tapXs[0]); // 离屏中默认位最远的 tapX
bMove = { kind: 'tap', x: Math.max(4, Math.min(386, farX)), y: cc.paddleY };
bRepeat = Math.max(1, cc.tapsPerTarget || 1);
}
// 2) 三趟串行各推进到 ≥target,落帧瞬间原子采像素+state(16ms 细轮询逼近精确落帧);B 趟派首招,A/A' 不派。
const gA = await feedbackTrip(connectFn, url, cdpHttp, null, target, 30000, 16); // 无输入自走基线
const gAp = await feedbackTrip(connectFn, url, cdpHttp, null, target, 30000, 16); // 无输入纯净探针
const gB = await feedbackTrip(connectFn, url, cdpHttp, bMove, target, 30000, 16, bRepeat); // 派首招(控制体类连派至远端边)
const aData = gA.img && gA.img.data, apData = gAp.img && gAp.img.data, bData = gB.img && gB.img.data;
const noise = diffPixels(aData, apData); // 无输入基线抖动(噪声地板)
const signal = diffPixels(aData, bData); // 输入趟 vs 无输入基线
const stateChanged = !!(gA.state && gB.state && (gA.state.phase !== gB.state.phase || gA.state.score !== gB.state.score || gA.state.moves !== gB.state.moves || gA.state.remaining !== gB.state.remaining));
const pixelsOk = (noise >= 0 && signal >= 0); // 两次像素采集都成功才论像素信号
// ⓪ 控制体维度(input-controllable):读 spec.controlCheck.paddlePath 指向的控制体位置(如 breakout paddle.x)——免动球/粒子抖动污染。
const ctrlPath = spec && spec.controlCheck && spec.controlCheck.paddlePath;
const moveMin = (spec && spec.controlCheck && typeof spec.controlCheck.moveMin === 'number') ? spec.controlCheck.moveMin : 25;
let ctrlA = null, ctrlB = null, ctrlDelta = null;
if (ctrlPath) {
ctrlA = getPath(gA.state, ctrlPath); // A 趟无输入→控制体停默认位
ctrlB = getPath(gB.state, ctrlPath); // B 趟点首招→控制体被移
if (typeof ctrlA === 'number' && typeof ctrlB === 'number') ctrlDelta = Math.abs(ctrlB - ctrlA);
}
let clean, feedback, reason;
if (ctrlDelta != null) {
// ⓪ 控制体维度可裁(最干净,免动球抖动):B 输入把控制体移开默认位 → 显著(>moveMin)=真输入因果 true;≈0=输入没动控制体=真 FAIL。
if (ctrlDelta > moveMin) { clean = true; feedback = true; reason = 'control-body-moved'; }
else { clean = true; feedback = false; reason = 'control-body-not-moved'; }
} else if (stateChanged) {
// ① state 级因果(次强信号):A vs B 的 phase/score/moves/remaining 变 = 输入真改变游戏态,无歧义 true。
clean = true; feedback = true; reason = 'state-causal';
} else if (!pixelsOk) {
// 像素采集失败(canvas readback 异常)且无控制体/state 信号 → 不可净判 SKIP。
clean = false; feedback = null; reason = 'pixel-capture-failed';
} else if (noise > NOISE_CEILING) {
// ③ 噪声地板超顶:两趟无输入已大幅分叉 = 非确定性(unseeded random/wall-clock 物理) → 不可净判 SKIP(血统债)。
clean = false; feedback = null; reason = 'nondeterministic-noise-over-ceiling';
} else if (signal >= MIN_SIGNAL && signal >= noise * SIGNAL_RATIO) {
// ② 像素级显著(仅无控制体路径时兜底):输入趟差异远超无输入基线抖动 → 真输入因果 true。
clean = true; feedback = true; reason = 'pixel-signal-over-noise';
} else {
// ④ signal≈noise 且无控制体维度可裁:动球抖动可能盖过信号,不敢断 FAIL → 诚实 SKIP(像素不可净判,血统债)。
clean = false; feedback = null; reason = 'pixel-signal-not-distinguishable-from-noise';
}
return {
clean, feedback, reason, noise, signal, ctrlDelta, ctrlPath: ctrlPath || null, ctrlA, ctrlB, moveMin, stateChanged,
bMove: { kind: bMove && bMove.kind, x: bMove && bMove.x, y: bMove && bMove.y, code: bMove && bMove.code, repeat: bRepeat }, // B 趟实派的因果探针招
signalRatio: SIGNAL_RATIO, minSignal: MIN_SIGNAL, noiseCeiling: NOISE_CEILING,
target, frameA: gA.frame, frameAp: gAp.frame, frameB: gB.frame, fbFrames: Math.max(6, fbFrames),
bootFrameA: gA.bootFrame, bootFrameAp: gAp.bootFrame, bootFrameB: gB.bootFrame,
phaseA: gA.state && gA.state.phase, phaseB: gB.state && gB.state.phase,
remainingA: gA.state && gA.state.remaining, remainingB: gB.state && gB.state.remaining,
};
}
}
/** 首局门 pre-flight(断言①②,独立 CDP 会话,与九门主流程隔离)。
* 流程:建会话 → 预热导航(暖 JIT/GPU/bundle 解析,丢弃计时;隔离 headless 冷启一次性开销,非游戏本征可玩时)
* → about:blank 清场 → 计时导航该 game → 读页面侧 __playableAtMs(断言① ≤thresholdMs)
* → 断言② 首反馈即时性:静态盘类(placement/avoidance)走原单趟口径(零输入帧不变→frameChanged=输入因果);
* skill/action 类(动画自走)走【A/B 无输入对照·信号/噪声严谨判】(复用 G 门同帧号范式,见 measureFirstFeedbackAB):
* A(无输入基线)/A'(无输入探针)/B(派一招)三趟同帧号采像素;noise=diff(A,A')=基线抖动地板,signal=diff(A,B)=输入趟差异;
* state 级因果(remaining/score 变)直接 true;否则 signal 显著超 noise→true(挡板被点动);noise 超顶=非确定性→SKIP(血统债,不假判)。
* 返回 {playableMs, firstFeedback, firstMove, ...};任一步失败带原因,不抛(首局门 FAIL 不阻断九门)。 */
async function measureFirstPlay(connectFn, url, cdpHttp, spec, category, opts) {
const thresholdMs = (opts && opts.playableThresholdMs) || 2000;
const fbWindowMs = (opts && opts.feedbackWindowMs) || 300;
// 输出字段用契约名 categoryDerived(路A 从 driver 家族反推所得品类);playableMs/firstFeedback/coreLoopReached 同为契约字段。
const out = { enabled: true, categoryDerived: category, playableMs: null, playableThresholdMs: thresholdMs, warmupMs: null, firstFeedback: false, firstMove: null, notes: [] };
let cdp = null;
try {
cdp = await connectFn(url, cdpHttp);
// 预热导航:connectFn 已导航一次,等其 boot(暖机),丢弃此计时。
try { await waitBoot(cdp, 30000); } catch (e) { out.notes.push('预热导航 boot 失败:' + ((e && e.message) || e)); }
const warmPm = await cdp.evaluate('(typeof window.__playableAtMs==="number"?window.__playableAtMs:null)').catch(() => null);
if (typeof warmPm === 'number') out.warmupMs = Math.round(warmPm);
// 清场 → 计时导航(量稳态 playableMs,规避一次性冷启)。
await cdp.send('Page.navigate', { url: 'about:blank' });
await delay(150);
await cdp.send('Page.navigate', { url });
// 断言①:轮询等可交互首帧(__genBooted ∧ 引擎接管),读页面侧 __playableAtMs(performance.now 以 navigationStart 为 0 点)。
const deadline = Date.now() + 30000;
let pm = null;
while (Date.now() < deadline) {
const st = await cdp.evaluate('({ b:!!window.__genBooted, e:!!window.__gameHostEngineInitFired, pm:(typeof window.__playableAtMs==="number"?window.__playableAtMs:null), err:(window.__genBootError||window.__gameHostBootError||null) })');
if (st && st.err) { out.notes.push('计时导航装载失败:' + st.err); break; }
if (st && st.b && st.e) { pm = st.pm; break; }
await delay(50);
}
if (typeof pm === 'number') {
out.playableMs = Math.round(pm); out.playableOk = out.playableMs <= thresholdMs;
// P2-1 局限标注:playableMs=预热后 warm 稳态(已隔离 headless 冷启),不覆盖 bundle 首加载退化。
out.playableScope = 'warm';
out.notes.push('playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。');
}
else { out.notes.push('未读到 __playableAtMs(页面侧打点缺失或装载未完成)'); out.playableOk = false; }
// 断言②:首反馈即时性。从暖机实例(cdp,已 boot)读初态选首招;skill 族走 A/B 同帧号对照(严谨判),静态盘走原单趟口径。
if (out.playableMs != null) {
const sInit = await readGameState(cdp); // 暖机实例初态:供 pickFirstMove 选靶(确定性→各 fresh 趟同此态)
const mv = pickFirstMove(sInit, spec, category);
out.firstMove = mv ? { kind: mv.kind, x: mv.x, y: mv.y, code: mv.code } : null;
if (!mv) {
// 无可驱动首招(如 degraded 或 design-agent 未自产 targets)→ SKIP(非 FAIL,标"driver 缺")。
out.firstFeedback = null; out.firstFeedbackSkipped = 'driver 缺/无可驱动合法首招';
} else if (category === 'skill') {
// ── skill/action 族(动画自走,如 breakout):A/B 无输入对照·严谨判(复用 G 门同帧号范式,见 measureFirstFeedbackAB)。──
// A(无输入)/A'(无输入探针)/B(派一招)各 fresh navigate+同 seed,串行推进到【同一绝对帧】比对;优先取控制体维度(paddle.x)免动球抖动污染:
// A 趟控制体停默认位、B 趟被输入移开 → Δ 显著=真输入因果(挡板动 vs 不动),非「球本就在动」的重言式;无控制体路径才退信号/噪声像素兜底。
const fbFrames = Math.max(6, Math.round((fbWindowMs / 1000) * 30)); // 反馈窗折算帧数(@60fps 理论~30/s;300ms≈18 帧)
try {
const ab = await measureFirstFeedbackAB(connectFn, url, cdpHttp, mv, spec, fbFrames);
out.firstFeedbackDetail = Object.assign({ method: 'AB-signal-noise', category }, ab);
// 证据串:控制体维度优先,否则信噪。
const ev = (ab.ctrlDelta != null) ? ('控制体' + ab.ctrlPath + ' Δ=' + Math.round(ab.ctrlDelta) + '(>moveMin' + ab.moveMin + '?)') : ('signal=' + ab.signal + ' noise=' + ab.noise);
if (!ab.clean) {
// 不可净判(像素信号≈噪声/噪声超顶/采集失败,且无控制体维度可裁) → 诚实 SKIP(标血统债,不假判)。
out.firstFeedback = null;
out.firstFeedbackSkipped = 'A/B 不可净判(' + ab.reason + '):' + ev + ' → 动球抖动盖过信号/非确定性,需受控面 seed 或导出控制体维度(血统债)';
out.notes.push('首反馈[SKIP] 动画自走类 A/B(' + ab.reason + ')@f' + ab.target + ':' + ev + ' → 不可净判(像素信号被动球/落帧抖动污染或非确定性;血统债:需受控面 seed 或游戏导出控制体位置维度)');
} else if (ab.feedback) {
// 可净判·真反馈:控制体被点动 / state 级因果 / 像素信号显著超噪声。
out.firstFeedback = true;
out.notes.push('首反馈[真判] 动画自走类 A/B(' + ab.reason + ')@f' + ab.target + ':' + ev + ' → 输入因果成立(控制体被点动/状态变/信号显著),非「球本就在动」的重言式');
} else {
// 可净判·真 FAIL:控制体维度可裁但输入没动控制体(坏输入游戏)。
out.firstFeedback = false;
out.notes.push('首反馈[FAIL] 动画自走类 A/B(' + ab.reason + ')@f' + ab.target + ':' + ev + ' → 输入未移动控制体(坏输入游戏,真 FAIL)');
}
} catch (eAB) {
// A/B 任一趟 navigate/推进异常 → 不可判 SKIP(非 FAIL,标对照失败原因)。
out.firstFeedback = null;
out.firstFeedbackSkipped = 'A/B 对照趟异常:' + ((eAB && eAB.message) || eAB);
out.notes.push('首反馈[SKIP] A/B 对照趟异常:' + ((eAB && eAB.message) || eAB));
}
} else {
// ── placement/avoidance(静态盘):保持原单趟口径(零输入帧不变→frameChanged 即输入因果,无须 A/B)。──
const s0 = sInit;
let h0 = null; try { h0 = await sampleHash(cdp, '#game-engine'); } catch (_) {}
await dispatchFirstMove(cdp, mv);
await delay(fbWindowMs);
const s1 = await readGameState(cdp);
let h1 = null; try { h1 = await sampleHash(cdp, '#game-engine'); } catch (_) {}
const stateChanged = !!(s0 && s1 && (s0.phase !== s1.phase || s0.score !== s1.score || s0.moves !== s1.moves));
const frameChanged = (h0 != null && h1 != null && h0 !== h1);
out.firstFeedback = stateChanged || frameChanged; // 静态盘:frameChanged=输入因果,保持原口径
out.firstFeedbackDetail = { method: 'single-trip', stateChanged, frameChanged, category, frameIsCausal: true, phase0: s0 && s0.phase, phase1: s1 && s1.phase, score0: s0 && s0.score, score1: s1 && s1.score, moves0: s0 && s0.moves, moves1: s1 && s1.moves };
}
}
} catch (e) {
out.notes.push('首局门 pre-flight 异常:' + ((e && e.message) || e));
} finally {
if (cdp) cdp.close();
}
return out;
}
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: [] };
// 首局门(W-G1 组C)开关:默认开(作 W-G1 验收门);--no-firstplay 关→九门原行为逐字不变。
// 首局门 FAIL【不阻断】九门 pass/fail(H 仍是机制硬地板,首局门是其上的品类化/时限/即时性派生维度)。
const firstPlayEnabled = !argv.includes('--no-firstplay') && (spec.firstPlay && spec.firstPlay.enabled === false ? false : true);
const category = deriveCategory(spec); // 路A:从 driver 家族反推品类
// 阈值:spec.firstPlay 可 override;缺省走 harness 默认(2s/60s,已经 mini-desktop 实测校准——预热导航后稳态 playableMs≈55-60ms)。
const fpCfg = spec.firstPlay || {};
const playableThresholdMs = fpCfg.playableThresholdMs != null ? fpCfg.playableThresholdMs : 2000;
const coreLoopThresholdMs = fpCfg.coreLoopThresholdMs != null ? fpCfg.coreLoopThresholdMs : 60000;
const feedbackWindowMs = fpCfg.feedbackWindowMs != null ? fpCfg.feedbackWindowMs : 300;
// 首局门断言①②:独立 pre-flight 会话(与九门主流程隔离,保证八门判定逐字不变)。先跑→顺带暖 Chrome(主流程 boot 更快)。
let firstPlay = null;
if (firstPlayEnabled) {
firstPlay = await measureFirstPlay(connect, url, cdpHttp, spec, category, { playableThresholdMs, feedbackWindowMs });
}
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 真玩段 + 后续 latch 轮询」,断言核心闭环达成在 ≤60s 内)。
const coreLoopStartMs = Date.now();
// 真玩:有 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 };
}
// 首局门断言③:60s 品类核心闭环可达——从 H 门结果派生 coreLoopReached(复用 H 判定,几乎零新判定)。
// coreLoopReached = (H 机制断言全过 ∧ (action/技巧类→latch 达终态 / 放置·规避类→进展断言含 moves↑/result==win 等核心反馈)) ∧ 墙钟 ≤60s。
// H 门已按品类分化(tictactoe moves↑、saolei result==win、breakout remaining↓+latch),故 H.pass 即「核心反馈闭环达成」语义;此处仅加 60s 时限维度。
if (firstPlayEnabled && firstPlay) {
const coreLoopElapsedMs = Date.now() - coreLoopStartMs;
const hg = verdict.guards.H_progress || {};
const hMechReached = !!hg.pass && !hg.skipped; // H 真过(非 SKIP 老游戏)= 核心机制闭环达成
firstPlay.coreLoopReached = hMechReached && coreLoopElapsedMs <= coreLoopThresholdMs;
firstPlay.coreLoopElapsedMs = coreLoopElapsedMs;
firstPlay.coreLoopThresholdMs = coreLoopThresholdMs;
if (hg.skipped) firstPlay.coreLoopNote = 'H 门 SKIP(老游戏未声明机制门)→ coreLoopReached 无判定基线';
else if (!hMechReached) firstPlay.coreLoopNote = 'H 门未过→核心闭环未达成';
else if (coreLoopElapsedMs > coreLoopThresholdMs) firstPlay.coreLoopNote = `核心闭环达成但超 60s(${coreLoopElapsedMs}ms)`;
// 首局门三断言汇总(playableOk ∧ firstFeedback ∧ coreLoopReached;firstFeedback=null 即 driver 缺=SKIP 不计入 FAIL)。
const fbOk = firstPlay.firstFeedback === true || firstPlay.firstFeedback === null;
firstPlay.pass = !!firstPlay.playableOk && fbOk && !!firstPlay.coreLoopReached;
}
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);
// 首局门是 H 门的派生超集:投射到 verdict.firstPlay,但【不并入】verdict.pass(不阻断九门 pass/fail,H 仍是机制硬地板)。
if (firstPlay) verdict.firstPlay = firstPlay;
} 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();
}
// 兜底投射首局门(即便九门主流程中途异常,pre-flight 的 ①②结果也落档;③ 若主流程没跑到则保持初值 false+note)。
if (firstPlay && !verdict.firstPlay) {
if (firstPlay.coreLoopReached === undefined) { firstPlay.coreLoopReached = false; firstPlay.coreLoopNote = '九门主流程异常,核心闭环段未执行'; firstPlay.pass = false; }
verdict.firstPlay = firstPlay;
}
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(' '));
// 首局门摘要(W-G1 组C · 派生维度,不计入九门 pass/fail)。
if (verdict.firstPlay) {
const f = verdict.firstPlay;
const fbStr = f.firstFeedback === null ? `SKIP(${f.firstFeedbackSkipped || 'driver缺'})` : (f.firstFeedback ? '✓' : '✗');
console.log(` ── 首局门[${f.categoryDerived}] ${f.pass ? '✅' : '⚠️ '}(派生·不阻断九门)`);
console.log(` ① 可玩 ${f.playableMs == null ? '?' : f.playableMs}ms ≤${f.playableThresholdMs}ms [${f.playableOk ? '✓' : '✗'}]${f.warmupMs != null ? ` (预热冷启=${f.warmupMs}ms,已隔离)` : ''}`);
const fbd = f.firstFeedbackDetail;
const fbEv = fbd && fbd.ctrlDelta != null ? `控制体${fbd.ctrlPath}Δ=${Math.round(fbd.ctrlDelta)}(A=${Math.round(fbd.ctrlA)}/B=${Math.round(fbd.ctrlB)})` : (fbd ? `signal=${fbd.signal} noise=${fbd.noise}` : '');
const fbExtra = fbd ? (fbd.method === 'AB-signal-noise'
? ` (A/B@f${fbd.target} ${fbEv} state变=${fbd.stateChanged} 判据=${fbd.reason} 净判=${fbd.clean})`
: ` (单趟 state变=${fbd.stateChanged} 帧变=${fbd.frameChanged})`) : '';
console.log(` ② 首反馈 [${fbStr}]${fbExtra}`);
console.log(` ③ 60s核心闭环 ${f.coreLoopElapsedMs == null ? '?' : f.coreLoopElapsedMs}ms ≤${f.coreLoopThresholdMs}ms [${f.coreLoopReached ? '✓' : '✗'}]${f.coreLoopNote ? ' ' + f.coreLoopNote : ''}`);
if (f.notes && f.notes.length) console.log(' notes:', f.notes.join(' | '));
}
if (verdict.error) console.log(' ⚠️ error:', verdict.error);
console.log(` evidence → ${path.relative(process.cwd(), evDir)}/`);
process.exit(verdict.pass ? 0 : 1);
}
main();