From 234c076f517300073155248a89f1294f3c685532 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 3 Jul 2026 14:02:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(cheap):=20T1=20=E9=A9=B1=E5=8A=A8=E5=99=A8?= =?UTF-8?q?=E7=BA=A6=E5=AE=9A=E6=94=B6=E6=95=9B=C2=B7C5=20=E8=80=83?= =?UTF-8?q?=E5=8D=B7=E5=A5=91=E7=BA=A6=E6=8E=A5=E7=BA=BF(startRitual=20?= =?UTF-8?q?=E8=B5=B7=E5=B1=80=E4=BB=AA=E5=BC=8F+driver=20=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=8C=96+sourceHash=20=E9=99=88=E6=97=A7=E9=87=8D?= =?UTF-8?q?=E7=94=9F=C2=B7=E9=87=91=E6=A0=87=20held-constant=20=E4=B8=8D?= =?UTF-8?q?=E8=AF=AF=E6=9D=80)=20(W-S1=20=E4=BF=AE=E5=A4=8D=E4=B8=89?= =?UTF-8?q?=E5=8D=95=C2=B7=E5=8D=95=E2=91=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cheap-worker/cheap_run.py | 161 ++++++++++-- cheap-worker/tests/test_play_spec_contract.py | 237 ++++++++++++++++++ .../games/_wg1-gen/_shared/play.cdp.cjs | 74 +++++- 3 files changed, 455 insertions(+), 17 deletions(-) create mode 100644 cheap-worker/tests/test_play_spec_contract.py diff --git a/cheap-worker/cheap_run.py b/cheap-worker/cheap_run.py index d65b1369..b59bceaa 100644 --- a/cheap-worker/cheap_run.py +++ b/cheap-worker/cheap_run.py @@ -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-/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//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//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//play-spec.json)。 - state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。 + game_id: stage 后的 gameId(写 _wg1-gen//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)} diff --git a/cheap-worker/tests/test_play_spec_contract.py b/cheap-worker/tests/test_play_spec_contract.py new file mode 100644 index 00000000..02829dcb --- /dev/null +++ b/cheap-worker/tests/test_play_spec_contract.py @@ -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) diff --git a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs index e62ffba6..457bb318 100644 --- a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs +++ b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs @@ -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); // 进过玩 且 有活性(状态变 或 画面动)