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