lili 14c79dbfdd feat(studio): A11 切片三 M4 模块重生成执行(有界单文件 LLM 重写 game-logic.js)
两段式第二段的 regenerate-module 类:改玩法 = 据 intent 有界重写 game-logic.js,不动其余。

复用机制(参数化、不复制 resume/熔断/收口):
- cheap_studio.run_studio 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare)
  切到 modify 态,create 路零行为变化(params=None 等价原逻辑)
- cheap_toolkit.build_toolkit 加 write_whitelist(非白名单 basename 写直接拒)
- cheap_roles.build_modify_system_prompt(复用 create 红线契约块)

cheap_modify.execute_regenerate_modify:
- 取 intent(空→failed)→ materialize base 源 → 重写前后对非目标文件算 hash
- 有界重写(写边界收窄到只 game-logic.js)→ 九门
- status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest{file,kind:behavior,intent,changed,untouchedStable}(断言②地基)

worker_service._process_regenerate_job(regen_fn):把 M3 的 regenerate-module 显式 failed 占位换成真执行;
deterministic/create/生成路一字未动。

测试:test_a11_m4_regenerate 10/10(全注入桩零 LLM)+ 全回归独立复跑 73 测试全绿。
一次真 LLM smoke(单跑):intent=连击递增 → status=succeeded、untouchedStable=True、九门 pass、
attempts=1、¥0.31、137s;独立 diff 佐证 5 非目标文件字节相同、仅 game-logic.js 变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 02:16:22 -07:00

501 lines
27 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.

"""cheap_modify.py — A11 切片三 M3 便宜档「确定性类纠错执行」(execution 段零 LLM)。
两段式回路第二段。第一段(cheap_classify,M2)用 LLM 判用户原话的意图 → 建议改动
{mode,target,payload};用户确认后前端调已有 /modify 执行。本模块是 deterministic 类
(换资产 / 调数值 / 改关卡)的执行核心:**改"规范工程那一处" → 落盘 → 重建(esbuild)→ 九门 → 改动清单**。
判意图的 LLM 在第一段已做完,故 execution 段是纯文本定位替换、**零 LLM**。
工程规范性(创始人 2026-06-28 定,本模块寻址的地基):便宜档产物已规范——
· 资产统一在 src/assets.js 的 IMAGE_FILES(键→文件名);
· 核心数值集中在 src/core.js 的 `export const NAME = VALUE`。
确定性执行 = 在这"一处"做文本替换,而不是 LLM 在散落代码里定位。
三件:
· apply_deterministic_modify(纯函数·TDD 核心):patch → 改后 files + 改动清单 manifest + ok/no-op 判定。
· materialize_source_project(系统级落盘):把 base 源 files 直接写到 game_dir——**系统恢复已知良源**,
绕 cheap_run.write_file 的 L1_FIXED 守卫(那守卫是防 *agent* 写坏 wiring;这里是系统落盘,只仍校验不越界)。
· execute_deterministic_modify(集成):apply → scaffold 已知良 plumbing → materialize 覆盖改后源 →
build → stage → smoke → ensure_play_spec → 九门 play,**复用 cheap_run 现有九门,不另造**。
apply no-op/unsupported → 直接 failed(带 manifest + error),不重建(省 esbuild/Chrome)。
best-effort 铁律:apply 失败 / 重建失败 → 返 failed + 诊断,绝不抛崩 worker。只改 L3(core/assets/game-logic),
不碰 L1 plumbing。
"""
import hashlib
import json
import re
from pathlib import Path
import cheap_run
# A11 M4 模块重生成behavior 在便宜档映射到玩法文件KTD4——只重写它其余文件保持不变。
_BEHAVIOR_TARGET = "game-logic.js"
_TARGET_REL = "src/game-logic.js"
# 断言②非目标稳定game_dir 根下要 track 稳定性的 L1 入口/页面src/*.js 用 globbuild 衍生物 bundle.iife.js 不 track
_ROOT_STABLE = ("index.html", "entry-bundle.js", "entry.js")
# 调数值(config/level)寻址的 L3 文件优先序:核心数值集中在 core.js,找不到再搜其它 L3。
_CONFIG_FILE_ORDER = ("core.js", "game-logic.js", "render.js", "balance.js")
# 换资产寻址的 L3 文件:资产清单统一在 assets.js。
_ASSET_FILE = "assets.js"
def log(msg: str) -> None:
"""带前缀日志(stdout,便于 nohup/setsid 落盘排障)。"""
print(f"[cheap-modify] {msg}", flush=True)
# ───────────────────────── 容错解析 / JS 字面量序列化 ─────────────────────────
def _as_dict(obj) -> dict:
"""obj 容错成 dict(后端经 HTTP 传的是 JSON 串;也容忍已是 dict)。坏输入返 {}"""
if isinstance(obj, dict):
return obj
if isinstance(obj, str) and obj.strip():
try:
parsed = json.loads(obj)
return parsed if isinstance(parsed, dict) else {}
except Exception: # noqa: BLE001 — 坏 JSON 容错返空,由上层判 no-op,不抛
return {}
return {}
def _to_js_literal(value) -> str:
"""把 Python 值序列化成一个合法 JS 字面量(数值/字符串/数组/对象/布尔)。
用 json.dumps:20000→"20000"、0.9→"0.9""#000"→双引号串、[1,2]→"[1, 2]"、True→"true"——
JSON 子集恒是合法 JS。既有源用单引号,这里出双引号亦合法(esbuild 照吃),不强求引号风格一致。
非 JSON 可序列化(罕见)兜底为 str()。
"""
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return str(value)
def _match_file_key(files: dict, basename: str):
"""在 files(路径→文本)里找 basename 对应的键(容忍 "src/core.js""core.js" 两种键形)。无 → None。"""
for key in files:
if key == basename or key.endswith("/" + basename):
return key
return None
# ───────────────────────── 文本定位替换:常量 / 资产键 ─────────────────────────
def _replace_const(text: str, name: str, new_literal: str):
"""把 `export const <name> = <旧值>;` 的值替换成 new_literal。返回 (新文本, 旧值字符串|None)。
锚 `export const NAME =` 到行尾 `;`,两层稳健:
· 单行优先:值不含换行与分号(数值 / 字符串 / 单行数组),`^[ \\t]*` 锚行首防误匹配注释/字符串内文本;
· 多行兜底:`Object.freeze([...])` 跨行、值内无行尾分号 → DOTALL 非贪到行尾分号。
只替第一处(`export const` 同名应唯一)。找不到 → (原文, None)(由上层记 found=false=no-op 地基)。
"""
# 单行:值在一行内,不跨换行、不含分号。
pat_single = re.compile(
r"(^[ \t]*export\s+const\s+" + re.escape(name) + r"\s*=\s*)([^\n;]*)(\s*;)",
re.MULTILINE,
)
m = pat_single.search(text)
if m is None:
# 多行兜底:跨行值(如 Object.freeze 多行数组),锚到「行尾的分号」。
pat_multi = re.compile(
r"(export\s+const\s+" + re.escape(name) + r"\s*=\s*)(.*?)(;[ \t]*$)",
re.DOTALL | re.MULTILINE,
)
m = pat_multi.search(text)
if m is None:
return text, None
old = m.group(2).strip()
new_text = text[: m.start(2)] + new_literal + text[m.end(2):]
return new_text, old
def _replace_asset_value(text: str, key: str, new_value: str):
"""把 IMAGE_FILES 里 `<key>: '<旧文件名>'` 的值替换成 new_value(保留原引号风格)。
容忍键带/不带引号(heroSprite: / 'heroSprite': / "heroSprite":);值引号用反引用配对(单/双引号都行)。
返回 (新文本, 旧值|None)。空 IMAGE_FILES = {} 或键不存在 → (原文, None)=no-op 地基。
"""
key_pat = r"['\"]?" + re.escape(key) + r"['\"]?"
pat = re.compile(r"(" + key_pat + r"\s*:\s*)(['\"])(.*?)\2")
m = pat.search(text)
if m is None:
return text, None
old = m.group(3)
# 只换值(group3),保留原引号 group2;new_value 是裸文件名/URL,引号由原文提供。
new_text = text[: m.start(3)] + str(new_value) + text[m.end(3):]
return new_text, old
def _replace_base_url(text: str, new_value: str):
"""把 loadAssets 的默认资产根 `baseUrl == null ? '<旧>' : baseUrl` 中的 <旧> 替换成 new_value。
返回 (新文本, 旧值|None)。匹配不到(无该默认表达式)→ (原文, None)。
"""
pat = re.compile(r"(baseUrl\s*==\s*null\s*\?\s*)(['\"])(.*?)\2")
m = pat.search(text)
if m is None:
return text, None
old = m.group(3)
new_text = text[: m.start(3)] + str(new_value) + text[m.end(3):]
return new_text, old
# ───────────────────────── ① apply_deterministic_modify(纯函数) ─────────────────────────
def _apply_config(files: dict, value: dict, kind: str) -> list:
"""config/level:对 value 的每个 {常量名:新值},在 L3 文件优先序里替换 `export const 名 = 值`。返回 manifest。"""
manifest = []
for name, new_val in value.items():
new_lit = _to_js_literal(new_val)
found = False
target_key = None
old = None
for base in _CONFIG_FILE_ORDER:
fk = _match_file_key(files, base)
if fk is None:
continue
new_text, old_val = _replace_const(files[fk], name, new_lit)
if old_val is not None:
files[fk] = new_text
found, target_key, old = True, fk, old_val
break
manifest.append({
"file": target_key or ("src/" + _CONFIG_FILE_ORDER[0]),
"kind": kind, "key": name,
"old": old, "new": new_lit, "found": found,
})
return manifest
def _apply_asset(files: dict, value: dict) -> list:
"""asset:对 value 的每个 {资产键:新文件名/URL}(或 baseUrl),在 assets.js 替换。返回 manifest。"""
manifest = []
fk = _match_file_key(files, _ASSET_FILE)
for key, new_val in value.items():
found = False
old = None
if fk is not None:
if key == "baseUrl":
new_text, old_val = _replace_base_url(files[fk], str(new_val))
else:
new_text, old_val = _replace_asset_value(files[fk], key, str(new_val))
if old_val is not None:
files[fk] = new_text
found, old = True, old_val
manifest.append({
"file": fk or ("src/" + _ASSET_FILE),
"kind": "asset", "key": key,
"old": old, "new": str(new_val), "found": found,
})
return manifest
def apply_deterministic_modify(source_project, modify_patch) -> dict:
"""确定性改一处(纯函数):据 patch 的 target.kind 改 base 源的规范一处,产改后 files + 改动清单。
Args:
source_project: base 源工程(dict 或 2.0 JSON 串,有 files=路径→文本)。
modify_patch: 改动意图(dict 或 JSON 串),形如
{"target":{"kind":"config|asset|level","path":"..."},"payload":{"value":{"":"新值",...}}}。
Returns:
{"files": 改后 files dict, "manifest": [...], "ok": bool, "error": str|None}。
manifest 每条 {"file","kind","key","old","new","found"};**no-op 检测**:目标常量/键找不到 → found=false;
若无任何一条 found=true 且实际值变化(old≠new)→ 整体 ok=false(诚实判 no-op,不是真改动)。
"""
sp = _as_dict(source_project)
patch = _as_dict(modify_patch)
raw_files = sp.get("files")
if not isinstance(raw_files, dict) or not raw_files:
return {"files": {}, "manifest": [], "ok": False, "error": "源工程无 files(无可改源)"}
files = dict(raw_files) # 浅拷贝:整串替换、不原地改入参
target = patch.get("target") if isinstance(patch.get("target"), dict) else {}
kind = target.get("kind")
payload = patch.get("payload") if isinstance(patch.get("payload"), dict) else {}
value = payload.get("value")
if kind not in ("config", "asset", "level"):
return {"files": files, "manifest": [], "ok": False,
"error": f"非确定性类 kind={kind}(M3 只处理 config/asset/level)"}
if not isinstance(value, dict) or not value:
# level 复杂布局重排(value 非 {常量名:新值} 映射)→ unsupported;其余类同样要求映射。
hint = "level 复杂布局重排不在 M3(仅支持关卡常量文本替换)" if kind == "level" else "payload.value 须为 {键:新值} 非空映射"
return {"files": files, "manifest": [], "ok": False, "error": hint}
if kind in ("config", "level"):
manifest = _apply_config(files, value, kind)
else: # asset
manifest = _apply_asset(files, value)
# no-op 判定:至少一条「命中且实际变化」才算真改动。
ok = any(r["found"] and r["old"] != r["new"] for r in manifest)
error = None if ok else "no-op:无任何目标命中且实际变化(常量/键未找到,或新值与旧值相同)"
return {"files": files, "manifest": manifest, "ok": ok, "error": error}
# ───────────────────────── ② materialize_source_project(系统级落盘) ─────────────────────────
def materialize_source_project(game_id: str, files) -> Path:
"""把 base 源工程的全部 files 直接写到 cheap_run.game_dir(game_id)(系统恢复已知良源)。
**绕 cheap_run.write_file 的 L1_FIXED 守卫**——那守卫是防 *agent* 把 wiring 写坏(host-config/game/index 等);
这里是 *系统* 落盘已知良源(L1 plumbing + L3 都照写),故不拦 L1。仍校验路径不逃出 game_dir(防越界写)。
这是「在既有产物上只重建+重过门」轻入口的前半;后半(build+九门)由 execute_deterministic_modify 接。
Args:
game_id: 本 run 的 gameId(写 games/amgen-<id>/)。
files: 路径(如 "src/core.js")→ 文本 的 dict。
Returns:
game_dir(Path)。
"""
gd = cheap_run.game_dir(game_id)
gd_real = gd.resolve()
gd.mkdir(parents=True, exist_ok=True)
if not isinstance(files, dict):
return gd
for rel_path, content in files.items():
abs_p = (gd / rel_path).resolve()
# 越界守卫:解析后必须仍落在 game_dir 内(防 ../ 或绝对路径逃逸)。
if abs_p != gd_real and gd_real not in abs_p.parents:
log(f"materialize 跳过越界路径(不在 game_dir 内):{rel_path}")
continue
abs_p.parent.mkdir(parents=True, exist_ok=True)
abs_p.write_text(content if isinstance(content, str) else str(content), encoding="utf-8")
return gd
# ───────────────────────── ③ execute_deterministic_modify(集成) ─────────────────────────
def execute_deterministic_modify(game_id: str, source_project, modify_patch,
*, port: int = 4320, cdp_port: int = 9222) -> dict:
"""确定性执行集成:apply 改一处 → 落盘已知良源 → 重建 → 九门。复用 cheap_run 九门,不另造。
流程:apply_deterministic_modify → 若 ok:(缺 plumbing 才 scaffold 已知良 entry-bundle/index.html)→
materialize 覆盖改后 L3 源 → build(esbuild)→ stage → smoke → ensure_play_spec → 九门 play。
apply no-op/unsupported → 直接 failed(带 manifest + error),**不重建**(省 esbuild/Chrome)。
任一步失败 → failed + 诊断(stage 标到哪步断的),绝不抛崩。
Returns:
{"status":"succeeded|failed", "manifest":[...], "verdict":{...}|None, "stage":"apply|scaffold|build|stage|play",
"error":str|None, "smokeOk":bool|None, "playOk":bool|None}。
"""
# 1. apply:确定性改一处(纯文本定位替换,零 LLM)。
applied = apply_deterministic_modify(source_project, modify_patch)
manifest = applied["manifest"]
if not applied["ok"]:
# no-op / unsupported:不重建,直接 failed + 带 manifest + error。
log(f"apply 未产生真改动(no-op/unsupported)game_id={game_id}: {applied['error']}")
return {"status": "failed", "manifest": manifest, "verdict": None,
"stage": "apply", "error": applied["error"], "smokeOk": None, "playOk": None}
# 2. 确保可构建的 game_dir:已存在 plumbing(既有产物)→ 复用(保留 assets/);缺 → scaffold 已知良 plumbing。
gd = cheap_run.game_dir(game_id)
if not (gd / "entry-bundle.js").exists():
sc = cheap_run.scaffold(game_id) # scaffold-saa:克隆 _template + SAA 信封 entry-bundle.js/index.html
if not sc["ok"]:
log(f"scaffold 失败 game_id={game_id}: {sc.get('output', '')[:300]}")
return {"status": "failed", "manifest": manifest, "verdict": None,
"stage": "scaffold", "error": "scaffold 失败:" + sc.get("output", ""),
"smokeOk": None, "playOk": None}
# 3. 落盘改后源(覆盖 scaffold 的默认 L3 src/*,把 base 源 + 改动那一处写进去)。
materialize_source_project(game_id, applied["files"])
# 4. 重建 esbuild。
bd = cheap_run.build(game_id)
if not bd["ok"]:
log(f"build 失败 game_id={game_id}: {bd.get('output', '')[:300]}")
return {"status": "failed", "manifest": manifest, "verdict": None,
"stage": "build", "error": "build 失败:" + bd.get("output", ""),
"smokeOk": None, "playOk": None}
# 5. stage → smoke → ensure_play_spec → 循环外九门 play(复用 cheap_studio 收口同一套)。
st = cheap_run.stage(game_id)
if not st["ok"]:
log(f"stage 失败 game_id={game_id}: {st.get('output', '')[:300]}")
return {"status": "failed", "manifest": manifest, "verdict": None,
"stage": "stage", "error": "stage 失败:" + st.get("output", ""),
"smokeOk": None, "playOk": None}
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port)
cheap_run.ensure_play_spec(game_id, sm.get("state")) # 已存在不覆盖;缺则据 state 形态补 driver(防裸跑假绿)
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
verdict = pr.get("verdict")
gates_pass = isinstance(verdict, dict) and verdict.get("pass") is True
status = "succeeded" if gates_pass else "failed"
log(f"确定性改重建+九门完成 game_id={game_id} status={status} smokeOk={sm.get('ok')} playOk={pr.get('ok')}")
return {"status": status, "manifest": manifest, "verdict": verdict,
"stage": "play", "error": None if status == "succeeded" else "九门未通过",
"smokeOk": sm.get("ok"), "playOk": pr.get("ok")}
# ───────────────────────── ④ execute_regenerate_modify模块重生成 · 改玩法)─────────────────────────
def _hash_file(p: Path) -> str:
"""文件内容 sha256断言②的稳定性指纹"""
return hashlib.sha256(p.read_bytes()).hexdigest()
def _snapshot_nontarget_hashes(game_id: str) -> dict:
"""对 game_dir 内**非目标源文件**算 hash 快照(断言②地基)。返回 {rel_path: sha256}。
非目标 = 除 game-logic.js 外的全部源文件src/ 下其余 .jscore/render/assets/balance + L1 的
host-config/game+ 根下 L1 入口/页面index.html/entry-bundle.js/entry.js。**不 track build 衍生物**
bundle.iife.js 每次 build 重生成track 它会假性不稳)。重写前后各拍一次、逐键比对即知非目标有没有被误伤。
"""
gd = cheap_run.game_dir(game_id)
out = {}
src = gd / "src"
if src.is_dir():
for p in sorted(src.glob("*.js")):
if p.name == _BEHAVIOR_TARGET:
continue # 目标文件本身不算「非目标」
out[f"src/{p.name}"] = _hash_file(p)
for name in _ROOT_STABLE:
p = gd / name
if p.is_file():
out[name] = _hash_file(p)
return out
def _regen_manifest(intent: str, *, changed, stable) -> dict:
"""模块重生成改动清单(单条 dict区别确定性类的多条 list哪个文件 / 哪类 / 意图 / 变没变 / 非目标稳没稳。"""
return {"file": _TARGET_REL, "kind": "behavior", "intent": intent,
"changed": changed, "untouchedStable": stable}
def _regen_why(gates_pass: bool, untouched_stable: bool) -> str:
"""失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)。"""
if not untouched_stable:
return "断言②失败模块重生成误伤了非目标文件core/render/assets/host-config 等应保持不变)"
if not gates_pass:
return "九门未通过"
return "模块重生成失败"
def _noop_prepare(game_id: str) -> dict:
"""run_studio 的 prepare 注入位game_dir 已由 execute_regenerate_modify 预备scaffold+materialize base 源),
不要再 scaffold 覆盖掉 base 源。返回 scaffold 同形契约 {ok,output}。"""
return {"ok": True, "output": "game_dir 已由 execute_regenerate_modify 预备scaffold+materialize base 源)"}
def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: int = 9222) -> dict:
"""默认重写器:跑 cheap_studio.run_studio 的 modify 态(自定义 system prompt + kick + 写边界白名单 +
prepare=noop[game_dir 已就绪]),复用同一套 ReAct + resume 熔断 + 三层校验收口。返回 run-summary含九门 verdict
懒导入 cheap_studio/cheap_roles只在真运行时装配 tier2 框架 + key单测注入 rewrite_fn 不触发本函数。
"""
import asyncio
import cheap_roles
import cheap_studio
sys_prompt = cheap_roles.build_modify_system_prompt(game_id, intent)
g = f"game-runtime/games/amgen-{game_id}"
kick = (f"{g}/src/game-logic.js 现在是一款能跑能玩的游戏。请**只重写它**来实现这个调整:「{intent}」。"
f"先 read_file 读当前的 {g}/src/game-logic.js 看现状,再据调整意图最小改写;"
"core/render/assets/host-config 等其余文件保持不变、别写它们(写会被拒)。"
"check 与 build 都绿了就立即 finish。")
# 复用既有 max_iters(40)/max_resumes(6) resume 熔断,不新造。
return asyncio.run(cheap_studio.run_studio(
game_id, intent, system_prompt=sys_prompt, initial_kick=kick,
write_whitelist={_BEHAVIOR_TARGET}, prepare=_noop_prepare,
port=port, cdp_port=cdp_port))
def execute_regenerate_modify(game_id: str, source_project, modify_patch,
*, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict:
"""模块重生成执行(改玩法):取 base 源 + intent → 有界单文件重写 game-logic.js → 九门 → 断言②非目标稳定 + 改动清单。
流程:取 intent空→failed 不重写)→ 取 base 源 files无源→failed→ scaffold-if-needed + materialize
base 源(同 deterministic 口径)→ 重写前对目标+非目标算 hash → 有界重写 game-logic.js复用 cheap_studio
ReAct+resume+三层校验,写边界收窄到只许写 game-logic.js→ 重写后再算 hash 判 changed / untouchedStable →
据九门 verdict + 断言②判 status。best-effort任一步异常→failed+诊断,绝不抛崩 worker。
Args:
game_id: 本 run gameId。
source_project: base 源工程dict 或 2.0 JSON 串,有 files=路径→文本)。
modify_patch: 改动意图,形如 {"target":{"kind":"behavior"},"payload":{"intent":"<改写意图>"}}。
rewrite_fn: (game_id, intent, *, port, cdp_port)->run_summary 的重写器注入位(单测桩免真 LLM/esbuild/Chrome
默认 _default_rewrite真跑 cheap_studio modify 态)。
port/cdp_port: serve/CDP 端口(透传 run_studio 收口)。
Returns:
{"status":"succeeded|failed", "stage":..., "manifest":{...}, "verdict":九门verdict|None,
"summary":run-summary|None, "error":str|None}。status=succeeded ⟺ 九门过 ∧ 断言②非目标稳定。
manifest = {"file":"src/game-logic.js","kind":"behavior","intent":intent,"changed":bool,"untouchedStable":bool}。
"""
# 1. 取 intent改写意图空 → failed不重写省一切重活
patch = _as_dict(modify_patch)
payload = patch.get("payload") if isinstance(patch.get("payload"), dict) else {}
intent = payload.get("intent")
intent = intent.strip() if isinstance(intent, str) else ""
if not intent:
log(f"模块重生成 intent 为空 → failed 不重写 game_id={game_id}")
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest("", changed=False, stable=None),
"verdict": None, "summary": None,
"error": "modifyPatch.payload.intent 为空(改写意图缺失),不重写"}
# 2. 取 base 源 files无源 → failed无可改源
sp = _as_dict(source_project)
base_files = sp.get("files") if isinstance(sp.get("files"), dict) else None
if not base_files:
log(f"模块重生成无 base 源 files → failed game_id={game_id}")
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "base 源工程无 files无可改源"}
try:
# 3. prep缺 plumbing 才 scaffold补 L1 entry-bundle/index.html→ materialize base 源覆盖 src/(同 deterministic 口径)。
gd = cheap_run.game_dir(game_id)
if not (gd / "entry-bundle.js").exists():
sc = cheap_run.scaffold(game_id)
if not sc["ok"]:
log(f"模块重生成 scaffold 失败 game_id={game_id}: {sc.get('output', '')[:300]}")
return {"status": "failed", "stage": "scaffold",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "scaffold 失败:" + sc.get("output", "")}
materialize_source_project(game_id, base_files)
# 4. 断言②地基:重写前对目标 + 非目标算 hash 快照。
target_p = gd / _TARGET_REL
target_before = _hash_file(target_p) if target_p.is_file() else None
nontarget_before = _snapshot_nontarget_hashes(game_id)
# 5. 有界单文件重写 game-logic.js默认 cheap_studio modify 态rewrite_fn 可注入桩)。
fn = rewrite_fn or _default_rewrite
run_summary = fn(game_id, intent, port=port, cdp_port=cdp_port)
run_summary = run_summary if isinstance(run_summary, dict) else {}
# 6. 断言②:重写后再算 hash判目标变没变 + 非目标稳没稳。
target_after = _hash_file(target_p) if target_p.is_file() else None
nontarget_after = _snapshot_nontarget_hashes(game_id)
changed = target_before != target_after
untouched_stable = nontarget_before == nontarget_after
manifest = _regen_manifest(intent, changed=changed, stable=untouched_stable)
except Exception as e: # noqa: BLE001 — 重写/落盘/hash 任一异常 best-effort 兜底,不抛崩 worker
log(f"模块重生成异常 game_id={game_id}: {type(e).__name__}: {e}")
return {"status": "failed", "stage": "code",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": f"模块重生成异常:{type(e).__name__}: {e}"}
# 7. 据九门 verdict + 断言②判 status九门过 ∧ 非目标稳 才算成功(误伤非目标=边界泄漏,即便九门过也判失败)。
verdict_full = run_summary.get("verdictFull") if isinstance(run_summary.get("verdictFull"), dict) else None
vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None
gates_pass = bool(vb and vb.get("pass") is True)
status = "succeeded" if (gates_pass and untouched_stable) else "failed"
stage = run_summary.get("stage") or "code"
log(f"模块重生成完成 game_id={game_id} status={status} changed={changed} "
f"untouchedStable={untouched_stable} gatesPass={gates_pass} stage={stage}")
return {"status": status, "stage": stage, "manifest": manifest,
"verdict": verdict_full, "summary": run_summary,
"error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable)}