2026-07-03 20:24:44 -07:00

449 lines
25 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_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 <id>`(含 node --check + 全部静态门 + 两条形状门、build=`node tools.mjs build-saa <id>`、
scaffold/stage 同 tools.mjs CLI、九门 play=`bash serve-and-play.sh <id>`U4 落地)。
Node 已验过不误伤的形状门,便宜档 shell-out 同一份代码自动继承,零口径漂移。
本文件 U2 先落 read/list/write/check/buildscaffold/stage/play/ensure_play_spec 由 U4 补全。
"""
import hashlib
import json
import os
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 Chromeplay.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 固定 plumbingboot 链/插件装配/viewport/工厂 wiringagent 不许写,对 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 gameDirgames/amgen-<id>/。"""
return _GAMES_DIR / f"amgen-{game_id}"
def wg1_game_dir(game_id: str) -> Path:
"""SAA stage 目标目录,对 tools.mjs saaGameDirgames/_wg1-gen/<id>/。"""
return _WG1_DIR / game_id
# 便宜档 Service 会话注册表目录C2driver 按 session_id 写 session→后端 gameId 映射 + 本 session 配置,
# Service 两工厂读它把框架分配的 session_id 解析回后端 gameId。_ 前缀 = 基建目录(同 _template/_wg1-gen
# 非游戏目录,不会被 scaffold/list 当游戏,避免在 games/ 堆 amgen-<session uuid> 空壳。
_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"
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
# ───────────────────────── read / list / writePython 直接实现)─────────────────────────
def read_file(path: str) -> dict:
"""read_filerepo 根只读。返回 {ok, content?, truncated?, error?}(对 tools.mjs readFileTool"""
try:
abs_p = _resolve_in_repo(path)
if abs_p.is_dir():
return {"ok": False, "error": f"是目录,不是文件:{path}(请用 list_dir"}
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}
except Exception as e: # noqa: BLE001 工具层统一软失败返回给 agent不抛断循环
return {"ok": False, "error": str(e)}
def list_dir(path: str) -> dict:
"""list_dirrepo 根内列目录(目录名带尾 /)。返回 {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-<id>/ 内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 固定 plumbingboot 链/插件装配/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)}
# ───────────────────────── check / buildshell-out node tools.mjs─────────────────────────
def _run_node_tools(cmd: str, game_id: str, timeout: float = 90.0) -> subprocess.CompletedProcess:
"""shell-out `node tools.mjs <cmd> <id>`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 <id>`node --check + 全部静态门 + 两条形状门)。
退出码 0=PASS非 0=FAILstdout 含逐条错误清单(直接喂 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 <id>`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}"}
# ───────────────────────── U4scaffold / stage / smoke / play / ensure_play_specshell-out node/cjs─────────────────────────
def _shell_env() -> dict:
"""shell-out node/bash 用的 env本机 Mac Chrome 路径 + NO_PROXYserve 是 localhostplay 不连内网,但对齐本机跑法)。"""
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) -> dict:
"""scaffoldshell-out `node tools.mjs scaffold-saa <id> [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)
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:
"""stageshell-out `node tools.mjs stage <id>`(原子拷 bundle.iife.js + index.html → _wg1-gen/<id>/)。返回 {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) -> dict:
"""smokeshell-out `node play.mjs smoke <id>`SAA 信封 boot + 帧推进 + 抓 _forensicsView state
解析 play.mjs 末行 `[smoke] PASS/FAIL {json}` 拿 ok + statestate 供 ensure_play_spec 推断 driver 形态)。
返回 {ok, state, raw}。
"""
try:
r = subprocess.run(
["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)],
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-<id>/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/<id>/play-spec.json(对 tools.mjs ensurePlaySpec)。
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/<id>/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 <id>`CDP、引擎无关九门
读 _wg1-gen/<id>/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}"}