""" cheap_run.py — 便宜档生成的工具底座实现(对照源:Node amodel-gen 的 tools.mjs + play.mjs)。 分工(KTD3): · read_file / list_dir / write_file —— Python 直接实现(对齐 tools.mjs 的 readFileTool/listDirTool/writeFileTool: repo 根只读 + 200KB 截断 / L1_FIXED 拦截 + 写边界),逻辑简单、无引擎重活。 · check / build / scaffold / stage / play —— shell-out 现有 node/cjs,不 Python 重写 esbuild/CDP/形状门: check=`node tools.mjs check `(含 node --check + 全部静态门 + 两条形状门)、build=`node tools.mjs build-saa `、 scaffold/stage 同 tools.mjs CLI、九门 play=`bash serve-and-play.sh `(U4 落地)。 Node 已验过不误伤的形状门,便宜档 shell-out 同一份代码自动继承,零口径漂移。 本文件 U2 先落 read/list/write/check/build;scaffold/stage/play/ensure_play_spec 由 U4 补全。 """ import hashlib import json import os import shutil import subprocess import time from pathlib import Path from typing import Optional # 路径基准:cheap-worker/cheap_run.py → parents[1]=repo 根。 _REPO_ROOT = Path(__file__).resolve().parents[1] _GAME_RUNTIME = _REPO_ROOT / "game-runtime" _GAMES_DIR = _GAME_RUNTIME / "games" _AMODEL_GEN = _GAME_RUNTIME / "tools" / "amodel-gen" _TEMPLATE_DIR = _GAMES_DIR / "_template" _WG1_DIR = _GAMES_DIR / "_wg1-gen" _SERVE_AND_PLAY = _WG1_DIR / "_shared" / "serve-and-play.sh" # 九门一键(cwd=game-runtime) # 本机 Mac Chrome:play.mjs smokeBoot 默认 /usr/bin/google-chrome 在 Mac 不存在,须显式 Mac 路径。 # 只在该路径真实存在(=Mac)才注入;Linux(mini-desktop)上注入它会把 smoke 全打成 spawn ENOENT → # state=None → 考卷全体回退 key-cycle(2026-07-04 生产实证:80007 族错配盲修 ¥14.9、80012 gatespec # driver=key-cycle 的共同上游根因)——Linux 交 play.mjs:22 自身默认 /usr/bin/google-chrome。 _DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" _MAX_READ_BYTES = 200 * 1024 # 单文件读上限,对 tools.mjs MAX_READ_BYTES,防撑爆 agent context # L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),agent 不许写,对 tools.mjs:89 L1_FIXED。 _L1_FIXED = {"host-config.js", "game.js", "index.html", "entry-bundle.js", "entry.js", "main.js"} def game_dir(game_id: str) -> Path: """本 run 的 game 目录(= 写边界),对 tools.mjs gameDir:games/amgen-/。""" return _GAMES_DIR / f"amgen-{game_id}" def wg1_game_dir(game_id: str) -> Path: """SAA stage 目标目录,对 tools.mjs saaGameDir:games/_wg1-gen//。""" return _WG1_DIR / game_id # 便宜档 Service 会话注册表目录(C2:driver 按 session_id 写 session→后端 gameId 映射 + 本 session 配置, # Service 两工厂读它把框架分配的 session_id 解析回后端 gameId)。_ 前缀 = 基建目录(同 _template/_wg1-gen), # 非游戏目录,不会被 scaffold/list 当游戏,避免在 games/ 堆 amgen- 空壳。 _CHEAP_SESSIONS_DIR = _GAMES_DIR / "_cheap-sessions" def session_cfg_path(session_id: str) -> Path: """本 session 的 cheap 配置 sidecar 路径(按 session_id 键;driver 在 /chat 前写、Service 工厂读)。 内容 {external_game_id, write_whitelist, scaffold_template, restricted}:因 AgentScope 工厂签名固定 (user_id, agent_id, session_id)、拿不到后端 gameId 也拿不到 session 记录,故用同机共享 FS sidecar 把 「session_id → 后端 gameId + 本 session 配置」跨进程传给工厂(C2 收口)。 """ return _CHEAP_SESSIONS_DIR / f"{session_id}.json" # ───────────────────────── W-AXIS 波1 真相层:per-run 归档 + evidence 清理清单化 ───────────────────────── # 病根(诊断档 §2.3 F0-b/F0-c): # F0-b「失败 run 被覆盖」——scaffoldSaa 每局起手 rmSync(amgen-/)(tools.mjs:471)整目录删,重跑同 gid # 直接抹掉上一 run 的 trace.jsonl / turns.jsonl / src / 收口采集,失败链路无从回放。 # F0-c「evidence 残留混杂」——scaffold 只删 amgen-/、绝不碰 _wg1-gen//,于是九门/续修反馈/截图在 # _wg1-gen//evidence/ 逐 run 累积(实证:hard-heritage 的 verdict-feedback.json@07-08 与 verdict.json@07-09 # 并存=不同 run 残留);旧红线③只清 verdict.json 一个文件,反馈/日志/截图残留照喂下一 run。 # 本节两件对症: # ① archive_prior_run:重跑同 gid 前把上一 run 的 amgen-/ 与 _wg1-gen// 整体【移】进带时间戳归档位, # 永不同 gid 覆盖;返回归档指针(供批次账每 run 记一笔)。 # ② clean_stale_evidence:scaffold 后按【固定清单】清 _wg1-gen//evidence/ 残留(verdict/反馈/日志/截图全列), # 不再只清一个文件——封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。 # 归档根:归档位 = games/_amgen-archive//<时间戳>/{amgen/, wg1/}。_ 前缀=基建目录,不被 scaffold/list 当游戏。 _ARCHIVE_DIR = _GAMES_DIR / "_amgen-archive" # scaffold 后应清的 _wg1-gen//evidence/ 残留清单(F0-c:全列,不再只清 verdict.json)。 # verdict-feedback.json 是残留混杂的元凶(旧红线③没清);game-log/截图/快照/报告一并清,防跨 run 混读。 _STALE_EVIDENCE_WG1 = ( "verdict.json", # 九门裁决(旧红线③只清它) "verdict-feedback.json", # C6 续修反馈(残留混杂元凶——旧代码不清,喂错下一 run 归因) "game-log.json", # smoke 抓的运行时日志 "world-snapshot.json", # 世界快照(若产) "play-report.json", # 真玩报告(若产) "first-paint.png", # 首帧截图 "after-play.png", # 真玩后截图 ) # amgen-/evidence/ 侧:scaffold 已 rmSync 整个 amgen-/、这些本已随之清掉,列此仅作【不归档/不 scaffold 时】 # 的防御兜底(如归档关 + 未走 scaffold 的异常路);service-run-summary.json = Service 收口采集,brief.json 本次早落不清。 _STALE_EVIDENCE_AMGEN = ( "service-run-summary.json", ) # 归档开关(默认开;env CHEAP_ARCHIVE_ENABLED ∈ {0,false,no,off} 关 → 退回旧行为=scaffold 直接覆盖)。 _ENV_ARCHIVE_ENABLED = "CHEAP_ARCHIVE_ENABLED" def _archive_enabled() -> bool: v = os.environ.get(_ENV_ARCHIVE_ENABLED) if v is None: return True return v.strip().lower() not in ("0", "false", "no", "off", "") def archive_prior_run(game_id: str) -> Optional[str]: """重跑同 gid 前把上一 run 的整套产物【移】进带时间戳归档位,永不同 gid 覆盖(F0-b)。 移 amgen-/(trace.jsonl/turns.jsonl/src/收口采集)与 _wg1-gen//(staged bundle/play-spec/九门证据) 到 games/_amgen-archive///{amgen/,wg1/};两目录皆不存在(首跑)→ 返回 None、no-op。 移走后 scaffold 重建 amgen-/、stage 重建 _wg1-gen//,旧 run 完整留档可回放。 best-effort:归档关 / 任一步失败 → 返回 None 并告警,**退回旧行为(scaffold 直接覆盖)**,绝不阻断生成 (归档是纯增量旁路,关开关即回)。返回归档目录绝对路径字符串(供批次账每 run 记指针)或 None。 """ if not _archive_enabled(): return None src_amgen = game_dir(game_id) src_wg1 = wg1_game_dir(game_id) if not src_amgen.exists() and not src_wg1.exists(): return None # 首跑:无上一 run 可归档 try: # 时间戳归档位;同秒重跑加 -n 后缀防撞(极少数并发/秒内重跑)。 stamp = time.strftime("%Y%m%d-%H%M%S") base = _ARCHIVE_DIR / str(game_id) dst = base / stamp n = 1 while dst.exists(): dst = base / f"{stamp}-{n}" n += 1 dst.mkdir(parents=True, exist_ok=True) moved = [] if src_amgen.exists(): shutil.move(str(src_amgen), str(dst / "amgen")) moved.append("amgen") if src_wg1.exists(): shutil.move(str(src_wg1), str(dst / "wg1")) moved.append("wg1") print(f"[cheap-run] per-run 归档:game={game_id} 上一 run [{'+'.join(moved)}] → {dst}", flush=True) return str(dst) except Exception as e: # noqa: BLE001 —— 归档失败退回旧行为(覆盖),不阻断生成;告警便于排障 print(f"[cheap-run] per-run 归档失败(退回旧行为=scaffold 覆盖,不阻断生成):{type(e).__name__}: {e}", flush=True) return None def clean_stale_evidence(game_id: str) -> dict: """scaffold 后按【固定清单】清 evidence 残留(F0-c:取代旧红线③只清 verdict.json 一个文件)。 主清 _wg1-gen//evidence/(scaffold 不碰它、逐 run 累积):verdict/反馈/日志/截图/快照/报告全列清; 再兜底清 amgen-/evidence/(正常已被 scaffold rmSync,列此防不归档/异常路)。封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。best-effort:逐文件 unlink(missing_ok),任何异常只告警、不阻断。返回 {removed:[...]}。 """ removed = [] try: wg1_ev = wg1_game_dir(game_id) / "evidence" for name in _STALE_EVIDENCE_WG1: p = wg1_ev / name if p.exists(): p.unlink(missing_ok=True) removed.append(f"wg1/evidence/{name}") amgen_ev = game_dir(game_id) / "evidence" for name in _STALE_EVIDENCE_AMGEN: p = amgen_ev / name if p.exists(): p.unlink(missing_ok=True) removed.append(f"amgen/evidence/{name}") except Exception as e: # noqa: BLE001 —— 清理 best-effort,失败只告警、不阻断生成 print(f"[cheap-run] evidence 清理清单化异常(忽略,不阻断生成):{type(e).__name__}: {e}", flush=True) if removed: print(f"[cheap-run] evidence 清理清单化:game={game_id} 清 {len(removed)} 项 {removed}", flush=True) return {"removed": removed} def _resolve_in_repo(rel: str) -> Path: """把 repo 相对路径解析为绝对路径,校验不逃出 repo 根(对 tools.mjs resolveInRepo)。""" abs_p = (_REPO_ROOT / rel).resolve() if abs_p != _REPO_ROOT and _REPO_ROOT not in abs_p.parents: raise ValueError(f"路径越界(必须在 repo 根内):{rel}") return abs_p # ───────────────────────── 知识件外置 shadow(W-CFG-KB 切片一)───────────────────────── # read_file 根解析:先查「知识根」(TIER2_KB_ROOT,externalized 知识件的物化落点,在 git 检出之外、 # deploy-safe),命中同相对路径的文件即读它;未命中回落「仓根」(_REPO_ROOT,api.d.ts 等代码类不外置、 # 仍读仓)。TIER2_KB_ROOT 未配置时整条 shadow 短路 —— read_file 行为与外置前逐字节一致(默认关,纯增量, # 不碰生成主链)。写侧(kb_store.materialize_active 物化到此根)与本读侧读同一个 env TIER2_KB_ROOT。 def _kb_root() -> Optional[Path]: """知识根:env TIER2_KB_ROOT(未配 / 空 → None,read_file 退回只读仓根、行为字节不变)。""" raw = os.environ.get("TIER2_KB_ROOT") if not raw or not raw.strip(): return None try: return Path(raw).expanduser().resolve() except Exception: # noqa: BLE001 —— 路径异常按未配处理(退回仓根,绝不因知识根拖垮读) return None def _resolve_in_root(rel: str, root: Path) -> Path: """把相对路径解析到给定根内,校验不逃出该根(知识根 shadow 用;与 _resolve_in_repo 同边界语义)。""" abs_p = (root / rel).resolve() if abs_p != root and root not in abs_p.parents: raise ValueError(f"路径越界(必须在根内):{rel}") return abs_p def _read_bounded(abs_p: Path) -> dict: """读一个已解析的文件:200KB 截断(对 tools.mjs MAX_READ_BYTES)。返回 {ok, content, truncated}。""" raw = abs_p.read_bytes() truncated = False if len(raw) > _MAX_READ_BYTES: raw = raw[:_MAX_READ_BYTES] # 截断:超大文件只给前 200KB truncated = True return {"ok": True, "content": raw.decode("utf-8", "ignore"), "truncated": truncated} # ───────────────────────── read / list / write(Python 直接实现)───────────────────────── def read_file(path: str) -> dict: """read_file:知识根 shadow 优先、回落仓根,两根均只读 + 200KB 截断 + 路径不逃逸(对 tools.mjs readFileTool)。 根解析(W-CFG-KB 切片一,纯增量):TIER2_KB_ROOT 配置了就先查知识根同相对路径的文件,命中即读 (externalized 知识件热换即生效);未命中(如插件 api.d.ts 等未外置的代码类)回落仓根。TIER2_KB_ROOT 未配时整条 shadow 短路,read_file 行为与外置前逐字节一致(默认关)。 """ try: kb_root = _kb_root() if kb_root is not None: # 知识根内解析(越界即拒;两根同相对路径,越界对仓根同样越界,不静默回落绕过边界)。 kb_abs = _resolve_in_root(path, kb_root) if kb_abs.is_file(): return _read_bounded(kb_abs) # 知识根命中:读 externalized 知识件 # 知识根内无此文件(未外置的代码类)→ 回落仓根(下面)。 abs_p = _resolve_in_repo(path) if abs_p.is_dir(): return {"ok": False, "error": f"是目录,不是文件:{path}(请用 list_dir)"} return _read_bounded(abs_p) except Exception as e: # noqa: BLE001 工具层统一软失败返回给 agent,不抛断循环 return {"ok": False, "error": str(e)} def list_dir(path: str) -> dict: """list_dir:repo 根内列目录(目录名带尾 /)。返回 {ok, entries?, error?}(对 tools.mjs listDirTool)。""" try: abs_p = _resolve_in_repo(path) entries = sorted((e.name + "/" if e.is_dir() else e.name) for e in abs_p.iterdir()) return {"ok": True, "entries": entries} except Exception as e: # noqa: BLE001 return {"ok": False, "error": str(e)} def write_file(game_id: str, path: str, content: str) -> dict: """write_file:仅许写本 run game 目录的 L3 文件。返回 {ok, bytes?, error?}(对 tools.mjs writeFileTool)。 边界:abs 必须落在 games/amgen-/ 内;basename 命中 L1_FIXED 则拒写(根除把 wiring 写坏类错误)。 """ try: abs_p = _resolve_in_repo(path) gd = game_dir(game_id).resolve() if abs_p != gd and gd not in abs_p.parents: return {"ok": False, "error": f"write 越界:只许写 games/amgen-{game_id}/ 内(你给的是 {path})"} base = abs_p.name if base in _L1_FIXED: return {"ok": False, "error": ( f"{base} 是 L1 固定 plumbing(boot 链/插件装配/viewport/工厂 wiring),不要写它。" "你只写 L3 游戏本体:src/{game-logic,core,render,balance,assets}.js。" "游戏逻辑写在 game-logic.js 的 createGame({plugins,bundle,viewport});插件经 plugins.<键> 直接用(已替你注入)。")} abs_p.parent.mkdir(parents=True, exist_ok=True) abs_p.write_text(content, encoding="utf-8") return {"ok": True, "bytes": len(content.encode("utf-8"))} except Exception as e: # noqa: BLE001 return {"ok": False, "error": str(e)} # ── edit_file 锚点容错(W-AXIS 波2:治「一字之差」5 连拒螺旋)────────────────────────────── # 精确匹配(逐字节)是首选、语义最稳;精确 0 命中时再走【空白归一回退】——只吸收行内空白/缩进差异(模型手抄 # 锚点时最常见的偏差),归一后仍须【唯一】命中才接受;绝不做相似度阈值类模糊匹配(会改错地方=危险)。归一后仍 # 找不到/不唯一 → 报错附【最近似片段 + 行号窗口】,帮模型下一次给准锚点,而不是只丢一句"没找到"(5 连拒的病根)。 def _norm_ws_line(ln: str) -> str: """行内空白归一:去首尾空白 + 内部连续空白(空格/制表)压成单空格(' '.join(split) 天然如此)。空行→''。""" return " ".join(ln.split()) def _locate_ws_normalized(content: str, old: str) -> dict: """空白归一回退定位:content 与 old 按【整行序列】做行内空白/缩进归一后找命中(只定位不改)。 返回 {status:'unique', start, klen} | {status:'ambiguous'|'notfound', hint}。 """ content_lines = content.split("\n") old_lines = old.split("\n") norm_content = [_norm_ws_line(l) for l in content_lines] norm_old = [_norm_ws_line(l) for l in old_lines] k = len(norm_old) if k == 0 or all(l == "" for l in norm_old): # old 归一后无实锚(纯空白)——无法安全定位。 return {"status": "notfound", "hint": ( "edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。old 归一后无实质内容(纯空白)、无法定位;" "请复制文件里的原文当 old(含唯一上下文),或改用 write_file 整体覆盖。")} starts = [i for i in range(0, len(norm_content) - k + 1) if norm_content[i:i + k] == norm_old] if len(starts) == 1: return {"status": "unique", "start": starts[0], "klen": k} if len(starts) > 1: rows = "/".join(str(s + 1) for s in starts) return {"status": "ambiguous", "hint": ( f"edit 的 old 忽略空白/缩进差异后仍命中 {len(starts)} 处(分别起于第 {rows} 行)、无法唯一定位;" "请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")} return {"status": "notfound", "hint": _edit_nearest_hint(content_lines, norm_content, norm_old)} def _edit_nearest_hint(content_lines: list, norm_content: list, norm_old: list) -> str: """找与 norm_old 整行重合最多的同长窗口作【最近似片段】提示(仅诊断、不作匹配接受),附原文行号窗口。""" k = len(norm_old) best_i, best_score = -1, 0 for i in range(0, max(0, len(norm_content) - k + 1)): score = sum(1 for j in range(k) if norm_old[j] and norm_content[i + j] == norm_old[j]) if score > best_score: best_score, best_i = score, i win = k if best_i < 0 or best_score == 0: # 全无整行重合 → 退化:按首个非空 old 行做子串近似定位(单行窗口)。 first = next((l for l in norm_old if l), "") for i, l in enumerate(norm_content): if first and first in l: best_i, best_score, win = i, 1, 1 break if best_i < 0: return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。确认 path 是否为你要改的文件、" "old 是否复制自该文件当前内容(可先 read_file 核对),或改用 write_file 整体覆盖。") end = min(len(content_lines), best_i + win) snippet = "\n".join(f"{best_i + 1 + off}| {content_lines[best_i + off]}" for off in range(0, end - best_i)) return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。" f"最接近的是第 {best_i + 1}-{end} 行(整行匹配 {best_score}/{win}):\n{snippet}\n" "若要改的就是这里,请复制上面【原文】(含其真实缩进/空白)当 old 重发;否则确认 path/old 来源。") def edit_file(game_id: str, path: str, old: str, new: str) -> dict: """edit_file:增量修改——把 game 目录内某 L3 文件里出现一次的 old 串替换成 new,避免"改三行→重写整文件"。 死圈修复 fix①-A(2026-07-08 双评审):便宜档六工具原本只有整文件覆盖的 write_file, "改一个 rng 小函数"被迫变成"重写 22KB",MiniMax 在超长 tool-call 参数上反复漏 path 触发熔断。 edit_file 让小修表达为短参数替换,天然绕开漏参高发区。 锚点容错(W-AXIS 波2):精确匹配(逐字节)唯一命中即用;精确 0 命中时走空白归一回退(只吸收行内空白/缩进 差异,归一后仍须唯一命中才接受,绝不相似度模糊匹配);仍找不到/不唯一 → 报错附最近似片段 + 行号窗口,帮 模型下一次给准锚点(治审计实证的「一字之差」5 连拒螺旋)。 边界继承:读现文件走 game_dir 内解析(不走 read_file 的知识根 shadow,要改的是工程盘上的真文件), 回写复用 write_file(继承 L1_FIXED 拒写 + game_dir 越界拒写);write_whitelist 由 toolkit 层把守。 """ try: abs_p = _resolve_in_repo(path) gd = game_dir(game_id).resolve() if abs_p != gd and gd not in abs_p.parents: return {"ok": False, "error": f"edit 越界:只许改 games/amgen-{game_id}/ 内(你给的是 {path})"} if not abs_p.is_file(): return {"ok": False, "error": f"文件不存在,无法 edit:{path}(先用 write_file 建它,再 edit 增量改)"} content = abs_p.read_text(encoding="utf-8") cnt = content.count(old) if cnt > 1: return {"ok": False, "error": ( f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。" "请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")} matched_by = "exact" if cnt == 1: new_content = content.replace(old, new, 1) # 精确唯一命中(首选、最稳) else: # cnt == 0:精确没找到 → 空白归一回退(只吸收行内空白/缩进差异,仍须唯一命中)。 loc = _locate_ws_normalized(content, old) if loc["status"] != "unique": return {"ok": False, "error": loc["hint"]} # 归一后仍非唯一命中 → 带最近似片段/行号窗口提示 i, k = loc["start"], loc["klen"] cl = content.split("\n") new_content = "\n".join(cl[:i] + new.split("\n") + cl[i + k:]) # 按整行替换命中窗口,落 new 原样 matched_by = "ws-normalized" # 回写经 write_file:继承 L1_FIXED 拒写 + game_dir 越界拒写(edit 不新开绕过面)。 r = write_file(game_id, path, new_content) if r.get("ok"): r["message"] = ("edit 已应用(替换 1 处)" if matched_by == "exact" else "edit 已应用(old 精确没匹配上,按行内空白/缩进归一后唯一命中 1 处;已落 new 原样)") return r except Exception as e: # noqa: BLE001 return {"ok": False, "error": str(e)} # ───────────────────────── check / build(shell-out node tools.mjs)───────────────────────── def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess: """shell-out `node tools.mjs `(cwd=game-runtime,对 tools.mjs 的路径基准)。""" return subprocess.run( ["node", str(_AMODEL_GEN / "tools.mjs"), cmd, game_id], cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=timeout, check=False, ) def check(game_id: str) -> dict: """check:循环内快反馈门,shell-out `node tools.mjs check `(node --check + 全部静态门 + 两条形状门)。 退出码 0=PASS;非 0=FAIL,stdout 含逐条错误清单(直接喂 agent 自纠)。返回 {ok, output}。 """ try: r = _run_node_tools("check", game_id) return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()} except Exception as e: # noqa: BLE001 return {"ok": False, "output": f"check shell-out 异常:{e}"} def build(game_id: str) -> dict: """build:循环内 esbuild 打包,shell-out `node tools.mjs build-saa `(SAA 信封 __GameBundle)。 返回 {ok, output}。 """ try: r = _run_node_tools("build-saa", game_id) return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()} except Exception as e: # noqa: BLE001 return {"ok": False, "output": f"build shell-out 异常:{e}"} # ───────────────────────── U4:scaffold / stage / smoke / play / ensure_play_spec(shell-out node/cjs)───────────────────────── def _shell_env() -> dict: """shell-out node/bash 用的 env:本机 Mac Chrome 路径 + NO_PROXY(serve 是 localhost,play 不连内网,但对齐本机跑法)。""" env = dict(os.environ) if os.path.exists(_DEFAULT_CHROME): # 仅 Mac 注入;Linux 不注入交 play.mjs 自身默认(见 _DEFAULT_CHROME 注) env.setdefault("CHROME_BIN", _DEFAULT_CHROME) no_proxy = env.get("NO_PROXY", "") for h in ("localhost", "127.0.0.1", "100.64.0.8"): if h not in no_proxy: no_proxy = (no_proxy + "," + h) if no_proxy else h env["NO_PROXY"] = no_proxy env["no_proxy"] = no_proxy return env def scaffold(game_id: str, template: Optional[str] = None, interaction_profile_id: Optional[str] = None) -> dict: """scaffold:shell-out `node tools.mjs scaffold-saa [template]`(clone 模板 + SAA 信封 __GameBundle)。返回 {ok, output}。 template=None(默认)→ 通用 _template(点圆得分起点);扩模板:传 per-genre 黄金骨架目录名 (经营=_template-shop 等),让难品类 AI 从预置经营循环起步、少写。模板不存在则 node 侧回落 _template。 """ try: argv = ["node", str(_AMODEL_GEN / "tools.mjs"), "scaffold-saa", game_id] if template: argv.append(template) elif interaction_profile_id is not None: # CLI 第四位是模板;只给 profile 时显式保留默认模板占位。 argv.append("_template") if interaction_profile_id is not None: argv.append(str(interaction_profile_id)) r = subprocess.run(argv, cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=90.0, check=False) return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()} except Exception as e: # noqa: BLE001 return {"ok": False, "output": f"scaffold shell-out 异常:{e}"} def stage(game_id: str) -> dict: """stage:shell-out `node tools.mjs stage `(原子拷 bundle.iife.js + index.html → _wg1-gen//)。返回 {ok, output}。""" try: r = _run_node_tools("stage", game_id) return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()} except Exception as e: # noqa: BLE001 return {"ok": False, "output": f"stage shell-out 异常:{e}"} def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222, interaction_profile_id: Optional[str] = None) -> dict: """smoke:shell-out `node play.mjs smoke `(SAA 信封 boot + 帧推进 + 抓 _forensicsView state)。 interaction_profile_id 只在调用方显式提供时追加为 CLI 第五参数;None 保持历史 argv 不变。 Match-3 generation-only 用它在页面创建前注入可信 profile,使固定 host-config 装配受保护 producer。 解析 play.mjs 末行 `[smoke] PASS/FAIL {json}` 拿 ok + state(state 供 ensure_play_spec 推断 driver 形态)。 返回 {ok, state, raw}。 """ try: argv = ["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)] if interaction_profile_id is not None: argv.append(str(interaction_profile_id)) r = subprocess.run( argv, cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=120, env=_shell_env(), check=False, ) out = r.stdout or "" ok = r.returncode == 0 state = None idx = out.rfind("[smoke]") if idx >= 0: line = out[idx:].splitlines()[0] brace = line.find("{") if brace >= 0: try: o = json.loads(line[brace:]) ok = bool(o.get("ok")) state = o.get("state") except json.JSONDecodeError: pass return {"ok": ok, "state": state, "raw": (out + (r.stderr or ""))[-2000:]} except Exception as e: # noqa: BLE001 return {"ok": False, "state": None, "raw": f"smoke shell-out 异常:{e}"} # 品类 → 通用 L2 插件前缀期望(held 期望,外生常量;对齐 fixtures/golden-specs/*.play-spec.json 三份金标的 expectedEngineCallPrefixes)。 # 防自证铁律(gen-path-parity skill):此表是【预置常量】,F_wiring 期望基准绝不从该局实际 __engineCalls 反推—— # 一旦反推,期望恒等于实际、F_wiring 退化自证、必过、假绿。tap-targets occupied 族三品类(点击得分/打地鼠/经营点客)共用此前缀集。 _TAP_TARGETS_CALL_PREFIXES = ("sessionScore.", "audioMusic.", "juice.") # C5 契约(contracts/play-loop/play-spec.schema.json)接线常量: # 本产线自动 spec 的 generator 标识——ensure_play_spec 据它区分「本产线自动 spec」与「手工金标/外部注入 spec」: # 只有本产线自动 spec 才做 sourceHash 陈旧重生;金标考卷(fixtures/golden-specs,经 compare_node.inject_golden # 强制覆写注入)不带此标识,held-constant 永不被 hash 检查覆盖(对照公平的落点,金标不误杀)。 _SPEC_GENERATOR = "cheap_run.ensure_play_spec" # 源工程 hash 不可算(src/ 缺失 / IO 异常)时写进 derivedFrom.sourceHash 的占位值(C5 required 非空): # 语义=「产 spec 时源不可读」;下次源可读时真 hash ≠ 占位 → 自然触发重生,不留恒等假绑定。 _SOURCE_HASH_UNAVAILABLE = "unavailable" def _source_hash(game_id: str) -> Optional[str]: """算源工程内容 hash(games/amgen-/src/ 全部文件,按相对路径排序聚合 sha256)。 C5 derivedFrom.sourceHash 的取数源:agent 只写 src/ 下 L3 文件(write_file 边界),故 src/ 内容 即「源工程会变的全部」;续修轮 agent 改了任一 src 文件 → hash 变 → 旧考卷判陈旧、重生。 读不到(目录缺失 / IO 异常)返回 None——调用方回落保守行为(不据 hash 重生),绝不抛。 """ try: src = game_dir(game_id) / "src" if not src.is_dir(): return None h = hashlib.sha256() for f in sorted(src.rglob("*")): if f.is_file(): # 路径与内容都进 hash(文件改名/增删也算源变),NUL 分隔防拼接歧义。 h.update(f.relative_to(src).as_posix().encode("utf-8")) h.update(b"\0") h.update(f.read_bytes()) h.update(b"\0") return h.hexdigest() except Exception: # noqa: BLE001 —— hash 失败按「不可算」回落,不连累门流水线 return None def _derived_from(source_hash: Optional[str]) -> dict: """组 C5 derivedFrom 派生绑定段(spec↔源工程绑定锚;hash 不可算时写占位、不留恒等假绑定)。""" return { "sourceHash": source_hash or _SOURCE_HASH_UNAVAILABLE, "generatedAt": str(int(time.time() * 1000)), # 毫秒时间戳字符串(C5 允许 string|integer,取 string 稳妥) "generator": _SPEC_GENERATOR, "regenerateOnSourceChange": True, } def _build_play_spec(state: Optional[dict], *, source_hash: Optional[str] = None) -> dict: """据 _forensicsView().state() 形态产 play-spec dict(纯函数、不写盘,便于单测;产物须过 C5 契约校验)。 tap-targets occupied 族(state 有 targets 数组):加厚到金标同质——补 F_wiring 的 expectedEngineCallPrefixes 语义期望(外生 held 期望、预置常量,防自证);key-cycle 族(按键类):薄版,期望前缀待输入键契约(WU-C 5.4)。 C5 接线(W-S1 单①):driver 带显式 selectionBasis(把「state 有无 targets/target 键定族」这条约定写进 考卷自解释,不再是只活在本函数里的隐形开关);key-cycle 族带 startRitual 默认起局仪式(菜单/开始页游戏 先起局再按键,治「driver 只按键、不会起局 → 卡 menu」的确定性漂移);两族都带 derivedFrom 派生绑定。 """ # 以键存在性判定「可点目标」类游戏:游戏暴露 targets/target 键即声明有可点目标,菜单态 null/空数组仍视为 tap-targets has_targets = isinstance(state, dict) and ("targets" in state or "target" in state) if has_targets: driver = { "type": "tap-targets", "targetMode": "occupied", "targetsPath": "targets", "steps": 50, "stepMs": 220, # C5:驱动器族选择依据显式进考卷(自解释,取代读代码才知道的隐形约定)。 "selectionBasis": "state 暴露 targets/target 键 → 可点目标类 → tap-targets occupied 族", } return { "schemaVersion": "play-spec/1", "derivedFrom": _derived_from(source_hash), "exportState": ["phase", "score", "targets"], "driver": driver, # U2 加厚到金标同质:F_wiring 语义期望前缀(外生 held 期望,绝不从实际调用反推 → 防自证假绿)。 "expectedEngineCallPrefixes": list(_TAP_TARGETS_CALL_PREFIXES), "assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}], "expectLatch": True, # latch advisory 兜底:限时类玩不到终局但有真进展→降 advisory 不致命 # tap-targets occupied 族不声明 startRitual:runTapTargets 已内建「非游玩态→中线扫点起局」 # (play.cdp.cjs 场景推进段),再声明会重复动作;key-cycle 族才需要显式仪式。 } driver = { "type": "key-cycle", "keys": ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], "steps": 60, "stepMs": 180, "downMs": 60, "selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族", } return { "schemaVersion": "play-spec/1", "derivedFrom": _derived_from(source_hash), "exportState": ["phase", "score"], "driver": driver, # C5 startRitual(W-S1 单①):key-cycle 游戏停在菜单/开始页时按键循环驱不动(fx4c 卡 menu 实证), # 先依序试 Enter / Space / 画布中心点起局(harness 每步后查 phase,进游玩态即止)。 "startRitual": { "kind": "input-sequence", "inputs": [ {"t": "key", "code": "Enter"}, {"t": "key", "code": "Space"}, {"t": "tap", "x": 195, "y": 422}, ], "why": "菜单/开始页游戏需先起局,按键循环才驱得动(未进游玩态则 G_input/H_progress 必挂)", }, "assertAfterPlay": [{"path": "score", "op": "increased", "why": "真玩应加分"}], "expectLatch": True, # key-cycle 薄版:expectedEngineCallPrefixes 待『两路绑同一套输入键』契约(WU-C 5.4),M1 不加厚成可能驱不动的厚 spec。 "_note": "key-cycle 自动 spec 薄版:F_wiring 期望前缀待输入键契约(WU-C 5.4)、M1 不加厚", } def _validate_c5_best_effort(spec: dict) -> None: """产出的 play-spec 对 C5 契约(contracts/play-loop)做 best-effort 自校验:失败只告警、绝不阻断门流水线。 校验器 = contracts/play-loop/validate.py(零依赖 stdlib),经 importlib 按路径加载(它不是包)。 为什么只告警不阻断:校验挂生产路径是为了漂移可见(响亮日志),阻断会让契约演进期的新字段直接打红生成 主链——强校验在单测层(tests/test_play_spec_contract.py 对两族产物全量过 C5)。 """ try: import importlib.util # noqa: PLC0415 vp = _REPO_ROOT / "contracts" / "play-loop" / "validate.py" sp = _REPO_ROOT / "contracts" / "play-loop" / "play-spec.schema.json" if not (vp.exists() and sp.exists()): return # 契约目录不在(裁剪部署)→ 静默跳过 mod_spec = importlib.util.spec_from_file_location("_playloop_validate", vp) mod = importlib.util.module_from_spec(mod_spec) mod_spec.loader.exec_module(mod) schema = json.loads(sp.read_text(encoding="utf-8")) errors = mod.validate(schema, spec, schema) if errors: print(f"[cheap-run] ⚠ 自动 play-spec 未过 C5 契约校验(不阻断,请修 _build_play_spec):{errors[:3]}", flush=True) except Exception as e: # noqa: BLE001 —— 校验器自身异常绝不连累生成 print(f"[cheap-run] C5 自校验异常(忽略):{type(e).__name__}: {e}", flush=True) def ensure_play_spec(game_id: str, state: Optional[dict]) -> dict: """据 _forensicsView().state() 形态自动产 _wg1-gen//play-spec.json(对 tools.mjs ensurePlaySpec)。 【退役·保留一个版本窗口(W-AXIS-V2 波2,2026-07-10)】生产验收链(cheap_gates/cheap_studio/cheap_modify/ gen.mjs done 门)已全部摘除对本函数的调用——便宜档验收权威 = 四门投影(A/B/C/D)∧ 测试 agent 真玩, 不再靠 tap-targets/key-cycle 驱动器把九门驱成 driven。实现保留仅供封存对照工具(auto_vs_golden/ compare_node)历史复跑与单测;窗口期满随 play-spec 生成层一并清理。 driver 推断(照 play.cdp.cjs 的 driver 家族):有 targets 数组(点击类)→ tap-targets occupied; 否则(按键类)→ key-cycle。让九门 driven=true、E_live/H_progress 由 advisory 升为致命, 根治"纯 harness 裸跑无 driver → 假绿"。 C5 陈旧可判定(W-S1 单①,取代旧「已存在一律不覆盖」): · 已存在且是【本产线自动 spec】(derivedFrom.generator 以 cheap_run.ensure_play_spec 开头): 比对 derivedFrom.sourceHash 与当前源工程 hash——一致=源未变,复用;不一致=源已变(续修轮 agent 改过代码),旧考卷陈旧、重生。这是「拿旧考卷判新工程 → 假绿/错判」的封口点。 · 已存在但【非本产线出品】(无 derivedFrom / generator 异,即手工金标或外部注入,如 compare_node.inject_golden 的金标考卷):held-constant 保持不覆盖——金标是对照公平的锚, 绝不被 hash 检查误杀;旧世代自动 spec(无 derivedFrom)同样落此保守支,新链路产的 spec 都带 绑定、增量收敛。 · 源 hash 不可算(src/ 缺失):保守不覆盖已有 spec(判不了陈旧就不动)。 Args: game_id: stage 后的 gameId(写 _wg1-gen//play-spec.json)。 state: smoke 抓到的 state 快照(None/非 dict → 保守按键类)。 """ try: dst = wg1_game_dir(game_id) spec_path = dst / "play-spec.json" cur_hash = _source_hash(game_id) if spec_path.exists(): old_generator = None old_hash = None try: old = json.loads(spec_path.read_text(encoding="utf-8")) df = old.get("derivedFrom") if isinstance(old, dict) else None if isinstance(df, dict): old_generator = df.get("generator") old_hash = df.get("sourceHash") except Exception: # noqa: BLE001 —— 坏 JSON:按非本产线处理(保守不覆盖,由人工清理) pass is_auto_spec = isinstance(old_generator, str) and old_generator.startswith(_SPEC_GENERATOR) if not is_auto_spec: # 手工金标 / 外部注入 / 旧世代无绑定 spec:held-constant,不覆盖(金标不误杀)。 return {"ok": True, "wrote": False, "reason": "已存在且非本产线自动 spec(手工金标/外部注入/旧世代),held-constant 不覆盖"} if cur_hash is None: return {"ok": True, "wrote": False, "reason": "已存在;源工程 hash 不可算,保守不覆盖"} if old_hash == cur_hash: return {"ok": True, "wrote": False, "reason": "已存在且 sourceHash 一致(源工程未变),复用"} print(f"[cheap-run] play-spec 陈旧(sourceHash 已变):game={game_id} 旧={str(old_hash)[:12]}… " f"新={cur_hash[:12]}… → 重生考卷", flush=True) spec = _build_play_spec(state, source_hash=cur_hash) _validate_c5_best_effort(spec) # 生产路径 C5 自校验(告警不阻断;强校验在单测) dst.mkdir(parents=True, exist_ok=True) tmp = dst / "play-spec.json.tmp" tmp.write_text(json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(spec_path) # 原子写,防 play 读半成品 return {"ok": True, "wrote": True, "driverType": spec["driver"]["type"]} except Exception as e: # noqa: BLE001 return {"ok": False, "wrote": False, "reason": str(e)} def play(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict: """play:循环外九门真玩,shell-out `bash serve-and-play.sh `(CDP、引擎无关九门)。 读 _wg1-gen//evidence/verdict.json 拿九门逐门 verdict。返回 {ok, verdict, exitCode, raw}。 """ try: r = subprocess.run( ["bash", str(_SERVE_AND_PLAY), game_id, str(port), str(cdp_port)], cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=180, env=_shell_env(), check=False, ) verdict = None vp = wg1_game_dir(game_id) / "evidence" / "verdict.json" if vp.exists(): try: verdict = json.loads(vp.read_text(encoding="utf-8")) except json.JSONDecodeError: pass return {"ok": r.returncode == 0, "verdict": verdict, "exitCode": r.returncode, "raw": (r.stdout + r.stderr)[-2000:]} except Exception as e: # noqa: BLE001 return {"ok": False, "verdict": None, "exitCode": -1, "raw": f"play shell-out 异常:{e}"}