"""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 → 九门 play(undriven),**复用 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_assert import cheap_run import cheap_verify # W-AXIS-V2 波1:modify 两档改调统一验收编排器(create/modify 同标准) def _read_game_brief(game_id: str) -> str: """读原游戏 brief(evidence/brief.json;modify 验收给测试员上下文——改后仍应是同一款可玩游戏)。缺/坏 → 空串。""" try: p = cheap_run.game_dir(game_id) / "evidence" / "brief.json" if p.is_file(): d = json.loads(p.read_text(encoding="utf-8")) return (d.get("brief") if isinstance(d, dict) else "") or "" except Exception: # noqa: BLE001 brief 读失败不致命,测试员用空 brief 仍可判 broken/hollow pass return "" def _resolve_a11_template_route(source_project) -> tuple[str | None, str | None]: """只从源工程可信元数据恢复 templateRoute/genre;v3 禁止用 brief 或单独 genre 反推 profile。""" sp = _as_dict(source_project) template = sp.get("scaffoldTemplate") genre = cheap_verify.GENRE_BY_TEMPLATE.get(template) if template else None if template and genre in cheap_verify._V3_GENRES: return str(template), str(genre) return None, None def _resolve_a11_origin_brief(source_project) -> tuple[str | None, str | None]: """从 base SourceProject 恢复不可变原题面;active v3 禁止依赖本机 evidence 残留。""" sp = _as_dict(source_project) brief = sp.get("originBrief") brief_hash = sp.get("originBriefHash") if (not isinstance(brief, str) or not brief.strip() or not isinstance(brief_hash, str) or hashlib.sha256(brief.encode("utf-8")).hexdigest() != brief_hash): return None, None return brief, brief_hash # 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 = <旧值>;` 的值替换成 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 里 `: '<旧文件名>'` 的值替换成 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-/)。 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, *, base_version_id=None, task_trace_id=None, 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 → 九门 play(undriven)。 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}。 """ mode = str(cheap_verify._acceptance_v3_cfg().get("mode") or "v3_shadow") task_binding_hash = None if mode in ("v3", "v3_shadow"): try: task_binding_hash = cheap_verify.task_binding_hash_v3(task_trace_id) except ValueError as exc: return {"status": "failed", "manifest": [], "verdict": None, "stage": "apply", "error": str(exc), "smokeOk": None, "playOk": 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 → 循环外九门 play(undriven;复用 cheap_studio 收口同一套;ensure_play_spec 波2 已摘)。 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) # boot 预检(smokeOk 供返回);其 state 不再喂 ensure_play_spec pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port) verdict = pr.get("verdict") # create 与 modify 不允许两套验收标准:确定性改也调用 v3 唯一入口;仅显式历史模式回放 v1/v2。 # brief 恒取原游戏题面;若 v3 最终给 verified reject + repairEligible,才调用一次受限 writer,并沿 parentRun # 复验同一 brief。同步执行路径用 asyncio.run 驱动异步验收。 import asyncio # noqa: PLC0415 template_route, genre = _resolve_a11_template_route(source_project) origin_brief, origin_brief_hash = _resolve_a11_origin_brief(source_project) brief = origin_brief if mode in ("v3", "v3_shadow") else _read_game_brief(game_id) if mode in ("v3", "v3_shadow") and (not template_route or genre not in cheap_verify._V3_GENRES or not origin_brief or not origin_brief_hash): import cheap_studio # noqa: PLC0415 _acc = cheap_studio.apply_v3_entry_failure( {"verdict": verdict}, "A11 sourceProject 缺可信 templateRoute/originBrief,禁止依赖本机 evidence 或修改指令猜原题面", mode=mode) gates_pass = False elif mode in ("v3", "v3_shadow"): import cheap_studio # noqa: PLC0415 acceptance_identity = cheap_verify.build_acceptance_v3_identity( game_id, brief, genre=genre, template_route=template_route, repair_ordinal=0, task_binding_hash=task_binding_hash) _acc_v3 = asyncio.run(cheap_verify.run_acceptance_v3(cheap_studio.build_acceptance_v3_request( game_id, brief, verdict, acceptance_identity=acceptance_identity, idempotency_key=f"{game_id}:a11-deterministic-v3"))) _acc = cheap_studio.apply_acceptance_v3({"verdict": verdict}, _acc_v3) decision = _acc_v3.get("decision") or {} if cheap_verify.is_v3_repair_authorized(_acc_v3): # 确定性改本身零 LLM;只有 v3 最终硬证拒绝时,才把同一产物交受限 writer 修一次。 try: repair_identity = cheap_verify.build_acceptance_v3_identity( game_id, brief, genre=acceptance_identity["genre"], template_route=acceptance_identity["templateRoute"], source_artifact_hash=_acc_v3["artifactHash"], parent_acceptance_request_hash=acceptance_identity["acceptanceRequestHash"], repair_ordinal=1, proof_profile_id=acceptance_identity["proofProfileId"], proof_registry_version=acceptance_identity["proofRegistryVersion"], task_binding_hash=acceptance_identity["taskBindingHash"], ) repair_summary = _default_rewrite( game_id, decision.get("repairFeedback") or "按 v3 硬证修复玩法闭环", port=port, cdp_port=cdp_port, acceptance_parent_run_id=_acc_v3.get("runId"), acceptance_repair_count=1, acceptance_brief=brief, genre=genre, acceptance_identity=repair_identity, acceptance_template_route=template_route, acceptance_task_trace_id=task_trace_id) except Exception as e: # noqa: BLE001 —— A11 执行器必须返回 failed 摘要,不能把 worker 主循环打崩 repair_summary = None log(f"v3 单次 writer 修复异常(保留首轮 reject):game_id={game_id} {type(e).__name__}: {e}") if isinstance(repair_summary, dict): final_acceptance = repair_summary.get("acceptanceV3") _acc = (cheap_studio.apply_acceptance_v3(repair_summary, final_acceptance, first_pass=_acc_v3) if isinstance(final_acceptance, dict) else repair_summary) verdict = repair_summary.get("verdictFull") or verdict gates_pass = cheap_verify.is_v3_publishable(_acc.get("acceptanceV3") or {}) else: _acc = asyncio.run(cheap_verify.run_acceptance( {"verdict": verdict}, game_id=game_id, brief=brief, verdict=verdict)) gates_pass = bool(_acc.get("accepted")) # M5 三结构断言:base→改后 真比对(断言①改动真生效 / 断言②非目标稳)+ 血缘(③ baseVersionId)。 # 断言②对 deterministic 是兜 apply bug(理应只改目标文件,真比对抓"是否顺手碰了非目标")。 base_files = _as_dict(source_project).get("files") if isinstance(_as_dict(source_project).get("files"), dict) else {} target_files = {m["file"] for m in manifest if m.get("file")} assertions = cheap_assert.three_assertions(manifest, base_files, applied["files"], target_files, base_version_id) a12_pass = assertions["a1_changeApplied"]["pass"] and assertions["a2_nontargetStable"]["pass"] status = "succeeded" if (gates_pass and a12_pass) else "failed" err = None if status != "succeeded": err = "验收未通过(统一验收入口)" if not gates_pass else "三断言未通过:" + json.dumps( {k: v for k, v in assertions.items() if isinstance(v, dict) and not v.get("pass")}, ensure_ascii=False) log(f"确定性改重建+统一验收+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass} " f"mode={_acc.get('acceptanceVersion')}") return {"status": status, "manifest": manifest, "verdict": verdict, "assertions": assertions, "stage": "play", "error": err, "smokeOk": sm.get("ok"), "playOk": pr.get("ok"), # 统一验收摘要 additive;v3 完整封存对象保留在 acceptanceV3。 "acceptance": {k: _acc.get(k) for k in ("acceptanceVersion", "floor", "playtest", "judge", "accepted", "acceptanceV3", "acceptanceV3FirstPass", "publishFrozen", "firstPassAccepted", "repairAttempted", "acceptedAfterRepair")}} # ───────────────────────── ④ 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, changed: bool = True) -> str: """失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)→ 断言①no-op → 九门。""" if not untouched_stable: return "断言②失败:模块重生成误伤了非目标文件(core/render/assets/host-config 等应保持不变)" if not changed: return "断言①失败:模块重生成未改变 game-logic.js(no-op,目标文件没动)" 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, acceptance_parent_run_id=None, acceptance_repair_count=0, acceptance_brief=None, genre=None, acceptance_identity=None, acceptance_template_route=None, acceptance_task_trace_id=None) -> 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, acceptance_parent_run_id=acceptance_parent_run_id, acceptance_repair_count=acceptance_repair_count, acceptance_brief=acceptance_brief, genre=genre, acceptance_identity=acceptance_identity, acceptance_template_route=acceptance_template_route, acceptance_task_trace_id=acceptance_task_trace_id)) def execute_regenerate_modify(game_id: str, source_project, modify_patch, *, base_version_id=None, task_trace_id=None, 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}。 """ mode = str(cheap_verify._acceptance_v3_cfg().get("mode") or "v3_shadow") task_binding_hash = None if mode in ("v3", "v3_shadow"): try: task_binding_hash = cheap_verify.task_binding_hash_v3(task_trace_id) except ValueError as exc: return {"status": "failed", "stage": "intent", "manifest": _regen_manifest("", changed=False, stable=None), "verdict": None, "summary": None, "error": str(exc)} # 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(无可改源)"} template_route, genre = _resolve_a11_template_route(sp) origin_brief, origin_brief_hash = _resolve_a11_origin_brief(sp) original_brief = origin_brief if mode in ("v3", "v3_shadow") else _read_game_brief(game_id) if rewrite_fn is None and mode in ("v3", "v3_shadow") \ and (not template_route or genre not in cheap_verify._V3_GENRES or not origin_brief or not origin_brief_hash): return {"status": "failed", "stage": "intent", "manifest": _regen_manifest(intent, changed=False, stable=None), "verdict": None, "summary": None, "error": "A11 sourceProject 缺可信 templateRoute/originBrief,禁止依赖本机 evidence 或修改指令猜原题面,不执行重生成"} 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 identity = None if rewrite_fn is None and mode in ("v3", "v3_shadow"): # 在默认 Writer 启动前冻结 ordinal=0 身份;后续 run_studio 只验证和透传,不重选 profile。 identity = cheap_verify.build_acceptance_v3_identity( game_id, original_brief, genre=genre, template_route=template_route, repair_ordinal=0, task_binding_hash=task_binding_hash) run_summary = fn( game_id, intent, port=port, cdp_port=cdp_port, genre=genre, acceptance_brief=original_brief, acceptance_identity=identity, acceptance_template_route=template_route, acceptance_task_trace_id=task_trace_id, ) 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:九门过 ∧ 改动真生效(①) ∧ 非目标稳(②) 才算成功 # (误伤非目标=边界泄漏、目标没变=no-op,即便九门过也判失败)。 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 # W-AXIS-V2 波1:_default_rewrite 走 run_studio(run_gates=True),收口已跑统一验收编排器,验收权威 = accepted # (create/modify 同标准);run_summary 缺 accepted(注入桩/老路)→ 回落四门投影,不回落九门 verdict.pass(退役口径)。 v3_payload = run_summary.get("acceptanceV3") v3_marker = isinstance(v3_payload, dict) or run_summary.get("acceptanceVersion") in ("v3", "v3_shadow") if v3_marker: # v3 标记存在但完整 payload 缺失时必须 fail-closed,禁止回落旧 accepted/四门。 gates_pass = cheap_verify.is_v3_publishable(v3_payload if isinstance(v3_payload, dict) else {}) else: legacy_accepted = run_summary.get("accepted") gates_pass = (bool(legacy_accepted) if legacy_accepted is not None else cheap_verify.project_floor(verdict_full)["pass"]) # 三断言 verdict(统一形态,与 deterministic 路一致):①changed ②非目标稳(含误伤清单)③血缘。 nontarget_changed = sorted(k for k in set(nontarget_before) | set(nontarget_after) if nontarget_before.get(k) != nontarget_after.get(k)) assertions = { "a1_changeApplied": {"pass": changed, "reason": "" if changed else "目标文件未变(no-op)"}, "a2_nontargetStable": {"pass": untouched_stable, "nontargetChanged": nontarget_changed}, "a3_lineageQueryable": cheap_assert.assert_lineage(base_version_id), "allPass": bool(changed and untouched_stable and base_version_id is not None), } a12_pass = changed and untouched_stable status = "succeeded" if (gates_pass and a12_pass) 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, "assertions": assertions, "error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable, changed)}