zizi 0fd0d4fb10 fix(tier2): 抽平台命脉进锁定 play-runtime.js + play-scene 契约门(根因3 另一半)
spike feie-003 实证(lock+schema 都修对后):F_wiring 仍 calls=0、E_live distinct=1/N、
latch=false。diff 出真因——M3 重写 play-scene 时把两件平台命脉一起改没了:
① 输入路由(doHitTest/doHandleInput/this.input.on pointerdown):没了或坐标错位
   (用 (col,row) 调 core.tapCell 而非 (i,j))→ driver 注入的 touch 落空/落错格 → 没合成 → F/G 门挂。
② 逐帧 core.update(dt):没了 → 订单耐心不倒计时、赢/输 latch 永不触发 → E_live/H/经济/latch 全挂。

这俩原先混在 agent 可写的 play-scene 里、只靠注释「别动」——无机器门=必被 M3 改掉。
现抽进平台锁定文件 src/scenes/play-runtime.js(bindInput 输入路由 + tick 逐帧推进),
加进 LOCKED_PLATFORM_FILES;fixture play-scene 重构成 create 调 bindInput(this,core)、
update 调 tick(core,delta),薄委托命中给锁定实现(坐标 layout 同源、driver-tap 焊死)。
加 validate_play_scene 契约门(build 段跑):play-scene 必须 import play-runtime 且调 bindInput+tick,
缺了响亮回喂精确指引。prompt/hint 同步:你只管画 render,别碰输入路由与 tick。

门未放松(judge 纯代码判);fixture 重构后须重跑金标 smoke 验仍 ACCEPT(下一步)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:53:53 +00:00

525 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.

"""run.py —— tier2 富游戏自治线 · Phaser 工程的落盘 / 构建 / 快检 / 真玩门 子进程封装。
对照 wg1/gen-worker/worker/run.py(LittleJS 单文件 iife),tier2 是 **Phaser 多文件 src/ 工程**:
- scaffold:落多文件 src/ 工程(对齐 A3 tier2-source-project.schema.json fileTree),不是单 generated-factory.js;
- build:esbuild 多文件打包(入口 = main.js),对照 Tier0/1 单包 --global-name=__GameBundle;
- headless_check:全套真玩门前的便宜快筛(node --check 各文件 + import 图 + bundle 非空);
- run_gates:真浏览器跑 L1 九门 + 富游戏三门,产 tier2-verdict 形状(判定零 LLM,judge 纯代码)。
落点纪律(tier2 .agent 红线):
- GEN_DIR 落在 game-runtime/games 下 tier2 独立目录(_tier2-gen),**绝不碰** _wg1-gen;
- 绝不碰 Tier0/1 ECS-lite 装载路与 wg1 现行产线代码,只读它做参考。
harness 现状(2026-06-23 集成):Phaser 构建脚本(tier2/engine/build-phaser.mjs)与 CDP 真玩 harness
(tier2/harness/{serve-and-play-phaser.sh,play-phaser.cdp.cjs})均已落地,本模块按真实路径调它们;**真跑在
mini-desktop**(6c6g 禁 chrome / 无 esbuild),6c6g 上 build/run_gates 段返回结构化失败 + 可追溯日志(诚实失败,
非静默成功),让上层(toolkit/编排器)把失败回喂 agent 或落 verdict。脚本路径集中在下方常量。
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ── 路径常量(全部由本模块计算,不读 wg1 全局)──
# REPO_ROOT = 仓根(本文件在 tier2/gen-worker/worker/run.py → 上溯 4 级:worker→gen-worker→tier2→仓根)。
REPO_ROOT = Path(__file__).resolve().parents[3]
GAME_RUNTIME = REPO_ROOT / "game-runtime"
# tier2 独立生成目录(绝不碰 _wg1-gen)。
GEN_DIR = GAME_RUNTIME / "games" / "_tier2-gen"
# tier2 Phaser 构建脚本 + CDP 真玩 harness 的真实落点(M1/M2 已就位;真跑在 mini-desktop)。
# 构建脚本在 engine/(M1 引擎线产物),CDP 探针 + serve 编排在 harness/(M2 探针/门产物)。
TIER2_DIR = REPO_ROOT / "tier2"
PHASER_BUILD_SCRIPT = TIER2_DIR / "engine" / "build-phaser.mjs"
# serve-and-play 一键编排(起 static-serve(仓根) + headless chrome + play-phaser.cdp.cjs);只在 mini-desktop 真跑。
PHASER_SERVE_PLAY_SCRIPT = TIER2_DIR / "harness" / "serve-and-play-phaser.sh"
# 直接的 CDP 探针(被 serve-and-play 调;也可单独 require 单测 judge 纯函数)。
PHASER_PLAY_SCRIPT = TIER2_DIR / "harness" / "play-phaser.cdp.cjs"
# tier2 宿主页面模板(scaffold 时拷进 gen 目录;经绝对路径 /tier2/engine/ 引 boot-phaser-host,故 serve 仓根)。
PHASER_INDEX_TEMPLATE = TIER2_DIR / "harness" / "index.template.html"
# mini-肥鹅 fixture 根(M4/M1 平台预建骨架 + 留空数据表;scaffold_init business-sim 模板的种子)。
FIXTURE_ROOT = TIER2_DIR / "fixtures" / "mini-fei-e"
# 默认 Phaser iife 全局挂载名(A3 buildProfile.globalName;宿主页面 index.template.html 取此全局名建工厂)。
DEFAULT_GLOBAL_NAME = "__Tier2GameBundle"
# scaffold_init 已知品类模板 → fixture 根映射(spike 期只有 business-sim → mini-肥鹅;后续品类增条目)。
TEMPLATE_FIXTURES = {
"business-sim": FIXTURE_ROOT, # 经营/合成富游戏(mini-肥鹅:资源/合成/订单三系统)
}
def load_fixture_scaffold(template_id: str) -> dict:
"""读品类模板对应的 fixture 平台预建骨架(返回 {path: content} + roles),供 scaffold_init 起手铺进工程。
平台预建分工(fixture-spec §预建分工):骨架先天过 boot(三系统纯逻辑 + game-core + main.js 装载胶水 +
最小占位 scene + 留空数据表);LLM 只填那约 56% 表现层 + 把留空数据表填成真值。
关键:数据表种子用 **datatable.empty.json**(schema-valid 占位 sentinel,先天过 boot)落成
tables.js 实际 import 的 **data/datatable.gold.json**——LLM 自治循环往里填真值。绝不拿金标当种子
(金标 = 参照答案,会让 spike 变成抄答案而非真生成)。
Args:
template_id: 品类模板 id(如 'business-sim')。未知 id → 返回空骨架(由调用方兜底)。
Returns:
{files: [{path, content, role}]}。files 是 fixture src/ + 留空数据表 + 资产占位。
"""
root = TEMPLATE_FIXTURES.get(template_id)
files: list[dict] = []
if root is None or not root.exists():
return {"files": files, "ok": False,
"reason": f"未知品类模板 template_id={template_id}(已知:{list(TEMPLATE_FIXTURES)})"}
# 角色推断(对齐 A3 fileTree[].role;按目录/文件名)。
def _role(rel: str) -> str:
if rel == "src/main.js":
return "entry"
if "/scenes/" in rel:
return "scene"
if "/systems/" in rel or rel == "src/game-core.js":
return "system"
if "/data/" in rel:
return "config"
if "/util/" in rel or rel.endswith("seeded-random.js"):
return "lib"
if "/assets/" in rel:
return "asset-manifest"
return "other"
# ① 平台预建 src/ 全树(纯逻辑系统 + game-core + main.js 装载胶水 + 占位 scene + 适配层 + util + 资产占位)。
src_dir = root / "src"
for p in sorted(src_dir.rglob("*")):
if p.is_file():
rel = str(p.relative_to(root)).replace(os.sep, "/")
files.append({"path": rel, "content": p.read_text(encoding="utf-8"), "role": _role(rel)})
# ② 数据表:留空模板(datatable.empty.json)落成 tables.js 实际 import 的 data/datatable.gold.json(LLM 填真值)。
empty = root / "data" / "datatable.empty.json"
if empty.exists():
files.append({"path": "data/datatable.gold.json",
"content": empty.read_text(encoding="utf-8"), "role": "config"})
# ③ 数据表 schema(给 LLM 知道填值要满足的结构;只读参照)。
schema = root / "data" / "datatable.schema.json"
if schema.exists():
files.append({"path": "data/datatable.schema.json",
"content": schema.read_text(encoding="utf-8"), "role": "config"})
return {"files": files, "ok": True, "reason": ""}
def _workdir(game_id: str) -> Path:
"""单款工程的工作目录(GEN_DIR/<game_id>);workdir 下放整个 Phaser src/ 工程。"""
return GEN_DIR / game_id
def scaffold(game_id: str, files: list[dict]) -> dict:
"""把一批源文件落进工程 workdir(对齐 A3 fileTree)。
Args:
game_id: 工程标识。
files: [{path, content}],path 是工程内相对路径(对齐 fileTree[].path,禁 '..' 越界)。
Returns:
{ok, workdir, written:[path], errors:[{path,reason}]}。
"""
wd = _workdir(game_id)
wd.mkdir(parents=True, exist_ok=True)
written, errors = [], []
for f in files or []:
rel = (f or {}).get("path") or ""
content = (f or {}).get("content")
# 安全:禁 '..' 与绝对路径(越界 = 安全风险,对齐 A3 path pattern)。
if not rel or ".." in rel.split("/") or rel.startswith("/"):
errors.append({"path": rel, "reason": "非法路径(空/含 '..'/绝对路径)"})
continue
if not isinstance(content, str):
errors.append({"path": rel, "reason": "content 必须是字符串(文件全文)"})
continue
dst = wd / rel
try:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(content, encoding="utf-8")
written.append(rel)
except Exception as e: # noqa: BLE001 —— 落盘失败逐文件记错、不中断其余
errors.append({"path": rel, "reason": f"写入失败:{type(e).__name__}: {e}"})
# 拷宿主页面模板进 gen 目录(真玩入口 index.html;serve 仓根 → 页面经绝对路径 /tier2/engine/ 引 host)。
# 缺模板不致命(真跑 mini-desktop 才需);6c6g 上模板存在即拷,便于离线对账。
if PHASER_INDEX_TEMPLATE.exists():
try:
shutil.copyfile(PHASER_INDEX_TEMPLATE, wd / "index.html")
except Exception as e: # noqa: BLE001 —— 拷模板失败记错不中断(真玩前会再校验)
errors.append({"path": "index.html", "reason": f"拷宿主模板失败:{type(e).__name__}: {e}"})
return {"ok": len(errors) == 0, "workdir": str(wd), "written": written, "errors": errors}
def build(game_id: str, entry: str = "src/main.js", *, global_name: str = DEFAULT_GLOBAL_NAME,
out_rel: str = "bundle.iife.js", timeout_s: int = 180) -> dict:
"""esbuild 多文件打包当前 src/ 工程(入口 entry,产 iife bundle)。
调 tier2/engine/build-phaser.mjs(M1 引擎线产物,真跑在 mini-desktop);
脚本未就位则返回结构化失败 + 安装/落地指引(不静默失败)。
Returns:
{ok, bundlePath, log, durationMs}。失败时 log 含 esbuild/脚本报错,供回喂 agent 改源。
"""
import time as _t
wd = _workdir(game_id)
entry_abs = wd / entry
out_abs = wd / out_rel
if not entry_abs.exists():
return {"ok": False, "bundlePath": None,
"log": f"入口文件不存在:{entry}(workdir={wd})", "durationMs": 0}
# 数据表 schema/可达性预检(spike feie-001 根因 2):平台 tables.js 按固定 key 读数据表,
# agent 自创 schema 会让合成系统空转却仍 boot(假绿)。在 esbuild 前先查,失败就响亮回喂精确修复指引,
# 让自纠循环知道症结在 schema、而非只看到下游「订单不可达」。这是诚实的早断,不放松验收门。
dv = validate_datatable(game_id)
if not dv["ok"]:
log = ("数据表未过平台 schema/可达性预检(esbuild 未执行)。逐项问题:\n"
+ "\n".join(f" - {e}" for e in dv["errors"])
+ ("\n" + dv["expectedSchemaHint"] if dv.get("expectedSchemaHint") else ""))
return {"ok": False, "bundlePath": None, "log": log, "durationMs": 0}
# play-scene 契约预检(根因 3 另一半):必须接平台锁定的 bindInput(输入路由)+ tick(终态推进),
# 否则游戏画得再好也过不了 F/G/H/经济/latch 门。同样是诚实早断 + 精确反馈,不放松门。
pv = validate_play_scene(game_id)
if not pv["ok"]:
log = ("play-scene 未过平台契约预检(esbuild 未执行)。逐项问题:\n"
+ "\n".join(f" - {e}" for e in pv["errors"]))
return {"ok": False, "bundlePath": None, "log": log, "durationMs": 0}
if not PHASER_BUILD_SCRIPT.exists():
# 诚实失败:脚本未就位则给落地指引,不假装成功。
return {"ok": False, "bundlePath": None, "durationMs": 0,
"log": (f"Phaser 构建脚本未就位:{PHASER_BUILD_SCRIPT}"
"真跑在 mini-desktop(6c6g 禁 chrome/无 esbuild)。")}
t0 = _t.perf_counter()
try:
# build-phaser.mjs CLI:<工程根目录> [--global-name=NAME] [--out=bundle.iife.js]。
# 它内部 join(projectDir, entry) 自解析入口(默认 src/main.js),不接收单独的 entry/out 绝对路径。
# 故传 workdir(工程根)+ 全局名 + 产物名;非默认 entry 经 --entry 透传(脚本支持 opts.entry)。
args = ["node", str(PHASER_BUILD_SCRIPT), str(wd), f"--global-name={global_name}",
f"--out={out_rel}"]
if entry != "src/main.js":
args.append(f"--entry={entry}")
r = subprocess.run(
args, cwd=str(GAME_RUNTIME), capture_output=True, text=True, timeout=timeout_s,
)
ok = r.returncode == 0 and out_abs.exists()
log = (r.stdout + r.stderr).strip()
except subprocess.TimeoutExpired:
ok, log = False, f"esbuild 构建超时(>{timeout_s}s)"
except Exception as e: # noqa: BLE001
ok, log = False, f"构建子进程异常:{type(e).__name__}: {e}"
dur = int((_t.perf_counter() - t0) * 1000)
return {"ok": ok, "bundlePath": str(out_abs) if ok else None, "log": log, "durationMs": dur}
def headless_check(game_id: str, *, timeout_s: int = 60) -> dict:
"""全套真玩门前的便宜快筛(无头):node --check 各源文件语法 + bundle 非空。
这是快筛、不是验收门 —— ok 不代表过 L1,只是值不值得上真玩门。结果不入 verdict 硬判
(对齐 toolkit-signatures A7 headless_check 边界)。
Returns:
{ok, signals:[{name, ok, detail}], log}。
"""
wd = _workdir(game_id)
signals = []
# ① 各 .js 源文件 node --check 语法门(Phaser 多文件:逐个查,允许 import)。
js_files = sorted(p for p in wd.rglob("*.js") if "node_modules" not in p.parts)
if not js_files:
signals.append({"name": "has_source", "ok": False, "detail": "workdir 下无 .js 源文件"})
for jf in js_files:
try:
r = subprocess.run(["node", "--check", str(jf)],
capture_output=True, text=True, timeout=timeout_s)
ok = r.returncode == 0
signals.append({"name": f"syntax:{jf.relative_to(wd)}", "ok": ok,
"detail": "" if ok else (r.stderr or r.stdout or "")[:300]})
except Exception as e: # noqa: BLE001
signals.append({"name": f"syntax:{jf.relative_to(wd)}", "ok": False,
"detail": f"node --check 异常:{type(e).__name__}: {e}"})
# ② bundle 非空(若已构建)。
bundle = wd / "bundle.iife.js"
if bundle.exists():
sz = bundle.stat().st_size
signals.append({"name": "bundle_nonempty", "ok": sz > 0, "detail": f"{sz} bytes"})
all_ok = all(s["ok"] for s in signals) and bool(signals)
return {"ok": all_ok, "signals": signals,
"log": "" if all_ok else "存在未过快筛项(详见 signals)"}
def validate_datatable(game_id: str) -> dict:
"""校验 agent 真填的 data/datatable.gold.json 是否符合平台锁定 schema + 富游戏可达性不变量。
为什么要它(spike feie-001 实证根因 2):平台锁定文件 src/data/tables.js 按**固定 key**消费数据表
(GOLD.mergeChains / GOLD.currencies.ingredients.initial / GOLD.winCondition.coinsTarget …);若 agent
自创 schema(如把 mergeChains 写成 recipes、把 currencies 拍平成 initialCoins),tables.js 读到 undefined →
MERGE_CHAINS=[]、START_INGREDIENTS={},合成系统空转,F_wiring / 三联动门全挂,但游戏仍能 boot/render(假绿)。
本函数把这种**沉默致命**变成**响亮可修反馈**:在 build/run_gates 前查,失败就精确告诉 agent「平台读哪些 key、
你缺/错了哪个」,让自纠循环有的放矢(而非只看到下游「订单不可达」却不知症结在 schema)。
校验三层(全是平台层硬约束,不是质量打分;防 Goodhart:只查结构契约,不替 agent 设计数值):
① 顶层必备 key:currencies{coins.initial,ingredients.initial} / items / mergeChains / orders /
winCondition.coinsTarget / loseCondition.consecutiveOrderFails(= datatable.schema.json required)。
② 合成链非空且 DAG 无环:mergeChains 至少 1 条、from→to 拓扑无环(tripleLink.dagAcyclic 同口径)。
③ 订单可达:每个 order.requires 的物品 ∈ mergeChains 可产出集合(to 集合) 开局库存
(tripleLink.requiresReachable 同口径)。
Returns:
{ok, errors:[人读字符串], expectedSchemaHint}。ok=True 表示数据表先天满足平台契约可上门。
"""
wd = _workdir(game_id)
dt = wd / "data" / "datatable.gold.json"
# 平台读的确切 key(给 agent 的修复指引;与 tables.js / datatable.schema.json 同源)。
schema_hint = (
"平台锁定文件 src/data/tables.js 按固定 key 读数据表,你**必须用这套 key**(就地填 datatable.empty.json 的 "
"sentinel,别改 key、别自创 recipes/initialCoins 之类):currencies{coins.initial:int, "
"ingredients.initial:{itemId:count}} / items[{id,name,tier,assetId}] / "
"mergeChains[{from,to,cost,unlockCoins?}](合成链,from/to 是 item id;非 recipes/in/out) / "
"orders[{id,requires:{itemId:qty},reward,patience}](requires 是 {物品id:数量} 映射,非 [{sku,qty}] 数组) / "
"winCondition.coinsTarget / loseCondition.consecutiveOrderFails。")
if not dt.exists():
return {"ok": False, "errors": [f"缺 data/datatable.gold.json(workdir={wd})。"],
"expectedSchemaHint": schema_hint}
try:
g = json.loads(dt.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
return {"ok": False, "errors": [f"datatable.gold.json 不是合法 JSON:{type(e).__name__}: {e}"],
"expectedSchemaHint": schema_hint}
errs: list[str] = []
# ── ① 顶层必备 key(逐项查,精确指出缺哪个;这是把沉默 KeyError 变响亮反馈的关键)──
cur = g.get("currencies")
if not isinstance(cur, dict):
errs.append("缺 currencies 对象(平台 tables.js 读 GOLD.currencies.ingredients.initial / coins.initial)。")
else:
if not isinstance((cur.get("coins") or {}).get("initial"), (int, float)):
errs.append("缺 currencies.coins.initial(开局金币,整数;不要用拍平的 initialCoins)。")
if not isinstance((cur.get("ingredients") or {}).get("initial"), dict):
errs.append("缺 currencies.ingredients.initial(开局库存 {itemId:count} 映射)。")
if not isinstance(g.get("items"), list) or not g.get("items"):
errs.append("缺 items 数组([{id,name,tier,assetId}])。")
chains = g.get("mergeChains")
if not isinstance(chains, list) or not chains:
errs.append("缺 mergeChains 数组([{from,to,cost}];这是合成链——不要写成 recipes/in/out。"
"合成链为空会直接让三联动门 dagAcyclic『0 条链』+ 所有订单不可达)。")
orders = g.get("orders")
if not isinstance(orders, list) or not orders:
errs.append("缺 orders 数组([{id,requires:{itemId:qty},reward,patience}])。")
if not isinstance((g.get("winCondition") or {}).get("coinsTarget"), (int, float)):
errs.append("缺 winCondition.coinsTarget(赢线金币,整数;不要用拍平的 winCoins)。")
if not isinstance((g.get("loseCondition") or {}).get("consecutiveOrderFails"), (int, float)):
errs.append("缺 loseCondition.consecutiveOrderFails(连续流失判输,整数;不要用拍平的 loseFailStreak)。")
# 顶层结构若已坏,下面的 DAG/可达性查无意义(且会误报),先返回让 agent 修 schema。
if errs:
return {"ok": False, "errors": errs, "expectedSchemaHint": schema_hint}
# ── ② 合成链 DAG 无环(tripleLink.dagAcyclic 同口径)──
edges = [(c.get("from"), c.get("to")) for c in chains
if isinstance(c, dict) and c.get("from") and c.get("to")]
if not edges:
errs.append("mergeChains 没有合法 from→to 边(每条链须有 from/to item id)。")
else:
# 拓扑判环(Kahn):有环 → 合成依赖成圈,合成系统会死锁。
from collections import defaultdict, deque
adj: dict = defaultdict(list)
indeg: dict = defaultdict(int)
nodes = set()
for a, b in edges:
adj[a].append(b)
indeg[b] += 1
nodes.update((a, b))
q = deque(n for n in nodes if indeg[n] == 0)
seen = 0
while q:
n = q.popleft()
seen += 1
for m in adj[n]:
indeg[m] -= 1
if indeg[m] == 0:
q.append(m)
if seen != len(nodes):
errs.append("mergeChains 存在环(from→to 拓扑有环);合成链必须是 DAG(无环)。")
# ── ③ 订单可达:requires 物品 ∈ 合成产出 开局库存(tripleLink.requiresReachable 同口径)──
producible = {b for _, b in edges} # 合成可产出物
start_inv = set(((cur.get("ingredients") or {}).get("initial") or {}).keys())
reachable = producible | start_inv
unreachable = []
for o in orders:
req = (o or {}).get("requires") or {}
# 平台契约:requires 是 {itemId:qty} 映射。若 agent 写成数组([{sku,qty}]),这里也能识别并明确报错。
if isinstance(req, list):
errs.append(f"订单 {o.get('id')} 的 requires 是数组;平台要 {{itemId:qty}} 映射(非 [{{sku,qty}}])。")
continue
for item_id in req:
if item_id not in reachable:
unreachable.append(f"{o.get('id')}{item_id}")
if unreachable:
errs.append(f"以下订单物品不可达(既非合成产出、又不在开局库存):{unreachable}"
"每个订单 requires 的物品必须能被某条 mergeChain 产出(to),或在 currencies.ingredients.initial 里。")
return {"ok": not errs, "errors": errs, "expectedSchemaHint": schema_hint if errs else ""}
def validate_play_scene(game_id: str) -> dict:
"""校验 agent 写的 play-scene.js 是否仍接平台锁定的两件命脉:输入路由 bindInput + 逐帧 tick。
为什么要它(spike feie-002/003 实证根因 3 的另一半):driver-tap 路径与终态推进原先混在 agent 可写的
play-scene 里,M3 重写时把它俩一起改没了:
- 没接 bindInput → driver 注入的 touch 不路由到 core 逻辑命令 → 没合成 → F/G 门挂、calls=0;
- 没调 tick(core.update) → 订单耐心不倒计时、赢/输 latch 永不触发 → E_live/H_progress/经济/latch 门挂。
这两件已抽进平台锁定文件 play-runtime.js,agent 只需在 play-scene 的 create 调 bindInput(this, core)、
update 调 tick(core, delta)。本检查确认这两个调用在,缺了就响亮告诉 agent 补上(否则它再怎么画表现层也过不了门)。
防 Goodhart:只查「这两个平台调用是否在场」(结构性,文本扫描即可判),不替 agent 设计表现层、不放松门。
Returns:
{ok, errors:[人读字符串]}。
"""
wd = _workdir(game_id)
ps = wd / "src" / "scenes" / "play-scene.js"
if not ps.exists():
return {"ok": False, "errors": ["缺 src/scenes/play-scene.js(表现层场景文件)。"]}
src = ps.read_text(encoding="utf-8")
errs: list[str] = []
# 必须从锁定运行时插座 import 这两件(允许命名导入;名字本身就够判)。
if "play-runtime" not in src:
errs.append("play-scene.js 没 import './play-runtime.js'——平台锁定的输入路由 + tick 在那;"
"必须 `import { bindInput, tick } from './play-runtime.js'`。")
# create 里必须接输入路由(driver-tap 路径焊死)。
if "bindInput(" not in src:
errs.append("play-scene.js 的 create() 必须调 bindInput(this, core) 接平台输入路由——"
"否则 harness driver 注入的点击不会路由到 core 逻辑命令,合成永不发生(F/G 门挂、calls=0)。")
# update 里必须逐帧推进 core(终态推进焊死)。
if "tick(" not in src:
errs.append("play-scene.js 的 update(_time, delta) 必须调 tick(core, delta) 逐帧推进 core——"
"否则订单耐心不倒计时、赢/输 latch 永不触发(E_live/H_progress/经济/latch 门挂)。")
return {"ok": not errs, "errors": errs}
def _derive_richgame_from_datatable(wd: Path) -> dict | None:
"""从工程内 data/datatable.gold.json 派生三联动门静态校验所需的 richGame.{mergeChains,orders}。
三联动门(play-phaser.cdp.cjs evalTripleLinkGate)的静态部分:
- requiresReachable:每个 order.requires 物品 ∈ mergeChains 可产出集合 → 读 mergeChains + orders;
- dagAcyclic:mergeChains from→to 拓扑无环 → 读 mergeChains。
本函数把 agent 真填的数据表适配成 richGate 形状,使门查的是 agent 的实际表(非硬编码样例)。
Returns:
{mergeChains:[{from,to,cost}], orders:[{id,requires,reward,patience}]} 或 None(无表/解析失败)。
"""
dt = wd / "data" / "datatable.gold.json"
if not dt.exists():
return None
try:
gold = json.loads(dt.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 —— 表坏 → 不派生(三联动门会因缺 richGame 走默认判,不静默 crash)
return None
chains = [{"from": c.get("from"), "to": c.get("to"), "cost": c.get("cost", 0),
**({"unlockCoins": c["unlockCoins"]} if c.get("unlockCoins") else {})}
for c in (gold.get("mergeChains") or [])]
orders = [{"id": o.get("id"), "requires": o.get("requires") or {},
"reward": o.get("reward", 0), "patience": o.get("patience", 0)}
for o in (gold.get("orders") or [])]
if not chains and not orders:
return None
return {"mergeChains": chains, "orders": orders}
def run_gates(game_id: str, play_spec: dict | None = None, *,
port: int = 4330, cdp_port: int = 9322, timeout_s: int = 300) -> dict:
"""真浏览器真玩 L1 九门 + 富游戏三门,返回 tier2-verdict 形状(judge 纯代码,零 LLM)。
调 serve-and-play-phaser.sh 一键编排(起 static-serve(仓根) + headless chrome + play-phaser.cdp.cjs);
harness 落 evidence/verdict.json(tier2-verdict.schema.json 形状),本函数读回。
脚本未就位则返回结构化失败(不静默成功),让上层把失败回喂 agent 或落 verdict。
serve 仓根而非 game-runtime:宿主页面 index.html 经绝对路径 /tier2/engine/ 引 boot-phaser-host,
而 tier2/ 是 game-runtime/ 的兄弟目录、在它外面;serve 仓根才同时够到 game-runtime/games/ 与 tier2/engine/。
Args:
play_spec: 本次真玩驱动规格(driver + assertAfterPlay + expectLatch + inputs);写进 play-spec.json 供 harness 读。
port / cdp_port: 真玩端口 / Chrome 调试端口(并行错开;tier2 用 4330/9322 段避开 wg1 的 4320/9222)。
Returns:
{rc, log, verdict}。verdict = tier2-verdict dict 或 None(harness 未产出)。
"""
wd = _workdir(game_id)
# 写 play-spec.json(harness 读它取 driver/断言);structureOk 由本函数据 build 产物补进 spec 透传给 harness。
ev_dir = wd / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
spec = dict(play_spec or {})
# 结构层(源工程校验+构建)由上游 build 段产出:bundle 非空即结构 OK(harness 本身只判可运行层,经 spec 透传)。
bundle = wd / "bundle.iife.js"
spec.setdefault("structureOk", bundle.exists() and bundle.stat().st_size > 0)
# 三联动门静态部分(requiresReachable/dagAcyclic)读 spec.richGame.{mergeChains,orders}:
# 生成游戏要据 agent 真填的数据表来判(而非硬编码样例),故 spec 没带 richGame 时从工程内
# data/datatable.gold.json 派生——证『订单要的东西合成系统造得出来 + 合成链无环』查的是 agent 的实际表。
if not spec.get("richGame"):
derived = _derive_richgame_from_datatable(wd)
if derived:
spec["richGame"] = derived
(wd / "play-spec.json").write_text(
json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
if not PHASER_SERVE_PLAY_SCRIPT.exists():
# 诚实失败:编排脚本未就位则返回 infra 失败,不计生成质量分母。
return {"rc": 1, "verdict": None,
"log": (f"serve-and-play 编排脚本未就位:{PHASER_SERVE_PLAY_SCRIPT}"
"真跑在 mini-desktop(6c6g 禁 chrome)。")}
# serve-and-play-phaser.sh <gameId> [port] [cdpPort];gameId 是仓内相对路径段 _tier2-gen/<id>,
# 脚本据它拼 URL = http://localhost:<port>/game-runtime/games/_tier2-gen/<id>/index.html(serve 仓根)。
rel_game = f"_tier2-gen/{game_id}"
args = ["bash", str(PHASER_SERVE_PLAY_SCRIPT), rel_game, str(port), str(cdp_port)]
try:
# cwd=仓根(脚本 serve 仓根 . ;play harness 内部 path.resolve('games',...) 不再依赖 cwd,evidence 由 --out 显式给)。
r = subprocess.run(args, cwd=str(REPO_ROOT),
capture_output=True, text=True, timeout=timeout_s)
rc, log = r.returncode, (r.stdout + r.stderr).strip()
except subprocess.TimeoutExpired:
rc, log = 1, f"真玩 harness 超时(>{timeout_s}s)"
except Exception as e: # noqa: BLE001
rc, log = 1, f"真玩 harness 异常:{type(e).__name__}: {e}"
verdict = None
vpath = ev_dir / "verdict.json"
if vpath.exists():
try:
verdict = json.loads(vpath.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
log += f"\n[run] verdict.json 解析失败:{type(e).__name__}: {e}"
return {"rc": rc, "verdict": verdict, "log": log}
def verdict_feedback(verdict: dict | None, harness_log: str = "") -> str:
"""把失败 verdict / harness 日志摘成给 agent 的回喂文字(承袭 wg1 _verdict_feedback 范式)。
tier2 verdict 形状:逐九门 gateResults + 富游戏三门 richGameGates。摘出未过门 + 失败细节。
"""
if not verdict:
return ("真玩未产出 verdict(疑装载即崩 / harness 未就位)。\n"
+ (harness_log[:600] if harness_log else ""))
lines = []
l1 = ((verdict.get("layerResults") or {}).get("L1") or {})
for g in (l1.get("gateResults") or []):
if not g.get("passed") and g.get("fatal"):
lines.append(f"- 致命门 {g.get('gate')} 未过:{g.get('detail', '')[:200]}")
rich = l1.get("richGameGates") or {}
for name in ("tripleLink", "economy", "latch"):
gate = rich.get(name)
if isinstance(gate, dict) and gate.get("passed") is False:
bad = [c.get("name") for c in (gate.get("checks") or []) if not c.get("ok")]
lines.append(f"- 富游戏门 {name} 未过(失败子检查:{bad})")
for f in (verdict.get("findings") or []):
if f.get("severity") in ("P0", "P1"):
lines.append(f"- 对抗 {f.get('severity')}:{f.get('issue', '')[:200]}")
return ("真玩未过:\n" + "\n".join(lines)) if lines else "真玩未过(原因未知,见 verdict 细节)。"