games-development-ai/cheap-worker/tests/test_a11_m4_regenerate.py
lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

361 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""test_a11_m4_regenerate.py — A11 切片三 M4「模块重生成执行」(改玩法)单测(全注入桩、零真 LLM)。
守的不变量:
· execute_regenerate_modify:有界单文件重写 game-logic.js,复用 cheap_studio resume + 三层校验,
但写边界收窄到只许写 game-logic.js;断言②对非目标文件 hash 稳定性把关。
- 注入假 rewrite(改 game-logic.js)→ manifest.changed=true、非目标稳(untouchedStable=true)、走到九门 verdict;
- intent 空 → failed 不重写(不调重写器);
- 假 rewrite 篡改非目标 → untouchedStable=false(断言②真能抓误伤)+ status failed;
- 无 base 源 / 重写异常 → best-effort failed,不抛。
· worker 接线:modifyMode=regenerate-module → 走 execute_regenerate_modify(不再占位 failed);
manifest(单 dict)附进 trace、成功带新版 sourceProject + 富 run-summary(cost/attempts)。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m4_regenerate.py
"""
import hashlib
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 # noqa: E402
import cheap_modify as M # noqa: E402
import worker_service as W # noqa: E402
import dedup # noqa: E402
import pytest # noqa: E402
# game_dir 与 dedup 登记表在本模块测试期间重定向到临时目录:materialize、worker 接线的落盘都进临时目录,
# 不碰真 games/(cheap_modify、worker_service 与本模块同引用一个 cheap_run 模块对象,改模块属性即对三者同时
# 生效);本测虽不走 create 路,dedup 登记表仍一并重定向以防万一。重定向收进 autouse fixture:只在本模块测试
# 期间生效、跑完即恢复原值,不再在 import 时无恢复地替换 cheap_run.game_dir 而污染同会话后续测试模块。
def _install_tmp_redirect():
"""把 cheap_run.game_dir、dedup.DEDUP_REGISTRY 指向新建临时目录,返回二者原值供恢复。"""
orig = (cheap_run.game_dir, dedup.DEDUP_REGISTRY)
tmp_games = Path(tempfile.mkdtemp(prefix="a11-m4-games-"))
cheap_run.game_dir = lambda gid: tmp_games / f"amgen-{gid}" # noqa: E731
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m4-dedup-")) / "_dedup_registry.jsonl"
return orig
@pytest.fixture(autouse=True, scope="module")
def _tmp_redirect():
orig_game_dir, orig_registry = _install_tmp_redirect()
try:
yield
finally:
cheap_run.game_dir = orig_game_dir
dedup.DEDUP_REGISTRY = orig_registry
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
_BASE_LOGIC = "// base game-logic\nexport function createGame({ plugins, bundle, viewport }) { return {}; }\n"
def _setup_base(game_id: str):
"""预备一个「已能玩」的便宜档产物:写好 L1 根件(entry-bundle/index,让 scaffold 跳过)+ 返回 base 源 dict。
base 源 files 含目标 game-logic.js + 两个非目标(core 数值 / host-config L1);execute_regenerate_modify 会
materialize 这些到 game_dir。返回 (game_dir, source_project_dict)。
"""
gd = cheap_run.game_dir(game_id)
(gd / "src").mkdir(parents=True, exist_ok=True)
# 预置 entry-bundle.js 让 execute 的 scaffold-if-needed 跳过(单测不 shell-out node scaffold)。
(gd / "entry-bundle.js").write_text("// L1 entry-bundle\n", encoding="utf-8")
(gd / "index.html").write_text("<html><body>g</body></html>", encoding="utf-8")
sp = {"files": {
"src/game-logic.js": _BASE_LOGIC,
"src/core.js": "export const SPEED = 5;\n", # 非目标(数值)
"src/host-config.js": "// L1 host-config plumbing\n", # 非目标(L1)
}}
return gd, sp
# ───────────────────────── ① execute_regenerate_modify ─────────────────────────
def _fake_rewrite_ok(game_id, intent, **kw):
"""假重写器:只改 game-logic.js(模拟 agent 有界重写),返回九门桩 run-summary(verdict pass)。"""
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text(
f"// rewritten for: {intent}\nexport function createGame({{ plugins, bundle, viewport }}) {{ return {{}}; }}\n",
encoding="utf-8")
return {"verdict": {"pass": True, "failedGates": []},
"verdictFull": {"pass": True, "guards": {}},
# W-AXIS-V2 波1:run_studio 经 run_acceptance 收口带 accepted(v2 验收结论);
# execute_regenerate_modify 据它判 gates_pass(create/modify 同标准),不再读九门 verdict.pass。
"accepted": True,
"attempts": 3, "stage": "play", "costRmb": 0.5,
"models": {"code": "MiniMax-M3"}, "gameId": game_id}
def _fake_rewrite_tamper(game_id, intent, **kw):
"""假重写器(误伤):改了 game-logic.js,**还动了非目标 core.js**——断言②应抓到。"""
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text("// rewritten\nexport function createGame(o){ return {}; }\n", encoding="utf-8")
(gd / "src" / "core.js").write_text("export const SPEED = 999;\n", encoding="utf-8") # 误伤非目标
return {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}},
"attempts": 2, "stage": "play", "gameId": game_id}
def test_regenerate_changed_and_nontarget_stable():
"""假 rewrite 改 game-logic → changed=true、非目标稳(untouchedStable=true)、走到九门 verdict、status succeeded。"""
gid = "m4ok"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "把命中加分从固定改成连击递增"}}
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4ok")
assert out["status"] == "succeeded", out # 九门桩 pass ∧ 非目标稳 → succeeded
man = out["manifest"]
assert man["file"] == "src/game-logic.js" and man["kind"] == "behavior"
assert man["intent"] == "把命中加分从固定改成连击递增"
assert man["changed"] is True, "game-logic 被重写 → changed"
assert man["untouchedStable"] is True, "非目标(core/host-config/entry/index)未动 → stable"
assert out["verdict"] == {"pass": True, "guards": {}}, "九门 verdict 透传(走到 verdict)"
assert out["stage"] == "play"
assert out["summary"]["attempts"] == 3, "富 run-summary 透传(cost/attempts 在)"
# 非目标文件内容确实没变。
assert (cheap_run.game_dir(gid) / "src" / "core.js").read_text(encoding="utf-8") == "export const SPEED = 5;\n"
def test_regenerate_empty_intent_failed_no_rewrite():
"""intent 空(空串/缺 intent/缺 payload)→ failed 不重写(不调用重写器)。"""
gid = "m4empty"
_, sp = _setup_base(gid)
called = {}
def _spy(game_id, intent, **kw):
called["x"] = True
return {"verdict": {"pass": True}}
for patch in ({"target": {"kind": "behavior"}, "payload": {"intent": " "}},
{"target": {"kind": "behavior"}, "payload": {"intent": ""}},
{"target": {"kind": "behavior"}, "payload": {}},
{"target": {"kind": "behavior"}}):
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_spy, task_trace_id="trace-m4empty")
assert out["status"] == "failed", out
assert out["stage"] == "intent"
assert "intent" in (out["error"] or "")
assert out["manifest"]["untouchedStable"] is None, "没重写 → 稳定性未知=None"
assert "x" not in called, "intent 空绝不应调用重写器"
def test_regenerate_tamper_nontarget_caught_by_assert2():
"""假 rewrite 篡改非目标(core.js)→ untouchedStable=false(断言②真能抓误伤)+ 即便九门过也判 failed。"""
gid = "m4tamper"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法但误伤了 core"}}
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_fake_rewrite_tamper, task_trace_id="trace-m4tamper")
assert out["manifest"]["changed"] is True, "game-logic 仍被改"
assert out["manifest"]["untouchedStable"] is False, "断言②应抓到非目标 core.js 被改"
assert out["status"] == "failed", "误伤非目标=边界泄漏,即便九门桩 pass 也判失败"
assert "断言②" in (out["error"] or "") or "非目标" in (out["error"] or "")
def test_regenerate_no_source_failed():
"""无 base 源 files → failed(无可改源),不重写。"""
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "随便改"}}
out = M.execute_regenerate_modify(
"m4nosrc", {"files": {}}, patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4nosrc")
assert out["status"] == "failed" and "源" in (out["error"] or "")
out2 = M.execute_regenerate_modify(
"m4nosrc2", "{bad json", patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4nosrc2")
assert out2["status"] == "failed", "坏 source_project 容错 failed,不抛"
def test_a11_route_only_accepts_source_project_scaffold_lineage():
"""v3 modify 只认 SourceProject.scaffoldTemplate;旧 genre/templateId/brief 推断全部 fail-closed。"""
assert M._resolve_a11_template_route({"scaffoldTemplate": "_template-trpg"}) == (
"_template-trpg", "trpg")
assert M._resolve_a11_template_route({"config": {"genre": "trpg"}}) == (None, None)
assert M._resolve_a11_template_route({"config": {"templateId": "_template-puzzle"}}) == (None, None)
assert M._resolve_a11_template_route({"templateId": "_template-shop"}) == (None, None)
def test_regenerate_fresh_worker_inherits_origin_brief_from_source_project(monkeypatch):
"""本机没有 evidence/brief.json 时,active v3 regenerate 仍从 base SourceProject 恢复原题面。"""
import cheap_verify
gid = "m4-origin-fresh"
_, sp = _setup_base(gid)
brief = "原始经营游戏题面"
sp.update({"scaffoldTemplate": "_template-shop", "originBrief": brief,
"originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()})
captured = {}
def _rewrite(game_id, intent, **kwargs):
captured.update(kwargs)
(cheap_run.game_dir(game_id) / "src" / "game-logic.js").write_text("// rewritten from source origin\n", encoding="utf-8")
return {"accepted": True, "verdict": {"pass": True},
"verdictFull": {"pass": True, "guards": {}}, "gameId": game_id}
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
monkeypatch.setattr(M, "_default_rewrite", _rewrite)
out = M.execute_regenerate_modify(
gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "增加连击"}},
base_version_id="base-1", task_trace_id="trace-m4-origin-fresh")
assert out["status"] == "succeeded"
assert captured["acceptance_brief"] == brief
assert captured["acceptance_identity"]["briefHash"] == sp["originBriefHash"]
def test_regenerate_active_v3_missing_origin_fails_before_writer(monkeypatch):
"""历史 SourceProject 缺原题面时,active v3 不得启动 Writer 或猜修改指令。"""
import cheap_verify
gid = "m4-origin-missing"
_, sp = _setup_base(gid)
sp["scaffoldTemplate"] = "_template-shop"
called = {}
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
monkeypatch.setattr(M, "_default_rewrite", lambda *a, **k: called.update(called=True) or {})
out = M.execute_regenerate_modify(
gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "增加连击"}},
base_version_id="base-1", task_trace_id="trace-m4-origin-missing")
assert out["status"] == "failed" and "originBrief" in out["error"]
assert not called
def test_regenerate_rewrite_exception_best_effort():
"""重写器抛异常 → best-effort failed + 诊断,绝不抛崩。"""
gid = "m4boom"
_, sp = _setup_base(gid)
def _boom(game_id, intent, **kw):
raise RuntimeError("LLM exploded")
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "x"}},
rewrite_fn=_boom, task_trace_id="trace-m4boom")
assert out["status"] == "failed" and "异常" in (out["error"] or "")
assert out["stage"] == "code"
def test_regenerate_gates_fail_is_plumbing_pass():
"""九门桩不过(verdict pass=false)但非目标稳 → status failed,但 changed/stable 正确(M4 验 plumbing,质量归 agent)。"""
gid = "m4gatefail"
_, sp = _setup_base(gid)
def _rewrite_gatefail(game_id, intent, **kw):
gd = cheap_run.game_dir(game_id)
(gd / "src" / "game-logic.js").write_text("// rewritten low quality\n", encoding="utf-8")
return {"verdict": {"pass": False, "failedGates": ["H_progress"]},
"verdictFull": {"pass": False, "guards": {"H_progress": {"pass": False}}},
"attempts": 6, "stage": "play", "gameId": game_id}
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法"}},
rewrite_fn=_rewrite_gatefail, task_trace_id="trace-m4gatefail")
assert out["status"] == "failed" and out["error"] == "九门未通过"
assert out["manifest"]["changed"] is True and out["manifest"]["untouchedStable"] is True, "plumbing 仍通(改了+非目标稳)"
# ───────────────────────── ② worker 接线 ─────────────────────────
def test_worker_regenerate_route_success_carries_manifest_bundle_source():
"""regenerate-module 成功 → result-out 带 engineBundle + manifest(单 dict)进 trace + 新版 sourceProject + 富 trace。"""
gid = "m4wok"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") # 成功路需 engineBundle
(gd / "src" / "game-logic.js").write_text("// rewritten\n", encoding="utf-8")
captured = {}
fake = {"status": "succeeded", "stage": "play",
"manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "连击递增",
"changed": True, "untouchedStable": True},
"verdict": {"pass": True, "guards": {}},
"summary": {"verdict": {"pass": True, "failedGates": []}, "verdictFull": {"pass": True, "guards": {}},
"attempts": 4, "stage": "play", "costRmb": 0.7, "models": {"code": "MiniMax-M3"}, "gameId": gid},
"error": None}
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
state = W.WorkerState(regen_fn=lambda *a, **kw: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: profile)
job = {"job_id": "rmok", "traceId": "rmok", "modifyMode": "regenerate-module", "gameId": gid,
"sourceProject": '{"scaffoldTemplate":"_template-shop"}',
"modifyPatch": "{}", "brief": "连击小游戏", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "succeeded"
assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle"
man = p["trace"]["modifyManifest"]
assert man["untouchedStable"] is True and man["changed"] is True, "manifest(单 dict)进 trace"
assert man["kind"] == "behavior"
assert "sourceProject" in p, "成功路 best-effort 带新版源(供链式改)"
assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-shop"
assert p["trace"]["attempts"] == 4, "富 run-summary → trace.attempts"
assert p["trace"]["cost"]["totalRmb"] == 0.7, "富 run-summary → trace.cost"
def test_worker_regenerate_route_failed_attaches_error_and_manifest():
"""regenerate-module 失败(误伤非目标)→ failed 回调 + manifest/error 进 trace(不静默吞)。"""
gid = "m4wfail"
captured = {}
fake = {"status": "failed", "stage": "play",
"manifest": {"file": "src/game-logic.js", "kind": "behavior", "intent": "x",
"changed": True, "untouchedStable": False},
"verdict": {"pass": True, "guards": {}}, "summary": None,
"error": "断言②失败:模块重生成误伤了非目标文件"}
state = W.WorkerState(regen_fn=lambda *a, **kw: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "rmf", "traceId": "rmf", "modifyMode": "regenerate-module", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
out = W.process_job(state, job)
assert out["status"] == "failed", "无 bundle + 执行器 failed → failed"
assert captured["payload"]["trace"]["modifyManifest"]["untouchedStable"] is False
assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace"
assert "sourceProject" not in captured["payload"], "失败路不带新版源"
def _boom_regen(*a, **kw):
raise AssertionError("deterministic/create 路不应调用 regen_fn")
def test_worker_deterministic_unaffected_by_regen_wiring():
"""modifyMode=deterministic 仍走 modify_fn、不触 regen_fn(regen 接线对 deterministic 零副作用)。"""
gid = "m4det"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
captured = {}
det = {"status": "succeeded",
"manifest": [{"file": "src/core.js", "kind": "config", "key": "SPEED", "old": "5", "new": "8", "found": True}],
"verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"}
state = W.WorkerState(modify_fn=lambda *a, **kw: det, regen_fn=_boom_regen,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "det1", "traceId": "det1", "modifyMode": "deterministic", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
W.process_job(state, job)
assert captured["payload"]["status"] == "succeeded"
assert isinstance(captured["payload"]["trace"]["modifyManifest"], list), "deterministic manifest 仍是 list"
def test_worker_unknown_mode_still_failed():
"""未知 modifyMode → 显式 failed 兜底回调(不静默吞;regen 接线后未知 mode 行为不变)。"""
captured = {}
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""))
job = {"job_id": "unk1", "traceId": "unk1", "modifyMode": "teleport", "gameId": "g",
"brief": "x", "callback": {"target": "http://cb"}}
out = W.process_job(state, job)
assert out["status"] == "failed" and out["mode"] == "teleport"
assert captured["payload"]["status"] == "failed"
if __name__ == "__main__":
_install_tmp_redirect() # 独立运行(非 pytest,autouse fixture 不触发):单进程一次性装重定向,退出即释放、无需恢复
_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[test_a11_m4_regenerate] {len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)