feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3

裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-10 04:22:01 -07:00
parent dc89701fcc
commit 882ca3f357
19 changed files with 1887 additions and 88 deletions

View File

@ -57,13 +57,13 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
能跑 ≠ 好玩。动手写码前,先据 brief 在 **game-logic.js 顶部写一段设计注释块**(≤8 行:⑨ 判定句三个空填好 + 核心数值锚 + ⑩ 的三段难度)定下**轻量玩法设计**,再实现——设计只在心里 = 收尾自查与验收都无从对照:
- **核心循环(好玩第一因 · 必含一层玩家决策或技巧)**:写清「玩家每次操作做**什么决策** → 得什么即时反馈 → 怎么变强」。**铁律:核心操作绝不能是「点了自动结算」的无脑点击**,必须让真人有发挥——三选一起步:**匹配**(顾客点指定菜、上对的才给分/给多分)、**时机**(在对的时刻操作 = 更多奖励)、**取舍/连击**(连续做对叠 combo、资源有限要权衡)。自检:把玩家换成「闭眼乱点」——若分数照样拿满,这循环就是无趣的,重设计。
- **决策层与自动验收解耦(工程现实,别绕开)**:九门自动验收会盲点 `occupied:true` 目标、判 score 涨,所以做成**两层奖励**——**基础分**(任意有效操作都加一点,保盲驱动器能跑通过门)+ **技巧分**(叠在玩家的匹配/时机/连击上,真人靠它玩出爽感)。盲驱动器拿基础分过门、真人靠技巧拿高分,两不耽误。**别为「让驱动器满分」把玩法做成无脑点;也别「不做对就零分」让驱动器过不了门。**
- **两层反馈:基础反馈保上手、技巧分给深度(好玩的设计惯例)**:**基础反馈**——任意有效操作都给一点即时回应(分数/音效/飘字),让新手一上手就有正反馈、不至于无所适从;**技巧分**——叠在玩家的匹配/时机/连击上,做对越多越准回报越高,让熟练玩家玩出掌控感与爽感。两层叠着来:随便玩也有反馈、不劝退新手,玩得好有额外回报、给足深度。**别把玩法做成「怎么点都一样」的无脑点(那样技巧分名存实亡);也别「不做对就颗粒无收」(那样没有上手缓冲、劝退新手)。**
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **3–4 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic;宁可程序化也别没有反馈音)。
定完过一遍 **10 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈 ⑨**核心操作非无脑**〔每次主操作有真实的决策/技巧含量,不是点了自动结算〕 ⑩**难度有曲线**〔一局内铺垫→拉紧→收束,张力持续制造「差一点」;前 10 秒即玩即教、零阅读〕)——**⑨ 是否决项:⑨ 不命中,其余九条全中也只是「有元素的无趣游戏」**(实测一款 6/8 命中却不好玩,正死在 ⑨);⑨ 命中的前提下,其余命中越多越好玩。**⑨ 的判定句式(设计完对着念)**:「玩家在__时要判断__,判错则__」——三个空都填得出、且第三个空是真代价(少得分/丢单/断连击),⑨ 才算命中。正例:顾客点了豆浆,上对 +8、上错顾客皱眉扣耐心——有判断、错有代价。反例:点任意顾客都 +5、点错零损失——无判断无代价,只是点击计数器。
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面两层奖励解耦)。
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面基础反馈+技巧分两层)。
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版(hitstop/震屏**不在**禁项——它们是单行 juice 调用不是系统:结算/暴击/大额成单时刻标配一次 hitStop + 轻 shake,好手感不算堆料)。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
@ -91,8 +91,8 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
- 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。
- **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。
- **别做**:别写/改任何 test/ 文件、别写额外脚本、别加 brief/README 没要求的东西。
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**(整文件重写在弱模型上易漏 `path` 参数、连撞几次会被熔断打断)。edit_file 的 `old` 必须逐字节匹配且唯一,找不到/不唯一它会明确报错,按提示加长 old 再试。
- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按去注释后的纯文本正则命中,**把裸调封装进函数内部照样被拦**(别信"函数里就不拦");需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**。edit_file 的 `old` 尽量逐字节复制文件原文(含缩进)以求唯一命中;即便只差行内空白/缩进它也会归一后再定位,找不到/不唯一会明确报错并附最近似片段与行号,照提示加长 old 或改用 write_file。
- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按词法真实命中(字符串/注释里的同名不误报、函数内与模板插值里的裸调照样命中),报错带行号照改即可;需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。
现在开始:**先 read_file 读手册与起点 game-logic.js,别直接写码。先写一版最小可玩核心 → 立即 check(趁文件小、红线好定位好小修,用 edit_file 修到 PASS)→ 再扩玩法**;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。"""
@ -101,11 +101,12 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
# 让 AI 知道起点已是一款该品类的完整游戏、按 brief 换皮而非重写(与 scaffold_template 配对,见 run_studio)。
_DEFAULT_SCAFFOLD_DESC = "一个跑通的脚手架起点「点圆得分」:menu→play→over 骨架 + 五法齐全 + 编排插件示范"
# 轻A(2026-07-08 n=5 收敛环坐实):per-genre 黄金模板换皮描述(单一事实源 = 此表)。route_genre 命中即喂
# build_reskin_system_prompt,让模型知道「⟦G⟧ 已是一款完整可玩的该品类游戏、只据 brief 换皮 game-logic.js」,
# 配 write_whitelist={game-logic.js, assets.js} 物理锁死 render/core 引擎文件——根除模型重写 render.js 整写截断
# 丢导出(#4/#12)、写坏 core.js 留孤儿(#18)、硬编码 viewport(#19)、自造楼层推进死锁(#1)等构建/坏死事故。
# 键 = scaffold_template 名(对齐 cheap_genre_route._GENRE_RULES 与 cheap_verify.GENRE_BY_TEMPLATE)。
# per-genre 黄金模板起点的一句话描述(单一事实源 = 此表;键 = scaffold_template 名,对齐
# cheap_genre_route._GENRE_RULES 与 cheap_verify.GENRE_BY_TEMPLATE)。route_genre 命中某品类时,create 路
# clone 该 per-genre 完整可玩模板作起点,并把此描述作 scaffold_desc 喂进 build_system_prompt,让模型知道
# 「⟦G⟧ 已是一款完整可玩的该品类游戏、据 brief 借插件能力改造成好玩切题的游戏」——**不写锁**(2026-07-08/09
# 创始人纠偏 22da3464 全撤写锁:只锁 L1_FIXED 绝对通用 plumbing,game-logic/core/render/assets 全放开,写坏
# 靠 harness 门兜、不靠限死模型的手;旧 reskin 换皮写锁管路 build_reskin_system_prompt 已随 W-AXIS 波2 拆除)。
SCAFFOLD_DESC_BY_TEMPLATE = {
"_template-feiyi": "一款完整可玩的非遗工艺游戏(工序链节拍 + 火候窗口判定 + 成品评分,menu→play→over 全链通过九门)",
"_template-trpg": "一款完整可玩的掷骰爬塔 TRPG(楼层推进 + 掷骰对抗结算 + 每几层升级选天赋,全链通过九门)",
@ -392,41 +393,3 @@ def build_modify_system_prompt(game_id: str, intent: str) -> str:
A 读手册 + 读当前 game-logic.js → B 定位「{intent}」要改玩法的哪一处 → C 读要用的插件 api.d.ts → D write_file 改写 **game-logic.js(只此一个文件)** 实现调整 → E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check+build 都绿后调 finish。"""
g = f"game-runtime/games/amgen-{game_id}"
return (head + "\n\n" + _contract_block()).replace("⟦G⟧", g)
def build_reskin_system_prompt(game_id: str, brief: str, genre_desc: str = None) -> str:
"""轻A(2026-07-08 n=5 收敛环坐实):per-genre 黄金模板已是完整可玩游戏 → create 路据 brief 只换皮 game-logic.js。
与 create(build_system_prompt)的关键差异,正是 n=5 里 heritage 坏死的病根修复:
① 起点是**该品类完整可玩游戏**(非通用「点圆得分」),据 brief 换皮而非从零造/重写引擎;
② 写边界物理收窄到**只许写 game-logic.js(+assets.js)**——render/core/host-config 等引擎文件锁死(写会被
toolkit 拒),根除模型重写 render.js 整写截断丢导出(#4/#12)、写坏 core 留孤儿(#18)、硬编码 viewport
(#19)、自造楼层推进死锁(#1)等构建/坏死事故。base create prompt 的「按需写 render/core」在此不适用。
红线契约块(入口五法 / 插件 API / footgun / 输入)经 _contract_block() 复用 create 同一份,不另抄。
Args:
game_id: 本 run 的游戏 id。
brief: 用户的一句话游戏需求(据它换皮)。
genre_desc: 该品类模板一句话描述(SCAFFOLD_DESC_BY_TEMPLATE 取);None → 通用回落。
Returns:
reskin system prompt 字符串(⟦G⟧ 已据 game_id 替换)。
"""
desc = genre_desc or "一款完整可玩的便宜档 LittleJS 小游戏"
head = f"""你是 A-model 游戏生成 agent。⟦G⟧/ 下已经是**{desc}**——一款**能跑能玩、全链通过九门**的便宜档 LittleJS 小游戏(多文件 src/ 工程)。
【本次任务 = 据 brief 换皮,不是从零造、更不是重写引擎】
- **唯一可写文件:⟦G⟧/src/game-logic.js(玩法本体)**,另可按需写 ⟦G⟧/src/assets.js(资产装载)。据 brief「{brief}」把这款已可玩游戏的**题材 / 文案 / 数值 / 画面参数 / 玩法细节**换成 brief 要的。
- **引擎文件一律锁死、绝不要写(写会被工具直接拒绝)**:src/render.js(画面绘制)/ src/core.js(品类核心循环机制与数值常量)/ src/host-config.js / src/game.js / index.html / entry-bundle.js。它们是**数据驱动引擎**——你 read_file 读它们看有哪些绘制入口、核心机制与可喂的数据位,再**通过 game-logic.js 喂数据 / 参数**驱动它们,而不是改它们。**尤其别自己重写 core.js 的核心循环(楼层推进 / 工序链 / 资源环 / 障碍生成等)——用模板现成的,自己重写极易写出死锁或坏死(实测 n=5 栽点)。**
- 这是完整可玩游戏的**换皮改造**:先 read_file 读当前 ⟦G⟧/src/game-logic.js + core.js + render.js 看现在怎么玩、有哪些可喂的数据位,再据 brief 换皮;别推倒重来、别改 brief 没要求的机制。
【先读(必须;别凭记忆猜 API)】
1. read_file('{SKILL_PATH}') —— code 层作业手册(结构 / 边界 / 红线 / 插件 API 速查)。
2. read_file('⟦G⟧/src/game-logic.js') + read_file('⟦G⟧/src/core.js') —— 当前玩法与核心机制(你换皮的起点与可喂数据位)。
3. read_file('⟦G⟧/README.md') —— 文件职责 +「你写什么 vs 调什么」边界 + ⚠bundle.tick 坑。
4. 用到某插件先 read_file 它的 api.d.ts 看精确签名(如 game-runtime/src/plugins/scene-fsm/api.d.ts)。
【步骤】
A 读手册 + 读当前 game-logic.js / core.js / render.js(看清核心循环怎么跑、哪些是可喂数据位)→ B 据 brief 定**换皮方案**(题材 / 文案 / 数值换成 brief,**复用模板核心循环别重写**;在 game-logic.js 顶部写 ≤8 行设计注释:⑨ 判定句三个空 + 核心数值锚 + ⑩ 三段难度)→ C 读要用的插件 api.d.ts → D write_file / edit_file 改写 **game-logic.js(+按需 assets.js)** 实现换皮(小改优先 edit_file)→ E 调 check,有错改到 PASS → F 调 build,有错改到 PASS → G check + build 都绿后调 finish。"""
g = f"game-runtime/games/amgen-{game_id}"
return (head + "\n\n" + _contract_block()).replace("⟦G⟧", g)

View File

@ -177,11 +177,15 @@ def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0:
"""
import cheap_run # noqa: PLC0415
import cheap_studio # noqa: PLC0415
from worker.gate_judge import judge_cheap_verdict # noqa: PLC0415
from worker.gate_judge import classify_cheap_failure_layer, judge_cheap_verdict # noqa: PLC0415
# C6 接线:带 game_id/staged 目录判(驱动器族/日志摘要口径与 Service 续修一致;此处只为 summary 归一,不再落 sidecar 也无妨——staged_dir 传入使口径同源)。
j = judge_cheap_verdict(verdict or {}, game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
# F0-归因分层(W-AXIS 波1):把九门失败归到三层(driver_contract/mechanical/gameplay)进 run-summary,
# 供 result_out/批次账消费与回喂措辞对齐真实层次;放行(passed)/未失败时 layer=none。
failure_layer = classify_cheap_failure_layer(verdict or {},
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = cheap_studio._verdict_brief(verdict) if verdict else None
svc = svc or {}
repairs = svc.get("repairs")
@ -205,6 +209,8 @@ def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0:
}
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
# F0-归因分层:失败三层归因随 summary 透传(additive;成功/未失败为 layer=none,不误标坏死)。
summary["failureLayer"] = failure_layer
# WU2 §3.9:Service 侧在模型调用抛错时(new-api 非 2xx)按 one-api 惯例分辨,把 failureReason 写进收口 sidecar;
# 这里原样透传进 summary,result_out._map_failure_reason 据它把额度耗尽落成 quota_exhausted(而非含糊 llm_error)。
# 仅当 Service 明确判额度耗尽(quota_exhausted)才透传,凭据失效/其他仍走 llm_error(可重试),避免误标。
@ -249,7 +255,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
import cheap_otlp_sink # noqa: PLC0415 —— 面四三跳传播:取当前 context 的 W3C carrier(顶层零重依赖)
import cheap_run # noqa: PLC0415
import cheap_verify # noqa: PLC0415
from cheap_roles import build_system_prompt, build_reskin_system_prompt, SCAFFOLD_DESC_BY_TEMPLATE # noqa: PLC0415
from cheap_roles import build_system_prompt, SCAFFOLD_DESC_BY_TEMPLATE # noqa: PLC0415
from service.control_plane import _wait_for_turn_end # noqa: PLC0415 —— 复用 tier2 SSE 等回合
game_id = job.get("gameId") or job.get("job_id")
@ -343,16 +349,21 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
return (_failed_summary(game_id, f"Service /sessions 未返 id(setup 失败):{str(session)[:200]}"),
cheap_run.game_dir(game_id))
# ④ scaffold(clone 模板 + SAA 信封)——必须在 /chat 前,**game_id=后端 gameId**(与 system prompt 的 ⟦G⟧ 一致),
# ④a per-run 归档(W-AXIS 波1 F0-b):scaffold 会 rmSync 整个 amgen-<id>/、重跑同 gid 直接抹掉上一 run 的
# trace.jsonl/turns.jsonl/证据。故 scaffold 前先把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体移进
# 带时间戳归档位(永不同 gid 覆盖),返回归档指针记进 run-summary/批次账。best-effort:关/失败退回旧覆盖行为。
archived_to = await asyncio.to_thread(cheap_run.archive_prior_run, game_id)
# ④b scaffold(clone 模板 + SAA 信封)——必须在 /chat 前,**game_id=后端 gameId**(与 system prompt 的 ⟦G⟧ 一致),
# agent 起点就位在 amgen-<后端 gameId>(subprocess 经 to_thread)。C2:产物目录统一后端 gameId、不用 session_id。
sc = await asyncio.to_thread(cheap_run.scaffold, game_id, scaffold_template)
if not sc["ok"]:
return _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}"), cheap_run.game_dir(game_id)
# 红线③:清残留 verdict + service-run-summary,保证回合后 _read_last_cheap_verdict/收口读到的恒是本次 Service 真写。
# 封零门跑路径(setup 静默失败 / SSE 超时未跑一次 check 时,读回上一局绿 verdict → 假 succeeded)。
for _stale in (cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json",
cheap_run.game_dir(game_id) / "evidence" / "service-run-summary.json"):
_stale.unlink(missing_ok=True)
_fs = _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}")
if archived_to:
_fs["archivedPriorRunTo"] = archived_to
return _fs, cheap_run.game_dir(game_id)
# ④c evidence 清理清单化(W-AXIS 波1 F0-c,取代旧红线③只清 verdict.json 一个文件):清 _wg1-gen/<id>/evidence/
# 残留(verdict/续修反馈/日志/截图全列)+ 兜底清 amgen-<id>/evidence/,封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
await asyncio.to_thread(cheap_run.clean_stale_evidence, game_id)
# ⑤ 写会话注册表 sidecar(C2:Service 两工厂据 session_id 读它解析回后端 gameId、绑六工具/评门/collector;
# create 路 write_whitelist=None → restricted=False)。external_game_id 恒写、正常路工厂必读到。
_write_session_cfg(session_id, external_game_id=game_id,
@ -387,6 +398,9 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
verdict = _read_last_cheap_verdict(game_id)
svc = _read_service_run_summary(game_id)
summary = _build_summary(game_id, brief, verdict, svc, turn, t0)
# F0-b:上一 run 归档指针随 run-summary 透传(批次账每 run 记一笔;首跑/关/失败为 None,不带该键)。
if archived_to:
summary["archivedPriorRunTo"] = archived_to
# ⑩ reply 外收口:非阻塞丰富度 LLM 评分(additive;不进 verdict、不改 ok;token 不污染生成成本台账)。
# genre 透传(T4):品类路由命中时同一次评分 additive 追加品类扩展条目(与 run_studio 的 genre 参数同义)。
@ -398,6 +412,17 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调
summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"}
# ⑪ 玩法地板判定 + ok 语义切换(W-AXIS 波2 · 质量模型 SoT 裁定三):机械九门(_build_summary 的 j.passed)
# 只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据(首帧/局中连拍/局末截图 + game-log + 取证时间线)
# 裁 broken/hollow/off-brief,阻断放行(ok = 预筛 ∧ 判定)。与丰富度(非阻塞观测)是两物:fail-closed,
# 评不出/无证据 → 不放行、标 degraded。apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛,
# additive 写 summary['accepted']/['judge'] 并更新 ['ok']——result_out 据 accepted 落 status。
summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief)
_js = summary.get("judge") or {}
print(f"[cheap-driver] game={game_id} 玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} "
f"verdict={_js.get('verdict')} rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} "
f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')}", flush=True)
print(f"[cheap-driver] game={game_id} 单 POST 结束: ok={summary['ok']} attempts={summary['attempts']} "
f"costRmb={summary['costRmb']} wallSec={summary['wallSec']}", flush=True)
return summary, cheap_run.game_dir(game_id)

View File

@ -21,7 +21,7 @@ from pathlib import Path
# 跨包 import 兜底 + key 注入(_bootstrap 模块级把 tier2/gen-worker 加进 sys.path)。
sys.path.insert(0, str(Path(__file__).resolve().parent)) # → cheap-worker/(CLI 直跑兼容)
import _bootstrap # noqa: E402,F401
from cheap_roles import build_system_prompt, build_reskin_system_prompt # noqa: E402
from cheap_roles import build_system_prompt # noqa: E402
from cheap_toolkit import CheapSession, build_toolkit # noqa: E402
import cheap_budget # noqa: E402 预算两段式同源工厂(CLI 与生产 Service 读同一配置源)
import cheap_run # noqa: E402
@ -29,7 +29,7 @@ import cheap_verify # noqa: E402 U-A2:便宜档「丰富度」LLM 验证 age
# tier2 框架层(import config 触发代理旁路 + 加载 agentscope,必须在裸 import agentscope/openai 之前)。
from worker import config # noqa: E402
from worker.gate_judge import judge_cheap_verdict # noqa: E402 CLI 回喂:九门判据与生产 RepairMiddleware 同源
from worker.gate_judge import judge_cheap_verdict, classify_cheap_failure_layer # noqa: E402 CLI 回喂:九门判据与生产 RepairMiddleware 同源;失败三层归因(W-AXIS 波1)
from worker.middleware import ( # noqa: E402 breaker 本体经 cheap_budget 同源工厂构造,此处只用 trace + 熔断异常
Tier2TraceMiddleware, Tier2CircuitBreak,
)
@ -226,6 +226,10 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
t0 = time.time()
_rec(f"model={_bootstrap.SPIKE_MODEL} id={game_id} brief=「{brief}」max_iters={max_iters} max_resumes={max_resumes}")
# W-AXIS 波1 F0-b per-run 归档:create 路 scaffold 会 rmSync 整个 amgen-<id>/、重跑同 gid 抹掉上一 run 的
# trace.jsonl/turns.jsonl/证据。故 scaffold 前先把上一 run 整体移进带时间戳归档位(永不覆盖)。modify 路
# (prepare 给定)base 源须保留、不归档。best-effort:关/失败退回旧覆盖行为(archived_to=None)。
archived_to = cheap_run.archive_prior_run(game_id) if prepare is None else None
# create=scaffold clone 模板(扩模板:scaffold_template 传 per-genre 黄金骨架名,如经营=_template-shop);modify=上游已预备的 noop
prep = prepare or (lambda gid: cheap_run.scaffold(gid, scaffold_template))
sc = prep(game_id)
@ -233,6 +237,10 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
return {"ok": False, "gameId": game_id, "fail": "准备失败:" + sc["output"]}
_rec(f"准备就绪 amgen-{game_id}({'scaffold clone _template + SAA 信封' if prepare is None else 'modify: base 源已预备'})")
_persist_brief_forensics(game_id, brief) # fix③:scaffold 后即落 brief 取证(硬失败/挂死也留得下,不进可交易工程根)
# W-AXIS 波1 F0-c evidence 清理清单化(create 路;取代旧红线③只清 verdict.json 一个文件)——清 _wg1-gen/<id>/evidence/
# 残留(verdict/续修反馈/日志/截图全列),封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。归档已移走则为 no-op。
if prepare is None:
cheap_run.clean_stale_evidence(game_id)
session = CheapSession(game_id=game_id)
toolkit = build_toolkit(session, write_whitelist=write_whitelist) # modify 收窄到只许写 game-logic.js
@ -263,17 +271,26 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
except Exception as _otlp_err:
_rec(f"OTLP sink 并接异常(回落原 jsonl sink,不阻断生成):{_otlp_err}")
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=_trace_sink)
# W-AXIS 波1 真相层:CLI 路同款逐 turn 全文落 amgen-<id>/turns.jsonl(模型文本/工具全参/工具返回/门反馈)。
# CLI 路无 RepairMiddleware,门反馈是外层 resume 循环重发的 kick(name=user)——turns 中间件在 on_reply 起点
# 读 input_kwargs['inputs'] 即收得到。observe-only、best-effort,默认开;失败/关→None 不加入 middlewares。
_turns_mw = None
try:
import cheap_turns_sink # noqa: PLC0415
_turns_mw = cheap_turns_sink.build_turns_middleware(game_id, trace_id=game_id)
except Exception as _te: # noqa: BLE001 —— 真相层接线失败绝不阻断生成
_rec(f"turns 真相层中间件接线异常(降级不落 turns,不阻断生成):{type(_te).__name__}: {_te}")
writer = Agent(
name="cheap-writer",
# 轻A:write_whitelist 收窄(create 路 route 命中 per-genre 模板)→ 用换皮 prompt(引擎锁死、只写 game-logic),
# 否则用通用 create prompt(从零造);modify 路由调用方显式传 system_prompt=build_modify_system_prompt。
system_prompt=system_prompt or (
build_reskin_system_prompt(game_id, brief, scaffold_desc) if write_whitelist
else build_system_prompt(game_id, scaffold_desc)),
# create 路统一用通用 create prompt(scaffold_desc 引导起点,不写锁、模型自由改全部游戏文件);
# modify 路由调用方显式传 system_prompt=build_modify_system_prompt(write_whitelist 只用于 toolkit 写边界收窄,
# 与 prompt 选择解耦——W-AXIS 波2 拆除旧 create 路换皮写锁 build_reskin_system_prompt 后此处不再分叉)。
system_prompt=system_prompt or build_system_prompt(game_id, scaffold_desc),
model=model,
toolkit=toolkit,
middlewares=[tracer, breaker], # trace 外层、熔断内层(列表序=洋葱序)
# trace 外层、熔断内层(列表序=洋葱序);turns 真相层 observe-only 置中(只读事件、不拦截,顺序无碍)。
middlewares=([tracer, _turns_mw, breaker] if _turns_mw is not None else [tracer, breaker]),
state=_bypass_state(),
react_config=ReActConfig(max_iters=max_iters),
context_config=config.build_context_config(), # 历史压缩
@ -418,6 +435,11 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
stage = _furthest_stage(finished=finished, staged=staged,
smoke_ran=(smoke_ok is not None), played=(verdict is not None))
summary.update(build_trace_source(verdict, driver_type, attempts, stage, _bootstrap.SPIKE_MODEL))
# ── W-AXIS 波1:失败三层归因 + per-run 归档指针随 summary 透传(additive;供批次账每 run 记一笔)──
# verdict=None(没跑到 play)时 classify 归 layer=none(不误标坏死);archived_to 首跑/关/失败为 None、不带该键。
summary["failureLayer"] = classify_cheap_failure_layer(verdict, staged_dir=cheap_run.wg1_game_dir(game_id))
if archived_to:
summary["archivedPriorRunTo"] = archived_to
# ── U-A2:便宜档「丰富度」LLM 评分(非阻塞·只报告·不进 verdict / 不改 ok 达标)──
# 架构红线:玩法「丰富不丰富」的校验需大模型能力,绝不写成 code-presence/正则/断言(违反创始人红线)。
@ -441,6 +463,23 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}
_rec(f"丰富度评分接线异常(已降级,不影响 run):{type(e).__name__}: {e}")
# ── W-AXIS 波2:独立模型玩法地板判定 + ok 语义切换(ok = 预筛 ∧ 判定;质量模型 SoT 裁定三)──
# 机械九门只作「未见明显死」预筛,过筛者交独立多模态档看真玩证据裁 broken/hollow/off-brief,阻断放行。
# 与丰富度(非阻塞观测)是两物:这里 fail-closed,评不出/无证据 → 不放行、标 degraded。
# 只在 run_gates(有真 verdict + 首帧/局中/局末截图证据)时接入;对照路(run_gates=False)零改动、不触发判定。
# apply_gameplay_judge 内建 fail-closed 与顶层兜底、绝不抛,additive 写 summary['accepted']/['judge'] 并更新 ['ok']。
if run_gates and finished:
# 预筛必须显式喂九门口径(裁定一/三):本路 summary.ok=finished 是「模型自称收敛+check/build 绿」,
# 不是机械九门信号——2026-07-09 W3 真跑(w3-verify)暴露:E/G/H 三门挂的局按 finished 当预筛,
# 被判定放行成 accepted=True,违 SoT『ok=预筛∧判定』,已修。verdict 缺/坏 → 预筛按未过(fail-closed)。
_prefilter = bool((verdict or {}).get("pass"))
summary = await cheap_verify.apply_gameplay_judge(summary, game_id=game_id, brief=brief,
prefilter=_prefilter)
_js = summary.get("judge") or {}
_rec(f"玩法地板判定 ran={_js.get('ran')} blocking={_js.get('blocking')} verdict={_js.get('verdict')} "
f"rejectClasses={_js.get('rejectClasses')} degraded={_js.get('degraded')} "
f"costRmb={_js.get('costRmb')} → accepted={summary.get('accepted')} ok={summary.get('ok')}")
ev_dir = cheap_run.game_dir(game_id) / "evidence"
ev_dir.mkdir(parents=True, exist_ok=True)
(ev_dir / "run-summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")

View File

@ -103,10 +103,23 @@ def build_toolkit(session: CheapSession, *, write_whitelist=None):
return r.get("message", "OK edit 已应用") if r["ok"] else "ERROR: " + r["error"]
async def check() -> str:
"""循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。"""
"""循环内快校验:node --check 各 src/*.js + 五法/导出名/红线/形状门 lint。返回 PASS 或错误清单。finish 前必须 PASS。
tsc 类型门(W-AXIS 波3·advisory):`node tools.mjs check` 会在主判定后附一节 [check:tsc-advisory]。
主判定 FAIL 时它随 output 一并回给模型;主判定 PASS 时——若 advisory 有【真发现】(红线/结构门放行、
但 tsc 抓到的类型 bug,如把数字当函数调/拼错本地属性名)——把那一节附在 PASS 后回给模型自纠。
**advisory 绝不改 r["ok"]/不阻断**:只是把「红线门漏掉的类型问题」原文喂给模型(0 发现/tsc 不可用不附,免噪音)。
"""
session.last_check = cheap_run.check(session.game_id)
r = session.last_check
return ("PASS" if r["ok"] else "FAIL:\n" + r["output"])[:_TOOL_RESULT_CAP]
if r["ok"]:
out = r["output"] or ""
idx = out.find("[check:tsc-advisory]")
adv = out[idx:].strip() if idx >= 0 else ""
# 仅当 advisory 有真发现才附("0 发现"/"不可用" 对模型是噪音,不附);不改 PASS 判定。
surface = bool(adv) and ("发现" in adv) and ("0 发现" not in adv)
return ("PASS" + ("\n" + adv if surface else ""))[:_TOOL_RESULT_CAP]
return ("FAIL:\n" + r["output"])[:_TOOL_RESULT_CAP]
async def build() -> str:
"""循环内 esbuild 打包(秒级,SAA 信封 __GameBundle)。返回 PASS 或编译错误。finish 前必须 PASS。"""

View File

@ -17,7 +17,9 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent(非阻塞·只报
"""
import asyncio
import base64
import json
import time
from pathlib import Path
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap,故纯函数可在无 agentscope 环境单测。
@ -393,6 +395,567 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
return _degraded(f"judge 异常:{type(e).__name__}: {e}")
# ══════════════════════════════════════════════════════════════════════════════
# 玩法地板判定(W-AXIS 波2 · 质量模型 SoT 裁定三的代码兑现)
# ──────────────────────────────────────────────────────────────────────────────
# 与上面的丰富度评分是两物,别混:
# · 丰富度(verify_richness):评「有多丰富/多耐玩」的【程度】——非阻塞软信号,只观测、永不拒发(L2)。
# · 玩法地板(judge_gameplay_floor):裁「作为游戏成不成立」的【地板布尔】——阻断放行(L1 的判定半)。
# 裁定三钉死的分界:判定只裁三类【拒绝】的有无,不判程度高低:
# · broken —— 真人玩不通 / 到不了终局 / 起不来 / 卡死一屏 / 核心操作无响应;
# · hollow —— 空壳:核心循环没有决策层(点哪都一样、无输赢取舍、纯自动结算的挂机点击),判「有没有」不判「多好」;
# · off_brief —— 不切题:画面/玩法与 brief 要的游戏是两回事。
# 三条铁律(裁定三):
# ① 出题≠被考(2026-07-10 创始人修正边界):判定只吃运行证据不吃自报;判定模型允许 M3(生成同源)——
# 地板只裁「有无」不裁品味、同源盲区风险最小,且 07-09 spike 实证 M3 当场抓出 M3 自己写的渲染 bug;
# 同源残余风险由金标正反例+创始人抽玩定标兜底。修正动因=glm-5.2 唯一通道(闲鱼二手中转)图像支持
# 按池轮换、07-09 整日全盲,判定层可用性不能押注二手供给。SoT 修订随验收 v2 双评审落档。
# ② fail-closed:判定失败/评不出/无证据 → 不放行、标 degraded(绝不静默降级成自动通过)。
# ③ 校准与阻断开关:金标正反例复验 + 创始人抽玩定标;judge.blocking 开关默认 true,可一键整体回退到修订前口径。
# 判定模型走 new-api:生产档由 generation.yaml judge.model 定(现 MiniMax-M3,一手直连通道);
# 单局判定成本目标 ≤¥0.3(实测 4-6 帧约 ¥0.10,记账进 judge.costRmb)。
# 判定默认档(genconfig 不可达时的编译回落,生产以 YAML 为准):2026-07-10 随创始人决策切 M3。
# 07-09 探针实证 glm-5.2 与 MiniMax-M3 均能读图;glm-5.2 因二手供给整日全盲弃用。
_JUDGE_DEFAULT_MODEL = "MiniMax-M3"
# 三类拒绝的机读键 → 对外类名(off_brief 用下划线,与 LLM 输出的 camelCase offBrief 解耦)。
_FLOOR_CLASSES = (("broken", "broken"), ("hollow", "hollow"), ("offBrief", "off_brief"))
_FLOOR_SYSTEM = (
"你是轻量小游戏的【玩法地板判定 agent】。你不是打分器——只判「这款游戏作为一个游戏,地板成不成立」,"
"只裁【有没有】、绝不裁【做得多好】(丰富/耐玩/好玩的程度问题不归你,归另一套软评分)。"
"你只看运行证据(真玩截图 / 运行日志 / 取证状态时间线),绝不采信任何自报、承诺或源码里写了什么。"
"逐条判这三类【拒绝】是否成立:\n"
"· broken(坏死):真人根本玩不通 —— 起不来 / 一直卡在同一屏 / 核心操作点了没反应 / 到不了任何终局或结算。\n"
"· hollow(空壳):核心循环没有决策层 —— 点哪都一样、没有输赢、没有取舍、判错没有代价,纯自动结算的挂机点击。"
"只判「有没有决策层」,不判「决策做得多有层次」。\n"
"· off_brief(不切题):画面与玩法跟 brief 要的游戏是两回事(主题漂移 / 品类不符)。\n"
"任一类成立 → 整体判 reject;三类都不成立 → 判 accept。"
"证据不足以断定某一类是否成立时,该类按【成立(有问题)】算——地板判定 fail-closed,宁可错拦、不可放过坏游戏"
"(后面有人工复核与金标校准兜)。严格只输出 JSON,不要任何额外文字、不要 markdown 围栏。"
)
_FLOOR_OUTPUT_CONTRACT = (
"严格只输出以下 JSON(problem=true 表示「这一类有问题、应拒绝」,why 给一句中文依据;"
"imagesSeen=你在本条消息里【真实看到】的截图张数,整数——一张都看不到就如实填 0,"
"这个字段用于检测图像传输故障,绝不据它奖惩你):\n"
'{"imagesSeen":0,"broken":{"problem":false,"why":"…"},"hollow":{"problem":false,"why":"…"},'
'"offBrief":{"problem":false,"why":"…"},"verdict":"accept","notes":"整体一句话"}'
)
def _degraded_floor(reason: str) -> dict:
"""fail-closed 降级结果(裁定三②):评不出/无证据/调用失败 → 不放行、标 degraded。
accepted=False 是【地板判定的 fail-closed 语义】,与丰富度的 score=None(非阻塞观测)完全不同:
这里 degraded 直接构成「拒绝」(rejectClasses=['degraded']),阻断放行,由人工复核兜。
"""
return {"accepted": False, "verdict": "reject", "rejectClasses": ["degraded"],
"checks": [], "notes": None, "degraded": True, "reason": reason}
def parse_floor_judgment(text) -> dict:
"""纯函数:把判定 LLM 的 JSON 输出解析成结构化地板裁决(含 fail-closed 兜底)。可单测、零网络、零 agentscope。
成功 → {
accepted: bool, # = 三类均无问题 且 LLM verdict 未判 reject(两者取交,任一拒即拒)
verdict: "accept"|"reject",
rejectClasses: [str], # 命中的拒绝类子集(broken/hollow/off_brief)
checks: [{class, problem, why}*3],
notes: str, degraded: False}
解析失败 / 非对象 / 缺任一类判定字段 → _degraded_floor(...)(accepted=False、fail-closed)。
每一类容忍两种形状:{problem,why} 对象,或裸 bool(模型偷懒直接给 true/false);缺字段一律 fail-closed
(无法确认地板 → 保守拒绝),绝不把「没判出来」静默当成通过。
"""
if not isinstance(text, str) or not text.strip():
return _degraded_floor("判定输出为空")
try:
import json_repair # 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出
data = json_repair.loads(text)
except Exception as e: # noqa: BLE001 解析层任何异常都 fail-closed,绝不抛
return _degraded_floor(f"判定输出解析失败:{type(e).__name__}")
if not isinstance(data, dict):
return _degraded_floor("判定输出不是 JSON 对象")
checks = []
for raw_key, cname in _FLOOR_CLASSES:
node = data.get(raw_key)
if isinstance(node, dict):
problem = _coerce_bool(node.get("problem"))
why = str(node.get("why", "")).strip()[:200]
elif node is not None: # 裸 bool/字符串:模型没按对象给,也接住
problem = _coerce_bool(node)
why = ""
else: # 缺这一类判定 → 无法确认地板 → fail-closed(不静默判过)
return _degraded_floor(f"判定输出缺 {raw_key} 字段(无法确认地板,fail-closed)")
checks.append({"class": cname, "problem": problem, "why": why})
reject_classes = [c["class"] for c in checks if c["problem"]]
llm_verdict = str(data.get("verdict", "")).strip().lower()
# accepted 需两者一致取 accept:三类均无问题 且 LLM 自评 verdict 不是 reject(任一给拒绝信号 → 拒,fail-closed 偏严)。
accepted = (not reject_classes) and (llm_verdict != "reject")
# imagesSeen:模型自报真实看到的截图张数(图像盲检测的结构化信号;缺失/非数字 → None=未知,不触发盲重掷)。
# 背景(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体确定性路由,~15% 载荷永远落在丢图通道——
# 同载荷重试必盲、任何字节微扰即换通道。靠措辞 grep 判盲太脆,改为契约字段模型自报。
images_seen = None
try:
raw_seen = data.get("imagesSeen")
if raw_seen is not None and not isinstance(raw_seen, bool):
images_seen = max(0, int(raw_seen))
except (TypeError, ValueError):
images_seen = None
return {"accepted": accepted, "verdict": "accept" if accepted else "reject",
"rejectClasses": reject_classes, "checks": checks, "imagesSeen": images_seen,
"notes": str(data.get("notes", "")).strip()[:300], "degraded": False}
class _JudgeResp:
"""判定多模态调用的极简响应载体(content=纯文本);与丰富度 _extract_text 的 str 分支兼容。"""
def __init__(self, text: str):
self.content = text if isinstance(text, str) else ""
class _NewapiVisionModel:
"""判定用的薄多模态客户端:直连 new-api /v1/chat/completions(OpenAI 兼容,支持 image_url 图像块)。
为什么不复用 build_cheap_model(agentscope M3):判定层要的是薄而可控的多模态 HTTP 客户端——
直连网关对图像块形状可控(image_url data-URI 已 probe 坐实)、finish_reason/reasoning 元信息可留痕,
与生成栈(agentscope 全家桶)解耦;仍走 new-api 统一出口(base+/v1 + NEWAPI_KEY),不违统一网关铁律。
(判定模型 2026-07-10 起=MiniMax-M3,由 YAML judge.model 定;出题≠被考边界修正见 §判定地板头注。)
trust_env=False:判定目标在内网 Tailscale(new-api 100.64.0.8),彻底绕开本机 clash 代理(fake-ip 拦本地/内网,
§7 内网直连必绕代理)。records 记 (in,out,cached) 供 judge 段成本记账(与生成成本台账隔离——判定用独立档)。
"""
def __init__(self, model_name: str, base_url: str, api_key: str, *,
max_tokens: int = 1500, timeout: float = 120.0):
self.model_name = model_name
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.max_tokens = max_tokens
self.timeout = timeout
self.records = [] # [(input_tokens, output_tokens, cached_tokens)]
self.last_meta = {} # 最近一次响应的诊断元信息(finishReason/reasoningChars)——content 空时据此归因
async def __call__(self, messages):
import httpx # noqa: PLC0415 惰性 import(顶层零重依赖;与 cheap_verify 纯函数可无 httpx 环境单测同源)
body = {"model": self.model_name, "max_tokens": self.max_tokens, "messages": messages}
async with httpx.AsyncClient(trust_env=False, timeout=self.timeout) as h:
r = await h.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
)
r.raise_for_status()
d = r.json()
u = d.get("usage") or {}
pi = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
po = int(u.get("completion_tokens") or u.get("output_tokens") or 0)
cached = 0
det = u.get("prompt_tokens_details") or {}
if isinstance(det, dict):
cached = int(det.get("cached_tokens") or 0)
self.records.append((pi, po, cached))
ch = (d.get("choices") or [{}])[0]
msg = ch.get("message") or {}
text = msg.get("content", "")
# 判定器自身的真相层:留 finish_reason 与 reasoning 长度。glm-5.2 带思考(reasoning_content),思考长度是
# 随机变量——吃光 max_tokens 时 content 为空、finish_reason=length(w2b-verify 2026-07-09 实测案),据此可诊断。
self.last_meta = {"finishReason": ch.get("finish_reason"),
"reasoningChars": len(msg.get("reasoning_content") or "")}
return _JudgeResp(text if isinstance(text, str) else "")
def usage_sum(self):
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))
def _wg1_evidence_dir(game_id: str) -> Path:
"""判卷证据目录 = _wg1-gen/<id>/evidence/(九门 play 落 first-paint/after-play/midplay/game-log/verdict 处)。"""
return cheap_run.wg1_game_dir(game_id) / "evidence"
def _natural_key(name: str):
"""midplay-2 排在 midplay-10 前:按名字里的数字段自然排序(纯字母段保原序)。"""
import re # noqa: PLC0415
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", name)]
def _collect_frames(evidence_dir, *, max_frames: int = 6) -> list:
"""收 evidence 目录的真玩截图 → [data:image/png;base64,...],顺序 first-paint → 局中连拍 midplay-* → after-play。
超 max_frames 时保头尾(首帧+局末)、中间连拍均匀采样(判定需要「局前→局中→局末」的时间跨度,不能只留一头)。
读不到目录 / 无截图 → [](上层据此 fail-closed:真玩过的游戏必有 first-paint,缺失即证据异常)。
"""
try:
ev = Path(evidence_dir)
if not ev.is_dir():
return []
ordered = []
fp = ev / "first-paint.png"
if fp.is_file():
ordered.append(fp)
ordered.extend(sorted(ev.glob("midplay-*.png"), key=lambda p: _natural_key(p.name)))
ap = ev / "after-play.png"
if ap.is_file():
ordered.append(ap)
if len(ordered) > max_frames:
head, tail, mid = ordered[0], ordered[-1], ordered[1:-1]
keep = max_frames - 2
if keep <= 0:
ordered = [head, tail]
else:
step = len(mid) / keep
ordered = [head] + [mid[int(i * step)] for i in range(keep)] + [tail]
out = []
for p in ordered:
try:
out.append("data:image/png;base64," + base64.b64encode(p.read_bytes()).decode())
except OSError:
continue
return out
except Exception: # noqa: BLE001 证据装配失败 → 返回空(上层 fail-closed),绝不抛
return []
def _read_state_timeline(evidence_dir) -> str:
"""从九门 verdict.json 抽一段取证状态时间线(判定的文字证据,补截图看不清的数值/终态信号)。
只搬运机械门已测出的客观信号(不加解读):H_progress 的 score 玩前/玩后、E_live 的 played/phase/活性、
latch 的终局 phase 与是否驻留、D_render 的亮度。读不到/坏 → 空串(判定仍靠截图,不伪造)。
"""
try:
ev = Path(evidence_dir)
p = ev / "verdict.json"
if not p.is_file():
return ""
v = json.loads(p.read_text(encoding="utf-8"))
guards = (v or {}).get("guards") or {}
lines = []
hg = guards.get("H_progress") or {}
for c in (hg.get("checks") or []):
if isinstance(c, dict):
lines.append(f"进展[{c.get('path')}] {c.get('op')}: 玩前={c.get('before')} 玩后={c.get('after')}"
f"(机械门判 {'达成' if c.get('pass') else '未达成'})")
latch = hg.get("latch") or {}
if isinstance(latch, dict) and latch:
lines.append(f"终局 latch: 到达终态={bool(latch.get('pass'))} 此刻 phase={latch.get('phaseNow') or latch.get('after')}"
f" {(latch.get('reason') or '')[:60]}")
el = guards.get("E_live") or {}
if isinstance(el, dict) and el:
lines.append(f"活性 E_live: 真进过游玩={el.get('played')} 玩后 phase={el.get('phase1')}"
f" 去重画面态数={el.get('distinctStates')} 状态真变={el.get('liveByState')}")
dr = guards.get("D_render") or {}
if isinstance(dr, dict) and dr:
lines.append(f"渲染 D_render: 亮度={dr.get('bright')} 最大通道={dr.get('maxCh')}")
return "\n".join(lines)
except Exception: # noqa: BLE001 取证时间线 best-effort,读不出降级空串
return ""
def _read_game_log_text(evidence_dir, *, max_entries: int = 12, max_chars: int = 800) -> str:
"""从 evidence/game-log.json 摘运行时日志(游戏语义 + 插件告警),给判定补运行期信号。读不到/坏 → 空串。"""
try:
ev = Path(evidence_dir)
p = ev / "game-log.json"
if not p.is_file():
return ""
arr = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(arr, list) or not arr:
return ""
def _fmt(e):
if not isinstance(e, dict):
return str(e)[:100]
return f"[{e.get('scope')}:{e.get('tag')}] {e.get('msg') or ''}"[:100]
return "\n".join(_fmt(e) for e in arr[-max_entries:])[:max_chars]
except Exception: # noqa: BLE001 日志摘要 best-effort
return ""
def _build_floor_messages(brief: str, state_text: str, log_text: str, data_uris: list) -> list:
"""拼判定的 OpenAI 多模态消息:brief + 取证时间线 + 日志 + 「首帧/局中/局末」截图序列 + 输出契约。"""
head = (
f"这款游戏的 brief(玩家想要的):{brief or '(未提供)'}\n\n"
"下面给你这一局【真玩】留下的运行证据。请只据这些运行证据判三类拒绝是否成立。\n\n"
f"【取证状态时间线(机械门客观测得,未加解读)】\n{state_text or '(无)'}\n\n"
f"【运行时日志(节选)】\n{log_text or '(无)'}\n\n"
f"【真玩截图】接下来 {len(data_uris)} 张图按时间顺序给出:第 1 张=首帧(刚进入),"
"中间=局中连拍(真玩过程),最后 1 张=局末(真玩结束时)。请对照 brief 看这些画面是不是一款玩得通、"
"有决策层、且切题的游戏:\n"
)
parts = [{"type": "text", "text": head}]
for uri in data_uris:
parts.append({"type": "image_url", "image_url": {"url": uri}})
parts.append({"type": "text", "text": "\n" + _FLOOR_OUTPUT_CONTRACT})
return [{"role": "system", "content": _FLOOR_SYSTEM},
{"role": "user", "content": parts}]
def _build_judge_model(model_name: str, max_tokens: int, timeout: float):
"""装判定多模态客户端(直连 new-api /v1)。key 从 env/凭据档解析(内网阶段单一事实源)。"""
import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入由 _bootstrap/client 处理
_bootstrap.ensure_api_key_env()
from worker import client # noqa: PLC0415
base = client.resolve_base_url().rstrip("/")
if not base.endswith("/v1"):
base = base + "/v1"
return _NewapiVisionModel(model_name, base, client.get_api_key(),
max_tokens=max_tokens, timeout=timeout)
def _estimate_judge_cost(model_name: str, ti: int, to: int, cached: int):
"""据 new-api /api/pricing 权威倍率把判定 token 折成 ¥(与生成成本台账同口径 observability.cost.compute)。
best-effort:取价失败(网关不可达 / 无 httpx)→ None(judge 段记 tokens、costRmb 留空,不伪造成本)。
"""
try:
from observability import cost as _cost, newapi_pricing as _np # noqa: PLC0415
params = _np.fetch_pricing_params()
if not params:
return None
d = _cost.compute(model_name, ti, to, params["pricing"], params["qpu"],
params["usd_rate"], group_ratio=1.0, cached_tokens=cached)
return round(d["rmb"], 5)
except Exception: # noqa: BLE001 成本记账 best-effort,绝不连累判定主体
return None
def _persist_judge_json(evidence_dir, result: dict, raw_text: str) -> None:
"""判定真相层落盘(best-effort,绝不抛):evidence/judge.json = 完整地板裁决 + LLM 原始输出头(截断 2000 字)。
动机(W-AXIS 波2 验收发现,2026-07-09):判定结果此前只进 run-summary → 后端 trace,本地证据目录零痕迹——
verdict.json 孤零零 pass=true,盘上看不出这局判定发生过什么;degraded(如「判定输出为空」)更无从诊断。
判定器自己也要有真相层:每次判定(含 degraded)都在证据目录留完整裁决与原始输出。目录不存在则静默跳过(单测 fake gid)。
"""
try:
ev = Path(evidence_dir) if evidence_dir else None
if ev is None or not ev.is_dir():
return
payload = dict(result)
payload["rawTextHead"] = (raw_text or "")[:2000]
payload["ts"] = int(time.time() * 1000)
(ev / "judge.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: # noqa: BLE001 落盘失败绝不连累判定主体
pass
async def judge_gameplay_floor(game_id: str, *, brief: str = "", model=None, frames=None,
state_text=None, log_text=None, evidence_dir=None,
model_name: str = None, max_tokens: int = 1500,
timeout: float = 120.0, max_frames: int = 6) -> dict:
"""对一局真玩证据跑独立多模态玩法地板判定(阻断权威;裁定三)。**fail-closed**:任何失败/无证据 → degraded(=拒绝)。
与 verify_richness 的非阻塞铁律相反——这里失败不是「静默降级放行」,而是「fail-closed 拒绝」:
评不出的游戏不许自动过,标 degraded 交人工复核。
Args:
game_id: 本局 id(读 _wg1-gen/<id>/evidence/ 下截图/日志/verdict)。
brief: 本局 brief(off_brief 判定必需)。
model: 可注入的判定 LLM 客户端(单测用 fake,零网络);None → 建 glm-5.2 多模态客户端。
frames/state_text/log_text: 可直接注入的证据(单测用,绕文件 I/O);None → 从 evidence_dir 收集。
evidence_dir: 证据目录;None → _wg1-gen/<id>/evidence/。
model_name/max_tokens/timeout/max_frames: 判定档旋钮(缺省走 glm-5.2 / 1500 / 120s / 6 帧)。
Returns:
parse_floor_judgment 的结构(附 judgeTokens/costRmb/model/frames 观测),或 _degraded_floor(...)(fail-closed)。
"""
mn = model_name or _JUDGE_DEFAULT_MODEL
ev = None
try:
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
imgs = frames if frames is not None else _collect_frames(ev, max_frames=max_frames)
if not imgs:
# 无真玩截图:真玩过的游戏必落 first-paint.png,缺失即证据异常 → fail-closed(绝不无证据放行)。
r = _degraded_floor("真玩截图证据缺失(无 first-paint/midplay/after-play),fail-closed 不放行")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
st = state_text if state_text is not None else _read_state_timeline(ev)
lg = log_text if log_text is not None else _read_game_log_text(ev)
m = model if model is not None else _build_judge_model(mn, max_tokens, timeout)
async def _roll(msgs):
"""一掷判定 = 一次调用 + 空输出有界重试一次(2026-07-09 w2b-verify 实测案):glm-5.2 带思考,
思考长度是随机变量——吃光 max_tokens 时 content 为空;同证据复跑即可得答案(主会话复现坐实)。
重试前放大 max_tokens 对症;绝不无限重试(判定成本有账)。放大取 max(原值, ×2封顶6000):
base 已调大(YAML 202752)时绝不反向缩小——旧式 min(×2,6000) 在大 base 下会把上限缩回 6000。"""
t = ""
used = 0
for attempt in range(2):
resp = await asyncio.wait_for(m(msgs), timeout=timeout)
used = attempt + 1
t = _extract_text(resp)
if isinstance(t, str) and t.strip():
break
if attempt == 0 and hasattr(m, "max_tokens"):
cur = int(getattr(m, "max_tokens", 1500))
m.max_tokens = max(cur, min(cur * 2, 6000))
return t, used
text, attempts_used = await _roll(_build_floor_messages(brief, st, lg, imgs))
result = parse_floor_judgment(text)
# 图像盲微扰重掷(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体【确定性】路由,部分载荷永远落在
# 丢图通道——同载荷重试必盲(3 例假阴复判 3/3 仍盲),而 brief 尾加一个空格微扰载荷即换路由、当场看见。
# 故:送了帧而模型自报 imagesSeen=0 → 微扰重掷一次;重掷仍自报 0 → 这是判定仪器故障不是游戏证据缺失,
# fail-closed degraded 归因「图像通道盲」交人工,不再把好游戏错杀成 hollow/off_brief(基线 3/20 假阴案)。
# imagesSeen=None(模型没报/老 fake)不触发,向后兼容。
if imgs and not result.get("degraded") and result.get("imagesSeen") == 0:
text2, used2 = await _roll(_build_floor_messages(brief + " ", st, lg, imgs))
attempts_used += used2
second = parse_floor_judgment(text2)
if not second.get("degraded") and second.get("imagesSeen") == 0:
second = _degraded_floor("判定模型两掷均自报看不见截图(网关同载荷确定性丢图)——判定仪器故障 fail-closed,待人工复核")
second["imageBlindRetried"] = True
result, text = second, text2
result["model"] = mn
result["frames"] = len(imgs)
if attempts_used > 1:
result["retries"] = attempts_used - 1
# 判定器真相层:附最近响应的 finish_reason(空输出归因的第一手证据;fake 模型无此属性则省略)。
meta = getattr(m, "last_meta", None)
if isinstance(meta, dict) and meta.get("finishReason") is not None:
result["finishReason"] = meta.get("finishReason")
# 判定成本记账(独立档,不污染生成 costRmb)——best-effort。
try:
ti, to = m.usage_sum()
cached = sum(r[2] for r in getattr(m, "records", []) if len(r) > 2)
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
result["costRmb"] = _estimate_judge_cost(mn, ti, to, cached)
except Exception: # noqa: BLE001 成本/观测字段失败不影响判定主体
pass
_persist_judge_json(ev, result, text)
return result
except (asyncio.TimeoutError, TimeoutError):
r = _degraded_floor(f"判定 LLM 超时(>{timeout}s),fail-closed 不放行")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
except Exception as e: # noqa: BLE001 fail-closed:任何异常(网络/import/解析)都判 degraded=拒绝,绝不放行
r = _degraded_floor(f"判定异常:{type(e).__name__}: {e}")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
def _judge_cfg() -> dict:
"""读判定档配置(genconfig judge.*;worker 不可达时回落编译默认,保证无 agentscope 环境也能取值/单测)。"""
try:
from worker import genconfig # noqa: PLC0415
g = genconfig.get
except Exception: # noqa: BLE001 无 worker(纯单测环境)→ 用编译默认
def g(_area, _key, default):
return default
return {
"blocking": bool(g("judge", "blocking", True)),
"model": g("judge", "model", _JUDGE_DEFAULT_MODEL),
"max_tokens": int(g("judge", "max_tokens", 1500)),
"timeout_s": float(g("judge", "timeout_s", 120.0)),
"max_frames": int(g("judge", "max_frames", 6)),
"cost_target_rmb": float(g("judge", "cost_target_rmb", 0.3)),
}
def _writeback_verdict_json(evidence_dir, accepted: bool, judge_summary: dict) -> None:
"""把最终验收权威增量写回盘上 evidence/verdict.json(best-effort,绝不抛)。
字段只加不减:`pass` 物理保留(语义=预筛/机械九门),新增 `accepted`(=预筛∧玩法判定,最终权威)与
`judge` 摘要段。动机(W-AXIS 波2 验收发现):此前判定只进 run-summary→后端 trace,盘上 verdict.json
孤零零 pass=true——证据链与真实验收结论脱节(§6.13 亲眼验收看的就是盘上证据)。verdict.json 不存在则跳过。
"""
try:
p = (Path(evidence_dir) / "verdict.json") if evidence_dir else None
if p is None or not p.is_file():
return
v = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(v, dict):
return
v["accepted"] = bool(accepted)
v["judge"] = judge_summary
p.write_text(json.dumps(v, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: # noqa: BLE001 写回失败绝不连累判定主链
pass
async def apply_gameplay_judge(summary: dict, *, game_id: str, brief: str = "",
model=None, blocking=None, evidence_dir=None,
prefilter=None) -> dict:
"""把玩法地板判定接到 run-summary,落 ok 语义切换(W-AXIS 波2 · SoT 裁定三代码兑现)。
ok 语义(裁定三):`ok = 预筛 ∧ 判定`。verdict 的 `pass` 字段物理保留、语义收窄为「预筛(机械九门)通过」,
新增 `accepted`(= 预筛 ∧ 玩法地板判定)与 `judge` 段(裁决/三类理由/degraded/成本)——字段只加不减。
· 预筛 = 显式 `prefilter` 入参;缺省(None)回落 summary 现有的 ok。**预筛必须是机械九门口径**(裁定一:
driven 时九门全量 AND)——Service 路 summary.ok=九门 judge.passed 天然合规可走缺省;CLI(cheap_studio)路
summary.ok=finished(模型自称收敛),**不是**九门信号,必须显式传 prefilter=finished∧verdict.pass
(2026-07-09 W3 真跑暴露:三门挂的局按 finished 预筛被判定放行成 accepted=True,违裁定三,已修)。
· 判定只对【过筛者】跑(省 ¥:预筛没过的游戏已被机械门拒,不再花判定钱;对齐诊断档 §5「预筛先挡明显死」)。
· blocking=True(默认)→ ok=accepted(判定阻断放行);blocking=False → ok=预筛(判定观测不阻断,整体回退位)。
· fail-closed:证据缺/调用失败/解析失败 → judge.degraded=True → floor 不成立 → (blocking) accepted=False。
绝不抛(顶层兜底):additive 写 summary['accepted'] 与 summary['judge'],并按上式更新 summary['ok']。
blocking/evidence_dir 缺省 None:blocking→读 genconfig;evidence_dir→按 game_id 推 _wg1-gen/<id>/evidence/。
"""
try:
cfg = _judge_cfg()
blk = cfg["blocking"] if blocking is None else bool(blocking)
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
if not prefilter:
# 预筛未过:机械门已拒,不花判定钱。accepted=预筛(False);ok 维持 False。
summary["judge"] = {"ran": False, "blocking": blk,
"reason": "预筛(机械九门)未过,不跑玩法判定"}
summary["accepted"] = False
summary["ok"] = False
_writeback_verdict_json(ev, False, summary["judge"])
return summary
floor = await judge_gameplay_floor(
game_id, brief=brief, model=model, evidence_dir=ev,
model_name=cfg["model"], max_tokens=cfg["max_tokens"],
timeout=cfg["timeout_s"], max_frames=cfg["max_frames"])
floor_ok = bool(floor.get("accepted")) and not bool(floor.get("degraded"))
accepted = prefilter and floor_ok
summary["judge"] = {
"ran": True, "blocking": blk,
"verdict": floor.get("verdict"),
"accepted": bool(floor.get("accepted")),
"rejectClasses": floor.get("rejectClasses"),
"checks": floor.get("checks"),
"degraded": bool(floor.get("degraded")),
"reason": floor.get("reason"),
"notes": floor.get("notes"),
"model": floor.get("model"),
"frames": floor.get("frames"),
"costRmb": floor.get("costRmb"),
"judgeTokens": floor.get("judgeTokens"),
}
summary["accepted"] = accepted
summary["ok"] = accepted if blk else prefilter
# 阻断路且未过:degraded 走可重试类失败因(result_out 缺省 llm_error 即可重试;不覆盖已有 quota_exhausted)。
if blk and not accepted and floor.get("degraded"):
summary.setdefault("failureReason", "llm_error")
# 盘上证据链与最终验收结论对齐(judge.json 由 judge_gameplay_floor 已落;这里补 verdict.json 增量段)。
_writeback_verdict_json(ev, accepted, summary["judge"])
return summary
except Exception as e: # noqa: BLE001 判定接线级异常:fail-closed(blocking→不放行),observe 模式保预筛。
blk = True if blocking is None else bool(blocking)
try:
blk = _judge_cfg()["blocking"] if blocking is None else bool(blocking)
except Exception: # noqa: BLE001
pass
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
summary["judge"] = {"ran": False, "blocking": blk, "degraded": True,
"reason": f"判定接线异常(fail-closed):{type(e).__name__}: {e}"}
summary["accepted"] = False if blk else prefilter
summary["ok"] = False if blk else prefilter
try: # 接线异常路也尽力对齐盘上证据(ev 推导自身可能失败,再兜一层)
_writeback_verdict_json(
evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id),
summary["accepted"], summary["judge"])
except Exception: # noqa: BLE001
pass
return summary
if __name__ == "__main__":
# 便捷自跑:python cheap-worker/cheap_verify.py <gameId> ["brief"] —— 对已有产物真跑一次评分。
import json

View File

@ -0,0 +1,51 @@
{
"schemaVersion": "judge-golden/1",
"_note": "判定金标语料(W-AXIS 波2 校准包)。这五份是 2026-07-09 审计在册的『仅凭 score 单调上升即过九门』样本(诊断档 §2.1),现登记为【待标注种子】。标注归创始人/主会话亲玩定标——不预设它们是反例(双评审明令:判定器对标注为反例的必须判不过、对标注为正例的必须判过,但五份是正是反由亲玩定,不许工具先入为主)。每条 expectReject 待填:true=这局应被判定拒(并在 rejectClasses 标 broken/hollow/off_brief 之一或多),false=应判过,null=未标注(金标复验跳过它、不参与漂移判定)。复验脚本 = cheap-worker/judge_golden.py(check 子命令对已标注金标跑判定、报与标注不符的漂移;list 子命令列清单)。",
"samples": [
{
"gid": "hard-heritage",
"evidenceDir": "game-runtime/games/_wg1-gen/hard-heritage/evidence",
"brief": "一个还原传统榫卯木工的非遗小游戏:按工序节拍(选料→画线→凿卯→修整)逐步操作,每步有节奏判定,完成得成品评分",
"expectReject": null,
"rejectClasses": [],
"labeledBy": null,
"labelNote": "待创始人亲玩定标(审计:firstFeedback=False 也照样 pass;是否真玩得通待亲验)"
},
{
"gid": "hard-trpg",
"evidenceDir": "game-runtime/games/_wg1-gen/hard-trpg/evidence",
"brief": "一个掷骰战斗的地牢爬塔:每层遇一个敌人,点击掷骰(可见骰点)按攻防结算,击败进下一层,失败结算,每3层升级选天赋",
"expectReject": null,
"rejectClasses": [],
"labeledBy": null,
"labelNote": "待创始人亲玩定标(审计:仅 score 0→216 过门、latch 未到终态降 advisory;决策层是否成立待亲验)"
},
{
"gid": "hard-idle-sim",
"evidenceDir": "game-runtime/games/_wg1-gen/hard-idle-sim/evidence",
"brief": "一个放置挂机农场:作物自动生长产出金币,点击收获,金币升级解锁新作物与自动化,有离线收益结算",
"expectReject": null,
"rejectClasses": [],
"labeledBy": null,
"labelNote": "待创始人亲玩定标(放置类核心操作是否含决策、是否切题待亲验)"
},
{
"gid": "c2v-1",
"evidenceDir": "game-runtime/games/_wg1-gen/c2v-1/evidence",
"brief": "一个点击屏幕上不断冒出的星星来得分的小游戏",
"expectReject": null,
"rejectClasses": [],
"labeledBy": null,
"labelNote": "待创始人亲玩定标(反应类薄品类,hollow 边界待亲验)"
},
{
"gid": "c2v-3",
"evidenceDir": "game-runtime/games/_wg1-gen/c2v-3/evidence",
"brief": "一个打地鼠小游戏:点击从洞里冒头的地鼠得分,漏掉太多只就结束",
"expectReject": null,
"rejectClasses": [],
"labeledBy": null,
"labelNote": "待创始人亲玩定标(审计:firstFeedback=False 也照样 pass;终局『漏太多结束』是否可达待亲验)"
}
]
}

View File

@ -0,0 +1,118 @@
"""judge_golden.py — 玩法地板判定的金标校准包读取器 + 复验脚本(W-AXIS 波2 · 裁定三校准纪律)。
这份解决什么:判定器自己会假阳假阴,裁定三③钉死靠【每品类金标正反例复验 + 创始人抽玩定标】治。本模块是那套
校准纪律的工程落点——把金标语料(fixtures/judge-golden/manifest.json)读进来,对【已标注】的金标跑一次判定、
报与标注不符的漂移(rubric 规范三同构:漂移超线先复采样再回退)。
现状(2026-07-09):五份审计在册的 score-only pass 样本登记为【待标注种子】,expectReject 全为 null——
标注归创始人/主会话亲玩定标,本模块【不预设它们是反例】(双评审明令)。故 check 现在会报「0 已标注金标、
5 待标注」;等亲玩把 expectReject 填上,check 才真正复验漂移。
用法:
cheap-worker/.venv/bin/python cheap-worker/judge_golden.py list # 列金标清单(标注状态)
cheap-worker/.venv/bin/python cheap-worker/judge_golden.py check # 对已标注金标真跑判定、报漂移(真花判定¥)
load_golden() 是纯函数(读 manifest、解析、把 evidenceDir 解析成绝对路径),可单测、零网络。
"""
import json
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
_MANIFEST = Path(__file__).resolve().parent / "fixtures" / "judge-golden" / "manifest.json"
def load_golden(manifest_path=None) -> dict:
"""读金标 manifest → {schemaVersion, samples:[{gid, brief, evidenceDir(绝对), expectReject, rejectClasses, ...}]}。
纯函数:把每条 evidenceDir(相对仓根)解析成绝对路径 + 附 evidenceExists 标记(证据在不在盘上)。
manifest 缺失/坏 → 返回 {"schemaVersion": None, "samples": []}(绝不抛,列不出就是空清单)。
"""
p = Path(manifest_path) if manifest_path else _MANIFEST
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 读不到/坏 → 空清单
print(f"[judge-golden] manifest 读取失败(按空清单):{type(e).__name__}: {e}", file=sys.stderr)
return {"schemaVersion": None, "samples": []}
out = {"schemaVersion": data.get("schemaVersion"), "_note": data.get("_note"), "samples": []}
for s in (data.get("samples") or []):
if not isinstance(s, dict) or not s.get("gid"):
continue
ev_rel = s.get("evidenceDir") or ""
ev_abs = (_REPO_ROOT / ev_rel) if ev_rel else None
out["samples"].append({
"gid": s.get("gid"),
"brief": s.get("brief") or "",
"evidenceDir": str(ev_abs) if ev_abs else None,
"evidenceExists": bool(ev_abs and ev_abs.is_dir()),
"expectReject": s.get("expectReject"), # True/False/None(None=未标注)
"rejectClasses": s.get("rejectClasses") or [], # 标注为拒时应命中的类
"labeledBy": s.get("labeledBy"),
"labelNote": s.get("labelNote"),
})
return out
def labeled_samples(golden: dict) -> list:
"""只取【已标注】(expectReject 非 None)的金标——金标复验只对已定标者跑,未标注种子不参与漂移判定。"""
return [s for s in (golden.get("samples") or []) if s.get("expectReject") is not None]
def _cmd_list() -> int:
g = load_golden()
samples = g.get("samples") or []
labeled = labeled_samples(g)
print(f"金标清单(schemaVersion={g.get('schemaVersion')}):{len(samples)} 份,已标注 {len(labeled)} 份,"
f"待标注 {len(samples) - len(labeled)} 份\n")
for s in samples:
state = "待标注" if s["expectReject"] is None else (
f"应拒[{','.join(s['rejectClasses']) or '?'}]" if s["expectReject"] else "应过")
ev = "证据在盘" if s["evidenceExists"] else "⚠证据缺失"
print(f"· {s['gid']:<16} [{state}] {ev} brief={s['brief'][:36]}")
if not labeled:
print("\n(尚无已标注金标——五份为待标注种子,标注归创始人/主会话亲玩定标;check 暂无可复验项。)")
return 0
def _cmd_check() -> int:
"""对已标注金标真跑判定、报漂移(判定器判定 ≠ 金标标注即漂移)。真花判定¥。"""
import asyncio
sys.path.insert(0, str(Path(__file__).resolve().parent))
import cheap_verify # noqa: PLC0415
g = load_golden()
labeled = labeled_samples(g)
if not labeled:
print("无已标注金标可复验(五份待标注种子的 expectReject 均为 null;先由创始人亲玩定标再 check)。")
return 0
async def _run():
drift, total_cost = [], 0.0
for s in labeled:
r = await cheap_verify.judge_gameplay_floor(
s["gid"], brief=s["brief"], evidence_dir=s["evidenceDir"])
got_reject = not bool(r.get("accepted")) # 判定拒 = 未 accept
expect_reject = bool(s["expectReject"])
cost = r.get("costRmb")
if isinstance(cost, (int, float)):
total_cost += cost
ok = (got_reject == expect_reject)
tag = "一致" if ok else "★漂移"
print(f"· {s['gid']:<16} 金标={'拒' if expect_reject else '过'} 判定={'拒' if got_reject else '过'}"
f" rejectClasses={r.get('rejectClasses')} degraded={r.get('degraded')} ¥{cost} [{tag}]")
if not ok:
drift.append(s["gid"])
print(f"\n复验完成:{len(labeled)} 份已标注金标,漂移 {len(drift)} 份{('('+','.join(drift)+')') if drift else ''},"
f"判定总成本 ≈¥{round(total_cost, 4)}")
return 1 if drift else 0
return asyncio.run(_run())
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
if cmd == "check":
sys.exit(_cmd_check())
sys.exit(_cmd_list())

View File

@ -202,6 +202,19 @@ def _build_trace(summary: dict) -> dict | None:
similarity = summary.get("similarity")
if similarity is not None:
trace["similarity"] = similarity
# gameplayJudge(W-AXIS 波2):additive 透传玩法地板判定裁决,给观测线区分「玩法判定失败」类目
# (后端 GenMetrics/OTLP 消费前 Jackson 静默接收、ReadinessScorer 不读 → 无害;消费后据它补 gate 归因)。
# 只带轻量裁决字段(不塞完整 checks/截图):accepted 是最终验收权威,rejectClasses 供 gate 桶归因。
judge = summary.get("judge")
if isinstance(judge, dict):
trace["gameplayJudge"] = {
"accepted": summary.get("accepted"),
"verdict": judge.get("verdict"),
"rejectClasses": judge.get("rejectClasses"),
"degraded": judge.get("degraded"),
"blocking": judge.get("blocking"),
"ran": judge.get("ran"),
}
return trace
@ -226,7 +239,12 @@ def build_result_out(job: dict, summary: dict, game_dir, *,
bundle_text = _read_bundle(game_dir)
verdict = summary.get("verdict") or {}
gates_pass = verdict.get("pass") is True
prefilter_pass = verdict.get("pass") is True
# W-AXIS 波2:验收权威 = accepted(= 预筛 ∧ 独立模型玩法判定,见 cheap_verify.apply_gameplay_judge)。
# summary 无 accepted 键(旧产物 / 对照路 / 判定未跑)→ 回落预筛通过(向后兼容,不误拒);
# 判定拒 / degraded 时 accepted=False → status=failed,判卷合约与既有 succeeded/failed 二分不变。
accepted = summary.get("accepted")
gates_pass = (accepted is True) if accepted is not None else prefilter_pass
engine_ok = bool(bundle_text) and "__GameBundle" in bundle_text
status = "succeeded" if (gates_pass and engine_ok) else "failed"

View File

@ -141,6 +141,9 @@ def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch):
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# W-AXIS 波1:driver 现在 scaffold 前会 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp,
# 否则真驱动会把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。
monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive")
scaffolded = {}
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: scaffolded.update(gid=gid) or {"ok": True, "output": ""})
# richness 非阻塞:stub 成同步返回(避免真 LLM)。
@ -192,11 +195,19 @@ def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch):
json.dumps({"costRmb": 0.5, "rmbGate": "active", "repairs": 1}), encoding="utf-8")
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
# W-AXIS 波2:本用例验的是单 POST 驱动器管路(scaffold/session/verdict/sidecar/costRmb → ok 映射)=机械预筛这一半;
# 玩法地板判定是新增的独立阻断层(无真截图+无判定模型的桩环境跑不了它),故这里关 blocking,让 ok=预筛,
# 隔离出管路本身。判定阻断路由 test_gameplay_judge.py 与真跑 w2a-verify 覆盖。判定仍会 observe-only 跑一遍
# (无截图 → fail-closed degraded、无网络无成本),下面顺带断言 judge 段已 additive 落盘、accepted 如实记 False。
monkeypatch.setenv("TIER2_GEN__JUDGE__BLOCKING", "false")
summary, gdir = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9", "brief": "点球"}))
assert scaffolded["gid"] == "70012", "C2:scaffold 用后端 gameId(str),非 session_id"
cfg = json.loads((tmp_path / "_cheap-sessions" / "sess-9.json").read_text(encoding="utf-8"))
assert cfg["external_game_id"] == "70012", "C2:注册表写 session→后端 gameId 映射"
assert summary["ok"] is True and summary["gameId"] == "70012" and summary["costRmb"] == 0.5
# 判定段 additive 落盘(observe-only:blocking=false 不阻断,但如实记裁决;无截图证据 → fail-closed degraded)。
assert summary["judge"]["blocking"] is False and summary["accepted"] is False
assert summary["judge"]["degraded"] is True
assert gdir == cheap_run.game_dir("70012")
# fix400 主防线锚:session parameters 必带 parallel_tool_calls=False(源头禁并行 call,防 M3 经 new-api
# 并行 call 同 index 拼桶 → 非法 JSON → 孤儿 tool result → 400 code 2013;纵深兜底见 m3_stream_patch)。
@ -238,6 +249,9 @@ def _install_common_stubs(tmp_path, monkeypatch):
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# W-AXIS 波1:driver scaffold 前 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp,
# 否则真驱动把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。
monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive")
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: {"ok": True, "output": ""})
async def _fake_richness(gid, *, brief=""): return {"score": None, "degraded": True, "reason": "stub"}

View File

@ -84,5 +84,87 @@ def test_edit_missing_file_rejected():
assert "不存在" in r["error"]
# ── W-AXIS 波2:锚点容错(空白归一回退 + 最近似片段提示)──────────────────────────────
def test_edit_ws_normalized_indentation_match():
"""空白归一回退:old 与文件只差行内空白/缩进(审计实证 trpg/story 5 连拒的『一字之差』)→ 归一后一次命中并落 new。"""
gd = cheap_run.game_dir(_GID)
# 文件真实缩进(handleTap 块,贴近生成产物)。
real = (
"export function createGame(){\n"
" function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current();\n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();\n"
" return;\n"
" }\n"
" }\n"
" return { _forensicsView(){}, handleTap };\n"
"}\n"
)
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
# 模型手抄的 old:缩进全歪(顶格/2 空格)+ 某行尾随空白 —— 逐字节精确必 0 命中。
old = (
"function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current(); \n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();\n"
" return;\n"
" }"
)
new = (
"function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current();\n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) startGame();\n"
" return;\n"
" }"
)
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), old, new)
assert r["ok"], r
txt = (gd / "src" / "game-logic.js").read_text(encoding="utf-8")
assert "startGame()" in txt and "enterPlay()" not in txt, txt
assert "归一" in r.get("message", ""), r # 明示走了归一路(不静默改语义)
def test_edit_nearest_fragment_hint_on_content_mismatch():
"""内容真不一致(非空白差异,w2a-verify 幻觉式:文件是单行 color:popColor、old 幻觉出多行 tier.mult)→ 精确+归一都不命中 → 报错带最近似片段 + 行号窗口(不做危险模糊匹配)。"""
gd = cheap_run.game_dir(_GID)
real = (
"export function createGame(){\n"
" function spawnPop(star, gain){\n"
" pops.push({ x: star.x, y: star.y, text: popText, color: popColor, age: 0, life: 0.8 });\n"
" }\n"
"}\n"
)
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
old = (
" text: popText,\n"
" color: tier.mult >= 4 ? '#ff5a5a' : (tier.mult >= 3 ? '#ffa500' : COLOR.pop),\n"
" age: 0, life: 0.8,\n"
" });"
)
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), old, "x")
assert not r["ok"]
assert "未找到" in r["error"], r
assert "最接近" in r["error"] and "行" in r["error"], r # 最近似片段 + 行号窗口
# 拒改后文件未变(绝不模糊匹配改错地方)。
assert "color: popColor" in (gd / "src" / "game-logic.js").read_text(encoding="utf-8")
def test_edit_ws_normalized_ambiguous_lists_lines():
"""归一后命中多处(内部多空格致精确 0 命中、归一后与两处同)→ 拒改并列出各命中起始行,提示加长 old。"""
gd = cheap_run.game_dir(_GID)
real = "export function createGame(){\n const a = 1;\n const a = 1;\n}\n"
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "const a = 1;", "const a = 2;") # 内部多空格→精确 0 命中
assert not r["ok"]
assert "无法唯一定位" in r["error"], r
assert "第 2/3 行" in r["error"], r # 列出两处命中起始行
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))

View File

@ -0,0 +1,452 @@
"""
test_gameplay_judge.py — W-AXIS 波2 独立模型玩法地板判定单测(schema / fail-closed / ok 语义切换 / 金标读取器)。
三层,全部零真网络(fake model 注入 + 注入证据 + tmp 证据目录):
① parse_floor_judgment 纯函数:accept/reject 三类解析、LLM verdict 覆盖、裸 bool 容忍、缺字段/坏 JSON → fail-closed degraded。
② judge_gameplay_floor 契约:无证据 → fail-closed;fake 模型 accept/异常/超时 → 结构正确;成本记账字段。
③ apply_gameplay_judge ok 语义:预筛未过不跑判定;accept∧blocking→ok=accepted;reject/degraded∧blocking→fail-closed;
blocking=false→观测不阻断(ok=预筛)。外加金标读取器 load_golden(五份待标注种子)与 _collect_frames 排序/采样。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_gameplay_judge.py -q
或:cheap-worker/.venv/bin/python cheap-worker/tests/test_gameplay_judge.py
"""
import asyncio
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_verify # noqa: E402
import judge_golden # noqa: E402
# ── fake 判定模型(async 可调用;默认无 usage_sum,使 judge 的成本块走 except、零网络)──
class _FakeResp:
def __init__(self, text):
self.content = text
class _FakeJudge:
"""返回固定 JSON / 抛异常 / 睡眠(测超时)。默认不带 usage_sum(成本块 AttributeError→跳过,零网络)。"""
def __init__(self, text="", raise_exc=None, sleep=0.0):
self._text, self._raise, self._sleep = text, raise_exc, sleep
async def __call__(self, messages, **kw):
if self._sleep:
await asyncio.sleep(self._sleep)
if self._raise:
raise self._raise
return _FakeResp(self._text)
class _FakeJudgeWithUsage(_FakeJudge):
"""带 usage_sum + records 的 fake(测成本记账字段流转)。"""
records = [(1000, 200, 100)]
def usage_sum(self):
return (1000, 200)
def _accept_json():
return json.dumps({"broken": {"problem": False, "why": "能玩通"},
"hollow": {"problem": False, "why": "有决策"},
"offBrief": {"problem": False, "why": "切题"},
"verdict": "accept", "notes": "地板成立"}, ensure_ascii=False)
def _reject_json(cls="hollow"):
node = {"broken": {"problem": False, "why": "x"},
"hollow": {"problem": False, "why": "x"},
"offBrief": {"problem": False, "why": "x"}}
key = {"broken": "broken", "hollow": "hollow", "off_brief": "offBrief"}[cls]
node[key] = {"problem": True, "why": "有问题"}
node["verdict"] = "reject"
node["notes"] = "地板不成立"
return json.dumps(node, ensure_ascii=False)
def _tmp_evidence(with_first=True, mids=0, with_after=True):
"""建 tmp 证据目录 + 写占位 png(内容随意,_collect_frames 只 base64 字节)。返回目录路径。"""
d = Path(tempfile.mkdtemp())
if with_first:
(d / "first-paint.png").write_bytes(b"\x89PNG-first")
for i in range(1, mids + 1):
(d / f"midplay-{i}.png").write_bytes(b"\x89PNG-mid%d" % i)
if with_after:
(d / "after-play.png").write_bytes(b"\x89PNG-after")
return str(d)
# ───────────────────────── ① parse_floor_judgment 纯函数 ─────────────────────────
def test_parse_accept():
r = cheap_verify.parse_floor_judgment(_accept_json())
assert r["degraded"] is False
assert r["accepted"] is True and r["verdict"] == "accept"
assert r["rejectClasses"] == []
assert len(r["checks"]) == 3 and {c["class"] for c in r["checks"]} == {"broken", "hollow", "off_brief"}
def test_parse_reject_hollow():
r = cheap_verify.parse_floor_judgment(_reject_json("hollow"))
assert r["degraded"] is False
assert r["accepted"] is False and r["verdict"] == "reject"
assert r["rejectClasses"] == ["hollow"]
def test_parse_reject_broken_and_offbrief():
node = {"broken": {"problem": True, "why": "b"}, "hollow": {"problem": False, "why": "x"},
"offBrief": {"problem": True, "why": "o"}, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False
assert set(r["rejectClasses"]) == {"broken", "off_brief"}
def test_parse_llm_verdict_reject_overrides():
"""三类 problem 都 false 但 LLM verdict=reject → 取交后仍判 reject(fail-closed 偏严,任一拒信号即拒)。"""
node = {"broken": {"problem": False}, "hollow": {"problem": False},
"offBrief": {"problem": False}, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False and r["rejectClasses"] == []
def test_parse_bare_bool_tolerance():
"""模型偷懒直接给裸 bool(非 {problem,why})也接住。"""
node = {"broken": False, "hollow": True, "offBrief": False, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False and r["rejectClasses"] == ["hollow"]
def test_parse_garbage_degraded():
r = cheap_verify.parse_floor_judgment("完全不是 JSON")
assert r["degraded"] is True and r["accepted"] is False and r["rejectClasses"] == ["degraded"]
def test_parse_missing_class_degraded():
"""缺任一类判定字段 → 无法确认地板 → fail-closed degraded(不静默判过)。"""
r = cheap_verify.parse_floor_judgment(json.dumps({"broken": {"problem": False}, "verdict": "accept"}))
assert r["degraded"] is True and r["accepted"] is False
def test_parse_none_empty_degraded():
assert cheap_verify.parse_floor_judgment(None)["degraded"] is True
assert cheap_verify.parse_floor_judgment("")["degraded"] is True
assert cheap_verify.parse_floor_judgment(" ")["degraded"] is True
def test_parse_fenced_json():
"""markdown 围栏 + 赘语 → json_repair 兜住。"""
text = "评定如下:\n```json\n" + _accept_json() + "\n```"
r = cheap_verify.parse_floor_judgment(text)
assert r["degraded"] is False and r["accepted"] is True
# ───────────────────────── ② judge_gameplay_floor 契约 ─────────────────────────
def test_judge_no_evidence_failclosed():
"""无截图证据(注入空 frames)→ fail-closed degraded,不放行,且未调用模型。"""
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="b", frames=[], model=_FakeJudge(raise_exc=RuntimeError("不该被调用"))))
assert r["degraded"] is True and r["accepted"] is False
assert "证据缺失" in r.get("reason", "")
def test_judge_happy_accept():
"""注入 frames + fake accept → 结构化 accept、model/frames 观测。"""
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="b", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text=_accept_json())))
assert r["degraded"] is False and r["accepted"] is True
assert r["model"] == cheap_verify._JUDGE_DEFAULT_MODEL and r["frames"] == 1
def test_judge_reject_from_model():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text=_reject_json("broken"))))
assert r["accepted"] is False and r["rejectClasses"] == ["broken"]
def test_judge_model_raises_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(raise_exc=RuntimeError("boom"))))
assert r["degraded"] is True and r["accepted"] is False
def test_judge_timeout_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"],
model=_FakeJudge(text=_accept_json(), sleep=0.3), timeout=0.05))
assert r["degraded"] is True and r["accepted"] is False
def test_judge_cost_accounting():
"""带 usage 的 fake + 打桩 _estimate_judge_cost → judgeTokens/costRmb 落进结果(零网络)。"""
orig = cheap_verify._estimate_judge_cost
cheap_verify._estimate_judge_cost = lambda mn, ti, to, cached: 0.087
try:
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudgeWithUsage(text=_accept_json())))
finally:
cheap_verify._estimate_judge_cost = orig
assert r["judgeTokens"] == {"in": 1000, "out": 200, "total": 1200}
assert r["costRmb"] == 0.087
# ───────────────────────── ③ apply_gameplay_judge ok 语义切换 ─────────────────────────
def test_apply_prefilter_false_no_judge():
"""预筛未过 → 不跑判定、不花¥;accepted=False、ok=False、judge.ran=False。"""
s = {"ok": False, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(raise_exc=RuntimeError("不该被调用")), blocking=True))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["ran"] is False
def test_apply_accept_blocking_ok_true():
"""预筛过 + 判定 accept + blocking → ok=accepted=True。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()), blocking=True, evidence_dir=ev))
assert out["accepted"] is True and out["ok"] is True
assert out["judge"]["ran"] is True and out["judge"]["verdict"] == "accept"
def test_apply_reject_blocking_ok_false():
"""预筛过 + 判定 reject + blocking → ok=accepted=False(阻断放行)。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("hollow")), blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["rejectClasses"] == ["hollow"]
def test_apply_degraded_blocking_failclosed():
"""预筛过 + 证据缺失(空目录)→ 判定 degraded → fail-closed → ok=False + failureReason 可重试。"""
ev = _tmp_evidence(with_first=False, with_after=False) # 空目录:无截图
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()), blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["degraded"] is True
assert out.get("failureReason") == "llm_error" # degraded → 可重试类
def test_apply_reject_nonblocking_observe():
"""预筛过 + 判定 reject + blocking=false → ok=预筛(True,不阻断);accepted 仍记 False(观测/整体回退位)。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("broken")), blocking=False, evidence_dir=ev))
assert out["ok"] is True # 观测模式:不阻断,ok 维持预筛
assert out["accepted"] is False # 判定裁决仍如实记(供观测/校准)
assert out["judge"]["blocking"] is False and out["judge"]["verdict"] == "reject"
# ───────────────────────── _collect_frames 排序/采样 + 金标读取器 ─────────────────────────
def test_collect_frames_order_and_cap():
"""顺序 first-paint → midplay-1..N → after-play;超 max_frames 保头尾均匀采样。"""
ev = _tmp_evidence(mids=8) # first + 8 mid + after = 10 张
uris = cheap_verify._collect_frames(ev, max_frames=4)
assert len(uris) == 4 # 头 + 2 采样 + 尾
# 头是 first-paint、尾是 after-play(解 base64 尾核对内容标记)
import base64 as _b64
head = _b64.b64decode(uris[0].split(",", 1)[1])
tail = _b64.b64decode(uris[-1].split(",", 1)[1])
assert head == b"\x89PNG-first" and tail == b"\x89PNG-after"
def test_collect_frames_natural_sort():
"""midplay-2 排在 midplay-10 之前(自然排序,非字典序)。"""
ev = _tmp_evidence(mids=12)
uris = cheap_verify._collect_frames(ev, max_frames=20) # 全收
assert len(uris) == 14 # first + 12 mid + after
import base64 as _b64
# 第 2 张(index1)应是 midplay-1
assert _b64.b64decode(uris[1].split(",", 1)[1]) == b"\x89PNG-mid1"
def test_load_golden_five_unlabeled_seeds():
"""金标 manifest:五份 score-only 样本登记为待标注种子(expectReject 全 null,不预设反例)。"""
g = judge_golden.load_golden()
assert g["schemaVersion"] == "judge-golden/1"
gids = [s["gid"] for s in g["samples"]]
assert gids == ["hard-heritage", "hard-trpg", "hard-idle-sim", "c2v-1", "c2v-3"]
assert all(s["expectReject"] is None for s in g["samples"]), "五份必须都未标注(双评审明令:不预设反例)"
assert judge_golden.labeled_samples(g) == [], "无已标注金标(待创始人亲玩定标)"
for s in g["samples"]:
assert s["brief"] and s["evidenceDir"] # 清单齐全
# ───────────────────── ⑤ 空输出有界重试 + 判定真相层落盘(W-AXIS 波2 验收补,2026-07-09)─────────────────────
# 背景:w2b-verify 真跑判定 degraded「判定输出为空」——glm-5.2 带思考,思考长度随机,吃光 max_tokens 时
# content 为空;主会话同证据复现即得答案 → 修=空输出重试一次(重试前放大 max_tokens)+ 判定落盘 evidence/。
class _FakeJudgeEmptyThenOk(_FakeJudge):
"""第一次返回空(模拟思考吃光 max_tokens),第二次返回合法 JSON——测有界重试恢复。"""
def __init__(self, ok_text):
super().__init__(text=ok_text)
self.calls = 0
self.max_tokens = 1500
async def __call__(self, messages, **kw):
self.calls += 1
if self.calls == 1:
return _FakeResp("")
return _FakeResp(self._text)
def test_judge_empty_retry_once_recovers():
m = _FakeJudgeEmptyThenOk(_accept_json())
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2, "空输出应重试一次(共两次调用)"
assert r["accepted"] is True and r["degraded"] is False
assert r.get("retries") == 1
assert m.max_tokens == 3000, "重试前应放大 max_tokens(思考吃光预算的对症解)"
def test_judge_empty_twice_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text="")))
assert r["degraded"] is True and r["accepted"] is False
assert "判定输出为空" in r["reason"]
def test_judge_persists_judge_json():
ev = _tmp_evidence()
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", evidence_dir=ev, model=_FakeJudge(text=_accept_json())))
assert r["accepted"] is True
jp = Path(ev) / "judge.json"
assert jp.is_file(), "判定必须在证据目录落 judge.json(判定器自身真相层)"
data = json.loads(jp.read_text(encoding="utf-8"))
assert data["accepted"] is True and data["verdict"] == "accept"
assert "rawTextHead" in data and data["ts"] > 0
def test_apply_explicit_prefilter_overrides_summary_ok():
"""显式 prefilter 参数必须压过 summary.ok(CLI 路修复,2026-07-09 W3 真跑暴露):
summary.ok=True(finished)但九门挂 → prefilter=False → 判定不跑、accepted=False。"""
s = {"ok": True}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(raise_exc=RuntimeError("预筛未过不该调判定")),
blocking=True, prefilter=False))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["ran"] is False and "预筛" in out["judge"]["reason"]
def test_apply_explicit_prefilter_true_runs_judge():
"""prefilter=True 显式给真(即便 summary.ok=False 的殊形)→ 判定照跑,ok=判定结果。"""
s = {"ok": False}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()),
blocking=True, prefilter=True, evidence_dir=_tmp_evidence()))
assert out["accepted"] is True and out["ok"] is True and out["judge"]["ran"] is True
def test_apply_writes_back_verdict_json():
ev = _tmp_evidence()
(Path(ev) / "verdict.json").write_text(
json.dumps({"pass": True, "guards": {}}), encoding="utf-8")
s = {"ok": True}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("hollow")),
blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
v = json.loads((Path(ev) / "verdict.json").read_text(encoding="utf-8"))
assert v["pass"] is True, "pass 物理保留(语义=预筛通过)"
assert v["accepted"] is False, "accepted=最终权威(判定拒)"
assert v["judge"]["verdict"] == "reject" and v["judge"]["rejectClasses"] == ["hollow"]
# ───────────────────── ⑥ 图像盲微扰重掷(n=5 基线终审补,2026-07-09)─────────────────────
# 背景:网关对 glm-5.2 按请求体确定性路由,部分载荷永远落在丢图通道——判定 JSON 合法却自报看不见截图,
# 把好游戏错杀成 hollow/off_brief(基线 3/20 假阴)。同载荷复判 3/3 仍盲;brief 尾加一空格微扰即换路由当场看见。
# 修:输出契约加 imagesSeen 自报字段;送帧而自报 0 → 微扰重掷一次;两掷均盲 → 仪器故障 degraded。
def _accept_json_seen(n):
"""带 imagesSeen 自报的 accept JSON(n=模型自称看到的截图张数)。"""
d = json.loads(_accept_json())
d["imagesSeen"] = n
return json.dumps(d, ensure_ascii=False)
class _FakeJudgeBlindThenSeen(_FakeJudge):
"""第一掷自报 imagesSeen=0(模拟丢图通道),第二掷自报 6——测微扰重掷恢复;记每掷收到的 brief 头文本。"""
def __init__(self):
super().__init__()
self.calls = 0
self.brief_heads = []
async def __call__(self, messages, **kw):
self.calls += 1
# 记录 user 消息首个 text 块的头部(含 brief),供断言两掷载荷确实不同(微扰生效)
parts = messages[-1]["content"]
self.brief_heads.append(parts[0]["text"][:80])
return _FakeResp(_accept_json_seen(0 if self.calls == 1 else 6))
def test_parse_images_seen_passthrough():
assert cheap_verify.parse_floor_judgment(_accept_json_seen(6))["imagesSeen"] == 6
assert cheap_verify.parse_floor_judgment(_accept_json_seen(0))["imagesSeen"] == 0
assert cheap_verify.parse_floor_judgment(_accept_json())["imagesSeen"] is None, "缺字段=未知,不触发盲重掷"
bad = json.loads(_accept_json()); bad["imagesSeen"] = "abc"
assert cheap_verify.parse_floor_judgment(json.dumps(bad, ensure_ascii=False))["imagesSeen"] is None
bad["imagesSeen"] = True # bool 不算数字(True==1 的坑),按未知处理
assert cheap_verify.parse_floor_judgment(json.dumps(bad, ensure_ascii=False))["imagesSeen"] is None
def test_judge_image_blind_retry_recovers():
m = _FakeJudgeBlindThenSeen()
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="掷骰爬塔", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2, "自报 0 张应微扰重掷一次"
assert m.brief_heads[0] != m.brief_heads[1], "第二掷载荷必须与第一掷不同(微扰换网关路由)"
assert r["accepted"] is True and r["degraded"] is False
assert r.get("imageBlindRetried") is True and r.get("imagesSeen") == 6
assert r.get("retries") == 1
def test_judge_image_blind_twice_degraded():
m = _FakeJudge(text=_accept_json_seen(0)) # 两掷都自报看不见
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="掷骰爬塔", frames=["data:image/png;base64,AAAA"], model=m))
assert r["degraded"] is True and r["accepted"] is False
assert r["rejectClasses"] == ["degraded"]
assert "看不见截图" in r["reason"], "归因必须是判定仪器故障,不是游戏证据缺失"
assert r.get("imageBlindRetried") is True
def test_judge_images_seen_positive_no_extra_call():
m = _FakeJudgeBlindThenSeen()
m.calls = 1 # 让首掷直接返回 seen=6(复用 fake:calls 从 2 起)
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2 and len(m.brief_heads) == 1, "自报看得见 → 不重掷"
assert r["accepted"] is True and r.get("imageBlindRetried") is None
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0
for _fn in _fns:
try:
_fn()
print(f" PASS {_fn.__name__}")
except AssertionError as e:
_failed += 1
print(f" FAIL {_fn.__name__}: {e}")
except Exception as e: # noqa: BLE001
_failed += 1
print(f" ERROR {_fn.__name__}: {type(e).__name__}: {e}")
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)

View File

@ -1,6 +1,6 @@
---
id: config.cheap-system
version: 1.6.2
version: 1.6.3
---
你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS),落在 ⟦G⟧/。
@ -27,13 +27,13 @@ version: 1.6.2
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
能跑 ≠ 好玩。动手写码前,先据 brief 在 **game-logic.js 顶部写一段设计注释块**(≤8 行:⑨ 判定句三个空填好 + 核心数值锚 + ⑩ 的三段难度)定下**轻量玩法设计**,再实现——设计只在心里 = 收尾自查与验收都无从对照:
- **核心循环(好玩第一因 · 必含一层玩家决策或技巧)**:写清「玩家每次操作做**什么决策** → 得什么即时反馈 → 怎么变强」。**铁律:核心操作绝不能是「点了自动结算」的无脑点击**,必须让真人有发挥——三选一起步:**匹配**(顾客点指定菜、上对的才给分/给多分)、**时机**(在对的时刻操作 = 更多奖励)、**取舍/连击**(连续做对叠 combo、资源有限要权衡)。自检:把玩家换成「闭眼乱点」——若分数照样拿满,这循环就是无趣的,重设计。
- **决策层与自动验收解耦(工程现实,别绕开)**:九门自动验收会盲点 `occupied:true` 目标、判 score 涨,所以做成**两层奖励**——**基础分**(任意有效操作都加一点,保盲驱动器能跑通过门)+ **技巧分**(叠在玩家的匹配/时机/连击上,真人靠它玩出爽感)。盲驱动器拿基础分过门、真人靠技巧拿高分,两不耽误。**别为「让驱动器满分」把玩法做成无脑点;也别「不做对就零分」让驱动器过不了门。**
- **两层反馈:基础反馈保上手、技巧分给深度(好玩的设计惯例)**:**基础反馈**——任意有效操作都给一点即时回应(分数/音效/飘字),让新手一上手就有正反馈、不至于无所适从;**技巧分**——叠在玩家的匹配/时机/连击上,做对越多越准回报越高,让熟练玩家玩出掌控感与爽感。两层叠着来:随便玩也有反馈、不劝退新手,玩得好有额外回报、给足深度。**别把玩法做成「怎么点都一样」的无脑点(那样技巧分名存实亡);也别「不做对就颗粒无收」(那样没有上手缓冲、劝退新手)。**
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **3–4 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic;宁可程序化也别没有反馈音)。
定完过一遍 **10 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈 ⑨**核心操作非无脑**〔每次主操作有真实的决策/技巧含量,不是点了自动结算〕 ⑩**难度有曲线**〔一局内铺垫→拉紧→收束,张力持续制造「差一点」;前 10 秒即玩即教、零阅读〕)——**⑨ 是否决项:⑨ 不命中,其余九条全中也只是「有元素的无趣游戏」**(实测一款 6/8 命中却不好玩,正死在 ⑨);⑨ 命中的前提下,其余命中越多越好玩。**⑨ 的判定句式(设计完对着念)**:「玩家在__时要判断__,判错则__」——三个空都填得出、且第三个空是真代价(少得分/丢单/断连击),⑨ 才算命中。正例:顾客点了豆浆,上对 +8、上错顾客皱眉扣耐心——有判断、错有代价。反例:点任意顾客都 +5、点错零损失——无判断无代价,只是点击计数器。
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面两层奖励解耦)。
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面基础反馈+技巧分两层)。
【MVP-first 铁律(钉死·关乎你能不能收敛,别一稿堆满)】
设计太满 → 你实现负担过重 → read/write 反复跳、跑不收敛(实测:满配设计循环截停、精简设计 9 步收敛)。首版**只做可玩核心**:核心循环 + 1 个主机制 + 1 个资源环(进货)+ 3–4 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版(hitstop/震屏**不在**禁项——它们是单行 juice 调用不是系统:结算/暴击/大额成单时刻标配一次 hitStop + 轻 shake,好手感不算堆料)。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
@ -61,7 +61,7 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
- 判据 = check PASS + build PASS(本产线**不跑** README 里的 node --test)。**check 与 build 都 PASS 后,过一遍下条收尾自查,随即调 finish**(summary 一句话)——自查是 30 秒对照,不是无限打磨的许可。
- **收尾自查(finish 前对照设计注释块念一遍)**:⑨ 判定句的三个空是否真映射到代码路径(判断点与代价都在逻辑里,不只在注释里);⑩ 的三段难度是否落在数值上;结算/暴击时刻的 juice 反馈是否在。**check/build 只是地板**——好玩以 10 条自检与品类 rubric 为准,别拿「能跑」当完成线。
- **别做**:别写/改任何 test/ 文件、别写额外脚本、别加 brief/README 没要求的东西。
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**(整文件重写在弱模型上易漏 `path` 参数、连撞几次会被熔断打断)。edit_file 的 `old` 必须逐字节匹配且唯一,找不到/不唯一它会明确报错,按提示加长 old 再试。
- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按去注释后的纯文本正则命中,**把裸调封装进函数内部照样被拦**(别信"函数里就不拦");需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。
- 你有 read_file / list_dir / write_file / **edit_file** / check / build / finish 七个工具。**小改用 edit_file**(改一个函数、几行:给 `path` + 精确复制文件原文当 `old` + `new`,参数短、不易漏字段),**整文件才用 write_file**。修一条红线、改一个数值这类小修**一律优先 edit_file,别动辄整文件重写**。edit_file 的 `old` 尽量逐字节复制文件原文(含缩进)以求唯一命中;即便只差行内空白/缩进它也会归一后再定位,找不到/不唯一会明确报错并附最近似片段与行号,照提示加长 old 或改用 write_file。
- **rng/nowMs 照 `_template` 起点逐字克隆**:起点的 `rng()` 回退是 `ctx ? ctx.random.next() : 0`(裸回退给 `0`)——**照抄,绝不自造 `Math.random()`/`Date.now()` 防御回退**。check 按词法真实命中(字符串/注释里的同名不误报、函数内与模板插值里的裸调照样命中),报错带行号照改即可;需要时间源走 `ctx.time.nowMs()`、别裸 `Date.now()`。
现在开始:**先 read_file 读手册与起点 game-logic.js,别直接写码。先写一版最小可玩核心 → 立即 check(趁文件小、红线好定位好小修,用 edit_file 修到 PASS)→ 再扩玩法**;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。

View File

@ -176,7 +176,7 @@ prompts:
# 首版正文 = _SYSTEM_PROMPT 内置原文逐字节对齐(含 ⟦G⟧/⟦SCAFFOLD_DESC⟧ 占位符),默认行为字节不变;
# 调一条 prompt = 改 04-config/cheap-system.md 正文 + 升 version,下次生成自动生效。
- id: config.cheap-system
version: 1.6.2 # v1.6.2:正文补 edit_file 工具契约(七工具/小改优先 edit_file、别整文件重写)——修热取源停在「六工具/write_file 整体覆盖」的 stale 欠账,治大文件整写截断(便宜档死圈病根之一)。v1.6.1:起局鲁棒性 + targets 键契约进红线——①起局/交互按钮命中矩形必须在状态进入点 onEnter 确定性建立、render 只画(治并发 rAF 饥饿下卡 menu)②点目标玩法 state 键名固定 `targets`(驱动器契约 C5 依赖,改名=盲驱动器选错族=H 门挂)。v1.6.0:打磨不降档(创始人 07-04 定调「档位切 scope 不切 polish」)——删 spike「能跑能玩即可」残留、hitstop 出禁项改结算标配、设计块强制落盘+finish 前收尾自查、新增 ⑩ 难度曲线、⑨ 经营决策模式指针。v1.5.0:策划知识包 v2——⑨ 否决项加判定句式+内嵌正反例;8/9 条计数口径统一;prompt.mjs 冻结为 A/B 史料(不再双源同改)。v1.4.0:W-GENRE 品类路由并集(剧情+6/TRPG+7/非遗+8/解谜+9)
version: 1.6.3 # v1.6.3(W-AXIS 波2 拆残笼):①「两层奖励」改纯游戏设计语(基础反馈保上手+技巧分给深度)、删「围盲驱动器/绕正则」措辞;②「check 正则命中」改「词法真实命中、报错带行号」(红线判定换词法);③删整文件重写熔断恐吓、edit_file 补锚点容错说明(空白归一/近似片段)。v1.6.2:正文补 edit_file 工具契约(七工具/小改优先 edit_file、别整文件重写)——修热取源停在「六工具/write_file 整体覆盖」的 stale 欠账,治大文件整写截断(便宜档死圈病根之一)。v1.6.1:起局鲁棒性 + targets 键契约进红线——①起局/交互按钮命中矩形必须在状态进入点 onEnter 确定性建立、render 只画(治并发 rAF 饥饿下卡 menu)②点目标玩法 state 键名固定 `targets`(驱动器契约 C5 依赖,改名=盲驱动器选错族=H 门挂)。v1.6.0:打磨不降档(创始人 07-04 定调「档位切 scope 不切 polish」)——删 spike「能跑能玩即可」残留、hitstop 出禁项改结算标配、设计块强制落盘+finish 前收尾自查、新增 ⑩ 难度曲线、⑨ 经营决策模式指针。v1.5.0:策划知识包 v2——⑨ 否决项加判定句式+内嵌正反例;8/9 条计数口径统一;prompt.mjs 冻结为 A/B 史料(不再双源同改)。v1.4.0:W-GENRE 品类路由并集(剧情+6/TRPG+7/非遗+8/解谜+9)
stage: "04-config"
owner: WS2
file: 04-config/cheap-system.md

View File

@ -976,6 +976,32 @@ async function measureFirstPlay(connectFn, url, cdpHttp, spec, category, opts) {
return out;
}
/**
* W-AXIS 波2:真玩窗口内的「局中连拍」——给独立模型玩法地板判定看局中画面。
* first-paint 是局前、after-play 是局末,中间过程此前没有任何视觉证据;这里在真玩进行时并发连拍 4-6 帧
* (midplay-1.png…),补上「局中」这一段。与 runDriver 共享同一 cdp 会话是安全的:CdpSession 按 msg.id
* 匹配应答(并发请求各按 id 归位),且 Page.captureScreenshot 是只读、不改游戏态——**不进任何门判据、不改
* 任何 guard 输入**(C_frame/frameDelta 在真玩窗口前已测完,E_live/G_input 不取连拍帧)。best-effort:单帧
* 截图失败静默吞,整段连拍失败也绝不连累真玩与门判定。
* 返回 { stop, done, shot }:调用方在真玩窗口结束后置 stop=true 并 await done,收束连拍(不越界拍到局末之后)。
*/
function startMidplayBurst(cdp, evDir, opts) {
const count = (opts && opts.count) || 5; // 目标帧数(4-6);驱动早停时实拍可能更少
const intervalMs = (opts && opts.intervalMs) || 450;
const state = { stop: false, shot: 0, done: null };
state.done = (async () => {
for (let i = 0; i < count && !state.stop; i++) {
await delay(intervalMs);
if (state.stop) break;
try {
await saveScreenshot(cdp, path.join(evDir, `midplay-${i + 1}.png`));
state.shot++;
} catch (_) { /* 连拍 best-effort:单帧失败绝不影响真玩与门判定 */ }
}
})();
return state;
}
async function main() {
const argv = process.argv.slice(2);
const gameId = argv[0];
@ -1048,6 +1074,9 @@ async function main() {
// 真玩:有 driver 走【适配性驱动】(读 state 自动接球,真玩技巧游戏,解盲打假阴性);否则走固定输入序列。每步采帧哈希供守卫E。
const hashes = [px0.hash];
// W-AXIS 波2:真玩进行时并发开「局中连拍」(midplay-*.png,供玩法地板判定看局中画面)。只读截图、不进门判据;
// 真玩窗口结束后置 stop 并 await 收束,绝不越界拍到 after-play 之后。
const midplay = startMidplayBurst(cdp, evDir, { count: 5, intervalMs: 450 });
if (spec.driver) {
await runDriver(cdp, spec.driver, hashes);
} else {
@ -1061,6 +1090,8 @@ async function main() {
}
}
await delay(250);
midplay.stop = true; // 收束局中连拍(真玩窗口已结束,后续是 after-play)
try { await midplay.done; } catch (_) {}
// 守卫D:真渲染(输入后再测,取较优)。
const px1 = await brightPixels(cdp, '#game-engine');

View File

@ -11,7 +11,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { _bannedLineErrors } from '../tools.mjs';
import { _bannedLineErrors, _bootCtxLine, _getInputLine, lexTokens } from '../tools.mjs';
/** 是否报了某红线(按正则 source 关键片段匹配)。 */
const hasBanned = (errs, frag) => errs.some((e) => e.includes(frag));
@ -134,3 +134,48 @@ test('⑤ 字符串内的 // 后接注释形态的文本零误杀', () => {
const errs = _bannedLineErrors(src, 'game-logic.js');
assert.equal(errs.length, 0, `串内 // 与 API 名都不该报,后续合规码也不该被误伤:${JSON.stringify(errs)}`);
});
// ───────── ⑥ 词法真实版新增能力(W-AXIS 波2:模板插值裸调 / boot.ctx / getInput 行号)─────────
test('⑥ 模板插值 ${} 内的裸调照样命中(词法真实,非纯文本近似)', () => {
// 旧 stripCode 把整段模板(含 ${})抹成空白 → 插值里的 Math.random 被漏报;词法版把 ${} 内按码扫描 → 命中。
const src = [
'export function createGame(){', // 1
' const label = `种子:${Math.random()}`;', // 2 插值内真实裸调
' return { _forensicsView(){}, handleTap(){} };', // 3
'}', // 4
].join('\n');
const errs = _bannedLineErrors(src, 'game-logic.js');
assert.ok(errs.some((e) => e.includes('Math\\.random')), `模板插值内的裸调应命中:${JSON.stringify(errs)}`);
assert.ok(errs.some((e) => e.includes('第 2 行')), `应报第 2 行:${JSON.stringify(errs)}`);
});
test('⑥ 模板纯文本(非插值)里的 API 名不误命中', () => {
const src = 'export function createGame(){\n const tip = `别写 Math.random() 或 Date.now()`;\n const r = ctx.random.next();\n return { _forensicsView(){}, handleTap(){} };\n}';
const errs = _bannedLineErrors(src, 'game-logic.js');
assert.equal(errs.length, 0, `模板纯文本里的 API 名不该报:${JSON.stringify(errs)}`);
});
test('⑥ _bootCtxLine:.boot.ctx 双层误用命中并给行号,正确 boot.ctx 零命中', () => {
const bad = 'export function createGame(){\n init(boot){ const ctx = boot.boot.ctx; }\n}';
assert.equal(_bootCtxLine(bad), 2, '.boot.ctx 应命中第 2 行');
const good = 'export function createGame(){\n init(boot){ const ctx = boot.ctx; ctx.log("t","x"); }\n}';
assert.equal(_bootCtxLine(good), 0, '正确 boot.ctx 不该命中');
// 字符串里的 .boot.ctx 文案不误命中
const strCase = 'export function createGame(){\n const tip = "别写 x.boot.ctx";\n}';
assert.equal(_bootCtxLine(strCase), 0, '字符串里的 .boot.ctx 不该命中');
});
test('⑥ _getInputLine:getInput( 命中并给行号,字符串里的 getInput 不误命中', () => {
const bad = 'export function createGame(){\n update(dt){ const i = ctx.getInput(); }\n}';
assert.equal(_getInputLine(bad), 2, 'getInput( 应命中第 2 行');
const strCase = 'export function createGame(){\n const tip = "不要调 getInput()";\n}';
assert.equal(_getInputLine(strCase), 0, '字符串里的 getInput 不该命中');
});
test('⑥ lexTokens:字符串/注释不产 token,码区标识符产 token', () => {
const toks = lexTokens('const a = "Math.random"; // Date.now\n foo.bar');
const ids = toks.filter((t) => t.k === 'id').map((t) => t.v);
assert.ok(ids.includes('const') && ids.includes('a') && ids.includes('foo') && ids.includes('bar'), JSON.stringify(ids));
assert.ok(!ids.includes('Math') && !ids.includes('random') && !ids.includes('Date') && !ids.includes('now'), `字符串/注释内的名不该成 token:${JSON.stringify(ids)}`);
});

View File

@ -24,6 +24,7 @@ import {
import { resolve, dirname, relative, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { scanUndefinedIdentifiers, scanImportChain } from './check-undef.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -145,7 +146,7 @@ const BANNED = [
* 单遍状态机逐字符扫,同一时刻只处于「码/行注释/块注释/单引/双引/模板」一种态,嵌套的 `//`、引号各归其态、
* 绝不越界——这是「注释与字符串互嵌」这类词法问题唯一正确的解法。逐字节保长、换行保留(行号对齐不变)。
*/
function stripCode(s) {
export function stripCode(s) {
const out = [];
const n = s.length;
let state = 'code'; // code | line | block | sq(单引) | dq(双引) | tpl(模板)
@ -266,20 +267,142 @@ export function _shapeGateErrors(rawSrc, f) {
return errs;
}
// ───────────────────────── 词法真实红线判定(W-AXIS 波2)─────────────────────────
// 动机(诊断档 §2.1/§2.4):红线八条与 .boot.ctx/getInput 的命中判定原本是「stripCode 抹字符串/注释 → 对纯文本跑
// 正则」。stripCode 修好后(0be0f61c 单遍状态机)字符串内 // 已不 desync,但「对纯文本跑正则」仍是近似:token 间
// 夹空白/注释(Math./*c*/random())、模板插值内的裸调(`${Math.random()}`)靠正则都不牢。改为真词法扫描——
// 字符串/模板文本/注释一律不产 token(其内的 // 与 API 名天然不参与匹配),码区与插值 ${} 内的标识符/标点才产
// token 带行号,按 token 序列判命中。**纯 JS 零依赖**:check 每回合 shell-out 跑,现货只有 esbuild(无 JS-AST
// API)、acorn 未装,若引入解析器硬依赖则一旦缺包 check 崩=生成全线停摆;词法器抛异常时兜底退回 stripCode+正则
// (_bannedLineErrorsViaStrip),既保词法真实又绝不 fail-open 放行裸调。
/**
* 红线判定(从 check 第3节抽出,便于单测):游戏源零裸时间/随机/DOM(时间随机经 boot.ctx、定时经 timer-scheduler)。
* 判定用整串正则(不漏 `\s*` 跨行的极端写法),行号用逐行定位——保行号的 stripCode 使「命中行 = 原文行」。
* 报错带【确切行号】+【edit_file 补救指向】:治死圈——agent 据此一行 edit 改回 ctx 形态,不必整文件重写(见 [[cheap-gen-deadloop-fix]] fix②)。
* @param {string} rawSrc 原始源码 @param {string} f 文件名(报错用) @returns {string[]} 红线 errors
* lexTokens —— 单遍词法扫描,只产「码区」判定需要的 token:标识符(含关键字如 new)与标点 . ( ?.(各带 1-based 行号)。
* 字符串(''/"")、模板文本、行/块注释内不产 token;模板插值 ${…} 内部按【码】扫描(裸调藏进插值也照样产 token)。
* 行号在任何状态遇 \n 自增(命中定位到原文行);跨行容错对齐 stripCode(单双引串内换行退回外层)。
* @param {string} src 原始源码 @returns {{k:'id'|'p', v:string, line:number}[]}
*/
export function lexTokens(src) {
const toks = [];
const n = src.length;
let line = 1;
let state = 'code'; // code | line(行注释) | block(块注释) | sq(单引) | dq(双引) | tpl(模板)
const stack = []; // 进字符串/插值前保存的外层 state(支持模板插值嵌套)
let brace = 0; // 当前 code 区 { } 深度(仅用于判 ${…} 的闭合 })
const braceBase = []; // 每层插值起始 brace 基准(回到基准的 } = 该层插值结束)
for (let i = 0; i < n; i++) {
const c = src[i];
const c2 = i + 1 < n ? src[i + 1] : '';
if (state === 'code') {
if (c === '\n') { line++; continue; }
if (c === '/' && c2 === '/') { state = 'line'; i++; continue; }
if (c === '/' && c2 === '*') { state = 'block'; i++; continue; }
if (c === "'") { stack.push('code'); state = 'sq'; continue; }
if (c === '"') { stack.push('code'); state = 'dq'; continue; }
if (c === '`') { stack.push('code'); state = 'tpl'; continue; }
if (c === '{') { brace++; continue; }
if (c === '}') {
if (braceBase.length && brace === braceBase[braceBase.length - 1]) {
braceBase.pop(); state = stack.pop(); continue; // 回到插值起始层 → 弹回模板态
}
if (brace > 0) brace--;
continue;
}
if (/[A-Za-z_$]/.test(c)) { // 标识符(关键字如 new 也当 id,只看 value)
let j = i + 1;
while (j < n && /[\w$]/.test(src[j])) j++;
toks.push({ k: 'id', v: src.slice(i, j), line });
i = j - 1;
continue;
}
if (c === '?' && c2 === '.') { toks.push({ k: 'p', v: '?.', line }); i++; continue; }
if (c === '.') { toks.push({ k: 'p', v: '.', line }); continue; }
if (c === '(') { toks.push({ k: 'p', v: '(', line }); continue; }
continue; // 其余(运算符/数字/;[]等)与红线匹配无关,跳过
}
if (state === 'line') { if (c === '\n') { line++; state = 'code'; } continue; }
if (state === 'block') {
if (c === '\n') line++;
else if (c === '*' && c2 === '/') { state = 'code'; i++; }
continue;
}
if (state === 'sq' || state === 'dq') {
const q = state === 'sq' ? "'" : '"';
if (c === '\\') { if (c2 === '\n') line++; i++; continue; } // 转义跳一对(含续行)
if (c === '\n') { line++; state = stack.pop(); continue; } // 未闭合换行:退回外层(跨行容错)
if (c === q) { state = stack.pop(); }
continue;
}
// state === 'tpl'(模板字面量)
if (c === '\\') { if (c2 === '\n') line++; i++; continue; }
if (c === '\n') { line++; continue; }
if (c === '`') { state = stack.pop(); continue; }
if (c === '$' && c2 === '{') { braceBase.push(brace); stack.push('tpl'); state = 'code'; i++; continue; } // 进插值:按码扫描
}
return toks;
}
/** 点号调用形态 [id:obj][p:.][id:prop][p:(] 是否在 toks[i](=obj 处)成立。 */
function _dotCallAt(toks, i, prop) {
return toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '.'
&& toks[i + 2] && toks[i + 2].k === 'id' && toks[i + 2].v === prop
&& toks[i + 3] && toks[i + 3].k === 'p' && toks[i + 3].v === '(';
}
/**
* _bannedLineErrors —— 红线八条命中判定【词法真实版】。用 lexTokens 产的 token 序列判命中,不再对去注释纯文本跑正则:
* ① 字符串/模板文本/注释内的同名 API 绝不误命中(0be0f61c 案回归);② 函数内/模板插值内的真实裸调照样命中
* (词法真实,非纯文本近似);③ 报错带确切行号 + edit_file 补救指向。词法器/匹配异常 → 兜底退回
* _bannedLineErrorsViaStrip(绝不 fail-open)。BANNED[] 保留作命中规则的报错源文案(下标须与匹配 add 一致)。
* @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]}
*/
export function _bannedLineErrors(rawSrc, f) {
try {
const toks = lexTokens(rawSrc);
const hits = new Map(); // BANNED 下标 → Set<line>
const add = (idx, line) => { let s = hits.get(idx); if (!s) { s = new Set(); hits.set(idx, s); } s.add(line); };
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.k !== 'id') continue;
const nxt = toks[i + 1];
const call = !!(nxt && nxt.k === 'p' && nxt.v === '('); // 后缀 name( 形态
switch (t.v) {
case 'Math': if (_dotCallAt(toks, i, 'random')) add(0, t.line); break;
case 'Date': if (_dotCallAt(toks, i, 'now')) add(1, t.line); break;
case 'performance': if (_dotCallAt(toks, i, 'now')) add(2, t.line); break;
case 'setTimeout': if (call) add(3, t.line); break;
case 'setInterval': if (call) add(4, t.line); break;
case 'requestAnimationFrame': if (call) add(5, t.line); break;
case 'new': if (nxt && nxt.k === 'id' && nxt.v === 'AudioContext') add(6, t.line); break;
case 'addEventListener': if (call) add(7, t.line); break;
default: break;
}
}
const errs = [];
for (let idx = 0; idx < BANNED.length; idx++) {
const set = hits.get(idx);
if (!set || !set.size) continue;
const sorted = [...set].sort((a, b) => a - b).slice(0, 10);
errs.push(`红线 ${f} 第 ${sorted.join('/')} 行: 禁用 ${BANNED[idx].source}——时间/随机经 boot.ctx(ctx.time.nowMs()/ctx.random.next())、定时经 timer-scheduler、输入经实例方法 handleTap/handleKey 由 L1 派发;词法真实扫:字符串/注释内的同名不误报、函数内/模板插值内的裸调照样命中(别靠 typeof 守卫或封装绕)。用 edit_file 把该行改回 ctx 形态,别整文件重写。`);
}
return errs;
} catch (e) {
return _bannedLineErrorsViaStrip(rawSrc, f); // 词法器异常:退回旧实现(绝不漏报裸调)
}
}
/**
* _bannedLineErrorsViaStrip —— 红线八条判定的【旧实现】:stripCode 抹注释/字符串 → 对纯文本跑 BANNED 正则,逐行定位行号。
* 现降为词法真实版 _bannedLineErrors 的【异常兜底】+【新旧 diff 对照基线】(换轴时并跑一批验差异)。逐字节保留旧行为、不删。
* @param {string} rawSrc 原始源码 @param {string} f 文件名 @returns {string[]} 红线 errors
*/
export function _bannedLineErrorsViaStrip(rawSrc, f) {
const errs = [];
const s = stripCode(rawSrc); // 已抹注释/字符串、保行号
const lines = s.split('\n');
for (const re of BANNED) {
const rx = new RegExp(re.source, re.flags.replace(/[gy]/g, '')); // 去 g/y 防 lastIndex 粘连
if (!rx.test(s)) continue; // 权威判定:整串命中才报(保持原检测强度,不漏跨行写法)
// 逐行定位命中行号(1-based,最多列 10 处);`\s*` 跨行的罕见写法逐行测不到 → 兜底不带行号,但绝不漏报。
if (!rx.test(s)) continue; // 整串命中才报(保持原检测强度,不漏跨行写法)
const hit = [];
for (let i = 0; i < lines.length && hit.length < 10; i++) {
if (rx.test(lines[i])) hit.push(i + 1);
@ -290,6 +413,38 @@ export function _bannedLineErrors(rawSrc, f) {
return errs;
}
/**
* _bootCtxLine —— `.boot.ctx` 双层 boot 误用的命中行(0=未命中);词法序列 [p:.][id:boot][p:.|?.][id:ctx]。
* 须有前导 `.`(即 X.boot.ctx),故正确形态 boot.ctx(boot 是入参、前面是 = 或 ( )不误命中。异常兜底退回正则。
*/
export function _bootCtxLine(rawSrc) {
try {
const toks = lexTokens(rawSrc);
for (let i = 0; i < toks.length; i++) {
if (toks[i].k === 'p' && toks[i].v === '.'
&& toks[i + 1] && toks[i + 1].k === 'id' && toks[i + 1].v === 'boot'
&& toks[i + 2] && toks[i + 2].k === 'p' && (toks[i + 2].v === '.' || toks[i + 2].v === '?.')
&& toks[i + 3] && toks[i + 3].k === 'id' && toks[i + 3].v === 'ctx') {
return toks[i + 1].line;
}
}
return 0;
} catch { return /\.boot\s*\??\.\s*ctx\b/.test(stripCode(rawSrc)) ? -1 : 0; } // 兜底:命中给 -1(有命中、行号未定位)
}
/** _getInputLine —— `getInput(` 命中行(0=未命中);词法序列 [id:getInput][p:(]。异常兜底退回正则。 */
export function _getInputLine(rawSrc) {
try {
const toks = lexTokens(rawSrc);
for (let i = 0; i < toks.length; i++) {
if (toks[i].k === 'id' && toks[i].v === 'getInput' && toks[i + 1] && toks[i + 1].k === 'p' && toks[i + 1].v === '(') {
return toks[i].line;
}
}
return 0;
} catch { return /\bgetInput\s*\(/.test(stripCode(rawSrc)) ? -1 : 0; }
}
/**
* check:循环内快反馈门(便宜模型自纠语法/契约错的头号手段)。
* 1) node --check 各 src/*.js + entry.js(语法);
@ -323,7 +478,8 @@ export function check(id) {
// 2) 结构契约(3 层架构:agent 写 game-logic.js;game.js/host-config 是 L1 固定 plumbing,writeFileTool 拒写)
const logicAbs = join(gd, 'src', 'game-logic.js');
if (existsSync(logicAbs)) {
const g = stripCode(readFileSync(logicAbs, 'utf8'));
const rawLogic = readFileSync(logicAbs, 'utf8');
const g = stripCode(rawLogic); // 结构存在门(createGame/bundle.tick/_forensicsView/handleTap)仍用去注释文本正则;红线命中判定走词法(下)
// 窄契约(L1 game.js wrapper 按此名 import;名写错→import 失败→boot 崩):必须命名导出 createGame。
if (!/export\s+function\s+createGame\b/.test(g)) {
errors.push('game-logic.js 必须 export function createGame({plugins,bundle,viewport})(L1 的 game.js wrapper 按此名 import,勿改名/勿默认导出)');
@ -337,15 +493,17 @@ export function check(id) {
}
// ctx 契约硬化(治 M3 实测错:把 boot.ctx 写成 boot.boot.ctx → 多套一层 → undefined → ctx=null → 不订阅输入 →
// 游戏点不动、卡菜单;且九门默认未驱动时 G_input skip → 漏判,带病过门)。host 传入的 boot 已是 {ctx,mainContext,canvas,seed,assets},
// 受控面就是 boot.ctx。匹配「.boot.ctx」(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 不含前导「.boot」不误伤。
if (/\.boot\s*\??\.\s*ctx\b/.test(g)) {
errors.push('init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx——host 传入的 boot 已是 {ctx,...};多套一层 .boot → undefined → ctx=null → 无输入、游戏点不动');
// 受控面就是 boot.ctx。命中判定走词法(W-AXIS 波2):序列 .boot.ctx(boot.boot.ctx / b.boot.ctx 皆中),正确的 boot.ctx 无前导「.boot」不误伤。
const _bootLn = _bootCtxLine(rawLogic);
if (_bootLn) {
errors.push(`init(boot) 受控面是 boot.ctx,不是 boot.boot.ctx${_bootLn > 0 ? `(第 ${_bootLn} 行)` : ''}——host 传入的 boot 已是 {ctx,...};多套一层 .boot → undefined → ctx=null → 无输入、游戏点不动`);
}
// 输入收归 L1(根治 B):L3 绝不自己订阅输入(ctx.getInput / pendingClicks 队列 / update 内挑时机消费点击 →
// M3 实测把点击消费放进 play 分支、在 menu early-return 之后 → 菜单点击永不消费 → 游戏启动不了)。
// 改为:只写实例方法 handleTap(x,y)[/handleKey(key)],L1 的 game.js wrapper 在 pointerdown/keydown 时直达调用。
if (/\bgetInput\s*\(/.test(g)) {
errors.push('game-logic.js 不要调 ctx.getInput()——输入订阅已收归 L1(game.js wrapper):只写实例方法 handleTap(x,y)(点击)/handleKey(key)(键盘),L1 在 pointerdown/keydown 时直达调用它(根治"点击在错 phase 被消费/永不消费→启动不了")');
// 改为:只写实例方法 handleTap(x,y)[/handleKey(key)],L1 的 game.js wrapper 在 pointerdown/keydown 时直达调用。命中判定走词法(W-AXIS 波2)。
const _giLn = _getInputLine(rawLogic);
if (_giLn) {
errors.push(`game-logic.js${_giLn > 0 ? ` 第 ${_giLn} 行` : ''} 不要调 ctx.getInput()——输入订阅已收归 L1(game.js wrapper):只写实例方法 handleTap(x,y)(点击)/handleKey(key)(键盘),L1 在 pointerdown/keydown 时直达调用它(根治"点击在错 phase 被消费/永不消费→启动不了")`);
}
// 必须暴露至少一个输入方法(否则游戏收不到输入、点不动)。handleTap(点击主输入)或 handleKey(方向键/空格)。
if (!/\bhandleTap\b/.test(g) && !/\bhandleKey\b/.test(g)) {
@ -427,6 +585,111 @@ export function check(id) {
return { ok: errors.length === 0, errors };
}
// ───────────────────────── tsc 类型门(W-AXIS 波3;advisory·独立通道·永不阻断)─────────────────────────
// 定位(诊断档 §四⑤ / plan 波3 ①):TapTap 类生成线都有「真编译/LSP 门」;本项目此前只有 node --check(语法)
// + esbuild 作用域分析(未定义标识符),缺一层「真类型检查」——ctx.time 用错形态、拼错本地属性名、把数字当函数
// 调、字符串做算术这类运行时才炸的错,语法门与作用域门都放行。tsc checkJs 对游戏源做真类型推断能把它们挡在
// CDP 真玩开销前(与 TS2551「拼错 push→建议 push」同族,带修复建议)。
//
// 硬约束(plan 波3 边界 + AGENTS.md §6.13):
// 1) 本波【只 advisory】——findings 绝不进 check 的 errors[]、绝不改 check 退出码/ok;转阻断是 n=5 校准后
// 的另一个决定(下面 _tscGateMode 留了 'block' 开关位,本波默认 'advisory',不激活阻断)。
// 2) tsc 不可用(未装 typescript / 解析失败 / 运行抛错)→ 输出一行降级说明即返回,check 主判定完全不受影响
// ——fail-open 只发生在本通道自身。故整函数不抛、任何异常都吞成 degraded。
// 3) 只查 agent 写的 L3 源(game-logic/core/render/balance/assets),不碰 L1 plumbing。
//
// 消噪校准(对 10 款金标语料——2 golden + 6 _template* + 2 _fewshot*——逐一跑到零误伤,W-AXIS 波3 实测):
// · lib 含 dom:游戏源 JSDoc 用 CanvasRenderingContext2D 等 DOM 类型,不含 dom 会误报 TS2304。
// · 8xxx(JSDoc 元数据码,如 @param 名不匹配 TS8024)整族丢弃:本门查逻辑/类型 bug,不查 JSDoc 卫生。
// · TS2307 指向平台类型契约(src/core/*.d.ts、src/plugins/*/api.d.ts)丢弃:真实产物在 games/amgen-<id>/src/
// 下该相对路径解析得到,只有金标 fixture 被移出 games/ 才深度错位——位置伪影、非代码错。
// · ctx.log / handleTap / handleKey / _forensicsView / _handleAction 这些【运行时附加、.d.ts 曾漏声明】的成员,
// 已在 src/core/{api,game-host}.d.ts 补声明(非在此消噪)——让类型面与运行时契约一致,真实的 handleTap 拼写
// 错等仍会被抓。ctx/plugins/bundle/engine 等运行时注入面无类型标注 → 天然 any → 不产生契约误报。
const _TSC_GATE_ENV = 'CHEAP_TSC_GATE'; // 门开关:'advisory'(默认,本波) | 'off'(关) | 'block'(留位,本波不激活)
const _TSC_L3_FILES = ['game-logic.js', 'core.js', 'render.js', 'balance.js', 'assets.js'];
/** 读门模式(env CHEAP_TSC_GATE;缺省 advisory)。block 是给 n=5 校准后转阻断留的开关位,本波【不激活】,遇到按 advisory 跑。 */
function _tscGateMode() {
const v = (process.env[_TSC_GATE_ENV] || '').trim().toLowerCase();
if (v === 'off') return 'off';
// 'block' 本波不激活:仍按 advisory 跑(留位不阻断);其余一律 advisory。
return 'advisory';
}
/** 惰性解析 typescript(装了才有;createRequire 从本文件 resolve → 命中 game-runtime/node_modules/typescript)。解析不到返回 null。 */
function _resolveTypescript() {
try {
const req = createRequire(import.meta.url);
return req('typescript');
} catch {
return null; // 未装 typescript:上层输出降级说明,主判定不受影响
}
}
/**
* tscAdvisory:对本 run 游戏 L3 源跑 tsc checkJs 真类型检查,产【advisory】反馈(永不阻断,永不抛)。
* 返回 {mode, available, findings:[{code,loc,msg}], note}——CLI/worker 只把它格式化进 check 输出的独立一节,
* 绝不折进 errors[]。tsc 不可用/任何异常 → available:false + note 降级说明。
*/
export function tscAdvisory(id) {
const mode = _tscGateMode();
if (mode === 'off') return { mode, available: false, findings: [], note: 'tsc 类型门已关(CHEAP_TSC_GATE=off)' };
try {
const ts = _resolveTypescript();
if (!ts) {
return { mode, available: false, findings: [],
note: 'tsc 不可用(未安装 typescript)——类型门 advisory 通道降级,check 主判定不受影响。装:cd game-runtime && npm i -D typescript' };
}
const srcDir = join(gameDir(id), 'src');
const files = _TSC_L3_FILES.map((f) => join(srcDir, f)).filter((p) => existsSync(p));
if (!files.length) return { mode, available: true, findings: [], note: '无 L3 源可查' };
const options = {
allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true,
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: false, noImplicitAny: false, noImplicitThis: false, types: [],
forceConsistentCasingInFileNames: true,
};
const program = ts.createProgram(files, options);
const fileSet = new Set(files.map((f) => resolve(f)));
const isNoise = (d) => {
if (d.code >= 8000 && d.code < 9000) return true; // JSDoc 元数据族
if (d.code === 2307 && d.file && d.start != null) { // 指平台类型契约的 module-not-found = fixture 位置伪影
const t = d.file.text.slice(d.start, d.start + 200);
if (/src\/(core|plugins)\/[^'"]*\.d\.ts/.test(t)) return true;
}
return false;
};
const findings = [];
for (const d of ts.getPreEmitDiagnostics(program)) {
if (!d.file || !fileSet.has(resolve(d.file.fileName)) || isNoise(d)) continue;
const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ').replace(/\s+/g, ' ').trim();
let loc = d.file.fileName.split('/').slice(-1)[0];
if (d.start != null) {
const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
loc += `:${line + 1}:${character + 1}`;
}
findings.push({ code: d.code, loc, msg });
}
return { mode, available: true, findings, note: null };
} catch (e) {
// fail-open 只在本通道:任何异常吞成降级,绝不连累 check 主判定(诊断档铁律:硬依赖不得让 check 崩)。
return { mode, available: false, findings: [], note: `tsc advisory 通道异常(已降级,不影响主判定):${e && e.message ? e.message : e}` };
}
}
/** 把 tscAdvisory 结果格式化成 check 输出里的独立 advisory 节(供 CLI/worker 追加打印;标记「仅参考不阻断」)。 */
export function formatTscAdvisory(adv) {
if (!adv) return '';
if (!adv.available) return `[check:tsc-advisory] ${adv.note || '不可用'}(仅参考,不阻断)`;
if (!adv.findings.length) return '[check:tsc-advisory] 类型检查 0 发现(仅参考,不阻断)';
const lines = adv.findings.slice(0, 30).map((f) => ` · TS${f.code} ${f.loc} ${f.msg}`);
const more = adv.findings.length > 30 ? `\n …(另 ${adv.findings.length - 30} 条,略)` : '';
return `[check:tsc-advisory] 类型检查 ${adv.findings.length} 发现(仅参考·不阻断·非红线;确认是真问题再改):\n${lines.join('\n')}${more}`;
}
/** build:esbuild 锁参打包(cwd=game-runtime)。返回 {ok, error?}。 */
export function build(id) {
const entry = `games/amgen-${id}/entry.js`;
@ -576,6 +839,9 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const r = check(id);
console.log('[check]', r.ok ? 'PASS' : 'FAIL');
r.errors.forEach((e) => console.log(' -', e));
// tsc 类型门:独立 advisory 节,只打印、绝不改退出码(本波不阻断;缺 tsc 自降级)。放在主判定之后,
// 即便它慢/降级也不动 r.ok 与 exit code——fail-open 只在本通道自身(plan 波3 边界)。
try { const line = formatTscAdvisory(tscAdvisory(id)); if (line) console.log(line); } catch { /* advisory 绝不连累主判定 */ }
process.exit(r.ok ? 0 : 1);
} else if (cmd === 'build') {
const r = build(id);

View File

@ -95,6 +95,18 @@ gates:
color_dist_min: 18 # harness render-reflects-state:有 item 格 vs 空格的色差 > 此值 → 判「渲染反映了状态」
var_delta_min: 120 # harness render-reflects-state:方差增量 > 此值 → 判「渲染反映了状态」(与色差任一满足即过)
# ── judge · 独立模型玩法地板判定(消费方:cheap-worker/cheap_verify.apply_gameplay_judge)──────────
# W-AXIS 波2(质量模型 SoT 裁定三):机械九门降位为「未见明显死」预筛,玩法地板由独立多模态模型看真玩证据
# (首帧/局中连拍/局末截图 + game-log + 取证时间线)裁 broken/hollow/off-brief 三类拒绝,阻断放行。
# ok = 预筛 ∧ 判定;判定只对过筛者跑(省¥);fail-closed(评不出/无证据 → 不放行、标 degraded)。
judge:
blocking: true # 玩法判定是否阻断放行(整体回退位:false=判定只观测、ok 仍=预筛,可一键回退到修订前口径)
model: MiniMax-M3 # 判定模型(创始人 2026-07-10 令切 M3):glm-5.2 唯一通道=闲鱼二手中转,图像支持按池轮换、07-09 整日全盲实证不可依赖;M3 读图亲验可靠(裸图/判定/spike 三路实证,且当场抓出 M3 自己写的渲染 bug)且走 MiniMax Direct 一手通道。裁定三①「出题≠被考」边界随本决策修正(地板只裁「有无」+金标亲玩兜同源盲区),SoT 修订随 v2 双评审落档。
max_tokens: 202752 # 判定输出上限。1500 实测太低(创始人 2026-07-09 令调大):glm-5.2 思考长度随机,21 局里 6 局撞线重试、1 局重试后仍截断被误拒(trpg-r5)。512000 经 new-api 实探 400 拒,202752=glm-5.2 网关实探可过上限;真实成本/时长闸=timeout_s(思考再长也被 120s 截停)。
timeout_s: 120 # 判定 LLM 调用超时(秒);超时 → fail-closed degraded
max_frames: 6 # 最多喂几帧证据截图(首帧 + 局中连拍 + 局末,控 token/成本;超出保头尾均匀采样)
cost_target_rmb: 0.3 # 单局判定成本目标(¥;仅记账对照/告警,不阻断——实测约 ¥0.10/4-6 帧)
# ── archetype · business-sim 品类 driver 参数(消费方:worker/archetypes.py)─────────────────
# 注:这里只放 driver 真玩规格里的【可调数值】(怎么玩);品类的【结构】(三系统/数据表 schema)
# 不在配置层,仍由 fixture 与品类注册表定。改这些 = 调 business-sim driver 怎么把游戏玩到终态。

View File

@ -236,6 +236,98 @@ def _h_zero_increment(g: dict) -> bool:
return False
# ══════════════════════════════════════════════════════════════════════════════
# 便宜档失败三层归因(W-AXIS 波1 · F0-归因分层)
# ──────────────────────────────────────────────────────────────────────────────
# 病根(诊断档 §2.2 判卷合约反噬):8 份失败反馈里 H_progress 8/8 挂,其中 4 例是「驱动器进不去
# 菜单」、3 例是「score 键没接上」——判卷驱动器合约没对上,反馈却统一措辞成「玩法坏死/终态不可达/
# 空壳」,既误导模型续修方向、也误导我们的根因结论。本节把「九门失败」拆成真实层次:
# · driver_contract(判卷驱动器合约失败):判卷器进不去菜单 / targets 没暴露 / 回退考卷驱不动——
# 是取证接口没对齐,不是游戏坏。信号:phaseNow 卡菜单类 / selectionBasis=回退且 H 零增量。
# · mechanical(机械门失败):黑屏/异常/不掌帧/装载失败——A_boot/B_uncaught/C_frame/D_render,真机械坏死。
# · gameplay(玩法层失败):机械与合约都不背锅的残项——真玩驱动起来了也没黑屏,但玩法本身到不了终局/
# 无真进展(score 真涨却 latch 不可达 等)。
# 两个消费口:classify_cheap_failure_layer(verdict→结构化 {layer,reason} 进 run-summary 与批次账);
# _feedback_layer_lead(obj→续修回喂顶部一句层次横幅,与前者同决策树、只是取数从已构好的 C6 obj)。
_MECHANICAL_GATES = ("A_boot", "B_uncaught", "C_frame", "D_render")
# 菜单类 phase(判卷驱动器停在这些态=没起局进游玩,合约层没接上,不是玩法坏)。
_MENU_PHASES = ("menu", "start", "title", "home", "ready", "loading", "idle")
def classify_cheap_failure_layer(play_result: dict, *, staged_dir=None) -> dict:
"""把便宜档九门 verdict 的失败归到三层之一,供 run-summary / 批次账消费(F0-归因分层)。
决策树(优先级 机械 > 合约 > 玩法):
① 任一机械门失败(A_boot/B_uncaught/C_frame/D_render)→ mechanical(黑屏/抛错/定帧/装载坏死,根因在机械);
② 否则若 H_progress 失败且信号指向判卷驱动器没接上(latch phaseNow 卡菜单类 / selectionBasis=回退且零增量)
→ driver_contract(取证接口没对齐,通常不是玩法坏);
③ 否则 → gameplay(机械与合约均未见问题,玩法本身未达真进展/终局)。
verdict 缺失 / 无 guards / 未见失败门 / 已放行 → layer='none'(无失败可归因,不硬造坏死结论)。
返回 {layer, reason, failedGates};纯读判定、不写盘、best-effort(异常降级 none)。
"""
try:
v = play_result or {}
guards = v.get("guards") or {}
if not guards:
return {"layer": "none", "reason": "九门未跑出 verdict(无 guards),无失败可归因", "failedGates": []}
failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False]
if not failed:
return {"layer": "none", "reason": "九门未见失败门", "failedGates": []}
mech = [g for g in _MECHANICAL_GATES if g in failed]
if mech:
return {"layer": "mechanical",
"reason": f"机械门失败({'/'.join(mech)}):装载/异常/掌帧/渲染层坏死(黑屏/抛错/定帧)",
"failedGates": failed}
hg = guards.get("H_progress")
if isinstance(hg, dict) and hg.get("pass") is False:
latch = hg.get("latch") or {}
phase_now = _latch_phase_now(latch) if isinstance(latch, dict) else None
phase_menu = phase_now is not None and any(m in str(phase_now).lower() for m in _MENU_PHASES)
selection_basis = _cheap_selection_basis(staged_dir)
fallback_zero = bool(selection_basis and "回退" in selection_basis and _h_zero_increment(hg))
if phase_menu or fallback_zero:
why = []
if phase_menu:
why.append(f"真玩停在菜单类 phase={phase_now}(判卷驱动器没起局进游玩)")
if fallback_zero:
why.append("判卷用回退考卷且真玩后零增量(考卷驱不动本游戏/targets 未暴露)")
return {"layer": "driver_contract",
"reason": "判卷驱动器合约失败:" + ";".join(why) + "——取证接口没对齐,通常不是玩法坏",
"failedGates": failed}
return {"layer": "gameplay",
"reason": f"玩法层失败({'/'.join(failed)}):机械与判卷合约均未见问题,玩法本身未达真进展/终局",
"failedGates": failed}
except Exception as e: # noqa: BLE001 —— 归因 best-effort,判不出降级 none,绝不连累门流水线
return {"layer": "none", "reason": f"归因异常(降级):{type(e).__name__}: {e}", "failedGates": []}
def _feedback_layer_lead(obj: dict) -> Optional[str]:
"""从已构好的 C6 反馈 obj 派生「续修回喂顶部一句失败层次横幅」(与 classify_cheap_failure_layer 同决策树)。
取数从 obj(不再读 verdict/staged):失败门名看 obj['failedGates'];菜单卡态看 H_progress item 的 phaseNow;
回退直指看该 item 的 fixDirection 是否已被 build_cheap_feedback_obj 改写成驱动器合约措辞。返回横幅字符串,
obj 为空/无失败 → None(不加横幅)。目的:让模型一眼看清「这是判卷接口没对齐,不是你玩法写坏」,治误导。
"""
if not obj:
return None
failed = obj.get("failedGates") or []
if not failed:
return None
mech = [g for g in _MECHANICAL_GATES if g in failed]
if mech:
return (f"【失败层次·机械门】装载/异常/掌帧/渲染层坏死({'/'.join(mech)})——"
"先修机械可运行性(别黑屏/别抛错/每帧真画),这一层过了再谈玩法。")
for it in (obj.get("items") or []):
if it.get("gate") == "H_progress":
phase = str(it.get("phaseNow") or "").lower()
fix = it.get("fixDirection") or ""
if any(m in phase for m in _MENU_PHASES) or "回退" in fix or "判卷驱动器" in fix:
return ("【失败层次·判卷驱动器合约】真玩停在菜单/判卷考卷驱不动本游戏——是取证接口没对齐,"
"通常不是你的玩法写坏;优先让运行态被真玩驱动(暴露可点 targets / 修 play-spec),别先改计分。")
return ("【失败层次·玩法层】机械门与判卷合约均未见问题——是玩法本身未达真进展/终局,"
"请据下列逐门把玩法修到「真玩有进展、终局可达」。")
def build_cheap_feedback_obj(v: dict, *, game_id=None, staged_dir=None,
driver_type=None) -> Optional[dict]:
"""把便宜档九门 verdict 构造成 C6 VerdictFeedback 结构化对象(contracts/play-loop/verdict-feedback.schema.json)。
@ -328,6 +420,11 @@ def _render_cheap_feedback(obj: dict) -> str:
lines.append(line)
text = (f"以下真玩验收门未通过(判卷驱动器={dt}),请据每条修复后再交付,不要直接结束:\n"
+ "\n".join(lines))
# F0-归因分层(W-AXIS 波1):顶部加一句「失败层次横幅」——把「判卷合约没对齐」与「玩法真坏」分清,
# 治诊断档 §2.2 的误导(合约缺口被统一措辞成玩法坏死)。横幅从同一 obj 派生,与 classify 同决策树。
lead = _feedback_layer_lead(obj)
if lead:
text = lead + "\n" + text
log_brief = (obj.get("evidence") or {}).get("gameLog")
if log_brief:
text += "\n游戏运行时日志信号(节选,含隔离告警/报错):\n" + log_brief

View File

@ -136,6 +136,16 @@ _BUILTIN_DEFAULTS: dict[str, dict[str, Any]] = {
"color_dist_min": 18, # harness render-reflects-state:色差判反映状态阈值(COLOR_DIST_MIN)
"var_delta_min": 120, # harness render-reflects-state:方差增量判反映状态阈值(VAR_DELTA_MIN)
},
# ── judge:独立模型玩法地板判定(cheap-worker/cheap_verify.apply_gameplay_judge;W-AXIS 波2 裁定三)──
# 机械九门降位为预筛,玩法地板由独立多模态档看真玩证据裁 broken/hollow/off-brief,阻断放行(ok=预筛∧判定)。
"judge": {
"blocking": True, # 判定是否阻断放行(整体回退位:false=只观测、ok 仍=预筛)
"model": "glm-5.2", # 判定模型(new-api 唯一实测多模态档;2026-07-09 probe 坐实 M3/MiniMax/deepseek 不吃图)
"max_tokens": 1500, # 判定输出上限
"timeout_s": 120.0, # 判定 LLM 超时(秒);超时 → fail-closed
"max_frames": 6, # 最多喂几帧证据截图(首帧+局中连拍+局末)
"cost_target_rmb": 0.3, # 单局判定成本目标(¥;仅记账对照,不阻断)
},
# 注:business-sim driver 参数(steps/stepMs/winThreshold/bankruptSteps/streakLose)不在本配置层——
# 曾登记过一个 "archetype" 区声明这五个键,但生成路(archetypes._business_sim_gate_spec /
# run.DEFAULT_BUSINESS_SIM_PLAY_SPEC)从不读它、真值一直硬编码在那两处 = 死旋钮(改配置无效、误导)。