diff --git a/docs/agent-specs/2026-06-16-W-G1开闸验收门-组C-execution.md b/docs/agent-specs/2026-06-16-W-G1开闸验收门-组C-execution.md new file mode 100644 index 00000000..8d824e55 --- /dev/null +++ b/docs/agent-specs/2026-06-16-W-G1开闸验收门-组C-execution.md @@ -0,0 +1,107 @@ +# W-G1 开闸验收门 · 组C(首局体验子门 · 品类化) · execution 版 + +> **状态**:execution 版 · 2026-06-16 · 供实现(harness-only,低风险,组C 比组A 轻一个量级)。 +> **底稿**:`docs/agent-specs/2026-06-16-W-G1开闸验收门-review.md` §3 组C;opus 接地 gap-map(本会话,file:line 已核实)。 +> **流程**:组C 低风险(只动 harness/gatespec、零改后端契约/DB/prompt) → streamline:跳 spec 双评审,直接 实现 → 1 轮 opus 审 → 创始人 push nod。 +> **纪律**:**路A 零改契约/prompt**;全部落 `play.cdp.cjs` 一个文件 + 3 款 play-spec;复用 H 门 + 现成 3 款 dispatch 面,**零新生成**。 + +--- + +## ① 目标与范围边界 + +**目标**:在九门 harness 加"**首局体验门**"(品类化),验主干口号"陌生人 10 秒觉得能玩"——3 条断言:**可玩≤2s / 首反馈即时 / 60s 内品类核心反馈闭环可达**。 + +**v0 = 路A(零改生成侧)**:harness 从**现有 gatespec 的 `driver` 家族反推品类**——`tap-targets`(无 safeOnly)→放置族 / `tap-targets`+`safeOnly`→规避族 / `paddle-intercept` 等→技巧/action 族 / `none`→退化。首局门 = **H 门的派生超集**(复用 H verdict,不另起一套判定)。 + +**范围边界(非范围 · 防越界)**: + +| 非范围项 | 归属 | 理由 | +|---|---|---| +| **路B**(gatespec 加 `firstPlay` 字段 + design-agent 自产 + `SaaPrompts.java` 逐字同改) | 完整版/推迟 | 触公平性铁律(Python+SAA 双侧字符级同步)+ 又一自产缺口;v0 路A 零改即够 | +| **"一句话现生成任意品类→首局门自动可达"** | 跟随 driver 自产 | 是 tap-driver 自产覆盖率的事,**不在组C 重复解 driver 缺口**(同一前置) | +| **经营/合成/放置纯品类**(无对应 driver/exportState) | Phase2+ | 现 3 款只覆盖 action/放置/规避;纯经营类现未必能生成 | +| **新生成游戏** | —— | v0 用现成 3 款固定 dispatch 面(breakout/tictactoe/saolei)验,零新生成 | + +> **本规格只交付"用现成 3 款验三条断言的 harness 门"。** 上表任一被实现 = 越界。 + +--- + +## ② 前置条件(已核实) + +1. **harness**:`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs`(九门 A–I)+ `serve-and-play.sh`(端口 4320/9222 **串行**单 Chrome)。 +2. **状态契约就绪**:游戏 `_forensicsView().state()` 导出 `{phase, score, moves, targets, result, remaining, progress, ...}` → `host.state()` → `window.__gameState` → harness `readGameState`(`play.cdp.cjs:166`)。`phase ∈ booting|playing|gameover` = 现成"可玩/终局"信号。 +3. **3 款 play-spec 齐**:`tictactoe`(放置·moves↑+latch)/`saolei`(规避·safeOnly·win)/`breakout`(技巧·latch),覆盖三品类。 +4. **现成门可复用**:`H_progress`(`:635-664`,assertAfterPlay+expectLatch,已按品类分化)/`I_control`(`:506`,跟手)/`C_frame`(`:559`,掌帧)/`runTapTargets`·`runPaddleIntercept`(driver 选靶逻辑)。 + +--- + +## ③ 涉及文件 + +| 文件 | 动作 | +|---|---| +| `play.cdp.cjs`(主改,唯一核心改动) | 加首局门(3 断言)+ navigate→首帧计时点 + 从 driver 家族反推品类 + 投射 `verdict.firstPlay` | +| `game-runtime/games/_wg1-gen/{tictactoe,saolei,breakout}/` play-spec(可选) | 加 `firstPlay` 配置位(阈值 override,缺省走 harness 默认) | +| `index.template.html`(可选,断言①精确版) | 页面侧 `performance.now()` 打点 `window.__playableAtMs` | +| `verdict.json` 形态 | 扩 `firstPlay:{playableMs, firstFeedback, coreLoopReached, categoryDerived}` | + +--- + +## ④ 三条断言实现(按工程量/依赖排序) + +### ③ 60s 品类核心闭环可达(**先做** · 小 · 复用 H) +- **净缺口** = H_progress(assertAfterPlay+latch,**已按品类分化**:tictactoe moves↑、saolei result==win、breakout remaining↓+latch)外**只缺 60s 时限**(H 现轮询 latch 上限 ~4.2s,driver 总时长各 spec 自定,无 60s 总闸)。 +- **实现**:`main()` 真玩段(driver/inputs + latch 轮询)外加 `Date.now()` 墙钟;断言「核心断言通过 ∧ latch 达成」发生在 **≤60s** 内;派生 `coreLoopReached = (assertAfterPlay 全过 ∧ (非 action 类 progress↑ / action 类 latch 达终态))`。**复用 H verdict,几乎零新判定。** + +### ① 可玩 ≤2s(**次做** · 小-中 · 无 driver 依赖) +- **净缺口** = navigate→"可交互首帧"计时(**现完全无计时**)。可交互首帧语义点 = `index.template.html` `window.__genBooted=true`(引擎接管+state 别名挂好)。 +- **实现**:① **必须先解 `waitBoot:81` 的 `delay(800)` 固定 settle floor**(否则 playableMs 恒 ≥800,断言失真)——改短轮询起步 或 从计时中扣除该 settle。② 精确版:页面侧 `performance.now()` 在 navigationStart→`__genBooted=true` 打点写 `window.__playableAtMs`(避 CDP 轮询 ~300ms 粒度抖动),harness 读它;粗版:harness 侧 navigate 前 `t0`、booted 命中 `t1`(扣 settle)。③ 断言 `playableMs ≤ 2000` 落 verdict。 +- **阈值校准**:headless Chrome 冷启首帧比真机慢,**2s 阈值须 mini-desktop 实测校准**(待确认①)。 + +### ② 首反馈即时(**可同批/稍后** · 中 · 依赖 driver 选靶) +- **净缺口** = 首次输入→可见反馈延迟(**现无**;最近的 I_control 是"持续跟手"非"第一拍延迟",且仅技巧类声明 controlCheck 才跑)。 +- **实现**:`main()` driver/inputs **之前**插一段——记输入前 `__gameState`+帧哈希 → 派发**一次**该品类合法首招(放置=点首个未占 `targets` / 技巧=tap 一次 / 规避=点首个 `safe` 格,**复用 `runTapTargets`/`runPaddleIntercept` 选靶逻辑取第一招**)→ ≤300ms 复采,断言 `phase`/`score`/`moves` 任一变 **或** 帧哈希 `changed`。 +- **依赖/坑**:① **强依赖 driver 选靶**(首招要打中目标才有反馈)——v0 三款现成 driver 齐故可绕过;某品类若 design-agent 没自产可驱动 driver,该款首反馈 **SKIP**(非 FAIL,标"driver 缺")。② saolei `safeOnly` 首招**须点 safe 格**(避免首招踩雷秒负污染"反馈"判定)。③ 与 I_control 轻量共存、不复跑连点。 + +--- + +## ⑤ 验证方法 + +- 复用 `serve-and-play.sh ` 跑 **breakout / tictactoe / saolei** 三款(串行 4320/9222),**无需新生成**。 +- **验证点**:`verdict.json` 三款各出 `firstPlay.{playableMs≤2s, firstFeedback=true, coreLoopReached(≤60s)}` 且 `categoryDerived` 反推正确(breakout=技巧/tictactoe=放置/saolei=规避)。 +- **校准**:先跑一轮基线测 `playableMs` 与 coreLoop 实际时长,据实调 2s/60s 阈值(headless 比真机慢)。 +- **零回归**:三款原九门 A–I 结果不变(首局门是 additive 派生维度)。 + +--- + +## ⑥ 边界 / 失败路径 + +| 场景 | 处置 | +|---|---| +| settle floor 改造后 `waitBoot` 误判装载失败 | 保留超时兜底(改 settle 不改装载判定语义),单测/实跑验三款仍 boot 成功 | +| 某品类 design-agent 无可驱动 driver/targets | 首反馈门该款 **SKIP**(非 FAIL,标"driver 缺");v0 三款现成 driver 齐,不触此路 | +| 首局门与 H 门关系 | 首局门是 **H 门的派生超集**:首局门 FAIL **不推翻** H 门 pass/fail(H 仍是机制硬地板);首局门是其上的品类化/时限/即时性补充维度 | +| 60s 对慢品类(saolei 揭尽 100 步)不够 | 实测校准(待确认①);saolei 现 driver 100×150ms≈15s,余量足 | + +--- + +## ⑦ 完成条件 + +1. `play.cdp.cjs` 加首局门 3 断言 + navigate→首帧计时 + driver 家族反推品类 + `verdict.firstPlay` 投射。 +2. 三款 dispatch 面跑绿,各出 `firstPlay` verdict;2s/60s 阈值实测校准落值。 +3. **零改契约/prompt(路A)、零新生成**;§1 非范围项一个没碰。 +4. 三款原九门 A–I **零回归**(首局门 additive,不改八门判定)。 + +--- + +## ⑧ 回滚 + +- 首局门由 harness flag / play-spec `firstPlay.enabled` 控(建议默认**开**=作 W-G1 验收门,但**不阻断九门 pass/fail**);关 = 九门原行为逐字不变。 +- 纯 additive(新 verdict 维度 + 计时点),不改八门判定逻辑、不改任何契约/产物。 + +--- + +## ⑨ 待确认(实现期实测拍值) + +1. **2s / 60s 阈值** 在 mini-desktop headless 的实测校准值(先跑基线再定)。 +2. **settle floor 改造方式**:`waitBoot:81` `delay(800)` 改短轮询起步 vs 从计时扣除(择稳)。 +3. **首局门默认开/关**:建议默认开(作验收门),但首局门 FAIL 不阻断九门 pass(H 仍是硬地板)。 diff --git a/game-runtime/games/_wg1-gen/_shared/index.template.html b/game-runtime/games/_wg1-gen/_shared/index.template.html index cc622a84..5505b2e7 100644 --- a/game-runtime/games/_wg1-gen/_shared/index.template.html +++ b/game-runtime/games/_wg1-gen/_shared/index.template.html @@ -49,6 +49,10 @@ // 就绪/错误标记初值。 window.__genBooted = false; window.__genBootError = null; + // W-G1 组C·首局门断言①「可玩≤2s」精确计时:页面侧 performance.now() 在「可交互首帧」(__genBooted=true) 打点, + // 写 window.__playableAtMs(= 自 navigationStart/timeOrigin 起的毫秒)。harness 直接读它, + // 规避 ① CDP 轮询 ~300ms 粒度抖动 ② waitBoot 固定 settle floor 对计时的污染——二者都会让 harness 侧计时失真。 + window.__playableAtMs = null; try { var G = window.__GameBundle; if (!G || typeof G.bootGameHost !== 'function') { @@ -69,6 +73,8 @@ // 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。 // 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。 window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } }; + // 首局门断言①打点:可交互首帧达成的墙钟(performance.now() 以 navigationStart 为 0 点),供 harness 读取判 ≤2s。 + try { window.__playableAtMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : null; } catch (_) {} window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」) }) .catch(function (e) { diff --git a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs index 40f27626..e5797772 100644 --- a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs +++ b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs @@ -102,14 +102,16 @@ async function frameDelta(cdp, ms) { return { f0, f1, delta: (f0 == null || f1 == null) ? null : (f1 - f0) }; } -/** 推进(对照)实例到目标帧号(real 引擎自走,轮询 __engine.frame)。返回到达的帧号(-1=超时)。 */ -async function advanceToFrame(cdp, targetFrame, timeoutMs) { +/** 推进(对照)实例到目标帧号(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(100); + await delay(pollMs || 100); } return -1; } @@ -523,6 +525,309 @@ async function runControlCheck(cdp, cc) { 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]; @@ -541,6 +846,23 @@ async function main() { 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); @@ -565,6 +887,9 @@ async function main() { 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) { @@ -663,14 +988,39 @@ async function main() { verdict.guards.H_progress = { pass: progressPass && latchPass, checks, latch, state0, state1 }; } + // 首局门断言③:60s 品类核心闭环可达——从 H 门结果派生 coreLoopReached(复用 H 判定,几乎零新判定)。 + // coreLoopReached = (H 机制断言全过 ∧ (action/技巧类→latch 达终态 / 放置·规避类→进展断言含 moves↑/result==win 等核心反馈)) ∧ 墙钟 ≤60s。 + // H 门已按品类分化(tictactoe moves↑、saolei result==win、breakout remaining↓+latch),故 H.pass 即「核心反馈闭环达成」语义;此处仅加 60s 时限维度。 + if (firstPlayEnabled && firstPlay) { + const coreLoopElapsedMs = Date.now() - coreLoopStartMs; + const hg = verdict.guards.H_progress || {}; + const hMechReached = !!hg.pass && !hg.skipped; // H 真过(非 SKIP 老游戏)= 核心机制闭环达成 + firstPlay.coreLoopReached = hMechReached && coreLoopElapsedMs <= coreLoopThresholdMs; + firstPlay.coreLoopElapsedMs = coreLoopElapsedMs; + firstPlay.coreLoopThresholdMs = coreLoopThresholdMs; + if (hg.skipped) firstPlay.coreLoopNote = 'H 门 SKIP(老游戏未声明机制门)→ coreLoopReached 无判定基线'; + else if (!hMechReached) firstPlay.coreLoopNote = 'H 门未过→核心闭环未达成'; + else if (coreLoopElapsedMs > coreLoopThresholdMs) firstPlay.coreLoopNote = `核心闭环达成但超 60s(${coreLoopElapsedMs}ms)`; + // 首局门三断言汇总(playableOk ∧ firstFeedback ∧ coreLoopReached;firstFeedback=null 即 driver 缺=SKIP 不计入 FAIL)。 + const fbOk = firstPlay.firstFeedback === true || firstPlay.firstFeedback === null; + firstPlay.pass = !!firstPlay.playableOk && fbOk && !!firstPlay.coreLoopReached; + } + verdict.pass = ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'E_live', 'F_wiring', 'G_input', 'H_progress', 'I_control'] .every((g) => verdict.guards[g] && verdict.guards[g].pass); + // 首局门是 H 门的派生超集:投射到 verdict.firstPlay,但【不并入】verdict.pass(不阻断九门 pass/fail,H 仍是机制硬地板)。 + if (firstPlay) verdict.firstPlay = firstPlay; } catch (e) { verdict.error = String((e && e.message) || e); verdict.guards.A_boot = verdict.guards.A_boot || { pass: false, err: verdict.error }; } finally { if (cdp) cdp.close(); } + // 兜底投射首局门(即便九门主流程中途异常,pre-flight 的 ①②结果也落档;③ 若主流程没跑到则保持初值 false+note)。 + if (firstPlay && !verdict.firstPlay) { + if (firstPlay.coreLoopReached === undefined) { firstPlay.coreLoopReached = false; firstPlay.coreLoopNote = '九门主流程异常,核心闭环段未执行'; firstPlay.pass = false; } + verdict.firstPlay = firstPlay; + } fs.writeFileSync(path.join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2)); // 控制台四件套摘要。 @@ -689,6 +1039,21 @@ async function main() { + (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); diff --git a/game-runtime/games/_wg1-gen/breakout/evidence/after-play.png b/game-runtime/games/_wg1-gen/breakout/evidence/after-play.png index 8a100410..37ef0687 100644 Binary files a/game-runtime/games/_wg1-gen/breakout/evidence/after-play.png and b/game-runtime/games/_wg1-gen/breakout/evidence/after-play.png differ diff --git a/game-runtime/games/_wg1-gen/breakout/evidence/first-paint.png b/game-runtime/games/_wg1-gen/breakout/evidence/first-paint.png index 65d60fb0..f10985a7 100644 Binary files a/game-runtime/games/_wg1-gen/breakout/evidence/first-paint.png and b/game-runtime/games/_wg1-gen/breakout/evidence/first-paint.png differ diff --git a/game-runtime/games/_wg1-gen/breakout/evidence/verdict.json b/game-runtime/games/_wg1-gen/breakout/evidence/verdict.json index 40a5747c..a8ecfb2c 100644 --- a/game-runtime/games/_wg1-gen/breakout/evidence/verdict.json +++ b/game-runtime/games/_wg1-gen/breakout/evidence/verdict.json @@ -8,8 +8,8 @@ }, "C_frame": { "pass": true, - "f0": 50, - "f1": 80, + "f0": 63, + "f1": 93, "delta": 30 }, "I_control": { @@ -19,30 +19,30 @@ { "tapX": 50, "before": 195, - "after": 50.00000000001471, - "moved": -144.99999999998528, - "distToTarget": 1.4708234630234074e-11, + "after": 50.00000000000827, + "moved": -144.99999999999173, + "distToTarget": 8.270717444247566e-12, "pass": true }, { "tapX": 340, - "before": 50.00000000001471, - "after": 338.5467236151453, - "moved": 288.5467236151306, - "distToTarget": 1.4532763848546892, + "before": 50.00000000000827, + "after": 339.99180684902717, + "moved": 289.9918068490189, + "distToTarget": 0.008193150972829244, "pass": true } ] }, "D_render": { "pass": true, - "bright": 154309, + "bright": 153107, "maxCh": 255 }, "E_live": { "pass": true, - "distinctStates": 24, - "samples": 24 + "distinctStates": 23, + "samples": 23 }, "B_uncaught": { "pass": true, @@ -50,7 +50,7 @@ }, "F_wiring": { "pass": true, - "callCount": 21, + "callCount": 24, "sample": [ "particles.spawnEmitter", "audio.synth.synthSfx", @@ -72,10 +72,10 @@ }, "G_input": { "pass": true, - "inputHash": "0fd7f8bc", - "controlHash": "7dcc3da8", - "atFrame": 836, - "ctrlFrame": 838 + "inputHash": "76bc4bff", + "controlHash": "9c76f3b1", + "atFrame": 943, + "ctrlFrame": 946 }, "H_progress": { "pass": true, @@ -102,9 +102,9 @@ "progress": 0, "gameoverReason": "", "ball": { - "x": 361.6493303574015, - "y": 530.5824887640108, - "vx": 199.9791964288825, + "x": 366.33506696425985, + "y": 468.3222860673734, + "vx": -199.9791964288825, "vy": -311.3010134831855 }, "paddle": { @@ -120,13 +120,13 @@ "progress": 0.033333333333333326, "gameoverReason": "ball_lost", "ball": { - "x": 352.5509849051085, - "y": 839.7168663631734, - "vx": 272.80340913561224, - "vy": 249.9565961601889 + "x": 377.6776433247279, + "y": 838.6239526944314, + "vx": -319.341400516327, + "vy": 186.87180075193487 }, "paddle": { - "x": 288.28229534102184, + "x": 302.3013097991137, "y": 810, "w": 70 } @@ -134,5 +134,64 @@ } }, "pass": true, - "notes": [] + "notes": [], + "firstPlay": { + "enabled": true, + "categoryDerived": "skill", + "playableMs": 61, + "playableThresholdMs": 2000, + "warmupMs": 24713, + "firstFeedback": true, + "firstMove": { + "kind": "tap", + "x": 198.33298660714803, + "y": 800 + }, + "notes": [ + "playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。", + "首反馈[真判] 动画自走类 A/B(control-body-moved)@f85:控制体paddle.x Δ=145(>moveMin25?) → 输入因果成立(控制体被点动/状态变/信号显著),非「球本就在动」的重言式" + ], + "playableOk": true, + "playableScope": "warm", + "firstFeedbackDetail": { + "method": "AB-signal-noise", + "category": "skill", + "clean": true, + "feedback": true, + "reason": "control-body-moved", + "noise": 1108, + "signal": 10681, + "ctrlDelta": 144.9999980491333, + "ctrlPath": "paddle.x", + "ctrlA": 195, + "ctrlB": 50.0000019508667, + "moveMin": 25, + "stateChanged": false, + "bMove": { + "kind": "tap", + "x": 50, + "y": 800, + "repeat": 6 + }, + "signalRatio": 3, + "minSignal": 200, + "noiseCeiling": 6000, + "target": 85, + "frameA": 85, + "frameAp": 85, + "frameB": 100, + "fbFrames": 9, + "bootFrameA": 45, + "bootFrameAp": 45, + "bootFrameB": 46, + "phaseA": "playing", + "phaseB": "playing", + "remainingA": 30, + "remainingB": 30 + }, + "coreLoopReached": true, + "coreLoopElapsedMs": 28323, + "coreLoopThresholdMs": 60000, + "pass": true + } } \ No newline at end of file diff --git a/game-runtime/games/_wg1-gen/breakout/index.html b/game-runtime/games/_wg1-gen/breakout/index.html index cc622a84..5505b2e7 100644 --- a/game-runtime/games/_wg1-gen/breakout/index.html +++ b/game-runtime/games/_wg1-gen/breakout/index.html @@ -49,6 +49,10 @@ // 就绪/错误标记初值。 window.__genBooted = false; window.__genBootError = null; + // W-G1 组C·首局门断言①「可玩≤2s」精确计时:页面侧 performance.now() 在「可交互首帧」(__genBooted=true) 打点, + // 写 window.__playableAtMs(= 自 navigationStart/timeOrigin 起的毫秒)。harness 直接读它, + // 规避 ① CDP 轮询 ~300ms 粒度抖动 ② waitBoot 固定 settle floor 对计时的污染——二者都会让 harness 侧计时失真。 + window.__playableAtMs = null; try { var G = window.__GameBundle; if (!G || typeof G.bootGameHost !== 'function') { @@ -69,6 +73,8 @@ // 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。 // 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。 window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } }; + // 首局门断言①打点:可交互首帧达成的墙钟(performance.now() 以 navigationStart 为 0 点),供 harness 读取判 ≤2s。 + try { window.__playableAtMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : null; } catch (_) {} window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」) }) .catch(function (e) { diff --git a/game-runtime/games/_wg1-gen/saolei/evidence/after-play.png b/game-runtime/games/_wg1-gen/saolei/evidence/after-play.png index dd3fe7ec..8e39d2e9 100644 Binary files a/game-runtime/games/_wg1-gen/saolei/evidence/after-play.png and b/game-runtime/games/_wg1-gen/saolei/evidence/after-play.png differ diff --git a/game-runtime/games/_wg1-gen/saolei/evidence/first-paint.png b/game-runtime/games/_wg1-gen/saolei/evidence/first-paint.png index 54069ff9..b521022f 100644 Binary files a/game-runtime/games/_wg1-gen/saolei/evidence/first-paint.png and b/game-runtime/games/_wg1-gen/saolei/evidence/first-paint.png differ diff --git a/game-runtime/games/_wg1-gen/saolei/evidence/verdict.json b/game-runtime/games/_wg1-gen/saolei/evidence/verdict.json index 326585b8..035cde0b 100644 --- a/game-runtime/games/_wg1-gen/saolei/evidence/verdict.json +++ b/game-runtime/games/_wg1-gen/saolei/evidence/verdict.json @@ -8,8 +8,8 @@ }, "C_frame": { "pass": true, - "f0": 48, - "f1": 78, + "f0": 62, + "f1": 92, "delta": 30 }, "I_control": { @@ -18,7 +18,7 @@ }, "D_render": { "pass": true, - "bright": 532087, + "bright": 531222, "maxCh": 255 }, "E_live": { @@ -54,10 +54,10 @@ }, "G_input": { "pass": true, - "inputHash": "0b264993", - "controlHash": "a1c5b37e", - "atFrame": 705, - "ctrlFrame": 710 + "inputHash": "2da8f027", + "controlHash": "c562f201", + "atFrame": 817, + "ctrlFrame": 819 }, "H_progress": { "pass": true, @@ -671,5 +671,38 @@ } }, "pass": true, - "notes": [] + "notes": [], + "firstPlay": { + "enabled": true, + "categoryDerived": "avoidance", + "playableMs": 57, + "playableThresholdMs": 2000, + "warmupMs": 15601, + "firstFeedback": true, + "firstMove": { + "kind": "tap", + "x": 35, + "y": 100 + }, + "notes": [ + "playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。" + ], + "playableOk": true, + "playableScope": "warm", + "firstFeedbackDetail": { + "method": "single-trip", + "stateChanged": true, + "frameChanged": true, + "category": "avoidance", + "frameIsCausal": true, + "phase0": "playing", + "phase1": "playing", + "score0": 0, + "score1": 28 + }, + "coreLoopReached": true, + "coreLoopElapsedMs": 27677, + "coreLoopThresholdMs": 60000, + "pass": true + } } \ No newline at end of file diff --git a/game-runtime/games/_wg1-gen/saolei/index.html b/game-runtime/games/_wg1-gen/saolei/index.html index cc622a84..5505b2e7 100644 --- a/game-runtime/games/_wg1-gen/saolei/index.html +++ b/game-runtime/games/_wg1-gen/saolei/index.html @@ -49,6 +49,10 @@ // 就绪/错误标记初值。 window.__genBooted = false; window.__genBootError = null; + // W-G1 组C·首局门断言①「可玩≤2s」精确计时:页面侧 performance.now() 在「可交互首帧」(__genBooted=true) 打点, + // 写 window.__playableAtMs(= 自 navigationStart/timeOrigin 起的毫秒)。harness 直接读它, + // 规避 ① CDP 轮询 ~300ms 粒度抖动 ② waitBoot 固定 settle floor 对计时的污染——二者都会让 harness 侧计时失真。 + window.__playableAtMs = null; try { var G = window.__GameBundle; if (!G || typeof G.bootGameHost !== 'function') { @@ -69,6 +73,8 @@ // 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。 // 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。 window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } }; + // 首局门断言①打点:可交互首帧达成的墙钟(performance.now() 以 navigationStart 为 0 点),供 harness 读取判 ≤2s。 + try { window.__playableAtMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : null; } catch (_) {} window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」) }) .catch(function (e) { diff --git a/game-runtime/games/_wg1-gen/tictactoe/evidence/after-play.png b/game-runtime/games/_wg1-gen/tictactoe/evidence/after-play.png index 6586b193..85533949 100644 Binary files a/game-runtime/games/_wg1-gen/tictactoe/evidence/after-play.png and b/game-runtime/games/_wg1-gen/tictactoe/evidence/after-play.png differ diff --git a/game-runtime/games/_wg1-gen/tictactoe/evidence/first-paint.png b/game-runtime/games/_wg1-gen/tictactoe/evidence/first-paint.png index fd36d7ab..b302aae8 100644 Binary files a/game-runtime/games/_wg1-gen/tictactoe/evidence/first-paint.png and b/game-runtime/games/_wg1-gen/tictactoe/evidence/first-paint.png differ diff --git a/game-runtime/games/_wg1-gen/tictactoe/evidence/verdict.json b/game-runtime/games/_wg1-gen/tictactoe/evidence/verdict.json index 0cde3ad5..eaac8ce7 100644 --- a/game-runtime/games/_wg1-gen/tictactoe/evidence/verdict.json +++ b/game-runtime/games/_wg1-gen/tictactoe/evidence/verdict.json @@ -8,9 +8,9 @@ }, "C_frame": { "pass": true, - "f0": 48, - "f1": 77, - "delta": 29 + "f0": 61, + "f1": 91, + "delta": 30 }, "I_control": { "pass": true, @@ -18,7 +18,7 @@ }, "D_render": { "pass": true, - "bright": 98809, + "bright": 98776, "maxCh": 255 }, "E_live": { @@ -54,10 +54,10 @@ }, "G_input": { "pass": true, - "inputHash": "7441dfc3", - "controlHash": "b761dd3d", - "atFrame": 228, - "ctrlFrame": 232 + "inputHash": "9d3aa365", + "controlHash": "997bf445", + "atFrame": 249, + "ctrlFrame": 254 }, "H_progress": { "pass": true, @@ -232,5 +232,40 @@ } }, "pass": true, - "notes": [] + "notes": [], + "firstPlay": { + "enabled": true, + "categoryDerived": "placement", + "playableMs": 52, + "playableThresholdMs": 2000, + "warmupMs": 24703, + "firstFeedback": true, + "firstMove": { + "kind": "tap", + "x": 85, + "y": 310 + }, + "notes": [ + "playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。" + ], + "playableOk": true, + "playableScope": "warm", + "firstFeedbackDetail": { + "method": "single-trip", + "stateChanged": true, + "frameChanged": true, + "category": "placement", + "frameIsCausal": true, + "phase0": "playing", + "phase1": "playing", + "score0": 0, + "score1": 2, + "moves0": 0, + "moves1": 2 + }, + "coreLoopReached": true, + "coreLoopElapsedMs": 7941, + "coreLoopThresholdMs": 60000, + "pass": true + } } \ No newline at end of file diff --git a/game-runtime/games/_wg1-gen/tictactoe/index.html b/game-runtime/games/_wg1-gen/tictactoe/index.html index cc622a84..5505b2e7 100644 --- a/game-runtime/games/_wg1-gen/tictactoe/index.html +++ b/game-runtime/games/_wg1-gen/tictactoe/index.html @@ -49,6 +49,10 @@ // 就绪/错误标记初值。 window.__genBooted = false; window.__genBootError = null; + // W-G1 组C·首局门断言①「可玩≤2s」精确计时:页面侧 performance.now() 在「可交互首帧」(__genBooted=true) 打点, + // 写 window.__playableAtMs(= 自 navigationStart/timeOrigin 起的毫秒)。harness 直接读它, + // 规避 ① CDP 轮询 ~300ms 粒度抖动 ② waitBoot 固定 settle floor 对计时的污染——二者都会让 harness 侧计时失真。 + window.__playableAtMs = null; try { var G = window.__GameBundle; if (!G || typeof G.bootGameHost !== 'function') { @@ -69,6 +73,8 @@ // 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。 // 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。 window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } }; + // 首局门断言①打点:可交互首帧达成的墙钟(performance.now() 以 navigationStart 为 0 点),供 harness 读取判 ≤2s。 + try { window.__playableAtMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : null; } catch (_) {} window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」) }) .catch(function (e) {