两段式第二段的 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>
501 lines
27 KiB
Python
501 lines
27 KiB
Python
"""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 用 glob,build 衍生物 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/ 下其余 .js(core/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)}
|