便宜档当前 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>
1171 lines
83 KiB
JavaScript
1171 lines
83 KiB
JavaScript
/**
|
||
* play.cdp.cjs —— W-G1 通用「CDP 真玩取证驱动」(模型无关,所有生成游戏共用一份)
|
||
* owner:W-G1 lane | cwd 必须 = game-runtime | 复用 test/harness/browser-evidence.cjs
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【职责】把一个 __GameBundle iife 装进 P1 宿主页、经 CDP 驱动本机 Chrome 真玩几步,产出四件套证据,
|
||
* 并以「九道假绿守卫」判定是否【真·可玩】——"能生成代码 ≠ 能玩",必须过真玩门才算 pass。
|
||
* 真玩驱动:spec.driver 存在=【适配性真玩】(读 state 自动接球,解盲打固定坐标打不动技巧游戏的假阴性);否则走 spec.inputs 固定序列。
|
||
*
|
||
* 【九道假绿守卫(全过才 PASS)】
|
||
* A 装载:__genBooted===true ∧ __gameHostEngineInitFired===true ∧ 无 bootError(白屏/装载失败拦截)。
|
||
* B 未捕获:__genInternals.uncaught 为空 ∧ 无 __gameHostBootError(运行期报错拦截)。
|
||
* C 掌帧:__engine.snapshot().frame 在 500ms 内增量 ∈ [20,45](活着且非失控/非单步假推进;@60fps 理论~30)。
|
||
* D 真渲染:#game-engine canvas readback「有色像素(maxCh>80) > 阈值」(非白屏/非全黑)。
|
||
* E 活性:真玩输入前后两张快照 FNV-1a 哈希【不同】(画面真在动/真在响应,非冻屏)。
|
||
* F 真接线:__engineCalls 含期望 call-ID(spec.expectedEngineCallPrefixes 任一前缀命中;证真经 ctx.getEngine() 调引擎/插件库,非自绘伪装)。
|
||
* G 输入有效:同 URL 同 seed 起【无输入】对照实例推进到同帧号,整帧哈希【不同】 ⇒ 输入真改变游戏走向(隔离「靠自走动画蒙混过 E」)。
|
||
* H 机制进展 + latch 终态(逐游戏·确定性·可选):spec.assertAfterPlay[] 机制断言全过(真有进展、非空壳)+ spec.expectLatch 时真玩到 phase='gameover' 且【驻留】(官方闭环锚)。
|
||
* 依赖游戏经【可选 _forensicsView()】导出可观测状态(host.state() 透传 → 页面 __gameState);未声明=老游戏→SKIP(向后兼容)。
|
||
* I 控制跟手(逐游戏·确定性·可选):spec.controlCheck 声明时,连点目标 x 验玩家控制体平滑逼近(逮「一格一跳」/卡死/不跟手);依赖游戏导出控制体位置(如 paddle.x)。未声明→SKIP。
|
||
*
|
||
* 【用法】node games/_wg1-gen/_shared/play.cdp.cjs <gameId> [--base=http://localhost:4320] [--cdp=http://localhost:9222] [--spec=<spec.json>]
|
||
* spec.json(缺省读 games/_wg1-gen/<gameId>/play-spec.json,缺失则用极简默认):
|
||
* { "inputs":[{"t":"tap","x":195,"y":600},{"t":"key","code":"ArrowLeft","downMs":300},{"t":"wait","ms":400}],
|
||
* "expectedEngineCallPrefixes":["particles.spawnEmitter","audio.synth"] }
|
||
* 产物:games/_wg1-gen/<gameId>/evidence/{verdict.json, first-paint.png, after-play.png};退出码 0=PASS / 1=FAIL。
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('node:path');
|
||
const http = require('node:http');
|
||
const fs = require('node:fs');
|
||
// 复用纯计算口径 + CDP 会话类 + 像素抓取/截图(test/harness/browser-evidence.cjs)。
|
||
const H = require(path.resolve(__dirname, '../../../test/harness/browser-evidence.cjs'));
|
||
const { CdpSession, captureImageData, saveScreenshot, fnv1a32 } = H;
|
||
// CDP 需 ws(仅本驱动用;cwd=game-runtime 已 npm i ws)。
|
||
const WebSocket = require('ws');
|
||
|
||
/** 纯 Promise 延时(不走被拦的 sleep 命令)。 */
|
||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/** 极简 HTTP(PUT 建 target / GET 列表)。browser-evidence 的 httpJson 未导出,此处复刻。 */
|
||
function httpJson(method, urlStr) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(urlStr);
|
||
const req = http.request(
|
||
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
|
||
(res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
|
||
try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(new Error('CDP HTTP 非 JSON:' + b.slice(0, 200))); } }); }
|
||
);
|
||
req.on('error', reject);
|
||
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** 建 target → 连 WS → 启用域 → 设固定取证视口(390×844/DPR2/touch)→ 导航。返回 CdpSession。 */
|
||
async function connect(url, cdpHttp) {
|
||
// Chrome 111+ 必须 PUT /json/new?<url>(GET 不行)。
|
||
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
|
||
const wsUrl = created.webSocketDebuggerUrl;
|
||
if (!wsUrl) throw new Error('建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
|
||
const ws = await new Promise((resolve, reject) => {
|
||
const s = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
|
||
s.on('open', () => resolve(s)); s.on('error', reject);
|
||
});
|
||
const cdp = new CdpSession(ws, created.id);
|
||
await cdp.send('Page.enable');
|
||
await cdp.send('Runtime.enable');
|
||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
|
||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {}
|
||
await cdp.send('Page.navigate', { url });
|
||
return cdp;
|
||
}
|
||
|
||
/** 守卫A:轮询等装载完成(__genBooted ∧ 引擎接管 __gameHostEngineInitFired);拿到任一 bootError 即抛。 */
|
||
async function waitBoot(cdp, timeoutMs) {
|
||
const deadline = Date.now() + (timeoutMs || 30000);
|
||
await delay(800); // 固定 settle,让脚本起步
|
||
while (Date.now() < deadline) {
|
||
const st = await cdp.evaluate(
|
||
'({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, ' +
|
||
'err: (window.__genBootError || window.__gameHostBootError || null) })'
|
||
);
|
||
if (st && st.err) throw new Error('装载失败(守卫A):' + st.err);
|
||
if (st && st.booted && st.eng) return;
|
||
await delay(300);
|
||
}
|
||
// 超时前再读一次错因,给确切信号。
|
||
const last = await cdp.evaluate('({ booted: !!window.__genBooted, eng: !!window.__gameHostEngineInitFired, err: (window.__genBootError||window.__gameHostBootError||null) })').catch(() => null);
|
||
throw new Error('装载超时(守卫A):' + JSON.stringify(last));
|
||
}
|
||
|
||
/** 守卫C:测 500ms 内引擎帧增量。读 window.__engine.snapshot().frame(real 通道)。 */
|
||
async function frameDelta(cdp, ms) {
|
||
const read = '(function(){ var e=window.__engine; if(!e) return null; var s=(typeof e.snapshot==="function")?e.snapshot():e; return (s&&typeof s.frame==="number")?s.frame:null; })()';
|
||
const f0 = await cdp.evaluate(read);
|
||
await delay(ms);
|
||
const f1 = await cdp.evaluate(read);
|
||
return { f0, f1, delta: (f0 == null || f1 == null) ? null : (f1 - f0) };
|
||
}
|
||
|
||
/** 推进(对照)实例到目标帧号(real 引擎自走,轮询 __engine.frame)。返回到达的帧号(-1=超时)。
|
||
* 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=玩后态)。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 === '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.46H–0.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 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 };
|
||
}
|
||
|
||
/** 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→gameover(snake 真终态=死亡),满足 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。受控面只给原始 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');
|
||
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-23):E_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 驻留校验:再等 600ms,phase 必须仍是终态(瞬时终态/自动重开=违约,宿主 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 ∧ coreLoopReached;firstFeedback=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/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();
|