plan 2026-06-18-001 U3 迭代3-4轮(据快走查根因)。
根因(全budget+stage2 都不解,3轮prompt不解):便宜/强模型均**顽固把 tap 命中门控在分离 state 实体/计时器/turn 上**
→ 盲点驱动点不中 → tap-targets 类系统性 E_live/F_wiring/G_input/H 挂。即「让模型写 tap-handler」这条路便宜模型走不通。
引擎线根治(gd-runtime.js):新增 **clickable 声明式基元** `{kind:'clickable',score?,mark?:{shape,color,r},hitW?,hitH?}`——
运行时内置:本帧点中带此组件且 !occupied 的实体 → 自动翻 occupied+spawn 标记(可见)+计分+fx。**离散点击类声明它即可、免写 handler**。
验证:gd-runtime 单测 16(含 clickable 派发) + 全套 60/60;**手写纯声明式(behaviors:[])clickable tap-targets gamedef 过 harness 9/9**(E_live distinct=8/F_wiring/G_input/H 全过)——证基元+管线正确。
GAMEDEF_SYSTEM:教 clickable(替「写 tap-handler」)+ 参照2 改纯声明式 clickable few-shot(经探针证 9/9)。
runtime-api-2d.md:补 clickable 组件文档。
**关键测量定论(终结本机迭代)**:便宜模型 tap-targets 单管线生成**高方差**(同 brief 一轮 8/9 一轮 5/9)——
N=1~2 小样 lili-mac 早读**信噪比不足**,无法可靠优化「模型产出过门率」(我前几轮在追噪声)。
**管线/运行时/约定已证正确(手写金样两品类均 9/9)+ 引擎补了 clickable 基元**;**模型产出 cutover 率是统计量**,
需大样+全 repair 预算 → **authoritative=mini-desktop SaaFullGraphE2eTest -Dsaa.e2e=1**(多 brief+5+3 repair+stage2)。
快走查工具:加 stage2 救场升档(QC_STAGE*)+ gamedef assert 字段 coerce 到 score(治 design 造 moves/totalRound 等取证读不到字段)。
设计决策待 6c6g:design↔gamedef 的 assertAfterPlay 字段词汇契约(design 应约束到 gamedef 暴露集 score/remaining,
或运行时暴露更多)——触共享 DESIGN_SYSTEM。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
9.3 KiB
Python
184 lines
9.3 KiB
Python
"""gamedef_quickcheck.py —— plan 2026-06-18-001 U3 gamedef 路 lili-mac 快走查(cutover 门早读)。
|
||
|
||
run.py 的 gamedef 版:把 generate(iife) → validate(静态) 换成
|
||
generate(GAMEDEF_SYSTEM) → build-from-source.mjs(校验 schema/引用/静态扫描 + 装配出工厂),
|
||
其余(scaffold/build/play 九门)复用 run.py(harness 零改)。
|
||
|
||
GAMEDEF_SYSTEM **从后端 SaaPrompts.java 抽取**(drift-free:跑的就是后端真用的 prompt)。
|
||
模型/网关/代理旁路复用 _client;play-spec/brief 复用现有 briefs/*.json(gatespec driver 同口径)。
|
||
|
||
用法:.venv/bin/python gamedef_quickcheck.py runner flappy whack pong # 默认这 4
|
||
仅 lili-mac 快走查(小样早读);authoritative ≥60% cutover 门在 mini-desktop(SaaFullGraphE2eTest)。
|
||
密钥经 _client 读 .env,绝不打印。
|
||
"""
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
from worker import _client, run # noqa: E402 复用 chat + scaffold/build/play
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
SAA_PROMPTS = (REPO_ROOT / "game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/"
|
||
"com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java")
|
||
GAME_RUNTIME = REPO_ROOT / "game-runtime"
|
||
GEN_DIR = GAME_RUNTIME / "games" / "_wg1-gen"
|
||
BRIEFS = Path(__file__).resolve().parent / "briefs"
|
||
import os
|
||
# 救场阶梯(mirror 真图 SaaStudioGraph):stage1 连续 STAGE1_REPAIRS 次失败 → 升档 stage2 再 STAGE2_EXTRA 次。
|
||
STAGE1_MODEL = os.environ.get("QC_STAGE1", "deepseek-v4-flash")
|
||
STAGE2_MODEL = os.environ.get("QC_STAGE2", "deepseek-v4-pro")
|
||
STAGE1_REPAIRS = int(os.environ.get("QC_STAGE1_REPAIRS", "5")) # 真图 maxRepairs 默认 5
|
||
STAGE2_EXTRA = int(os.environ.get("QC_STAGE2_EXTRA", "3")) # 真图 stage2ExtraRepairs 默认 3
|
||
|
||
|
||
def extract_block(name):
|
||
"""从 SaaPrompts.java 抽指定 SYSTEM 文本块(去 Java 文本块 12 空格缩进)。drift-free=跑后端真 prompt。"""
|
||
java = SAA_PROMPTS.read_text(encoding="utf-8")
|
||
m = re.search(name + r' = """\n(.*?)\n\s*""";', java, re.DOTALL)
|
||
if not m:
|
||
raise RuntimeError("未能从 SaaPrompts.java 抽取 " + name)
|
||
lines = [(ln[12:] if ln.startswith(" " * 12) else ln) for ln in m.group(1).split("\n")]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def extract_gatespec(design_text):
|
||
"""复刻 SaaPrompts.extractGatespec:抠 ```gatespec JSON 块 → 补 expectLatch=true + ballPath .y→.x。无则 None。"""
|
||
m = re.search(r"```gatespec\s*(.+?)```", design_text or "", re.DOTALL)
|
||
if not m:
|
||
return None
|
||
gs = extract_json_obj(m.group(1))
|
||
if not isinstance(gs, dict):
|
||
return None
|
||
if "assertAfterPlay" in gs and not isinstance(gs["assertAfterPlay"], list):
|
||
gs["assertAfterPlay"] = []
|
||
gs.setdefault("expectLatch", True)
|
||
# gamedef 取证只暴露 score/remaining/progress/实体位置——把 assertAfterPlay 的顶层自定义标量字段(moves/totalRound 等)强制到 score
|
||
# (设计 agent 常造取证读不到的字段→H_progress 必挂;不动含 '.' 的实体位置断言如 ball.x)。
|
||
EXPOSED = ("score", "remaining", "progress")
|
||
for a in (gs.get("assertAfterPlay") or []):
|
||
if isinstance(a, dict) and isinstance(a.get("path"), str):
|
||
p = a["path"].lstrip("/")
|
||
top = p.split(".")[0].split("[")[0]
|
||
if "." not in p and top not in EXPOSED:
|
||
a["path"] = "score"
|
||
a["op"] = a.get("op", "increased")
|
||
drv = gs.get("driver")
|
||
if isinstance(drv, dict) and drv.get("type") == "paddle-intercept":
|
||
bp = drv.get("ballPath")
|
||
if isinstance(bp, str) and bp.endswith(".y"):
|
||
drv["ballPath"] = bp[:-2] + ".x"
|
||
return gs
|
||
|
||
|
||
def extract_json_obj(content):
|
||
"""从模型输出抠最外层 {...}(去 ```json 围栏/前后说明)。"""
|
||
s = content.strip()
|
||
s = re.sub(r"^```(?:json)?\s*", "", s)
|
||
s = re.sub(r"\s*```$", "", s)
|
||
lo, hi = s.find("{"), s.rfind("}")
|
||
if lo < 0 or hi <= lo:
|
||
return None
|
||
try:
|
||
return json.loads(s[lo:hi + 1])
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def assemble_via_cli(game_id, gamedef_obj):
|
||
"""写 source.json → 跑 build-from-source.mjs 校验+装配 → generated-factory.js。返回 (ok, err)。"""
|
||
gdir = GEN_DIR / game_id
|
||
gdir.mkdir(parents=True, exist_ok=True)
|
||
src = gdir / "source.json"
|
||
src.write_text(json.dumps(gamedef_obj, ensure_ascii=False), encoding="utf-8")
|
||
r = subprocess.run(
|
||
["node", "src/host/build-from-source.mjs", str(src), str(gdir / "generated-factory.js")],
|
||
cwd=str(GAME_RUNTIME), capture_output=True, text=True, timeout=60,
|
||
)
|
||
return r.returncode == 0, (r.stdout + r.stderr).strip()
|
||
|
||
|
||
def run_one(game_id, gamedef_system, design_system):
|
||
brief_text, play_spec = run._load_brief(game_id)
|
||
gid = "gd-" + game_id
|
||
out = {"game_id": gid, "stage": None, "pass": False, "guards": {}, "err": "", "driver": None, "model": STAGE1_MODEL}
|
||
# ① design 步(mirror 真图 design 节点):产 gatespec(driver/controlCheck/assert) 覆写 play_spec + enriched。
|
||
enriched = brief_text
|
||
try:
|
||
dresp = _client.chat(STAGE1_MODEL, design_system, brief_text, max_tokens=16000)
|
||
enriched = brief_text + "\n\n## 设计稿\n" + dresp["content"]
|
||
gs = extract_gatespec(dresp["content"])
|
||
if gs:
|
||
for k in ("exportState", "driver", "controlCheck", "assertAfterPlay", "expectLatch"):
|
||
if k in gs and gs[k] is not None:
|
||
play_spec[k] = gs[k]
|
||
out["driver"] = (gs.get("driver") or {}).get("type")
|
||
except Exception as e:
|
||
out["err"] = "design 降级:" + str(e)[:80] # design 失败不致命,凭 brief 继续 generate
|
||
# ② generate→assemble→build→play **repair 回环**(mirror 真图:失败→verdict/错误回喂→重生成,≤max_retries 次)。
|
||
# 这是 ≥60% cutover 门的真机制(单发首攻 ≠ 终率);design 只跑一次,repair 只重 generate。
|
||
gdir = GEN_DIR / gid
|
||
feedback = None
|
||
total = STAGE1_REPAIRS + STAGE2_EXTRA
|
||
for attempt in range(total):
|
||
gen_model = STAGE2_MODEL if attempt >= STAGE1_REPAIRS else STAGE1_MODEL # 救场升档:stage1 耗尽→stage2 强档
|
||
out["attempts"] = attempt + 1
|
||
out["model"] = gen_model
|
||
user = "请为下面这款游戏产出一份 gameDefinition(只输出一个 JSON 对象,不要 ```代码块、不要解释):\n\n" + enriched
|
||
if feedback:
|
||
user += "\n\n———\n上一次生成【未通过】,原因如下,请针对性修正后重新产出完整 gameDefinition JSON:\n" + feedback
|
||
try:
|
||
resp = _client.chat(gen_model, gamedef_system, user, max_tokens=16000)
|
||
except Exception as e:
|
||
out["stage"] = "generate"; feedback = "模型调用失败:" + str(e)[:160]; continue
|
||
gd = extract_json_obj(resp["content"])
|
||
if gd is None:
|
||
out["stage"] = "parse"; feedback = "未能解析出 gameDefinition JSON。请只输出一个 JSON 对象(顶层 entities/components/behaviors/scenes/rules)。"; continue
|
||
ok, err = assemble_via_cli(gid, gd)
|
||
if not ok:
|
||
out["stage"] = "validate/assemble"; feedback = "校验/装配未过:\n" + err[:500]; continue
|
||
shutil.copyfile(GEN_DIR / "_shared" / "entry-bundle.template.js", gdir / "entry-bundle.js")
|
||
shutil.copyfile(GEN_DIR / "_shared" / "index.template.html", gdir / "index.html")
|
||
(gdir / "play-spec.json").write_text(json.dumps(play_spec, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
bok, blog = run.build(gid)
|
||
if not bok:
|
||
out["stage"] = "build"; feedback = "打包失败(esbuild):\n" + blog[:500]; continue
|
||
rc, _, verdict = run.play(gid)
|
||
out["stage"] = "play"
|
||
out["guards"] = {k: g.get("pass") for k, g in ((verdict or {}).get("guards") or {}).items()}
|
||
if rc == 0 and verdict and verdict.get("pass"):
|
||
out["pass"] = True
|
||
break
|
||
feedback = run._verdict_feedback(verdict)
|
||
out["err"] = feedback[:300]
|
||
return out
|
||
|
||
|
||
def main():
|
||
games = sys.argv[1:] or ["runner", "flappy", "whack", "pong"]
|
||
gds = extract_block("GAMEDEF_SYSTEM")
|
||
dsys = extract_block("DESIGN_SYSTEM")
|
||
print(f"[gamedef-qc] stage1={STAGE1_MODEL}({STAGE1_REPAIRS})→stage2={STAGE2_MODEL}({STAGE2_EXTRA}) games={games} (全图镜像+救场升档)\n")
|
||
results = []
|
||
for g in games:
|
||
r = run_one(g, gds, dsys)
|
||
results.append(r)
|
||
mark = "✅ PASS" if r["pass"] else f"❌ {r['stage']}"
|
||
gpass = sum(1 for v in r["guards"].values() if v)
|
||
gtot = len(r["guards"])
|
||
drv = r.get("driver") or "-"
|
||
att = r.get("attempts", 1)
|
||
print(f" {r['game_id']:14s} {mark:16s} drv={drv:14s} att={att} gates={gpass}/{gtot} {('' if r['pass'] else r['err'][:100])}")
|
||
npass = sum(1 for r in results if r["pass"])
|
||
print(f"\n[gamedef-qc] 过门 {npass}/{len(results)} (cutover 门基线=iife 60%; 本为 lili-mac 小样早读, authoritative=mini-desktop)")
|
||
Path(__file__).resolve().parent.joinpath("results", "gamedef-quickcheck.json").write_text(
|
||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|