feat(cheap): T1 驱动器约定收敛·C5 考卷契约接线(startRitual 起局仪式+driver 显式化+sourceHash 陈旧重生·金标 held-constant 不误杀) (W-S1 修复三单·单①)

- cheap_run:_build_play_spec 产 C5 形状(schemaVersion/derivedFrom/selectionBasis;key-cycle 带默认 startRitual);
  ensure_play_spec 以 derivedFrom.generator 区分本产线自动 spec 与手工金标——自动 spec 按 sourceHash 判陈旧重生,
  金标/旧世代 held-constant 永不覆盖;产出走 C5 best-effort 自校验(强校验在单测)
- play.cdp.cjs:NONPLAY 升模块级;runStartRitual(tap-selector 读 state 坐标/input-sequence/默认 Enter→Space→中心点,
  进游玩态即止);主流程 runDriver 前执行声明仪式(无声明零行为变化);runKeyCycle 对 MENULIKE 前置态做默认起局尝试
  (镜像 runTapTargets 扫点范式,治 fx4c 卡 menu 驱动器约定漂移)
- tests/test_play_spec_contract.py:两族过 C5 强校验/显式字段/hash 重生/金标不误杀/保守支 14 用例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-03 14:02:53 -07:00
parent 96de98b345
commit 234c076f51
3 changed files with 455 additions and 17 deletions

View File

@ -12,9 +12,11 @@ cheap_run.py — 便宜档生成的工具底座实现(对照源:Node amodel-
本文件 U2 先落 read/list/write/check/build;scaffold/stage/play/ensure_play_spec 由 U4 补全。
"""
import hashlib
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Optional
@ -232,28 +234,101 @@ def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
_TAP_TARGETS_CALL_PREFIXES = ("sessionScore.", "audioMusic.", "juice.")
def _build_play_spec(state: Optional[dict]) -> dict:
"""据 _forensicsView().state() 形态产 play-spec dict(纯函数、不写盘,便于单测)。
# C5 契约(contracts/play-loop/play-spec.schema.json)接线常量:
# 本产线自动 spec 的 generator 标识——ensure_play_spec 据它区分「本产线自动 spec」与「手工金标/外部注入 spec」:
# 只有本产线自动 spec 才做 sourceHash 陈旧重生;金标考卷(fixtures/golden-specs,经 compare_node.inject_golden
# 强制覆写注入)不带此标识,held-constant 永不被 hash 检查覆盖(对照公平的落点,金标不误杀)。
_SPEC_GENERATOR = "cheap_run.ensure_play_spec"
# 源工程 hash 不可算(src/ 缺失 / IO 异常)时写进 derivedFrom.sourceHash 的占位值(C5 required 非空):
# 语义=「产 spec 时源不可读」;下次源可读时真 hash ≠ 占位 → 自然触发重生,不留恒等假绑定。
_SOURCE_HASH_UNAVAILABLE = "unavailable"
def _source_hash(game_id: str) -> Optional[str]:
"""算源工程内容 hash(games/amgen-<id>/src/ 全部文件,按相对路径排序聚合 sha256)。
C5 derivedFrom.sourceHash 的取数源:agent 只写 src/ 下 L3 文件(write_file 边界),故 src/ 内容
即「源工程会变的全部」;续修轮 agent 改了任一 src 文件 → hash 变 → 旧考卷判陈旧、重生。
读不到(目录缺失 / IO 异常)返回 None——调用方回落保守行为(不据 hash 重生),绝不抛。
"""
try:
src = game_dir(game_id) / "src"
if not src.is_dir():
return None
h = hashlib.sha256()
for f in sorted(src.rglob("*")):
if f.is_file():
# 路径与内容都进 hash(文件改名/增删也算源变),NUL 分隔防拼接歧义。
h.update(f.relative_to(src).as_posix().encode("utf-8"))
h.update(b"\0")
h.update(f.read_bytes())
h.update(b"\0")
return h.hexdigest()
except Exception: # noqa: BLE001 —— hash 失败按「不可算」回落,不连累门流水线
return None
def _derived_from(source_hash: Optional[str]) -> dict:
"""组 C5 derivedFrom 派生绑定段(spec↔源工程绑定锚;hash 不可算时写占位、不留恒等假绑定)。"""
return {
"sourceHash": source_hash or _SOURCE_HASH_UNAVAILABLE,
"generatedAt": str(int(time.time() * 1000)), # 毫秒时间戳字符串(C5 允许 string|integer,取 string 稳妥)
"generator": _SPEC_GENERATOR,
"regenerateOnSourceChange": True,
}
def _build_play_spec(state: Optional[dict], *, source_hash: Optional[str] = None) -> dict:
"""据 _forensicsView().state() 形态产 play-spec dict(纯函数、不写盘,便于单测;产物须过 C5 契约校验)。
tap-targets occupied 族(state 有 targets 数组):加厚到金标同质——补 F_wiring 的 expectedEngineCallPrefixes
语义期望(外生 held 期望、预置常量,防自证);key-cycle 族(按键类):薄版,期望前缀待输入键契约(WU-C 5.4)。
C5 接线(W-S1 单①):driver 带显式 selectionBasis(把「state 有无 targets/target 键定族」这条约定写进
考卷自解释,不再是只活在本函数里的隐形开关);key-cycle 族带 startRitual 默认起局仪式(菜单/开始页游戏
先起局再按键,治「driver 只按键、不会起局 → 卡 menu」的确定性漂移);两族都带 derivedFrom 派生绑定。
"""
# 以键存在性判定「可点目标」类游戏:游戏暴露 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}
driver = {
"type": "tap-targets", "targetMode": "occupied", "targetsPath": "targets",
"steps": 50, "stepMs": 220,
# C5:驱动器族选择依据显式进考卷(自解释,取代读代码才知道的隐形约定)。
"selectionBasis": "state 暴露 targets/target 键 → 可点目标类 → tap-targets occupied 族",
}
return {
"schemaVersion": "play-spec/1",
"derivedFrom": _derived_from(source_hash),
"exportState": ["phase", "score", "targets"],
"driver": driver,
# U2 加厚到金标同质:F_wiring 语义期望前缀(外生 held 期望,绝不从实际调用反推 → 防自证假绿)。
"expectedEngineCallPrefixes": list(_TAP_TARGETS_CALL_PREFIXES),
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
"expectLatch": True, # latch advisory 兜底:限时类玩不到终局但有真进展→降 advisory 不致命
# tap-targets occupied 族不声明 startRitual:runTapTargets 已内建「非游玩态→中线扫点起局」
# (play.cdp.cjs 场景推进段),再声明会重复动作;key-cycle 族才需要显式仪式。
}
driver = {"type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], "steps": 60, "stepMs": 180, "downMs": 60}
driver = {
"type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"],
"steps": 60, "stepMs": 180, "downMs": 60,
"selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族",
}
return {
"schemaVersion": "play-spec/1",
"derivedFrom": _derived_from(source_hash),
"exportState": ["phase", "score"],
"driver": driver,
# C5 startRitual(W-S1 单①):key-cycle 游戏停在菜单/开始页时按键循环驱不动(fx4c 卡 menu 实证),
# 先依序试 Enter / Space / 画布中心点起局(harness 每步后查 phase,进游玩态即止)。
"startRitual": {
"kind": "input-sequence",
"inputs": [
{"t": "key", "code": "Enter"},
{"t": "key", "code": "Space"},
{"t": "tap", "x": 195, "y": 422},
],
"why": "菜单/开始页游戏需先起局,按键循环才驱得动(未进游玩态则 G_input/H_progress 必挂)",
},
"assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}],
"expectLatch": True,
# key-cycle 薄版:expectedEngineCallPrefixes 待『两路绑同一套输入键』契约(WU-C 5.4),M1 不加厚成可能驱不动的厚 spec。
@ -261,27 +336,85 @@ def _build_play_spec(state: Optional[dict]) -> dict:
}
def ensure_play_spec(game_id: str, state: Optional[dict]) -> dict:
"""据 _forensicsView().state() 形态自动产 _wg1-gen/<id>/play-spec.json(对 tools.mjs ensurePlaySpec)。
def _validate_c5_best_effort(spec: dict) -> None:
"""产出的 play-spec 对 C5 契约(contracts/play-loop)做 best-effort 自校验:失败只告警、绝不阻断门流水线。
driver 推断(照 play.cdp.cjs 的 driver 家族):有 targets 数组(点击类)→ tap-targets occupied;
否则(按键类)→ key-cycle。**已存在不覆盖**。让九门 driven=true、E_live/H_progress 由 advisory 升为致命,
根治"纯 harness 裸跑无 driver → 假绿"(本周链路三修的便宜档 Python 侧对应)。
校验器 = contracts/play-loop/validate.py(零依赖 stdlib),经 importlib 按路径加载(它不是包)。
为什么只告警不阻断:校验挂生产路径是为了漂移可见(响亮日志),阻断会让契约演进期的新字段直接打红生成
主链——强校验在单测层(tests/test_play_spec_contract.py 对两族产物全量过 C5)。
"""
try:
import importlib.util # noqa: PLC0415
vp = _REPO_ROOT / "contracts" / "play-loop" / "validate.py"
sp = _REPO_ROOT / "contracts" / "play-loop" / "play-spec.schema.json"
if not (vp.exists() and sp.exists()):
return # 契约目录不在(裁剪部署)→ 静默跳过
mod_spec = importlib.util.spec_from_file_location("_playloop_validate", vp)
mod = importlib.util.module_from_spec(mod_spec)
mod_spec.loader.exec_module(mod)
schema = json.loads(sp.read_text(encoding="utf-8"))
errors = mod.validate(schema, spec, schema)
if errors:
print(f"[cheap-run] ⚠ 自动 play-spec 未过 C5 契约校验(不阻断,请修 _build_play_spec):{errors[:3]}",
flush=True)
except Exception as e: # noqa: BLE001 —— 校验器自身异常绝不连累生成
print(f"[cheap-run] C5 自校验异常(忽略):{type(e).__name__}: {e}", flush=True)
def ensure_play_spec(game_id: str, state: Optional[dict]) -> dict:
"""据 _forensicsView().state() 形态自动产 _wg1-gen/<id>/play-spec.json(对 tools.mjs ensurePlaySpec)。
driver 推断(照 play.cdp.cjs 的 driver 家族):有 targets 数组(点击类)→ tap-targets occupied;
否则(按键类)→ key-cycle。让九门 driven=true、E_live/H_progress 由 advisory 升为致命,
根治"纯 harness 裸跑无 driver → 假绿"。
C5 陈旧可判定(W-S1 单①,取代旧「已存在一律不覆盖」):
· 已存在且是【本产线自动 spec】(derivedFrom.generator 以 cheap_run.ensure_play_spec 开头):
比对 derivedFrom.sourceHash 与当前源工程 hash——一致=源未变,复用;不一致=源已变(续修轮 agent
改过代码),旧考卷陈旧、重生。这是「拿旧考卷判新工程 → 假绿/错判」的封口点。
· 已存在但【非本产线出品】(无 derivedFrom / generator 异,即手工金标或外部注入,如
compare_node.inject_golden 的金标考卷):held-constant 保持不覆盖——金标是对照公平的锚,
绝不被 hash 检查误杀;旧世代自动 spec(无 derivedFrom)同样落此保守支,新链路产的 spec 都带
绑定、增量收敛。
· 源 hash 不可算(src/ 缺失):保守不覆盖已有 spec(判不了陈旧就不动)。
Args:
game_id: stage 后的 gameId(写 _wg1-gen/<id>/play-spec.json)。
state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。
game_id: stage 后的 gameId(写 _wg1-gen/<id>/play-spec.json)。
state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。
"""
try:
dst = wg1_game_dir(game_id)
spec_path = dst / "play-spec.json"
cur_hash = _source_hash(game_id)
if spec_path.exists():
return {"ok": True, "wrote": False, "reason": "已存在不覆盖"}
spec = _build_play_spec(state)
old_generator = None
old_hash = None
try:
old = json.loads(spec_path.read_text(encoding="utf-8"))
df = old.get("derivedFrom") if isinstance(old, dict) else None
if isinstance(df, dict):
old_generator = df.get("generator")
old_hash = df.get("sourceHash")
except Exception: # noqa: BLE001 —— 坏 JSON:按非本产线处理(保守不覆盖,由人工清理)
pass
is_auto_spec = isinstance(old_generator, str) and old_generator.startswith(_SPEC_GENERATOR)
if not is_auto_spec:
# 手工金标 / 外部注入 / 旧世代无绑定 spec:held-constant,不覆盖(金标不误杀)。
return {"ok": True, "wrote": False,
"reason": "已存在且非本产线自动 spec(手工金标/外部注入/旧世代),held-constant 不覆盖"}
if cur_hash is None:
return {"ok": True, "wrote": False, "reason": "已存在;源工程 hash 不可算,保守不覆盖"}
if old_hash == cur_hash:
return {"ok": True, "wrote": False, "reason": "已存在且 sourceHash 一致(源工程未变),复用"}
print(f"[cheap-run] play-spec 陈旧(sourceHash 已变):game={game_id} 旧={str(old_hash)[:12]}… "
f"新={cur_hash[:12]}… → 重生考卷", flush=True)
spec = _build_play_spec(state, source_hash=cur_hash)
_validate_c5_best_effort(spec) # 生产路径 C5 自校验(告警不阻断;强校验在单测)
dst.mkdir(parents=True, exist_ok=True)
tmp = dst / "play-spec.json.tmp"
tmp.write_text(json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(spec_path) # 原子写,防 play 读半成品
tmp.replace(spec_path) # 原子写,防 play 读半成品
return {"ok": True, "wrote": True, "driverType": spec["driver"]["type"]}
except Exception as e: # noqa: BLE001
return {"ok": False, "wrote": False, "reason": str(e)}

View File

@ -0,0 +1,237 @@
"""test_play_spec_contract.py — C5 考卷契约接线单测(W-S1 修复三单·单①)。
守的不变量:
· 自动 spec(两族)必过 contracts/play-loop/play-spec.schema.json(C5)强校验——契约面收口。
· driver 显式化:type/selectionBasis 进考卷(不再是只活在 _build_play_spec 里的隐形开关);
key-cycle 族带 startRitual 默认起局仪式;两族都带 derivedFrom 派生绑定(sourceHash/generator)。
· 陈旧可判定:本产线自动 spec 已存在时,sourceHash 一致=复用、不一致=重生(取代旧「已存在一律不覆盖」);
手工金标/外部注入/旧世代 spec(非本产线 generator)held-constant 永不被 hash 检查覆盖(金标不误杀);
源 hash 不可算时保守不覆盖。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_play_spec_contract.py -v
"""
import importlib.util
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run as R # noqa: E402
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PLAYLOOP = _REPO_ROOT / "contracts" / "play-loop"
def _load_validator():
"""按路径加载 contracts/play-loop/validate.py(零依赖 stdlib 校验器,不是包)。"""
spec = importlib.util.spec_from_file_location("_playloop_validate_t", _PLAYLOOP / "validate.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_V = _load_validator()
_C5 = json.loads((_PLAYLOOP / "play-spec.schema.json").read_text(encoding="utf-8"))
def _assert_c5(spec_dict):
errors = _V.validate(_C5, spec_dict, _C5)
assert errors == [], f"自动 spec 未过 C5 契约校验:{errors}"
# ───────────────────────── 两族产物过 C5 强校验 ─────────────────────────
def test_tap_targets_spec_passes_c5():
spec = R._build_play_spec({"phase": "play", "targets": [{"x": 1, "y": 1, "occupied": True}]},
source_hash="a" * 64)
_assert_c5(spec)
def test_key_cycle_spec_passes_c5():
spec = R._build_play_spec({"phase": "menu", "score": 0}, source_hash="b" * 64)
_assert_c5(spec)
def test_spec_without_hash_still_passes_c5():
"""hash 不可算时占位 'unavailable' 仍满足 C5 sourceHash 非空 required。"""
for state in (None, {"targets": []}):
_assert_c5(R._build_play_spec(state))
# ───────────────────────── driver 显式化(单① C5 接线)─────────────────────────
def test_driver_selection_basis_explicit():
"""驱动器族选择依据写进考卷(自解释),两族都有;不再单靠隐形键约定。"""
tap = R._build_play_spec({"targets": []})
keyc = R._build_play_spec({"score": 0})
assert "targets" in tap["driver"]["selectionBasis"]
assert "key-cycle" in keyc["driver"]["selectionBasis"]
def test_key_cycle_has_start_ritual():
"""key-cycle 族带默认起局仪式(input-sequence:Enter→Space→中心点),治卡 menu 漂移。"""
spec = R._build_play_spec({"phase": "menu"})
sr = spec["startRitual"]
assert sr["kind"] == "input-sequence"
codes = [ev.get("code") for ev in sr["inputs"] if ev["t"] == "key"]
assert codes == ["Enter", "Space"]
taps = [ev for ev in sr["inputs"] if ev["t"] == "tap"]
assert taps and taps[0]["x"] == 195 # 画布中心(390×844 竖屏中线)
def test_tap_targets_no_start_ritual():
"""tap-targets occupied 族不声明 startRitual(runTapTargets 已内建菜单扫点,声明会重复动作)。"""
spec = R._build_play_spec({"targets": []})
assert "startRitual" not in spec
def test_derived_from_binding():
"""两族都带 derivedFrom 派生绑定:sourceHash 透传、generator=本产线标识、重生语义声明。"""
spec = R._build_play_spec({"targets": []}, source_hash="deadbeef")
df = spec["derivedFrom"]
assert df["sourceHash"] == "deadbeef"
assert df["generator"] == R._SPEC_GENERATOR
assert df["regenerateOnSourceChange"] is True
# ───────────────────────── _source_hash ─────────────────────────
def test_source_hash_changes_with_src_content():
"""src/ 内容变 → hash 变;内容同 → hash 同(确定性)。目录缺失 → None。"""
with tempfile.TemporaryDirectory() as d:
orig = R._GAMES_DIR
R._GAMES_DIR = Path(d)
try:
gid = "hash-t"
src = R.game_dir(gid) / "src"
src.mkdir(parents=True)
(src / "game-logic.js").write_text("v1", encoding="utf-8")
h1 = R._source_hash(gid)
h1b = R._source_hash(gid)
(src / "game-logic.js").write_text("v2", encoding="utf-8")
h2 = R._source_hash(gid)
assert h1 == h1b and h1 != h2
assert R._source_hash("no-such-game") is None
finally:
R._GAMES_DIR = orig
# ───────────────────────── ensure_play_spec 陈旧重生 + 金标不误杀 ─────────────────────────
class _SpecEnv:
"""把 cheap_run 的 games 根与 staged 根都指到临时目录(测试隔离)。"""
def __enter__(self):
self._d = tempfile.TemporaryDirectory()
self._orig = (R._GAMES_DIR, R._WG1_DIR)
root = Path(self._d.name)
R._GAMES_DIR = root / "games"
R._WG1_DIR = root / "wg1"
return self
def __exit__(self, *a):
R._GAMES_DIR, R._WG1_DIR = self._orig
self._d.cleanup()
@staticmethod
def write_src(gid, content):
src = R.game_dir(gid) / "src"
src.mkdir(parents=True, exist_ok=True)
(src / "game-logic.js").write_text(content, encoding="utf-8")
def _spec_on_disk(gid):
return json.loads((R.wg1_game_dir(gid) / "play-spec.json").read_text(encoding="utf-8"))
def test_ensure_regenerates_on_source_change():
"""本产线自动 spec + 源工程已变(hash 不一致)→ 重生(封「拿旧考卷判新工程」)。"""
with _SpecEnv() as env:
gid = "regen-t"
env.write_src(gid, "v1")
r1 = R.ensure_play_spec(gid, {"score": 0})
assert r1["wrote"] is True
h1 = _spec_on_disk(gid)["derivedFrom"]["sourceHash"]
env.write_src(gid, "v2 changed") # 续修轮 agent 改了源
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is True, f"源变后应重生,实得 {r2}"
h2 = _spec_on_disk(gid)["derivedFrom"]["sourceHash"]
assert h1 != h2
def test_ensure_reuses_when_hash_unchanged():
"""本产线自动 spec + 源未变(hash 一致)→ 复用不覆盖(不做无谓重写)。"""
with _SpecEnv() as env:
gid = "reuse-t"
env.write_src(gid, "same")
assert R.ensure_play_spec(gid, {"score": 0})["wrote"] is True
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is False and "一致" in r2["reason"]
def test_ensure_never_overwrites_golden_spec():
"""金标不误杀:手工金标考卷(无本产线 generator)已存在 → held-constant,源怎么变都不覆盖。"""
with _SpecEnv() as env:
gid = "golden-t"
env.write_src(gid, "v1")
gdir = R.wg1_game_dir(gid)
gdir.mkdir(parents=True, exist_ok=True)
golden = {"driver": {"type": "tap-targets", "targetsPath": "targets"}, "_golden": True}
(gdir / "play-spec.json").write_text(json.dumps(golden), encoding="utf-8")
r = R.ensure_play_spec(gid, {"score": 0})
assert r["wrote"] is False and "held-constant" in r["reason"]
env.write_src(gid, "v2") # 源变了也不许覆盖金标
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is False
assert _spec_on_disk(gid)["_golden"] is True
def test_ensure_keeps_legacy_spec_without_binding():
"""旧世代自动 spec(无 derivedFrom)落保守支:不覆盖(与既有 test_ensure_play_spec 不覆盖用例同口径)。"""
with _SpecEnv() as env:
gid = "legacy-t"
env.write_src(gid, "v1")
gdir = R.wg1_game_dir(gid)
gdir.mkdir(parents=True, exist_ok=True)
(gdir / "play-spec.json").write_text('{"existing": true}', encoding="utf-8")
r = R.ensure_play_spec(gid, {"score": 0})
assert r["wrote"] is False
assert _spec_on_disk(gid)["existing"] is True
def test_ensure_conservative_when_hash_unavailable():
"""源 hash 不可算(src/ 缺失)+ 已有本产线自动 spec → 保守不覆盖(判不了陈旧就不动)。"""
with _SpecEnv() as env:
gid = "nohash-t"
env.write_src(gid, "v1")
assert R.ensure_play_spec(gid, {"score": 0})["wrote"] is True
# 删掉 src(hash 不可算),spec 已存在 → 不覆盖
import shutil
shutil.rmtree(R.game_dir(gid) / "src")
r = R.ensure_play_spec(gid, {"score": 0})
assert r["wrote"] is False and "不可算" in r["reason"]
def test_ensure_written_spec_passes_c5_on_disk():
"""落盘的自动 spec(经完整 ensure_play_spec 路径)也过 C5(防「纯函数过、落盘路径夹带脏字段」)。"""
with _SpecEnv() as env:
gid = "disk-c5-t"
env.write_src(gid, "v1")
assert R.ensure_play_spec(gid, {"targets": [{"occupied": True}]})["wrote"] is True
_assert_c5(_spec_on_disk(gid))
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0
for _fn in _fns:
try:
_fn()
print(f" PASS {_fn.__name__}")
except Exception as e: # noqa: BLE001
_failed += 1
print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}")
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)

View File

@ -174,6 +174,52 @@ async function readGameState(cdp) {
);
}
// 非游玩态 phase 集(模块级单一口径):E_live「真玩起来了」判定与起局仪式共用。
// 含 booting/menu 等前置态与 over/win 等终局态——「不在此集」即视为游玩态。
const NONPLAY = new Set(['booting', 'boot', 'menu', 'title', 'ready', 'home', 'over', 'gameover', 'win', 'lose', 'result', 'settle', 'end', 'paused']);
// 菜单/前置类 phase(起局尝试只对这些做,终局/暂停不算——终局要交 latch 校验、暂停不该被乱点重开)。
const MENULIKE = new Set(['booting', 'boot', 'menu', 'title', 'ready', 'home', 'start', 'idle', 'intro']);
// 默认起局动作序(C5 startRitual 缺省时的兜底;key-cycle 起局尝试同源):Enter → Space → 画布中心点 →
// 常见按钮区扫点(390×844 竖屏,坐标经验来自 runTapTargets 菜单扫点实证:wanglanmei「开门营业」y≈502)。
const DEFAULT_START_KICKS = [
{ t: 'key', code: 'Enter' },
{ t: 'key', code: 'Space' },
{ t: 'tap', x: 195, y: 422 },
{ t: 'tap', x: 195, y: 502 },
{ t: 'tap', x: 195, y: 557 },
];
/** 起局仪式(C5 spec.startRitual,W-S1 单①):从「装载完成」到「可被驱动器真玩」之间的一次性动作。
* kind=tap-selector:从可观测 state 读 selector 键的 {x,y} 点它(canvas 游戏无 DOM 按钮,按 _forensicsView
* 导出的按钮位置点;读不到回退默认序);kind=input-sequence:走声明的固定输入;缺省/auto:不动。
* 每步动作前读 phase,已进游玩态(不在 NONPLAY 集)即止——仪式是「到位即停」的尝试,不是必走完的序列。 */
async function runStartRitual(cdp, ritual) {
const inPlay = (s) => !!(s && typeof s.phase === 'string' && !NONPLAY.has(s.phase));
let seq = DEFAULT_START_KICKS;
if (ritual && ritual.kind === 'tap-selector' && ritual.selector) {
const s = await readGameState(cdp);
if (inPlay(s)) return;
const el = s ? getPath(s, ritual.selector) : undefined;
if (el && typeof el.x === 'number' && typeof el.y === 'number') {
await tap(cdp, el.x, el.y);
await delay(350);
return;
}
// selector 在 state 里读不到 → 回退默认起局序(不让一次导出缺失卡死起局)。
} else if (ritual && ritual.kind === 'input-sequence' && Array.isArray(ritual.inputs) && ritual.inputs.length) {
seq = ritual.inputs;
}
for (const ev of seq) {
const s = await readGameState(cdp);
if (inPlay(s)) return; // 已进游玩态,仪式完成
if (ev.t === 'tap') await tap(cdp, ev.x, ev.y);
else if (ev.t === 'key') await key(cdp, ev.code, ev.downMs || 60);
else if (ev.t === 'drag') await drag(cdp, ev.from, ev.to, ev.ms);
else if (ev.t === 'wait') { await delay(ev.ms || 200); continue; }
await delay(300); // 给游戏一拍响应时间再查 phase
}
}
/** Phase 4 E_live 校准(2026-06-20):判玩前→玩后「游戏进展态」是否真变化(score/remaining 任一数值变=游戏在响应输入)。
* 用于给 E_live 补「状态活性」证据:无运动点击类(打地鼠等)画面只在命中瞬间变、像素采样易错过→假阴;
* 但其 score/remaining 会真变。真冻屏 score/remaining 不变→返 false→E_live 仍判否,绝不放过真冻屏(只放宽不收紧)。 */
@ -433,15 +479,30 @@ async function runTapPairs(cdp, driver, hashes) {
return { drove, steps };
}
/** type='key-cycle':循环按下 driver.keys 列表里的键,到 gameover 即停。驱动 Tetris/2048/Asteroids 等纯按键游戏(盲态铺满/合并/射击→暴露上限)。 */
/** type='key-cycle':循环按下 driver.keys 列表里的键,到 gameover 即停。驱动 Tetris/2048/Asteroids 等纯按键游戏(盲态铺满/合并/射击→暴露上限)。
* 【起局尝试(W-S1 单①)】游戏停在菜单/标题等前置态(MENULIKE)时,按键循环驱不动(fx4c 驱动器约定漂移实证:
* driver 只会按方向键、不会起局 → 永卡 menu、played=false)。镜像 runTapTargets 的场景推进段:非游玩前置态
* 先依序试默认起局动作(Enter → Space → 中心/常见按钮区点,DEFAULT_START_KICKS),进游玩态后再走按键循环。
* 终局态不做起局尝试(交 latch 校验);phase 缺失/非字符串保持原「盲按键」行为不变。 */
async function runKeyCycle(cdp, driver, hashes) {
const keys = (driver.keys && driver.keys.length) ? driver.keys : ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'];
const steps = driver.steps || 40;
const stepMs = driver.stepMs != null ? driver.stepMs : 200;
let drove = 0;
let drove = 0, kickIdx = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && s.phase === 'gameover') break; // 终局即停(交 latch 校验)
// 起局尝试:菜单/标题等前置态 → 每拍换一个候选起局动作(键/点轮替),命中进游玩后下拍走按键循环。
if (s && typeof s.phase === 'string' && MENULIKE.has(s.phase)) {
const k = DEFAULT_START_KICKS[kickIdx % DEFAULT_START_KICKS.length];
if (k.t === 'key') await key(cdp, k.code, 60);
else await tap(cdp, k.x, k.y);
kickIdx++;
drove++;
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
continue;
}
await key(cdp, keys[i % keys.length], driver.downMs || 60); drove++;
try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {}
await delay(stepMs);
@ -978,6 +1039,13 @@ async function main() {
// 首局门断言③:核心闭环墙钟起点(包住「driver 真玩段 + 后续 latch 轮询」,断言核心闭环达成在 ≤60s 内)。
const coreLoopStartMs = Date.now();
// 起局仪式(C5 spec.startRitual,W-S1 单①):声明了才跑(向后兼容,无声明的 spec 行为逐字不变)。
// 停在菜单/开始页的游戏先做一次性起局动作(tap-selector / input-sequence),再交驱动器判分——
// 否则 driver 对着死菜单空转,played=false、G_input/H_progress 必挂(fx4c 卡 menu 实证根因之一)。
if (spec.startRitual && spec.startRitual.kind && spec.startRitual.kind !== 'auto') {
await runStartRitual(cdp, spec.startRitual);
}
// 真玩:有 driver 走【适配性驱动】(读 state 自动接球,真玩技巧游戏,解盲打假阴性);否则走固定输入序列。每步采帧哈希供守卫E。
const hashes = [px0.hash];
if (spec.driver) {
@ -1010,7 +1078,7 @@ async function main() {
// live = played 且 (进展态变 或 驱动期画面活性)。死菜单 played=false → 必挂(帧哈希再怎么闪也救不回)。
const distinct = new Set(hashes);
const liveByState = stateProgressed(s0, s1);
const NONPLAY = new Set(['booting', 'boot', 'menu', 'title', 'ready', 'home', 'over', 'gameover', 'win', 'lose', 'result', 'settle', 'end', 'paused']);
// NONPLAY 已提升为模块级常量(与起局仪式 runStartRitual 共用同一口径),此处直接用。
const phase1 = (s1 && typeof s1.phase === 'string') ? s1.phase : null;
const played = liveByState || (phase1 != null && !NONPLAY.has(phase1)); // 真进过游玩(状态变 或 phase 是游玩态)
const live = played && (liveByState || distinct.size >= 2); // 进过玩 且 有活性(状态变 或 画面动)