lili 8b491d1330 fix(九门): latch 终态口径修法 — 命名同义 + 限时类有进展降 advisory
便宜档当前 HEAD 九门真实基线诊断(n=5 收敛环):harness 健康、3/5 真可玩;
0/5 主因 = latch 终态词表 bug(只认 gameover、漏 over/win/lose)+ 限时类
driver 玩不到终局。按创始人 2026-06-25 口径修 play.cdp.cjs:
- latch 认 gameover/over/win/lose 同义(对齐 driver 家族 runTapTargets)
- 未达终态但有真进展的限时/无快速失败态类 → latch 降 advisory(不致命),
  无进展仍致命(不放水)
- 验证:base1-5 重跑 0/5→3/5(base1/4/5 真可玩转绿,base2/3 卡 menu 无进展仍正确挂)

SoT① §5.4 同步 latch 口径演进(文档↔代码兑现)。
附:docs/内网凭据与端点.md 登记 glm-5.2(opus 高级档/512K,作复杂档证路高级档)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:26:37 -07:00

1171 lines
83 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 真玩取证驱动」(模型无关,所有生成游戏共用一份)
* ownerW-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-IDspec.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));
/** 极简 HTTPPUT 建 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().framereal 通道)。 */
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; } })()'
);
}
/** Phase 4 E_live 校准2026-06-20判玩前→玩后「游戏进展态」是否真变化score/remaining 任一数值变=游戏在响应输入)。
* 用于给 E_live 补「状态活性」证据:无运动点击类(打地鼠等)画面只在命中瞬间变、像素采样易错过→假阴;
* 但其 score/remaining 会真变。真冻屏 score/remaining 不变→返 false→E_live 仍判否,绝不放过真冻屏(只放宽不收紧)。 */
function stateProgressed(s0, s1) {
if (!s0 || !s1) return false;
for (const k of ['score', 'remaining']) {
if (typeof s0[k] === 'number' && typeof s1[k] === 'number' && s0[k] !== s1[k]) return true;
}
return false;
}
/** 判一条机制断言s0=玩前态 / s1=玩后态。opincreased/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 === 'seek-food') return runSeekFood(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/见缝插针/节奏/经营服务客等「点击离散目标」类游戏。
* 【家族三分·按 driver.targetMode】
* · 放置族(默认,井字棋/invaders…任一未占用目标都是合法推进招= 盲点首个 occupied!==true;
* · 反应/服务族targetMode='occupied',打地鼠/经营服务等待的客)= 只点 occupied===true冒头/有客才可点),无则空转等下拍;
* · 规避/推理族(扫雷…配 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, menuTapIdx = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && (s.phase === 'gameover' || s.phase === 'over' || s.phase === 'win' || s.phase === 'lose')) break; // 终局即停(含 over/win/lose 常见终态名;交 latch 校验)
// 场景推进(反应/服务族):非游玩态(menu/title/paused…)→ 点「开始」按钮推进场景(多数游戏 menu「中心按钮起局」、结算→菜单
// 否则停在 menu、targets 恒空 → 永不进 play实测 wanglanmei/打地鼠菜单卡死根因——driver 只找目标、不会起局)。仅 occupied 族用,不动放置族既有行为。
// 起局按钮位置随游戏而异(实测 wanglanmei「开门营业」在 y≈502,旧固定点 (195,422) 落空→永远进不去)→
// 改为【中线纵向扫点】:每拍换一个候选纵坐标(覆盖常见 0.46H0.90H 竖屏区,常见按钮区在前),命中按钮即进 play,下拍走目标点击。
if (driver.targetMode === 'occupied' && s && typeof s.phase === 'string' && s.phase !== 'play' && s.phase !== 'playing') {
const CY = [489, 523, 456, 557, 422, 591, 388, 625, 690, 752]; // 390×844 竖屏起局按钮候选纵坐标(常见区在前);x=中线 195
await tap(cdp, 195, CY[menuTapIdx % CY.length]);
menuTapIdx++;
drove++;
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
continue;
}
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 if (driver.targetMode === 'occupied') {
// 反应/服务族(打地鼠/经营服务客…):只点 occupied===true 的目标(地鼠冒头/顾客在等才可点);
// 本拍无可点(没冒头/没客)→ 不 tap、空转等下一拍targets 随时变,下拍重采)。与放置族相反语义。
t = targets.find((g) => g && g.occupied === true && typeof g.x === 'number');
} 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 pxy 越小越高),留下落余量→落入缝心而非贴下沿
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 };
}
/** type='seek-food':读蛇头 head.x/head.y 与食物 food.x/food.y贪心选向(先消较大轴差)发方向键朝食物走;
* 防 180° 反向(snake 反向键被运行时忽略)→记上一步方向、其反向时改走另一轴。把贪吃蛇的盲循环换成朝食物 steer
* (类比 flap-to-gap 读 nextGap);零放水——只把"盲走"换"朝食物走"score/latch 仍照判。 */
async function runSeekFood(cdp, driver, hashes) {
const hxPath = driver.headXPath || 'head.x';
const hyPath = driver.headYPath || 'head.y';
const fxPath = driver.foodXPath || 'food.x';
const fyPath = driver.foodYPath || 'food.y';
const steps = driver.steps || 70;
const stepMs = driver.stepMs != null ? driver.stepMs : 110;
const scoreTarget = driver.scoreTarget != null ? driver.scoreTarget : 30; // 得分够即转终态阶段
const OPP = { ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft', ArrowUp: 'ArrowDown', ArrowDown: 'ArrowUp' };
let drove = 0, phx = null, phy = null, actualDir = null;
// 阶段1朝食物 steer 得分(防反向基于蛇头位移推断的实际方向)。
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') return { drove, steps };
if (s && typeof s.score === 'number' && s.score >= scoreTarget) break; // 得分够→转终态阶段
const hx = getPath(s, hxPath), hy = getPath(s, hyPath);
const fx = getPath(s, fxPath), fy = getPath(s, fyPath);
if (typeof hx === 'number' && typeof hy === 'number' && typeof fx === 'number' && typeof fy === 'number') {
// 从蛇头位移推断【实际行进方向】(仅在真移动时更新)——防反向须基于实际方向,
// 而非已发的键(发的反向键会被蛇忽略→驱动误以为转了→继续直行撞墙)。
if (phx != null && (hx !== phx || hy !== phy)) {
if (Math.abs(hx - phx) >= Math.abs(hy - phy)) actualDir = hx > phx ? 'ArrowRight' : 'ArrowLeft';
else actualDir = hy > phy ? 'ArrowDown' : 'ArrowUp';
}
phx = hx; phy = hy;
const dx = fx - hx, dy = fy - hy;
const horiz = Math.abs(dx) >= Math.abs(dy);
// 主选(消较大轴差) + 次选(另一轴);选第一个「非空且非实际方向反向」的(反向会被忽略→撞墙)。
const cands = horiz
? [dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null), dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null)]
: [dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null), dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null)];
const dir = cands.find((d) => d && d !== OPP[actualDir]) || cands.find(Boolean);
if (dir) { await key(cdp, dir, driver.downMs || 60); drove++; }
} else { await delay(stepMs); }
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
}
// 阶段2终态——已得分但蛇太稳不死 → 保持直行撞墙触发 lose→gameoversnake 真终态=死亡),满足 latch不放水门用游戏真有的失败态
for (let i = 0; i < 30; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') break;
if (actualDir) { await key(cdp, actualDir, driver.downMs || 60); }
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
}
return { drove, steps };
}
/** 触屏拖拽touchStart(from) → 多帧 touchMove 插值到 to → touchEnd。受控面只给原始 pointerswipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */
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';
// 其余 driverpaddle-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 ∧ 引擎接管),读页面侧 __playableAtMsperformance.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 族(动画自走,如 breakoutA/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/failH 仍是机制硬地板,首局门是其上的品类化/时限/即时性派生维度)。
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');
const s0 = await readGameState(cdp); // Phase 4 E_live 校准:玩前进展态(与玩后对比补「状态活性」)
// 守卫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 s1 = await readGameState(cdp); // Phase 4 E_live 校准:玩后进展态
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 个不同画面态(真在动/真在响应);全程恒同一帧=冻屏判否。
// Phase 4 校准2026-06-20像素活性 OR 状态活性score/remaining 真变)——治无运动点击类(打地鼠等)像素采样
// 错过命中瞬间的假阴;真冻屏像素+状态皆不变→仍判否(只加 OR、只放宽不收紧绝不放过真冻屏
// 根治 A创始人「门要可信」2026-06-23E_live 旧定义 `distinct>=2 || liveByState` 假阳——菜单 blink 动画也让帧哈希变,
// 一个【启动不了的死菜单游戏】(distinctStates=2 全来自 booting→menu + 闪烁)曾过此门。重定义为【真玩起来了】的真不变量:
// played = 真离开非游玩态(进展态真变 liveByState,或玩后 phase 落在游玩态——不在 NONPLAY 集);
// live = played 且 (进展态变 或 驱动期画面活性)。死菜单 played=false → 必挂(帧哈希再怎么闪也救不回)。
const distinct = new Set(hashes);
const liveByState = stateProgressed(s0, s1);
const NONPLAY = new Set(['booting', 'boot', 'menu', 'title', 'ready', 'home', 'over', 'gameover', 'win', 'lose', 'result', 'settle', 'end', 'paused']);
const phase1 = (s1 && typeof s1.phase === 'string') ? s1.phase : null;
const played = liveByState || (phase1 != null && !NONPLAY.has(phase1)); // 真进过游玩(状态变 或 phase 是游玩态)
const live = played && (liveByState || distinct.size >= 2); // 进过玩 且 有活性(状态变 或 画面动)
verdict.guards.E_live = { pass: live, played, phase1, distinctStates: distinct.size, samples: hashes.length, liveByState };
// 守卫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');
// 根治 A:旧定义只看「输入实例帧哈希 ≠ 无输入对照」——被动画时序差假阳(死菜单 blink 相位差也让哈希不同 → 谎称"输入有效")。
// 加 `&& played`:必须游戏【真响应了】(phase 进过游玩态 或 进展态变),才算输入有效。点击被吞的死菜单 played=false → G 必挂。
verdict.guards.G_input = { pass: (px1.hash !== ch.hash) && played, hashDiffer: px1.hash !== ch.hash, played, 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));
const progressPass = checks.every((c) => c.pass);
// 终态同义词集:与 driver 家族runTapTargets:242统一口径治「游戏终态叫 over/win/lose 而 latch 只认 gameover」的误判2026-06-25 base5 实证:真玩到 over、score↑却判终态不可达
const isTerminal = (p) => p === 'gameover' || p === 'over' || p === 'win' || p === 'lose';
let latch = null;
if (expectLatch) {
// 停止输入后轮询等进入终态(无失败态/永不结束=终态不可达);最多 ~4.2s。
let phaseNow = state1.phase;
for (let i = 0; i < 14 && !isTerminal(phaseNow); i++) { await delay(300); const s = await readGameState(cdp); phaseNow = s ? s.phase : null; }
if (!isTerminal(phaseNow)) {
// 未到终态:限时/无快速失败态类倒计时、目标多driver 在 ~4.2s 窗口内玩不到终局——有真进展assertAfterPlay 全过)则 latch 降 advisory不致命无进展才致命真空壳。创始人 2026-06-25 口径:不一刀切要求玩到 gameover但不放水仍须真进展 + 八门)。
latch = { pass: false, advisory: progressPass, reason: progressPass ? '未驱动到终态但有真进展(限时/无快速失败态类→advisory' : '真玩未到达终态且无进展(终态不可达/空壳)', phaseNow };
} else {
// latch 驻留校验:再等 600msphase 必须仍是终态(瞬时终态/自动重开=违约,宿主 500ms 轮询会漏读)。
await delay(600);
const s2 = await readGameState(cdp);
const dwell = !!(s2 && isTerminal(s2.phase));
latch = { pass: dwell, dwelledMs: 600, after: s2 ? s2.phase : null, reason: dwell ? '' : '终态未驻留(下帧自动重开/复位=latch 违约)' };
}
}
// latch 致命性:仅「未达终态且无真进展」让 H 挂未达终态但有真进展→latch 降 advisory限时类口径驻留违约仍致命。
const latchHardFail = expectLatch && latch && !latch.pass && !latch.advisory;
verdict.guards.H_progress = { pass: progressPass && !latchHardFail, 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 ∧ coreLoopReachedfirstFeedback=null 即 driver 缺=SKIP 不计入 FAIL
const fbOk = firstPlay.firstFeedback === true || firstPlay.firstFeedback === null;
firstPlay.pass = !!firstPlay.playableOk && fbOk && !!firstPlay.coreLoopReached;
}
// E_live/H_progress 是「被驱动下的可玩性」门:游戏未被驱动(spec 无 driver 且无 inputs)则无法公平评判 → 降 advisory(仍跑+报告,不计入 pass)。
// 创始人 B 裁(2026-06-22):amodel 现无 driver(design 未产 driver,driver 契约待建)→ E_live/H 暂 advisory;
// 将来 design 产 driver / spec 给 inputs → driven=true → 两门自动恢复致命(无需再改本处)。factory/gamedef 有 driver → 不受影响。
const driven = !!(spec && (spec.driver || (Array.isArray(spec.inputs) && spec.inputs.length > 0)));
const HARD_GATES = driven
? ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'E_live', 'F_wiring', 'G_input', 'H_progress', 'I_control']
: ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'F_wiring', 'G_input', 'I_control'];
verdict.advisoryGates = driven ? [] : ['E_live', 'H_progress'];
verdict.pass = HARD_GATES.every((g) => verdict.guards[g] && verdict.guards[g].pass);
// 首局门是 H 门的派生超集:投射到 verdict.firstPlay但【不并入】verdict.pass不阻断九门 pass/failH 仍是机制硬地板)。
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();