lili 5bcaf6ac1e
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS-V2 波1——测试 agent 生产化+三路统一验收编排器+字段迁移
opus 执行位交付、主会话四步验收过(测试亲跑/红线 diff 亲读/硬证亲眼):

- playtest.cdp.cjs 生产版(§3 八件:DPR=1 playtest/1 坐标协议/坐标尺/落点回显/
  同点硬提示/反早退/预算 14→24 随进展扩/fail 二掷显式 seed+清存档/图像自检
  fail-closed exit=3)+ serve-and-playtest.sh;证据落 evidence/playtest/roll-N/
- cheap_verify:project_floor 四门投影(预筛权威唯一产地,绝不复用 harness 原生
  verdict.pass)+ run_playtest(子进程/超时/记账/playtest.json 真相层/端口派生避
  Chrome unsafe 5060/5061)+ run_acceptance 编排器(v2=floor∧测试员阻断/
  shadow=旧口径+shadowV2Accepted 对照/v1=旧判定器;修复反馈只引现象段)
- 三路接线:cheap_studio/cheap_service_driver/cheap_modify 两档全改调编排器,
  create 与 modify 单一验收标准
- §4 字段迁移:run-summary acceptanceVersion+playtest+floor 段、trace.playtest
  additive(firstPlay 三字段镜像 ReadinessScorer 口径)、批账 floorPass+accepted+
  acceptanceVersion(verdictPass 仅对照);result_out 三级取值(floor→verdictFull
  投影→verdict.pass 回落)、gate_judge 续修触发器切四门、xtheme 同批;判定语义单轨
- §5 Java 读侧:ReadinessScorer 可玩性/firstPlay 优先读 trace.playtest,
  GenMetrics 归因加 playtest/tester_degraded(向后兼容,未编译验证——本机无后端
  构建环境,待 mini-desktop 构建窗口)
- genconfig/generation.yaml:acceptance.mode 三态(波1 灰度默认 shadow)+
  playtest 旋钮(steps/二掷/超时/成本上限);judge 段未动
- 单测 32 项新增(二掷/图像 fail-closed/mode 三态/投影/现象分离/退出码/unsafe 端口)

验收:pytest 512 绿 + node 44/44(主会话亲跑);10 局考卷生产件重跑判对 9/9
(真坏 3/3 零放行、假阴 0/6,heritage-r4/puzzle-r1 二掷翻案、sb2 假超时根因=
Chrome unsafe 端口已修);Service E2E(w1v2-e2e1 shadow→v2 一发收敛)与 modify
回归(w1v2-mod1 ROUND_MS 60000→45000,src 与局内截图双证)真浏览器硬证;
总成本 ¥2.3。已知余项:真续修回喂环仅单测覆盖(波2 n=3 冒烟自然覆盖)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:07:08 -07:00

550 lines
30 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_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 ""
# 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,
*, base_version_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 → 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")
# W-AXIS-V2 波1:create 与 modify 不允许两套验收标准——确定性改也过统一编排器(四门投影 ∧ 测试员真玩)。
# modify 同步执行(worker_service 懒绑),用 asyncio.run 跑 async 编排器(与 _default_rewrite 同款);
# brief 取原游戏 brief(改数值行为不变,验证「改后仍是同一款可玩游戏」);测试员端口自动派生(与九门 4320 分段)。
import asyncio # noqa: PLC0415
_acc = asyncio.run(cheap_verify.run_acceptance(
{"verdict": verdict}, game_id=game_id, brief=_read_game_brief(game_id), 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 = "验收未通过(v2:四门∧测试员)" 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"确定性改重建+v2验收+三断言完成 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"),
# v2 验收摘要 additive(供 worker_service 组 run-summary / trace):
"acceptance": {k: _acc.get(k) for k in ("acceptanceVersion", "floor", "playtest", "judge", "accepted")}}
# ───────────────────────── ④ 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, changed: bool = True) -> str:
"""失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)→ 断言①no-op → 九门。"""
if not untouched_stable:
return "断言②失败模块重生成误伤了非目标文件core/render/assets/host-config 等应保持不变)"
if not changed:
return "断言①失败:模块重生成未改变 game-logic.jsno-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) -> 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,
*, base_version_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}。
"""
# 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九门过 ∧ 改动真生效(①) ∧ 非目标稳(②) 才算成功
# (误伤非目标=边界泄漏、目标没变=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(退役口径)。
_acc = run_summary.get("accepted")
gates_pass = bool(_acc) if _acc 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)}