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:
parent
96de98b345
commit
234c076f51
@ -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,
|
||||
"selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族",
|
||||
}
|
||||
driver = {"type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], "steps": 60, "stepMs": 180, "downMs": 60}
|
||||
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)}
|
||||
|
||||
237
cheap-worker/tests/test_play_spec_contract.py
Normal file
237
cheap-worker/tests/test_play_spec_contract.py
Normal 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)
|
||||
@ -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); // 进过玩 且 有活性(状态变 或 画面动)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user