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>
525 lines
30 KiB
Python
525 lines
30 KiB
Python
"""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 细节)。"
|