fix(WU-C 5.4): tap-targets harness 认单数/可空 target — 解 M2 Node 退役阻塞

M2 auto-vs-golden 门抓到的生成契约漂移修复(Codex 实现 + 主代理诊断/受控验证)。
根因:~20% click-score 款 _forensicsView 暴露单数 target(对象、菜单态为 null)
而非复数 targets(数组),tap-targets harness 只认复数数组:① _build_play_spec
推断漏判→误推 key-cycle;② 驱动器 targetsPath 找不到单数 target。

两处修(认两种合法形态、既有数组行为零变):
- cheap_run.py:_build_play_spec 以【键存在性】判 tap-targets(targets/target 键
  在即可,菜单态 null/空数组仍归 tap-targets)——非值类型(空数组过/null 不过的
  非对称是漏判根因)。
- play.cdp.cjs:runTapTargets 取值非数组时回退读单数 target、包成 1 元素数组。

验证:退化款 avg-click-score-14 auto 路 E_live/G_input/H_progress 0.667→1.0
(3/3 确定性复现);test_ensure_play_spec 8→13 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-27 10:41:16 -07:00
parent 3d64129d8e
commit ffe3a5c993
3 changed files with 42 additions and 6 deletions

View File

@ -214,7 +214,8 @@ def _build_play_spec(state: Optional[dict]) -> dict:
tap-targets occupied 族(state 有 targets 数组):加厚到金标同质——补 F_wiring 的 expectedEngineCallPrefixes
语义期望(外生 held 期望、预置常量,防自证);key-cycle 族(按键类):薄版,期望前缀待输入键契约(WU-C 5.4)。
"""
has_targets = isinstance(state, dict) and isinstance(state.get("targets"), list)
# 以键存在性判定「可点目标」类游戏:游戏暴露 targets/target 键即声明有可点目标,菜单态 null/空数组仍视为 tap-targets
has_targets = isinstance(state, dict) and ("targets" in state or "target" in state)
if has_targets:
driver = {"type": "tap-targets", "targetMode": "occupied", "targetsPath": "targets", "steps": 50, "stepMs": 220}
return {

View File

@ -31,6 +31,30 @@ def test_tap_targets_adds_golden_prefixes():
assert spec["expectedEngineCallPrefixes"] == _GOLDEN_PREFIXES
def test_singular_target_object_uses_tap_targets():
"""单数 target 对象是合法点击目标形态,应走 tap-targets 驱动。"""
spec = R._build_play_spec({"target": {"x": 1, "y": 2, "r": 3, "occupied": True}, "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_singular_target_null_menu_state_uses_tap_targets():
"""菜单态 target 可为 null,但暴露 target 键即声明点击目标类。"""
spec = R._build_play_spec({"target": None, "phase": "menu", "score": 0})
assert spec["driver"]["type"] == "tap-targets"
def test_targets_array_uses_tap_targets():
"""复数 targets 数组保持既有 tap-targets 驱动行为。"""
spec = R._build_play_spec({"targets": [{"x": 1, "y": 2, "r": 3, "occupied": True}], "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_empty_targets_array_still_uses_tap_targets():
"""空 targets 数组仍代表点击类导出形态,不可退回 key-cycle。"""
spec = R._build_play_spec({"targets": [], "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_matches_golden_fixtures():
"""加厚后的 prefixes 与三份金标 fixtures 的 expectedEngineCallPrefixes 逐字段一致(同质)。"""
spec = R._build_play_spec({"targets": []}) # targets=[] 仍是 list → tap-targets
@ -71,6 +95,12 @@ def test_key_cycle_thin_no_prefixes():
assert "WU-C 5.4" in spec.get("_note", ""), "key-cycle 薄版应标注延后原因"
def test_state_without_target_keys_is_key_cycle_thin():
"""没有 targets/target 键时保持按键类薄版回退。"""
spec = R._build_play_spec({"phase": "play", "score": 0})
assert spec["driver"]["type"] == "key-cycle"
def test_none_state_is_key_cycle_thin():
spec = R._build_play_spec(None) # None → 保守按键类
assert spec["driver"]["type"] == "key-cycle"

View File

@ -254,21 +254,26 @@ async function runTapTargets(cdp, driver, hashes) {
continue;
}
const targets = getPath(s, targetsPath);
if (Array.isArray(targets) && targets.length) {
// 兼容单数 target 对象:若 targets 不是数组,尝试读单数 target 并包成单元素数组。
let resolvedTargets = Array.isArray(targets) ? targets : (() => {
const singular = getPath(s, 'target');
return (singular && typeof singular.x === 'number' && typeof singular.y === 'number') ? [singular] : [];
})();
if (Array.isArray(resolvedTargets) && resolvedTargets.length) {
let t;
if (driver.safeOnly === true) {
// 规避/推理族(扫雷):仅点 safe===true 的目标,确定性避负 → 把核心循环(洪水填充/邻数揭示/win 判定)跑透;
// 无安全目标(核心应已揭完→win)即停,交 latch 校验 win 终态;绝不回退点不安全目标(否则又踩雷、核心零暴露)。
t = targets.find((g) => g && g.occupied !== true && g.safe === true && typeof g.x === 'number');
t = resolvedTargets.find((g) => g && g.occupied !== true && g.safe === true && typeof g.x === 'number');
if (!t) break;
} else if (driver.targetMode === 'occupied') {
// 反应/服务族(打地鼠/经营服务客…):只点 occupied===true 的目标(地鼠冒头/顾客在等才可点);
// 本拍无可点(没冒头/没客)→ 不 tap、空转等下一拍(targets 随时变,下拍重采)。与放置族相反语义。
t = targets.find((g) => g && g.occupied === true && typeof g.x === 'number');
t = resolvedTargets.find((g) => g && g.occupied === true && typeof g.x === 'number');
} else {
// 安全放置族(井字棋/invaders…):任一未占用目标都是合法推进招,盲点首个;全占用/未导出 occupied → 按序轮点。
t = targets.find((g) => g && g.occupied !== true && typeof g.x === 'number');
if (!t) { t = targets[cyc % targets.length]; }
t = resolvedTargets.find((g) => g && g.occupied !== true && typeof g.x === 'number');
if (!t) { t = resolvedTargets[cyc % resolvedTargets.length]; }
}
cyc++;
if (t && typeof t.x === 'number') { await tap(cdp, Math.max(4, Math.min(386, t.x)), Math.max(4, Math.min(840, t.y))); drove++; }