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:
parent
dc89701fcc
commit
882ca3f357
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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。"""
|
||||
|
||||
@ -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
|
||||
|
||||
51
cheap-worker/fixtures/judge-golden/manifest.json
Normal file
51
cheap-worker/fixtures/judge-golden/manifest.json
Normal 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;终局『漏太多结束』是否可达待亲验)"
|
||||
}
|
||||
]
|
||||
}
|
||||
118
cheap-worker/judge_golden.py
Normal file
118
cheap-worker/judge_golden.py
Normal 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())
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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"]))
|
||||
|
||||
452
cheap-worker/tests/test_gameplay_judge.py
Normal file
452
cheap-worker/tests/test_gameplay_judge.py
Normal 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)
|
||||
@ -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。
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)}`);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 怎么把游戏玩到终态。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)从不读它、真值一直硬编码在那两处 = 死旋钮(改配置无效、误导)。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user