Compare commits
12 Commits
c2a86b3a02
...
85f48228cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f48228cd | ||
|
|
882ca3f357 | ||
|
|
dc89701fcc | ||
|
|
5d0f351050 | ||
|
|
1c4d5838ec | ||
|
|
8f2ca254ad | ||
|
|
69307df8a5 | ||
|
|
c8a15db349 | ||
|
|
824b4eaba3 | ||
|
|
9bd428040f | ||
|
|
e4e4e23564 | ||
|
|
3c0c105aeb |
@ -47,6 +47,9 @@
|
||||
|---|---|
|
||||
| `add-business-module.md` | 新增一个 game-module 业务模块的标准步骤 |
|
||||
| `littlejs-game-dev.md` | AI 写 LittleJS 游戏代码作业手册(code 层):12 插件 API + 代码结构 + 工厂五法 + 资产/mmx 流 + 受控面铁律,终态产物 = `src/` 多文件工程 |
|
||||
| `recipes/`(README + 5 篇) | 高频场景落地形态(要做 X→读 Y):命中矩形/计时器/资产回退/场景机/结算演出,各给「抄这段形态 + 头号病根」;littlejs-game-dev 卡壳时读 |
|
||||
| `plugin-capability-map.md` | 插件能力图谱:11 注入插件(键/目录/能力/api.d.ts+PLUGIN.md 指针)+ runtime-probe(取证不注入)+ 6 件未注入储备(逐件裁定依据 + 注入触发条件);机器门 `plugin-surface-gate.py` 的人读台账 |
|
||||
| `engine-capabilities-brief.md` | 引擎能力 ≤3KB 摘要(从 `game-runtime/docs/ENGINE-CAPABILITIES.md` 蒸馏):引擎重活已被插件封装、少碰 getEngine + 坐标系 2× 换算坑 |
|
||||
| `sim-business-game-design.md` | 经营模拟小游戏玩法设计手册(给生成 agent 的设计阶段):爆款范式/数值/美术/UI/音 → 映射 12 插件 + 反"无趣"8 条自检;与 littlejs-game-dev 配对 |
|
||||
| `narrative-game-design.md` | 剧情/互动叙事品类设计手册(W-GENRE 件①):分支选择+多结局图鉴范式 / 文本红线 / 可达性红线含取证契约 |
|
||||
| `trpg-game-design.md` | TRPG/掷骰冒险品类设计手册(W-GENRE 件①):掷骰可见 / 风险回报取舍 / 难度爬坡登顶 / 反无趣自检 |
|
||||
@ -85,6 +88,7 @@
|
||||
| `docs-gate.py` / `docs-gate.sh` | 文档治理机器门(engineering-conventions §10.7):七检——品牌不变量 / canonical 唯一 / 死链 / 入口卫生 / 留痕隔离 / 设计档申报 / 计划谱系(`上级:` 单指针) |
|
||||
| `plan-tree.py` | 计划谱系树查询视图(engineering-conventions §10.8):沿 frontmatter `上级:` 拼树打印,回答「某计划在上线计划的哪个位置 / 某线推进到哪」 |
|
||||
| `rubric-sync-gate.py` | 品类 rubric fixture ↔ skill 双写对账门(Δ5「无校验器不算契约」执行面):以 `cheap-worker/fixtures/genre-rubrics/<genre>.json` 为单源,校验各品类 skill 的内联副本名目一致 / 纯指针可达,漂移 exit 1;已挂 pre-commit + Gitea Actions |
|
||||
| `plugin-surface-gate.py` | 便宜档插件面三方一致性对账门(防漂移):插件目录 / prompt 白名单(cheap-system.md)/ host-config 注入 + tools.mjs PLUGIN_KEY_DIR 必须对齐,且每个插件目录显式裁定为「注入/取证/储备」之一;漂移 exit 1;已挂 pre-commit + Gitea Actions |
|
||||
| `doc-organizer.{sh,-analyze.mjs}` + `doc-organizer-state.json` | doc-organizer 底层:检测(scan/health/stale/ledger)+ 可逆归档 + 记时 + 三轴分析 Workflow + 增量窗口锚点 |
|
||||
|
||||
---
|
||||
|
||||
@ -133,7 +133,7 @@ W-G1 实证后创始人亲玩校准拍定:**生产环无 Claude**,质量由*
|
||||
|
||||
✅ **方向②扩模板 数据点(2026-06-29,难品类经营,修正了假设)**:建经营黄金骨架 `game-runtime/games/_template-shop/`(蒸馏过门的 bake-shop-serve 实证 pattern + 母语化 API + 多样性参数空间 + fill-in 标记,独立过九门)+ 品类路由接入(`scaffold-saa <id> [template]` / `cheap_run.scaffold(template)` / `run_studio(scaffold_template, scaffold_desc)`,缺省回落 _template)。同一咖啡馆 brief 三路对照(n=1):**软脚手架**(给骨架 + 软提示)892 行/¥0.85——AI 把预算填进增富化(自加 combo+VIP)、对 ~890 行基线三指标全平**没降**;**强制换皮**(write_whitelist 锁 game-logic.js、只许改 core 主题数值+render 观感)696 行(-22%)/¥0.34(-51%)/tokens-64%,game-logic **0-diff**、仍过门仍 distinct。**结论:"扩模板降工作面"成立但只在"强制换皮"模式**(脚手架+锁循环只换皮)——降本杠杆不是脚手架本身、是写边界强制;软脚手架买的是质量/过门鲁棒(增富化)、不买成本。取舍:强制换皮省成本但封顶增富化,软脚手架反之。**n=5 收敛环确认(经营,5 个不同店主题 奶茶/书店/花店/拉面/冰淇淋):5/5 全过九门 · 成本均 ¥0.252(-64% vs 基线)· game-logic 全 0-diff · 全 1 轮 · 5 店菜单互异——成本腰斩稳 / 过门 100% / 多款不雷同 三件全确认**。🔴 **创始人纠正(2026-06-29,推翻 reskin 策略框)**:上面 reskin 的成本数据作留痕(锁写省成本机制为真),但**「reskin 锁循环只换皮作阶段二成本主线」= 错、越线、已废**。① **便宜 = LLM 低参与度,不是游戏低质量**;底线 = 2D 丰富游戏(进货/解锁/成长/音乐/丰富玩法),reskin 砍复杂度违背底线;"低参与" = agent 不在引擎/plumbing 上耗(脚手架+插件包了)、精力放游戏设计。② **架构红线:项目代码只做机械确定性的事;玩法/美术/音乐的「丰富生成」= 生成 agent 设计创作职责(靠 [sim-business-game-design](../skills/sim-business-game-design.md) + 组合插件),「丰富校验」= 纯 LLM 验证 agent 非阻塞——绝不写成代码校验、不进九门、不进脚手架**。故"丰富脚手架/脚手架好玩门断言"也废。**修正后下一步**:接 sim-business 设计指导到生成 agent(现 prompt 漏)+ 建 LLM 丰富度验证 agent → 喂 M1 达标门(标=过九门+丰富);脚手架保持轻起点。
|
||||
|
||||
✅ **切片一收口落地(2026-06-30,上面"修正后下一步"全部交付并验证)**:生成 agent 接上 sim-business 设计指导(`cheap_roles.py` + `prompt.mjs` 双源,"先设计后写码"步 + MVP-first 铁律 + 8 条好玩自检);新建纯 LLM 丰富度验证 agent `cheap_verify.py`——读产物源码逐条裁 8 条好玩清单、写进 run-summary 的 richness 字段,**非阻塞(失败降级不阻断)、不进 verdict、不改达标判定、零 code-presence 断言**(命中与否 100% 由 LLM judge 裁,代码只搬运计数——红线落地;sim-business §10 那 4 条"可机检好玩门"刻意不实现)。富游戏重验 M1 达标门(全新 gameId、三品类各 n=5):**click/whack/shop 各 5/5 = 100% 过九门、整体达标 ✅**——richness 加固没压垮达标,plan 担心的"丰富后跌破"未发生;richness 均分 shop 4.2 / click 3.4 / whack 2.4(8 条 rubric 偏经营,最贴 shop,whack 低是品类不匹配非质量差)。退役授权据新富游戏 M1 报告重算 `authorized=True`。基建线同轮交付:统一 trace 落库(tier2 `JsonlFileSink` + cheap-worker 接线 + SAA 扩展段 schema)、配置注册表运行时热取(cheap_roles 三级回落加载器、Java `@Scheduled` TTL、`check_registry.py` 一致性 CI 门)、生成控制面只读管理面(后端 3 端点 + game-admin Vue 三 card)。**两个测试坑(留给后来人)**:① bake-off 复跑同一 gameId 会复用 `_wg1-gen/<id>/play-spec.json` 旧 spec(`ensure_play_spec` 是"已存在不覆盖"语义)——旧薄游戏 spec 驱新富游戏会假失败(自动驱动器卡菜单 `phaseNow=menu`、score 0→0、E_live/G_input/H_progress 齐挂),`auto_vs_golden.py` 有 staged-spec 残留断言守卫而 `bake_off.py` 没有,故达标门复验务必 `--offset` 取全新 gameId(或先清 `_wg1-gen` 实验 staging);② `cheap_run.game_dir("base4")=games/amgen-base4` 是 load_brief 的 brief 来源依赖,清理 `amgen-*` 实验产物时**必须排除 `amgen-base*`**,否则断掉所有品类的 bake-off brief。
|
||||
✅ **切片一收口落地(2026-06-30,上面"修正后下一步"全部交付并验证)**:生成 agent 接上 sim-business 设计指导(`cheap_roles.py` + `prompt.mjs` 双源,"先设计后写码"步 + MVP-first 铁律 + 8 条好玩自检);新建纯 LLM 丰富度验证 agent `cheap_verify.py`——读产物源码逐条裁 8 条好玩清单、写进 run-summary 的 richness 字段,**非阻塞(失败降级不阻断)、不进 verdict、不改达标判定、零 code-presence 断言**(命中与否 100% 由 LLM judge 裁,代码只搬运计数——红线落地;sim-business §10 那 4 条"可机检好玩门"刻意不实现)。富游戏重验 M1 达标门(全新 gameId、三品类各 n=5):**click/whack/shop 各 5/5 = 100% 过九门、整体达标 ✅**——richness 加固没压垮达标,plan 担心的"丰富后跌破"未发生;richness 均分 shop 4.2 / click 3.4 / whack 2.4(8 条 rubric 偏经营,最贴 shop,whack 低是品类不匹配非质量差)。退役授权据新富游戏 M1 报告重算 `authorized=True`。基建线同轮交付:统一 trace 落库(tier2 `JsonlFileSink` + cheap-worker 接线 + SAA 扩展段 schema)、配置注册表运行时热取(cheap_roles 三级回落加载器、Java `@Scheduled` TTL、`check_registry.py` 一致性 CI 门)、生成控制面只读管理面(后端 3 端点 + game-admin Vue 三 card)。**两个测试坑(留给后来人)**:① bake-off 复跑同一 gameId 会复用 `_wg1-gen/<id>/play-spec.json` 旧 spec(`ensure_play_spec` 是"已存在不覆盖"语义)——旧薄游戏 spec 驱新富游戏会假失败(自动驱动器卡菜单 `phaseNow=menu`、score 0→0、E_live/G_input/H_progress 齐挂),`auto_vs_golden.py` 有 staged-spec 残留断言守卫而 `bake_off.py` 没有,故达标门复验务必 `--offset` 取全新 gameId(或先清 `_wg1-gen` 实验 staging);② `cheap_run.game_dir("base4")=games/amgen-base4` 是 load_brief 的 brief 来源依赖,清理 `amgen-*` 实验产物时**必须排除 `amgen-base*`**,否则断掉所有品类的 bake-off brief。(2026-07-09 勘误:本条两处定性已被裁定三收窄——「各 5/5 = 100% 过九门、整体达标」中「过九门」自此只算机械预筛通过,玩法层达标另需独立模型玩法判定;而当日审计正认定「LLM 丰富度非阻塞、不进 verdict、不改达标判定」这套设计是病根之一——懂玩法的模型判断被钉成旁路、判定权威落给不懂玩法的机械门,故丰富度里「玩法有无」的布尔裁决升为阻断权威(程度评分仍软)。当时测得 5/5 是历史事实,真实率按新基线重锚(W-AXIS 收尾波)。见质量 SoT《游戏质量与爆火能力》裁定三与诊断档 §2.1。)
|
||||
|
||||
## 6. 决策落地的关键工程参数(供选型校验,技术决策版 §4/§7)
|
||||
|
||||
|
||||
@ -85,4 +85,4 @@ ssh -o ProxyCommand=none root@100.64.0.7 'cd /root/game-staging/repo/game-runtim
|
||||
|
||||
## 8. 现行集成与状态(指针)
|
||||
|
||||
reframe 后本 harness 的工具循环由便宜档 `cheap-worker`(AgentScope/Python)shell-out 复用;便宜档生成质量已收口(M1 达标门三品类各 5/5 过九门、richness 加固不跌破,见 [`../knowledge/tech-decisions.md`](../knowledge/tech-decisions.md) §5.1),旧「Phase4 cutover 删 gamedef/factory」路径随 gameDefinition 废除、A-model 直写真 `src/` 而 moot。当前构建态另见 memory `amodel-generation-build-state`。
|
||||
reframe 后本 harness 的工具循环由便宜档 `cheap-worker`(AgentScope/Python)shell-out 复用;便宜档生成质量已收口(M1 达标门三品类各 5/5 过九门、richness 加固不跌破,见 [`../knowledge/tech-decisions.md`](../knowledge/tech-decisions.md) §5.1),旧「Phase4 cutover 删 gamedef/factory」路径随 gameDefinition 废除、A-model 直写真 `src/` 而 moot。当前构建态另见 memory `amodel-generation-build-state`。(2026-07-09 勘误:「过九门 = 生成质量已收口」的口径已被裁定三收窄——过九门自此只算机械预筛通过,玩法层验收另需独立模型玩法判定,便宜档真实率按新基线重锚(W-AXIS 收尾波);当日审计并证「richness 非阻塞旁路」是病根之一,所引 §5.1 条目已同步补注。见质量 SoT《游戏质量与爆火能力》裁定三。)
|
||||
|
||||
@ -17,7 +17,7 @@ description: "当需要 new-api 便宜模型成本口径、前缀缓存降本、
|
||||
|
||||
## 1. 链路(已退役 · 仅留指针)
|
||||
|
||||
旧 gamedef/factory 链路 = brief → 便宜模型写 `generated-factory.js`(一个 `GameHostFactory`)→ 静态校验门 → 套官方 generic 壳(`entry-generic.js` + `buildGenericHostConfig`)→ esbuild `--global-name=__GameBundle` iife → P1 宿主 `bootGameHost` 装载 → 九门真玩、失败回喂重试。**reframe 后废**:便宜档改 `cheap-worker`(Python/AgentScope)直写 LittleJS `src/` 多文件、import 复用 tier2 框架层(见篇首注 + [`agentic-amodel-generation.md`](agentic-amodel-generation.md))。铁律不变——**能编译 / 能渲染 / 能动 ≠ 能玩,必须过九门真玩才算 pass**。
|
||||
旧 gamedef/factory 链路 = brief → 便宜模型写 `generated-factory.js`(一个 `GameHostFactory`)→ 静态校验门 → 套官方 generic 壳(`entry-generic.js` + `buildGenericHostConfig`)→ esbuild `--global-name=__GameBundle` iife → P1 宿主 `bootGameHost` 装载 → 九门真玩、失败回喂重试。**reframe 后废**:便宜档改 `cheap-worker`(Python/AgentScope)直写 LittleJS `src/` 多文件、import 复用 tier2 框架层(见篇首注 + [`agentic-amodel-generation.md`](agentic-amodel-generation.md))。铁律不变——**能编译 / 能渲染 / 能动 ≠ 能玩,必须过九门真玩才算 pass**。(2026-07-09 勘误:九门 pass 自此降为「未见明显死」的机械预筛地板,验收 = 机械预筛 ∧ 独立模型对真玩证据的玩法判定,过九门不再单独等于 pass;铁律里「不由生成模型自评、必须真玩取证」的内核不变、且被强化。见质量 SoT《游戏质量与爆火能力》裁定三。)
|
||||
|
||||
## 2. 模型与成本(2026-06-14 网关实测)
|
||||
|
||||
@ -114,4 +114,4 @@ SAA gameDefinition 路已随 reframe 废(A-model 直写真 `src/`)。Plan B
|
||||
|
||||
**附带的可追溯性缺口**:brief 原文在 worker/Service 任何日志与产物里都**没落盘**(worker 只记 trace_id/gameId、Service 没记 input、agent 找的 `start.brief` 根本不存在)——生成的核心输入事后不可审计,该补落盘。
|
||||
|
||||
修法方向(不写代码,经 Codex+Opus 双评审校正,详见计划档):①给便宜档一条**可靠的增量 edit/apply-patch 工具**(read→精确替换→复用 `cheap_run.write_file` 的 L1/L3+whitelist 回写、不触发 check、加入软停 blocked-tools) + 对「连撞同签名畸形参数」做**窄口径**纠偏(仅 schema 缺字段类、不重置 stuck 计数、cheap-only 别改坏共享 tier2 熔断)——治死圈这一终止模式的最高杠杆;②**`_template` 的 rng 本就合规(`ctx?ctx.random.next():0`)、根本无 nowMs,违规是模型无视合规模板自造的**——不是"改模板",而是让 check 红线报错**指到行并点破「函数内回退也算裸调、正则按纯文本命中」**+ 提示层加「照模板逐字克隆、禁自造裸回退」与 check-early(写完即 check),比造工具更上游更省;③补 brief 落盘(落 `game_dir/evidence/` 脱敏限长、别落工程根——源工程可进素材市场交易)。**死圈只是失败模式之一**:实测 23 局真过门率仅 ~39%(跑到门 83% 但多为 E/G/H 玩法坏死挂门),三修消除死圈但到不了 MVP ≥80%,80% 缺口(玩法正确性)属质量线另一工作面。
|
||||
修法方向(不写代码,经 Codex+Opus 双评审校正,详见计划档):①给便宜档一条**可靠的增量 edit/apply-patch 工具**(read→精确替换→复用 `cheap_run.write_file` 的 L1/L3+whitelist 回写、不触发 check、加入软停 blocked-tools) + 对「连撞同签名畸形参数」做**窄口径**纠偏(仅 schema 缺字段类、不重置 stuck 计数、cheap-only 别改坏共享 tier2 熔断)——治死圈这一终止模式的最高杠杆;②**`_template` 的 rng 本就合规(`ctx?ctx.random.next():0`)、根本无 nowMs,违规是模型无视合规模板自造的**——不是"改模板",而是让 check 红线报错**指到行并点破「函数内回退也算裸调、正则按纯文本命中」**+ 提示层加「照模板逐字克隆、禁自造裸回退」与 check-early(写完即 check),比造工具更上游更省;③补 brief 落盘(落 `game_dir/evidence/` 脱敏限长、别落工程根——源工程可进素材市场交易)。**死圈只是失败模式之一**:实测 23 局真过门率仅 ~39%(跑到门 83% 但多为 E/G/H 玩法坏死挂门),三修消除死圈但到不了 MVP ≥80%,80% 缺口(玩法正确性)属质量线另一工作面。(2026-07-09 勘误:这三个数字与归因已被五路审计推翻——「真过门率 ~39%」是 stripCode 假阴修复前所测,字符串内 `//`(如赛博文案 'JACK IN // FLOOR')被误当注释抹掉真代码、把写对的模型反复拒到熔断烧钱(commit 0be0f61c),修复并给 per-genre 起点后 xtheme/it5-lockcore/r1 三批各 5/5、模型未变,过门率随工具移动不随模型;「多为 E/G/H 玩法坏死」大半是误标,真因多为判卷驱动器合约缺口——进不去菜单/认不出终局/score 键没对上,小补丁即过,真坏死是少数且已被 per-genre scaffold 修掉;故「80% 缺口=玩法正确性」不成立。诚实边界:只能判 80% 不是 MiniMax 天花板,不能反推已稳定 95%+。见质量 SoT《游戏质量与爆火能力》裁定三与同日诊断档 `docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md` §2.5。)
|
||||
|
||||
21
.agents/skills/engine-capabilities-brief.md
Normal file
21
.agents/skills/engine-capabilities-brief.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 引擎能力摘要(给生成 agent 的 ≤3KB 版)
|
||||
|
||||
引擎 = LittleJS 1.18.19。**你几乎不直接碰它**——重活(粒子/音频/物理/手感/调色)都被 L2 插件封装好了,你调 `plugins.<键>` 就行。这份摘要只让你知道「插件背后是什么、坑在哪」,以及极少数要经 `ctx.getEngine()` 够引擎时的事实。完整逐函数签名在 `game-runtime/docs/ENGINE-CAPABILITIES.md`(277KB 引擎 .d.ts 的抽取版),本篇是够用的地板。
|
||||
|
||||
## 铁律:先找插件,别碰引擎
|
||||
- 画面:画在 `render(g)` 的 `g`(= 引擎 `mainContext`,一块 Canvas2D 叠加层)。用 `g.fillRect/drawImage/fillText` 直接画即可,**别** `ctx.getContext2d()`(那是另一块 overlay)。
|
||||
- 粒子/打击感 → `plugins.juice`;音乐音效 → `plugins.audioMusic`;缓动手感 → `plugins.gamefeel`;碰撞 → `plugins.collision`;运动 → `plugins.physics`;调色 → `plugins.palettePost`。**这些引擎里都有,但一律走插件**——直接碰引擎会踩下面的坐标系坑。
|
||||
- 时间走 `ctx.time()`(或 `.nowMs()`)、随机走 `ctx.random()`(或 `.next()/.range(a,b)`)——**确定性可复现**,别用引擎全局 `time`/`rand`(受暂停/timeScale 污染、不可复现)。
|
||||
|
||||
## `ctx.getEngine()`:极少用,用则容错
|
||||
返回一条受控透传通道(粒子发射器 / 音频合成核 / 数学缓动库)——**但这三样插件都已封装**,正常你用不到。`getEngine()` 在无引擎环境(node/桩)返回 `null`,碰它必须先判空降级,别裸调。真要用数学缓动,`plugins.gamefeel.easing` 有 13 条曲线,比引擎全(引擎只有 lerp/smoothStep/oscillate,没有 elastic/back/cubic)。
|
||||
|
||||
## 坐标系坑(碰引擎才会踩,也是插件封装它的原因)
|
||||
- 引擎默认**世界坐标**、几何是 `Vector2` 类;插件受控面是朴素 `{x,y}` number 对。
|
||||
- 引擎 AABB 用 `size`=**全宽全高**;插件 AABB 用 `{x,y,hw,hh}`=中心+**半宽半高**——差 **2× 换算**。混用必错,所以一律走插件的 `{x,y,w,h}`/`{x,y,hw,hh}` 形状。
|
||||
- 引擎碰撞查询只返 bool 或命中位置,**没有**穿透深度/MTV 法线/标量 t;要 `{hit,depth,nx,ny}` 这种结构化结果走 `plugins.collision`(它替你补齐)。
|
||||
|
||||
## 几个「引擎有 vs 没有」的事实(省得你猜)
|
||||
- 音乐**没有** `Music` 类——引擎是 `ZzFXMusic`/`Sound.playMusic()`;你走 `audioMusic` 就别管这个。
|
||||
- **没有** `engineUpdate` 导出——主循环是引擎内部 RAF;你只在 `update(dt)` 里 `bundle.tick(dt)` 驱动插件,别想手摇引擎帧。
|
||||
- 成就/勋章系统(Medal)、GLSL 后处理注入引擎里有,但**非本档范围**,别用。
|
||||
@ -5,6 +5,8 @@ description: "当新功能/跨模块/改用户可见行为/触及外部服务·
|
||||
|
||||
# 功能设计文档作业手册(feature-design-doc)
|
||||
|
||||
> **定位修订(2026-07-09,创始人)**:两份产物重新定位——**人审版 = brainstorm / SoT 设计文档**(方向诊断落 brainstorm,设计本体直接改所属 SoT,人审对象就是 SoT diff 与 brainstorm),**执行版 = `docs/plans/` 的 plan**(工单六要素,可派单)。已有 SoT 的改动**不再另造平行"功能设计文档"**——先改 SoT 的意识是硬要求;独立设计档只留给"尚无 SoT 的全新子系统"(产出后它本身成为/并入 SoT)。本 skill 的骨架、图规范、防漂移纪律对 SoT 设计档与上述独立设计档继续适用。
|
||||
>
|
||||
> 每个有真实复杂度的功能,开工前产出**一份**「功能设计文档」,取代旧的 `-review.md` + `-execution.md` 双档。一份文档同时承载 **WHAT**(意图/目标/边界/验证)与 **HOW**(方案/步骤),并以**一式两份**的表达服务两类读者:**文字 + Mermaid** 给人和 AI(AI 主靠它),**SVG / HTML** 给人(更重要,看图即懂边界与核心思想)。
|
||||
|
||||
## 何时用
|
||||
|
||||
@ -55,7 +55,7 @@ node_type: skill
|
||||
|
||||
## 达标门口径:质量地板 vs 生产真实交付率(M1 U3 实证)
|
||||
|
||||
对照验证比的是「两路等价」;达标门(bake-off)换一个问题——「这条路生成出来的游戏,按品类稳不稳定够格上线」,按品类聚合九门过门率、逐品类 ≥80% 判。建这道门时,**算过门率的分母怎么定**是个会直接翻转结论的口径决策。
|
||||
对照验证比的是「两路等价」;达标门(bake-off)换一个问题——「这条路生成出来的游戏,按品类稳不稳定够格上线」,按品类聚合九门过门率、逐品类 ≥80% 判。建这道门时,**算过门率的分母怎么定**是个会直接翻转结论的口径决策。(2026-07-09 勘误:「按品类九门过门率 ≥80% 判达标」自此只度量机械预筛地板——九门 pass 降为「未见明显死」的预筛,够不够格上线还须过独立模型玩法判定,达标口径随之收窄;下文分母口径〔收敛款为分母、rawPassRate 并报〕本身不受影响。见质量 SoT《游戏质量与爆火能力》裁定三。)
|
||||
|
||||
一次生成会以两种性质完全不同的方式「不过」:
|
||||
|
||||
|
||||
@ -24,6 +24,16 @@ description: "当生成 agent 要写一款可上线 LittleJS 小游戏的 game-l
|
||||
|
||||
---
|
||||
|
||||
## 0.5 卡壳就查(场景 recipe + 能力图谱 + 引擎摘要)
|
||||
|
||||
写某个具体场景卡住了、或不确定某能力从哪来,先读对应的一篇,别硬想:
|
||||
|
||||
- **高频场景怎么落地** → `read_file('.agents/skills/recipes/README.md')` 是「要做 X → 读 Y」索引:命中按钮点不动 / 计时慢 1000 倍 / 缺图崩 / 场景机卡菜单 / 结算不驻留——五类头号翻车各有一篇「抄这段形态 + 病根」。卡在哪读哪篇。
|
||||
- **有哪些插件、每件详细用法** → `read_file('.agents/skills/plugin-capability-map.md')`:11 件注入插件各配 `api.d.ts`(精确签名)+ `PLUGIN.md`(用法散文/装配坑/示例,比 api.d.ts 更全)的指针;另有 6 件**未注入储备**——它们**你用不到**(`plugins.<键>` 取不到、会 undefined),别写。
|
||||
- **引擎能到底能干什么** → `read_file('.agents/skills/engine-capabilities-brief.md')`(≤3KB):引擎重活都被插件封装、你少碰 `getEngine()`,外加坐标系 2× 换算坑。
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码结构(可导航多文件)
|
||||
|
||||
一款游戏 = 一个目录,固定分层。**每个文件单一职责,改一处不牵动全身**:
|
||||
@ -112,6 +122,7 @@ export function createGame({ plugins, bundle, viewport }) {
|
||||
## 4. 11 注入插件能力速查(调 API,别重写;另 runtime-probe 仅取证不注入)
|
||||
|
||||
> **完整签名以 `game-runtime/src/plugins/<name>/api.d.ts` 为准**(本表只给「选型 + 头部调用」)。每件都是 `createXxxPlugin(opts)` 工厂,经 host-config 实例化、注入。命名全 engine 级、零玩法语义。
|
||||
> **想看用法散文/装配坑/示例(比 api.d.ts 更全)** → 同目录 `PLUGIN.md`(如 `game-runtime/src/plugins/scene-fsm/PLUGIN.md`);一张全表 + 未注入储备清单见 `.agents/skills/plugin-capability-map.md`。
|
||||
|
||||
### 基元层(8 件 · 算法/物理/手感/音/存档)
|
||||
|
||||
@ -205,7 +216,10 @@ mmx music generate --prompt "<情绪/配器/场景>" --instrumental --bpm 92 --o
|
||||
|
||||
## 相关 skill / 契约
|
||||
|
||||
- 插件库总览与受控面 → `game-runtime/src/core/api.d.ts`(PluginContext 6 项 + getEngine)、各 `plugins/<name>/api.d.ts`
|
||||
- 高频场景 recipe(要做 X→读 Y) → `.agents/skills/recipes/README.md`(命中矩形/计时器/资产回退/场景机/结算演出五篇)
|
||||
- 插件能力图谱(11 注入 + 6 储备 + PLUGIN.md 指针) → `.agents/skills/plugin-capability-map.md`
|
||||
- 引擎能力摘要(≤3KB) → `.agents/skills/engine-capabilities-brief.md`
|
||||
- 插件库总览与受控面 → `game-runtime/src/core/api.d.ts`(PluginContext 6 项 + getEngine)、各 `plugins/<name>/api.d.ts` 与 `PLUGIN.md`
|
||||
- 装载契约 → `game-runtime/src/core/game-host.d.ts`(GameInstance 五法 + GameHostFactory)
|
||||
- 契约匹配范例 → `game-runtime/games/_template/`(克隆起点 + 照它改;签名以现契约为准)。`wanglanmei-ref/` 是旧 opts 签名漂移的进阶参照,只看其规模/styleWords,**别照它的 game-logic 写**
|
||||
- 便宜模型造游戏 worker loop / 九门 → skill `cheap-model-game-generation`、`game-e2e-cdp-harness`
|
||||
|
||||
42
.agents/skills/plugin-capability-map.md
Normal file
42
.agents/skills/plugin-capability-map.md
Normal file
@ -0,0 +1,42 @@
|
||||
# 插件能力图谱 —— 注入了什么、储备着什么、为什么
|
||||
|
||||
> 便宜档生成 agent 只能用**已注入**的插件(经 `plugins.<键>` 取,不能 import)。这份图谱回答:现在桌上有哪 11 件、
|
||||
> 每件去哪读详细用法、还有哪些造好了但没摆上桌、以及为什么没摆。改这里的裁定要同步改机器门
|
||||
> `.agents/tools/plugin-surface-gate.py` 的台账常量(三方一致性它会自动对账)。
|
||||
|
||||
## 已注入的 11 件(`plugins.<键>` 直接用)
|
||||
|
||||
方法**头部选型**见 littlejs-game-dev.md §4;**精确签名**读各插件 `api.d.ts`;**用法散文 + 集成注意**读各插件 `PLUGIN.md`(比 api.d.ts 多了「怎么装、坑在哪、示例」)。
|
||||
|
||||
| 键 | 目录 | 一句话能力 | 详细读 |
|
||||
|---|---|---|---|
|
||||
| `sceneFsm` | scene-fsm | 场景/状态机(menu→play→over) | `game-runtime/src/plugins/scene-fsm/{api.d.ts,PLUGIN.md}` |
|
||||
| `sessionScore` | session-score | 计分 + 胜负闸 | `game-runtime/src/plugins/session-score/{api.d.ts,PLUGIN.md}` |
|
||||
| `hudUi` | hud-ui | HUD/UI 绘制 + 命中判定 | `game-runtime/src/plugins/hud-ui/{api.d.ts,PLUGIN.md}` |
|
||||
| `timerScheduler` | timer-scheduler | 受控时钟调度(毫秒) | `game-runtime/src/plugins/timer-scheduler/{api.d.ts,PLUGIN.md}` |
|
||||
| `save` | save-progress | KV 持久化(最佳分等) | `game-runtime/src/plugins/save-progress/{api.d.ts,PLUGIN.md}` |
|
||||
| `gamefeel` | gamefeel | 手感:缓动 + 输入缓冲 + coyote/combo | `game-runtime/src/plugins/gamefeel/{api.d.ts,PLUGIN.md}` |
|
||||
| `juice` | particles-juice | 粒子 + 打击感(顿帧/震屏/闪白) | `game-runtime/src/plugins/particles-juice/{api.d.ts,PLUGIN.md}` |
|
||||
| `palettePost` | palette-post | 调色 + 后处理(预设一键观感) | `game-runtime/src/plugins/palette-post/{api.d.ts,PLUGIN.md}` |
|
||||
| `audioMusic` | audio-music | 程序化音乐 + 语义音效 | `game-runtime/src/plugins/audio-music/{api.d.ts,PLUGIN.md}` |
|
||||
| `collision` | collision | 碰撞查询(纯数学) | `game-runtime/src/plugins/collision/{api.d.ts,PLUGIN.md}` |
|
||||
| `physics` | physics-lite | 街机运动原语(非刚体) | `game-runtime/src/plugins/physics-lite/{api.d.ts,PLUGIN.md}` |
|
||||
|
||||
`runtime-probe`(取证不注入):只在收口取证六锚(启动耗时等),不进 `plugins` 面,生成 agent 用不到。
|
||||
|
||||
## 未注入的能力储备(6 件·造好了但没摆上桌)
|
||||
|
||||
这 6 件都是通过测试的真插件(各有 `api.d.ts`/`impl.js`/`test`),但当前 5 个注入品类(经营/剧情/TRPG/解谜/非遗)全是
|
||||
**点击/回合制** tap 玩法,它们服务的是别的品类或需要额外装配,故本波不注入。生成 agent **现在读不到、也用不了它们**——
|
||||
别在 game-logic 里写 `plugins.autoTargeting` 之类,会是 undefined。每件的裁定依据与「什么时候该注入」:
|
||||
|
||||
| 目录 | 是什么 | 为什么没注入 | 注入触发条件 |
|
||||
|---|---|---|---|
|
||||
| **auto-targeting** | 索敌策略族(最近/锥内/随机选目标) | 服务射击/动作类「自动锁敌」,当前 tap 品类用不上 | W-GENRE 上动作/射击品类时 |
|
||||
| **swarm-steering** | 均匀网格 + 群体运动学(同屏千级实体) | 服务动作/idle-sim 的大规模实体,tap 品类无此需求 | W-GENRE 上动作或放置模拟品类时 |
|
||||
| **entity-pool** | 对象池 + swap-remove(局内零 GC) | 密集实体(子弹/敌群)性能件;粒子已由 particles-juice 承接 | 同上,动作品类需要密集实体时 |
|
||||
| **virtual-joystick** | 浮动原点虚拟摇杆 + 轨迹 driver | 连续移动输入,与当前 `handleTap` 输入契约不同轴;tap 品类不需要 | W-GENRE 上需要连续移动/瞄准的品类,且输入契约扩展后 |
|
||||
| **canvas-ui-kit** | 卡片选择器 / 滚动列表 / 顶栏(hud-ui 之上的组合件) | 需 `draw: hudUi` 复合装配;对 TRPG 选天赋、经营选货有价值,但注入是行为面变更 | 后续给 TRPG/经营注入的**首选候选**——单起一个装配任务,同步 3 方对账 |
|
||||
| **hit-feedback** | 反馈档位编排(一次事件→顿帧/震屏/音效/飘字) | 需 `juice + sfx` 复合装配;与直调 `juice`+`audioMusic` 重叠,skill/settlement recipe 已教直调 | 若要把反馈收敛成单入口 + 音画同帧取证时 |
|
||||
|
||||
注入任一件 = 改 6 个 `_template*/src/host-config.js` 的 `plugins` 装配 + prompt 白名单(cheap-system.md)+ tools.mjs `PLUGIN_KEY_DIR`,三处同改、再跑 `plugin-surface-gate.py` 验三方对齐。gate 会拦「注入了但白名单漏列」「白名单列了但没注入」「新插件目录没裁定」。
|
||||
14
.agents/skills/recipes/README.md
Normal file
14
.agents/skills/recipes/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# recipes 索引 —— 要做 X → 读 Y
|
||||
|
||||
> 品类 skill 教「什么好玩」,littlejs-game-dev 教「代码怎么分层」;这里补最后一块:**高频场景的落地形态**。
|
||||
> 每篇给你「抄这段形态 + 头号病根」,不讲原理。卡在哪就读哪一篇,抄它的结构改,别从零想。
|
||||
|
||||
| 你要做的事 | 读这篇 | 一句话病根(九成栽在这) |
|
||||
|---|---|---|
|
||||
| 起局/交互按钮,点了要响应 | [hit-rect.md](hit-rect.md) | 命中矩形只在 render 里生成 → 帧饥饿时 render 没跑过 → 矩形 null → 点不动卡菜单 |
|
||||
| 倒计时 / 每 N 秒 spawn / 顾客耐心递减 | [timer.md](timer.md) | dt 是秒,毫秒常量累加漏 ×1000 → 慢 1000 倍 → 永不到阈值、一局不结束 |
|
||||
| 用美术图,缺图不崩 | [asset-fallback.md](asset-fallback.md) | 直接 drawImage(assets[ref].image) 不判 null → 缺图当场崩 |
|
||||
| 菜单 → 游玩 → 结算 三态切换 | [scene-fsm.md](scene-fsm.md) | define({menu,play,over}) 单对象批量注册 → 第一参变 [object Object] → 全没注册 → 卡菜单 |
|
||||
| 成单/暴击/大额时刻的爽感 + 结算页驻留 | [settlement.md](settlement.md) | 结算态不 latch 驻留 → 瞬时终态漏读、闭环断裂;juice.render(g) 忘手调 → 粒子不显示 |
|
||||
|
||||
方法名一律以 `game-runtime/src/plugins/<name>/api.d.ts` 为准,别按常见库套路臆测(幻觉清单见 littlejs-game-dev.md §4)。
|
||||
32
.agents/skills/recipes/asset-fallback.md
Normal file
32
.agents/skills/recipes/asset-fallback.md
Normal file
@ -0,0 +1,32 @@
|
||||
# recipe: 资产回退(缺图/加载失败不崩)
|
||||
|
||||
**要做**:用 `assets/` 里的美术图渲染角色/物品,缺图或加载失败时程序化画个占位、绝不崩。
|
||||
|
||||
**病根九成在这**:`g.drawImage(assets[ref].image, ...)` 直接取用不判 null。缺图时 `assets[ref].image` 是 `null` → `drawImage(null)` 当场抛 → 整局崩。headless 有图测不到,真人换环境缺图才爆。
|
||||
|
||||
**根治**:**每次取图都 `?.image` + `if (img) 画 else 程序化回退`**。ref = manifest 条目 `file` 去扩展名(`shopkeeper-idle.jpg` → `'shopkeeper-idle'`)。
|
||||
|
||||
**抄这段形态**:
|
||||
|
||||
```js
|
||||
async init(boot) {
|
||||
this.ctx = boot.ctx;
|
||||
this.assets = boot.assets || {}; // host 已预载;无美术游戏时是 {}
|
||||
}
|
||||
|
||||
render(g) {
|
||||
const img = this.assets['shopkeeper-idle']?.image; // ★?. 兜 null
|
||||
if (img) {
|
||||
g.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
// 程序化回退:画个色块占位,绝不因缺图崩
|
||||
g.fillStyle = '#c8a06a';
|
||||
g.fillRect(x, y, w, h);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**别踩**:
|
||||
- 先 `read_file('assets/manifest.json')` 看有哪些资产(非空才有图);**无 manifest / 无美术 → 全程序化绘制即可**,别硬编码文件名。
|
||||
- BGM/音效**不在** `boot.assets`——走 `plugins.audioMusic`(`playSfx('coin')` / `play(intensity)`)。
|
||||
- 改一张图:`mmx` 重生成覆盖同名文件 + 更新 manifest 的 sha256/bytes/prompt;**文件名没变 → 代码一行不用动**。
|
||||
36
.agents/skills/recipes/hit-rect.md
Normal file
36
.agents/skills/recipes/hit-rect.md
Normal file
@ -0,0 +1,36 @@
|
||||
# recipe: 命中矩形(按钮点不动的头号根治)
|
||||
|
||||
**要做**:菜单的「开始」、结算的「重来」、玩中的可点区——点了要真响应。
|
||||
|
||||
**病根九成在这**:命中矩形只在 `render` 的副作用里生成(`menuStartBtn = renderMenu(...)` 那种回写)。并发渲染帧饥饿时 `render` 可能一次都没跑过 → 命中矩形恒 `null` → 点了没反应 → 卡在菜单一局起不来。这是「卡 menu」的头号成因。
|
||||
|
||||
**根治口诀**:按钮几何抽成纯函数,**场景 `onEnter`(状态进入点)确定性建立命中区,`render` 只画不赋值**——同一来源,不漂移。
|
||||
|
||||
**抄这段形态**:
|
||||
|
||||
```js
|
||||
// 纯函数:按钮几何只算一次的来源(onEnter 与 render 都调它,值一致)
|
||||
function menuStartRect(viewport) {
|
||||
return { x: viewport.w / 2 - 90, y: 520, w: 180, h: 64 };
|
||||
}
|
||||
|
||||
sceneFsm.define('menu', {
|
||||
onEnter() { this.startRect = menuStartRect(viewport); }, // ★命中区在这里建,不在 render
|
||||
render(g) {
|
||||
const btn = hudUi.drawButton(g, this.startRect, { label: '开始' }); // drawButton 回吐命中矩形
|
||||
// 也可直接 hudUi.drawButton(g, menuStartRect(viewport), {label:'开始'})——同源即可
|
||||
},
|
||||
});
|
||||
|
||||
// 输入:只写实例方法 handleTap,L1 在 pointerdown 时直达调用它(别自己订阅输入)
|
||||
handleTap(x, y) {
|
||||
if (sceneFsm.current() === 'menu' && hudUi.pointInRect(x, y, this.startRect)) {
|
||||
sceneFsm.transition('play'); // 命中 → 起局
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**别踩**:
|
||||
- `drawButton(g, rect, opts)` 第二参是 **rect 对象 `{x,y,w,h}`**,不是 label/坐标位置参——别写 `drawButton(g, '开始', x, y, w, h)`。
|
||||
- 命中判定只有 `hudUi.pointInRect(px, py, rect)`(collision 插件**没有** pointInRect)。
|
||||
- 输入不写 `ctx.getInput`、不自建 pendingClicks 队列——只暴露 `handleTap(x,y)`/`handleKey(key)`。
|
||||
36
.agents/skills/recipes/scene-fsm.md
Normal file
36
.agents/skills/recipes/scene-fsm.md
Normal file
@ -0,0 +1,36 @@
|
||||
# recipe: 场景机(menu → play → over 三态)
|
||||
|
||||
**要做**:菜单页 → 游玩 → 结算页,能来回切。
|
||||
|
||||
**病根九成在这**:`sceneFsm.define({ menu:{...}, play:{...}, over:{...} })` 一个对象批量注册。`define` 第一参**恒为场景名字符串**——传对象 → 第一参被当成场景名 `"[object Object]"`,menu/play/over 全没真注册 → 后续 `transition('play')` 找不到目标态被静默忽略 → 卡菜单进不去 play(合成/2048 类翻车点)。
|
||||
|
||||
**根治**:**每个场景各调一次 `define(name, handlers)`**,第一参是字符串。
|
||||
|
||||
**抄这段形态**:
|
||||
|
||||
```js
|
||||
sceneFsm
|
||||
.define('menu', {
|
||||
onEnter() { this.startRect = menuStartRect(viewport); }, // 命中区在 onEnter 建(见 hit-rect.md)
|
||||
render(g) { hudUi.drawButton(g, this.startRect, { label: '开始' }); },
|
||||
})
|
||||
.define('play', {
|
||||
onEnter() { this.score = 0; this.remainMs = ROUND_MS; },
|
||||
update(dt) { /* 只收 dt!推进玩法、判胜负 */ },
|
||||
render(g) { /* 画游玩画面,只收 g */ },
|
||||
})
|
||||
.define('over', {
|
||||
onEnter() { sessionScore.isOver() || sessionScore.lose(); }, // 结算态:latch 驻留(见 settlement.md)
|
||||
render(g) { hudUi.drawButton(g, this.overRestartRect, { label: '再来' }); },
|
||||
});
|
||||
|
||||
sceneFsm.start('menu'); // 起手停在菜单
|
||||
// update/render 五法里把帧转交场景机:
|
||||
update(dt) { bundle.tick(dt); sceneFsm.update(dt); }
|
||||
render(g) { sceneFsm.render(g); }
|
||||
```
|
||||
|
||||
**别踩**:
|
||||
- 回调表里 `update(dt)` 只收 dt、`render(g)` 只收 g——与五法同形,别臆造 `update(scene, dt)`。
|
||||
- `_forensicsView().state().phase` 必须**实时读** `sceneFsm.current()`(在 state() 函数体内),别在外层先算好闭包返回(host 只在 boot 调一次 _forensicsView,外层算的值会定格成 boot 快照=永远 menu → 自动验收永判死菜单)。
|
||||
- 状态名你自己定(menu/play/over 只是惯例);`transition(to)` 的 to 必须是你 define 过的名。
|
||||
47
.agents/skills/recipes/settlement.md
Normal file
47
.agents/skills/recipes/settlement.md
Normal file
@ -0,0 +1,47 @@
|
||||
# recipe: 结算演出(爽感反馈 + 结算页驻留)
|
||||
|
||||
**要做**:成单/暴击/大额那一下有「打击感」,一局结束进结算页并**停住不动**。
|
||||
|
||||
**两个病根**:
|
||||
1. **结算态不驻留(自动重开)** → 瞬时终态被漏读 → 宿主 500ms 轮询 phase 代发 `game_end` 时已经不在 over 态 → 结算闭环静默断裂。`phase` 进 `over/gameover` 后**必须恒驻留**(禁自动重开,等玩家点「再来」才回 menu)。
|
||||
2. **`juice.render(g)` 忘了每帧手调** → 粒子/屏震/闪白全不显示(juice 的 render 不是自动的,要在你的 render 里每帧调)。
|
||||
|
||||
**抄这段形态(结算时刻的爽感)**:
|
||||
|
||||
```js
|
||||
// 成单/暴击那一下:一次 hitStop + 轻 shake + 音效(单行调用,不是系统,好手感标配)
|
||||
onServeSuccess(x, y, isCrit) {
|
||||
juice.spawnEmitter('burst', x, y); // 粒子迸发
|
||||
juice.hitStop(isCrit ? 90 : 40); // 顿帧(毫秒)
|
||||
juice.shakeScreen(isCrit ? 6 : 3); // 轻震屏
|
||||
audioMusic.playSfx(isCrit ? 'win' : 'coin'); // 语义音效名,自动映射
|
||||
sessionScore.addScore(isCrit ? 8 : 5);
|
||||
}
|
||||
|
||||
render(g) {
|
||||
sceneFsm.render(g);
|
||||
juice.render(g); // ★每帧手调,否则粒子/打击感看不见
|
||||
}
|
||||
```
|
||||
|
||||
**抄这段形态(结算页 latch 驻留)**:
|
||||
|
||||
```js
|
||||
.define('over', {
|
||||
onEnter() {
|
||||
if (!sessionScore.isOver()) sessionScore.lose(); // 终态一次性落定
|
||||
this.overRestartRect = overRestartRect(viewport); // 「再来」命中区在 onEnter 建
|
||||
audioMusic.playSfx('fail');
|
||||
},
|
||||
render(g) {
|
||||
hudUi.drawText(g, `本局 ${sessionScore.getScore()} 分`, { x: viewport.w/2, y: 300 });
|
||||
hudUi.drawButton(g, this.overRestartRect, { label: '再来' });
|
||||
},
|
||||
// ★没有 update 里的自动 transition('menu')——驻留,等 handleTap 命中「再来」才回 menu
|
||||
})
|
||||
```
|
||||
|
||||
**别踩**:
|
||||
- 音效名用语义名(`coin/win/hit/jump/fail/click`)或预设(`blip/thud/chime`),未知名自动兜底 blip;`audioMusic.play(intensity)` 入参是**情绪强度 0..1**、不是曲目名。
|
||||
- `juice.spawnEmitter('burst', x, y)` 也可写 `juice.burst(x, y)`(母语别名);`hitStop`/`shakeScreen` 收毫秒/像素。
|
||||
- 结算的 `_forensicsView().state().phase` 要能读到 `gameover`(自动验收据 latch 驻留判终态可达)。
|
||||
36
.agents/skills/recipes/timer.md
Normal file
36
.agents/skills/recipes/timer.md
Normal file
@ -0,0 +1,36 @@
|
||||
# recipe: 计时器 / 倒计时 / spawn 间隔 / 耐心递减
|
||||
|
||||
**要做**:一局限时倒计时、每隔 N 秒刷一个顾客/敌人、顾客耐心随时间掉。
|
||||
|
||||
**病根九成在这**:`update(dt)` 的 **dt 单位是「秒」(≈1/60)**,但你的计时常量常写成毫秒(倒计时 60000、spawn 间隔 1300、耐心 6000)。直接 `elapsedMs += dt` → 慢 1000 倍 → 永远到不了 spawn 阈值 → 零顾客、一局永不结束(计时显示还可能是 `NaNs`)。
|
||||
|
||||
**两条换算铁律**:
|
||||
- 自己累加毫秒:**`elapsedMs += dt * 1000`**(漏 ×1000 是头号翻车)。
|
||||
- 用 `timerScheduler`:它**收毫秒**——3 秒写 `after(3000, cb)`、每秒写 `every(1000, cb)`(**别写 `after(3)`**;与 dt=秒相反,别混)。
|
||||
- 无论哪种,`update(dt)` 内**必须 `bundle.tick(dt)`**,否则插件 onFrame/timer 全不推进、timer 永不到期、一局不结束。
|
||||
|
||||
**抄这段形态(自累加毫秒版)**:
|
||||
|
||||
```js
|
||||
const ROUND_MS = 60000; // 一局 60 秒(毫秒常量)
|
||||
const SPAWN_MS = 1300; // 每 1.3 秒来一个客
|
||||
|
||||
update(dt) {
|
||||
bundle.tick(dt); // ★必调,否则一切计时不动
|
||||
if (sceneFsm.current() !== 'play') return;
|
||||
const ms = dt * 1000; // ★秒 → 毫秒
|
||||
this.remainMs -= ms;
|
||||
this.spawnAcc += ms;
|
||||
if (this.spawnAcc >= SPAWN_MS) { this.spawnAcc -= SPAWN_MS; this.spawnCustomer(); }
|
||||
if (this.remainMs <= 0) sceneFsm.transition('over'); // 时间到 → 结算
|
||||
}
|
||||
```
|
||||
|
||||
**抄这段形态(timerScheduler 版,波次刷新)**:
|
||||
|
||||
```js
|
||||
timerScheduler.every(1300, () => this.spawnCustomer()); // 毫秒!每 1.3s 一个
|
||||
timerScheduler.after(60000, () => sceneFsm.transition('over')); // 60s 后结算
|
||||
```
|
||||
|
||||
**别踩**:`scene` 的 `update` 回调只收 `(dt)` 一个参数——别臆造 `update(running, dt)`(写错 → dt 落到第二参=undefined → `elapsedMs += undefined = NaN` → 空转看着像没 bug 实则永不推进)。
|
||||
214
.agents/tools/plugin-surface-gate.py
Normal file
214
.agents/tools/plugin-surface-gate.py
Normal file
@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""plugin-surface-gate.py —— 便宜档插件面三方一致性对账机器门(防漂移)。
|
||||
|
||||
背景:便宜档生成 agent 只能用「已注入」的插件——它经 `plugins.<键>` 取,不能 import。而「有哪些插件
|
||||
可用」这件事在三个地方各写一份、彼此独立会漂移:
|
||||
· 插件目录 = game-runtime/src/plugins/<name>/(谁真实存在,含未注入的储备件);
|
||||
· prompt 白名单 = contracts/prompts/04-config/cheap-system.md 告诉模型「可用插件键」的那一行;
|
||||
· host-config 注入清单 = game-runtime/games/_template/src/host-config.js 的 `const plugins = {…}`(真装配进运行时的)。
|
||||
外加一份 tools.mjs 的 PLUGIN_KEY_DIR(check 的 API 静态门据它把 plugins.<键>.<方法> 映到 api.d.ts 查方法名)。
|
||||
四者任一漂移都会出真事故:白名单列了但没注入 → 模型写 plugins.x → 运行时 undefined、boot 崩;注入了但白名单没列
|
||||
→ 模型不知道有这件、能力白摆(诊断档 §四⑦点名的「暗资产」);目录新增一件却没人裁定它该注入还是入储备 → 悄悄成
|
||||
第七个暗插件。本门把「这三方 + PLUGIN_KEY_DIR 必须对齐,且每个插件目录都被显式裁定为『注入/取证/储备』之一」编译
|
||||
成 CI 与 pre-commit 消费的退出码。
|
||||
|
||||
裁定台账(单一事实源 = 本门的三个常量,改这里即改裁定;人读依据见 .agents/skills/plugin-capability-map.md):
|
||||
· INJECTED = 三方对齐后应得的 11 键(不写死键名——从三方源解析后取交集/并集对账,键名漂移由对账本身抓);
|
||||
· PROBE_DIRS = 取证不注入(runtime-probe:只在收口取证、不进 plugins 面,api.d.ts 明示);
|
||||
· RESERVE_DIRS = 未注入能力储备(6 件:当前 5 注入品类是 tap/回合制,这些是动作/群体/摇杆/组合件类,
|
||||
归 W-GENRE 动作线或需复合装配,本波不注入。逐件依据见 plugin-capability-map.md)。
|
||||
|
||||
判据:
|
||||
1. 三方注入键集必须【完全相等】:prompt 白名单 == host-config 注入 == PLUGIN_KEY_DIR 键集 —— 否则硬失败并打差集。
|
||||
2. 每个注入键经 PLUGIN_KEY_DIR 映到的目录必须真实存在(含 impl.js)—— 否则硬失败(注入了不存在的插件)。
|
||||
3. 目录分区完备:每个真实插件目录(含 impl.js)必须恰好落在 {注入目录, PROBE_DIRS, RESERVE_DIRS} 之一 ——
|
||||
多出未裁定的目录(新插件没决定注入/储备)硬失败;台账里的 PROBE/RESERVE 目录消失(插件被删/改名)硬失败。
|
||||
4. 储备/取证目录绝不能出现在任何注入源里(储备件被误注入 → 必须先从 RESERVE_DIRS 挪走再注入)—— 否则硬失败。
|
||||
|
||||
零依赖:仅标准库(re/sys/pathlib),与同仓 docs-gate.py / rubric-sync-gate.py 同栈同风格。
|
||||
|
||||
用法:
|
||||
python3 .agents/tools/plugin-surface-gate.py # 对本仓
|
||||
python3 .agents/tools/plugin-surface-gate.py --root DIR # 对指定仓根(供负向演示跑改过的副本)
|
||||
# exit 0 = 三方对齐且目录全裁定;exit 1 = 有漂移/未裁定/储备误注入
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── 裁定台账(单一事实源;人读依据见 .agents/skills/plugin-capability-map.md)──────────────────────
|
||||
PROBE_DIRS = {"runtime-probe"} # 取证不注入(收口取证,不进 plugins 面)
|
||||
RESERVE_DIRS = { # 未注入能力储备(逐件依据见 plugin-capability-map.md)
|
||||
"auto-targeting", # 索敌策略族——动作/射击品类,归 W-GENRE 动作线
|
||||
"swarm-steering", # 均匀网格+群体运动学——同屏千级实体,动作/idle-sim 品类
|
||||
"entity-pool", # 对象池+swap-remove——密集实体零 GC,动作品类;粒子已由 particles-juice 承接
|
||||
"virtual-joystick", # 虚拟摇杆——连续移动输入,当前 5 品类全 tap/回合制,且与 handleTap 输入契约不同轴
|
||||
"canvas-ui-kit", # 卡片选择器/滚动列表/顶栏——组合件,需 draw:hudUi 复合装配;可为 TRPG/经营未来注入候选
|
||||
"hit-feedback", # 反馈档位编排——需 juice+sfx 复合装配,与直调 juice/audioMusic 重叠(skill 已教直调)
|
||||
}
|
||||
NON_PLUGIN_DIRS = {"_example"} # 示例脚手架,非真插件
|
||||
|
||||
# 三方源在仓内的相对路径。
|
||||
PROMPT_MD = "contracts/prompts/04-config/cheap-system.md"
|
||||
HOST_CONFIG = "game-runtime/games/_template/src/host-config.js"
|
||||
TOOLS_MJS = "game-runtime/tools/amodel-gen/tools.mjs"
|
||||
PLUGINS_DIR = "game-runtime/src/plugins"
|
||||
|
||||
|
||||
def _fail(msgs: list[str]) -> None:
|
||||
print("✘ plugin-surface-gate 未过:")
|
||||
for m in msgs:
|
||||
print(" - " + m)
|
||||
|
||||
|
||||
def parse_prompt_whitelist(root: Path) -> set[str] | None:
|
||||
"""从 cheap-system.md 抽「可用插件键(…已注入…):k1 / k2 / …」那一行的键集。抽不到返回 None。"""
|
||||
p = root / PROMPT_MD
|
||||
try:
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
if "可用插件键" in line and "已注入" in line:
|
||||
# 取中文冒号「:」后的部分,按 / 切,只留 [A-Za-z] 词元。
|
||||
after = line.split(":", 1)[-1] if ":" in line else line
|
||||
keys = {t for t in re.findall(r"[A-Za-z][A-Za-z0-9]*", after)}
|
||||
return keys or None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_host_inject(root: Path) -> set[str] | None:
|
||||
"""从 _template/src/host-config.js 抽 `const plugins = { k1, k2, … }` 的键集。抽不到返回 None。"""
|
||||
p = root / HOST_CONFIG
|
||||
try:
|
||||
txt = p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r"const\s+plugins\s*=\s*\{([^}]*)\}", txt)
|
||||
if not m:
|
||||
return None
|
||||
# 对象是简写键(shorthand),逐词元取标识符即键名。
|
||||
keys = {t for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", m.group(1))}
|
||||
return keys or None
|
||||
|
||||
|
||||
def parse_plugin_key_dir(root: Path) -> dict[str, str] | None:
|
||||
"""从 tools.mjs 抽 `const PLUGIN_KEY_DIR = { key: 'dir', … }` 映射。抽不到返回 None。"""
|
||||
p = root / TOOLS_MJS
|
||||
try:
|
||||
txt = p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r"const\s+PLUGIN_KEY_DIR\s*=\s*\{(.*?)\}", txt, re.S)
|
||||
if not m:
|
||||
return None
|
||||
pairs = dict(re.findall(r"(\w+)\s*:\s*'([^']+)'", m.group(1)))
|
||||
return pairs or None
|
||||
|
||||
|
||||
def list_real_plugin_dirs(root: Path) -> set[str] | None:
|
||||
"""列 src/plugins/ 下含 impl.js 的真实插件目录名。目录不存在返回 None。"""
|
||||
d = root / PLUGINS_DIR
|
||||
if not d.is_dir():
|
||||
return None
|
||||
out = set()
|
||||
for sub in d.iterdir():
|
||||
if sub.is_dir() and (sub / "impl.js").is_file():
|
||||
out.add(sub.name)
|
||||
return out
|
||||
|
||||
|
||||
def run(root: Path) -> int:
|
||||
msgs: list[str] = []
|
||||
|
||||
whitelist = parse_prompt_whitelist(root)
|
||||
inject = parse_host_inject(root)
|
||||
key_dir = parse_plugin_key_dir(root)
|
||||
dirs = list_real_plugin_dirs(root)
|
||||
|
||||
# 任一源解析不出 = 结构被改坏/路径漂移,硬失败(不静默放行)。
|
||||
if whitelist is None:
|
||||
msgs.append(f"prompt 白名单解析失败(找不到「可用插件键…已注入…」行):{PROMPT_MD}")
|
||||
if inject is None:
|
||||
msgs.append(f"host-config 注入清单解析失败(找不到 const plugins = {{…}}):{HOST_CONFIG}")
|
||||
if key_dir is None:
|
||||
msgs.append(f"PLUGIN_KEY_DIR 解析失败:{TOOLS_MJS}")
|
||||
if dirs is None:
|
||||
msgs.append(f"插件目录不存在:{PLUGINS_DIR}")
|
||||
if msgs:
|
||||
_fail(msgs)
|
||||
return 1
|
||||
|
||||
key_dir_keys = set(key_dir.keys())
|
||||
|
||||
# 判据 1:三方注入键集完全相等。
|
||||
if not (whitelist == inject == key_dir_keys):
|
||||
msgs.append("三方注入键集不一致(prompt 白名单 / host-config 注入 / PLUGIN_KEY_DIR 必须完全相等):")
|
||||
msgs.append(f" prompt 白名单({len(whitelist)}):{sorted(whitelist)}")
|
||||
msgs.append(f" host-config 注入({len(inject)}):{sorted(inject)}")
|
||||
msgs.append(f" PLUGIN_KEY_DIR({len(key_dir_keys)}):{sorted(key_dir_keys)}")
|
||||
only_wl = whitelist - inject - key_dir_keys
|
||||
only_hc = inject - whitelist - key_dir_keys
|
||||
only_kd = key_dir_keys - whitelist - inject
|
||||
missing_wl = (inject & key_dir_keys) - whitelist
|
||||
if only_wl:
|
||||
msgs.append(f" 仅在 prompt 白名单(列了但没注入→运行时 undefined、boot 崩):{sorted(only_wl)}")
|
||||
if only_hc:
|
||||
msgs.append(f" 仅在 host-config(注入了但白名单/静态门没列→模型不知道有它、能力白摆):{sorted(only_hc)}")
|
||||
if only_kd:
|
||||
msgs.append(f" 仅在 PLUGIN_KEY_DIR:{sorted(only_kd)}")
|
||||
if missing_wl:
|
||||
msgs.append(f" 注入了但 prompt 白名单漏列:{sorted(missing_wl)}")
|
||||
|
||||
injected_dirs = {key_dir[k] for k in key_dir_keys}
|
||||
|
||||
# 判据 2:每个注入键的目录真实存在(含 impl.js)。
|
||||
for k in sorted(key_dir_keys):
|
||||
if key_dir[k] not in dirs:
|
||||
msgs.append(f"注入键 {k} 映到目录 src/plugins/{key_dir[k]}/ 不存在或缺 impl.js")
|
||||
|
||||
# 判据 4:储备/取证目录绝不能出现在任何注入源(键名≠目录名,按目录集判)。
|
||||
reserve_probe = RESERVE_DIRS | PROBE_DIRS
|
||||
bad_injected = injected_dirs & reserve_probe
|
||||
if bad_injected:
|
||||
msgs.append(f"储备/取证目录被误注入(须先从 RESERVE_DIRS/PROBE_DIRS 台账挪走再注入):{sorted(bad_injected)}")
|
||||
|
||||
# 判据 3:目录分区完备——每个真实插件目录恰好落在 {注入, PROBE, RESERVE} 之一;NON_PLUGIN 豁免。
|
||||
classified = injected_dirs | PROBE_DIRS | RESERVE_DIRS | NON_PLUGIN_DIRS
|
||||
unclassified = dirs - classified
|
||||
if unclassified:
|
||||
msgs.append("发现未裁定的插件目录(新插件必须显式裁定:注入 / 取证 PROBE_DIRS / 储备 RESERVE_DIRS 三选一,"
|
||||
"并在 plugin-capability-map.md 写一句依据):")
|
||||
msgs.append(f" {sorted(unclassified)}")
|
||||
# 台账里登记的 PROBE/RESERVE 目录若已从盘上消失(删/改名)→ 台账过时,硬失败。
|
||||
ghost = (PROBE_DIRS | RESERVE_DIRS) - dirs
|
||||
if ghost:
|
||||
msgs.append(f"台账登记的取证/储备目录在盘上不存在(插件被删/改名?更新本门台账与 plugin-capability-map.md):{sorted(ghost)}")
|
||||
|
||||
if msgs:
|
||||
_fail(msgs)
|
||||
return 1
|
||||
|
||||
print(f"✔ plugin-surface-gate 全绿:{len(key_dir_keys)} 注入键三方对齐"
|
||||
f"(prompt 白名单 == host-config == PLUGIN_KEY_DIR);"
|
||||
f"{len(PROBE_DIRS)} 取证 + {len(RESERVE_DIRS)} 储备目录全登记在册,{len(dirs)} 个真实插件目录全部已裁定。")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 缺省仓根锚定到脚本自身位置(<repo>/.agents/tools/ → parents[2] = 仓根),不吃 cwd——
|
||||
# 2026-07-09 验收实测:cwd 在别处跑会把四路解析全报「失败」,是误导性假漂移(pre-commit/CI 恰好
|
||||
# 都从仓根跑才没暴露)。--root 覆盖位保留(负向演示跑改过的副本用)。
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
argv = sys.argv[1:]
|
||||
if "--root" in argv:
|
||||
i = argv.index("--root")
|
||||
if i + 1 < len(argv):
|
||||
root = Path(argv[i + 1]).resolve()
|
||||
return run(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -35,6 +35,9 @@ jobs:
|
||||
- name: 品类 rubric fixture↔skill 双写对账
|
||||
run: python3 .agents/tools/rubric-sync-gate.py
|
||||
|
||||
- name: 便宜档插件面三方一致性对账(目录/prompt 白名单/host-config 注入 + PLUGIN_KEY_DIR)
|
||||
run: python3 .agents/tools/plugin-surface-gate.py
|
||||
|
||||
- name: Prompt Registry 版本一致性
|
||||
run: python3 contracts/prompts/check_registry.py
|
||||
|
||||
|
||||
@ -29,6 +29,12 @@ if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.a
|
||||
python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; }
|
||||
fi
|
||||
|
||||
# ── 门 6:便宜档插件面三方一致性对账 —— 触发 = 触及插件目录/prompt 白名单/host-config 注入/PLUGIN_KEY_DIR ──
|
||||
# 防漂移:白名单列了但没注入(运行时崩)、注入了但白名单没列(暗资产)、新增插件目录未裁定(悄悄成暗插件)。
|
||||
if printf '%s\n' "$STAGED" | grep -qE '(game-runtime/src/plugins/|contracts/prompts/04-config/cheap-system\.md|game-runtime/games/_template/src/host-config\.js|amodel-gen/tools\.mjs)'; then
|
||||
python3 "$ROOT/.agents/tools/plugin-surface-gate.py" || { echo '✘ 插件面三方对账未过'; fail=1; }
|
||||
fi
|
||||
|
||||
# ── 门 5:Prompt 契约双闸 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ──
|
||||
# 闸 a = 版本一致性(check_registry:registry.yaml↔.md frontmatter 的 version 必须相等);
|
||||
# 闸 b = 四道闸·闸0 版本升号(check_version_bump:改了 prompt 正文相对 HEAD 必须升 version)。
|
||||
|
||||
@ -36,7 +36,7 @@ MVP 目标:交付一条**种子用户可试用的全链路闭环** —— create
|
||||
|
||||
现行技术口径(决策史与依据一律见 [`.agents/knowledge/tech-decisions.md`](.agents/knowledge/tech-decisions.md),运行时单一真相见 [`docs/architecture/架构/生成引擎/agentic运行时架构图说.md`](docs/architecture/架构/生成引擎/agentic运行时架构图说.md)):
|
||||
|
||||
- **游戏生成统一收敛 AgentScope**(2026-06-25):按 AI 参与深度分三档(Tier0/1/2),全部高度模板化;产物终态 = `src/` 多文件源工程(gameDefinition 已废);各档过九门兜底真玩判定。tier2 富游戏档 = AgentScope + Phaser 独立 Python service。
|
||||
- **游戏生成统一收敛 AgentScope**(2026-06-25):按 AI 参与深度分三档(Tier0/1/2),全部高度模板化;产物终态 = `src/` 多文件源工程(gameDefinition 已废);各档过分层验收(2026-07-09,裁定见质量模型 SoT 裁定三):九门机械预筛兜「未见明显死」地板;玩法地板便宜档 = 独立模型判定(W-AXIS 落地中)、tier2 = 富三门双路真玩(对位物,是否叠加模型判定随校准另议)。tier2 富游戏档 = AgentScope + Phaser 独立 Python service。
|
||||
- **引擎按表现复杂度选**:轻-中档 LittleJS 增强发行版 / 最高档 Phaser;引擎不是分档轴;Cocos 只留 3D/渠道导出(人在环)。
|
||||
- **SAA / Dify / coze 降为最低优先级远期**(适配验证可插拔目标,不在 MVP runtime)。
|
||||
- **Nacos / RocketMQ / Sentinel = MVP 生产 runtime**(2026-07-01 反转:Spring Cloud Alibaba 原生三件套,自托管 mini-infra;配置控制面在飞,见 [`docs/agent-specs/_index.md`](docs/agent-specs/_index.md))。
|
||||
@ -112,6 +112,7 @@ games-development-ai/
|
||||
10. **文档治理机器门**(2026-06-20 立,2026-07-02 落地脚本):规则必须编译成机器门,否则等于没有。门 = [`.agents/tools/docs-gate.sh`](.agents/tools/docs-gate.sh) 七检——G1 品牌不变量(活层禁退役品牌名;唯一字面量存于门脚本)/ G2 canonical 唯一性 + 注册表对账 / G3 死链 / G4 入口卫生(本文件禁机器 setup、个人路径、硬编码端口)/ G5 留痕隔离(策展层不链非 canonical 留痕)/ G6 设计档申报(topic/status/sot-impact)/ G7 计划谱系(新建 plan/设计档必带 frontmatter `上级:` 单指针,沿链可达 canonical SoT;谱系树用 `.agents/tools/plan-tree.py` 查询)。挂载:pre-commit + Gitea Actions + wave-close 第 8 步;细则与白名单见 [engineering-conventions §10.7](.agents/rules/engineering-conventions.md)。门③(doc↔code 兑现断言)规格同见 §10.7,随生成主线 harness 落地。
|
||||
11. **横切一致性主人 + AGENTS.md 自审**(2026-06-20,创始人):跨文档/跨时间的一致性(SoT 收敛、品牌统一、设计↔代码兑现、本文件新鲜度)显式归**创始人 + 6c6g 文档/设计线**(有状态主体),负责机器门做不出的灰色判断。任何改名/子系统增删/核心决策推翻,**同一 commit 必须重审本文件**(品牌、死链、canonical 对账、入口卫生——docs-gate 固定扫)。
|
||||
12. **落档文档 = 资深工程师写的散文**(2026-06-21,创始人):流畅人读中文、逻辑连续、必要处配直白图表。**禁三样**:元叙述("本文讲什么/下面介绍/前面讲过")、AI 造词与黑话堆砌(项目既有术语可用,首次出现讲清)、套话空强调("至关重要/本质上/值得注意的是")。派子代理写文档,必须把本标准连同正反例一起传下去(只传"写散文"不够,输出会退回 AI 味)。
|
||||
13. **工具输出只是参考,不是事实;最终产物必须真浏览器验收**(2026-07-08,创始人):任何工具 / harness 的判定(`check.ok` / 九门 `verdict.pass` / smoke / lint / bright、distinctStates 等)一律**只作参考、绝不当成事实**——它们会假阳也会假阴。2026-07-08 坐实:`stripCode` 遇字符串内 `//`(如赛博文案 `'JACK IN // FLOOR'`)误把真代码当注释抹掉,致 check **假阴性**误报红线缺失、模型明明写对却被反复拒到熔断烧钱;反向地,九门也放行过秒死 / 存档刷分 / 死锁的坏死游戏(**假阳性**)。因此:**每一个最终产物(生成的游戏 / 页面 / 任何可交付物)必须真实用浏览器打开、亲眼看、亲手玩,以截图为硬证验收**——工具判过 ≠ 验收过。产物失败或报错时,**根因分析「为什么失败 / 报错」下钻到具体代码 / 数据 / 配置**,绝不停在「工具说通过 / 工具说不行」。模型能力类判断(玩法、美术、音乐、好不好玩)本就不进代码校验,只能靠真看真玩 + agent/skill/mcp。
|
||||
|
||||
## 7. 知识累积机制(同样是硬约束)
|
||||
|
||||
|
||||
@ -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,6 +101,20 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
|
||||
# 让 AI 知道起点已是一款该品类的完整游戏、按 brief 换皮而非重写(与 scaffold_template 配对,见 run_studio)。
|
||||
_DEFAULT_SCAFFOLD_DESC = "一个跑通的脚手架起点「点圆得分」:menu→play→over 骨架 + 五法齐全 + 编排插件示范"
|
||||
|
||||
# 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(楼层推进 + 掷骰对抗结算 + 每几层升级选天赋,全链通过九门)",
|
||||
"_template-shop": "一款完整可玩的经营点客游戏(顾客点单 + 上菜匹配 + 收银攒钱 + 3-4 级解锁阶梯,全链通过九门)",
|
||||
"_template-puzzle": "一款完整可玩的解谜游戏(观察→推理→点解 + 规则递进关卡阶梯 + 卡壳兜底,全链通过九门)",
|
||||
"_template-story": "一款完整可玩的剧情互动叙事游戏(分支选择 + 多结局 latch + 结局图鉴,全链通过九门)",
|
||||
}
|
||||
|
||||
|
||||
# ── C2a:cheap_roles 运行时热取配置 ─────────────────────────────────────────────
|
||||
# 把 _SYSTEM_PROMPT 从「硬编码字符串」改成「运行时从 contracts/prompts 读 + mtime 缓存 + 回落内置」。
|
||||
|
||||
@ -15,6 +15,7 @@ cheap_run.py — 便宜档生成的工具底座实现(对照源:Node amodel-
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@ -65,6 +66,119 @@ def session_cfg_path(session_id: str) -> Path:
|
||||
return _CHEAP_SESSIONS_DIR / f"{session_id}.json"
|
||||
|
||||
|
||||
# ───────────────────────── W-AXIS 波1 真相层:per-run 归档 + evidence 清理清单化 ─────────────────────────
|
||||
# 病根(诊断档 §2.3 F0-b/F0-c):
|
||||
# F0-b「失败 run 被覆盖」——scaffoldSaa 每局起手 rmSync(amgen-<id>/)(tools.mjs:471)整目录删,重跑同 gid
|
||||
# 直接抹掉上一 run 的 trace.jsonl / turns.jsonl / src / 收口采集,失败链路无从回放。
|
||||
# F0-c「evidence 残留混杂」——scaffold 只删 amgen-<id>/、绝不碰 _wg1-gen/<id>/,于是九门/续修反馈/截图在
|
||||
# _wg1-gen/<id>/evidence/ 逐 run 累积(实证:hard-heritage 的 verdict-feedback.json@07-08 与 verdict.json@07-09
|
||||
# 并存=不同 run 残留);旧红线③只清 verdict.json 一个文件,反馈/日志/截图残留照喂下一 run。
|
||||
# 本节两件对症:
|
||||
# ① archive_prior_run:重跑同 gid 前把上一 run 的 amgen-<id>/ 与 _wg1-gen/<id>/ 整体【移】进带时间戳归档位,
|
||||
# 永不同 gid 覆盖;返回归档指针(供批次账每 run 记一笔)。
|
||||
# ② clean_stale_evidence:scaffold 后按【固定清单】清 _wg1-gen/<id>/evidence/ 残留(verdict/反馈/日志/截图全列),
|
||||
# 不再只清一个文件——封「读回上一 run 反馈/绿 verdict → 误归因/假绿」。
|
||||
|
||||
# 归档根:归档位 = games/_amgen-archive/<gid>/<时间戳>/{amgen/, wg1/}。_ 前缀=基建目录,不被 scaffold/list 当游戏。
|
||||
_ARCHIVE_DIR = _GAMES_DIR / "_amgen-archive"
|
||||
|
||||
# scaffold 后应清的 _wg1-gen/<id>/evidence/ 残留清单(F0-c:全列,不再只清 verdict.json)。
|
||||
# verdict-feedback.json 是残留混杂的元凶(旧红线③没清);game-log/截图/快照/报告一并清,防跨 run 混读。
|
||||
_STALE_EVIDENCE_WG1 = (
|
||||
"verdict.json", # 九门裁决(旧红线③只清它)
|
||||
"verdict-feedback.json", # C6 续修反馈(残留混杂元凶——旧代码不清,喂错下一 run 归因)
|
||||
"game-log.json", # smoke 抓的运行时日志
|
||||
"world-snapshot.json", # 世界快照(若产)
|
||||
"play-report.json", # 真玩报告(若产)
|
||||
"first-paint.png", # 首帧截图
|
||||
"after-play.png", # 真玩后截图
|
||||
)
|
||||
# amgen-<id>/evidence/ 侧:scaffold 已 rmSync 整个 amgen-<id>/、这些本已随之清掉,列此仅作【不归档/不 scaffold 时】
|
||||
# 的防御兜底(如归档关 + 未走 scaffold 的异常路);service-run-summary.json = Service 收口采集,brief.json 本次早落不清。
|
||||
_STALE_EVIDENCE_AMGEN = (
|
||||
"service-run-summary.json",
|
||||
)
|
||||
|
||||
# 归档开关(默认开;env CHEAP_ARCHIVE_ENABLED ∈ {0,false,no,off} 关 → 退回旧行为=scaffold 直接覆盖)。
|
||||
_ENV_ARCHIVE_ENABLED = "CHEAP_ARCHIVE_ENABLED"
|
||||
|
||||
|
||||
def _archive_enabled() -> bool:
|
||||
v = os.environ.get(_ENV_ARCHIVE_ENABLED)
|
||||
if v is None:
|
||||
return True
|
||||
return v.strip().lower() not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def archive_prior_run(game_id: str) -> Optional[str]:
|
||||
"""重跑同 gid 前把上一 run 的整套产物【移】进带时间戳归档位,永不同 gid 覆盖(F0-b)。
|
||||
|
||||
移 amgen-<id>/(trace.jsonl/turns.jsonl/src/收口采集)与 _wg1-gen/<id>/(staged bundle/play-spec/九门证据)
|
||||
到 games/_amgen-archive/<gid>/<YYYYmmdd-HHMMSS[-n]>/{amgen/,wg1/};两目录皆不存在(首跑)→ 返回 None、no-op。
|
||||
移走后 scaffold 重建 amgen-<id>/、stage 重建 _wg1-gen/<id>/,旧 run 完整留档可回放。
|
||||
|
||||
best-effort:归档关 / 任一步失败 → 返回 None 并告警,**退回旧行为(scaffold 直接覆盖)**,绝不阻断生成
|
||||
(归档是纯增量旁路,关开关即回)。返回归档目录绝对路径字符串(供批次账每 run 记指针)或 None。
|
||||
"""
|
||||
if not _archive_enabled():
|
||||
return None
|
||||
src_amgen = game_dir(game_id)
|
||||
src_wg1 = wg1_game_dir(game_id)
|
||||
if not src_amgen.exists() and not src_wg1.exists():
|
||||
return None # 首跑:无上一 run 可归档
|
||||
try:
|
||||
# 时间戳归档位;同秒重跑加 -n 后缀防撞(极少数并发/秒内重跑)。
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
base = _ARCHIVE_DIR / str(game_id)
|
||||
dst = base / stamp
|
||||
n = 1
|
||||
while dst.exists():
|
||||
dst = base / f"{stamp}-{n}"
|
||||
n += 1
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
moved = []
|
||||
if src_amgen.exists():
|
||||
shutil.move(str(src_amgen), str(dst / "amgen"))
|
||||
moved.append("amgen")
|
||||
if src_wg1.exists():
|
||||
shutil.move(str(src_wg1), str(dst / "wg1"))
|
||||
moved.append("wg1")
|
||||
print(f"[cheap-run] per-run 归档:game={game_id} 上一 run [{'+'.join(moved)}] → {dst}", flush=True)
|
||||
return str(dst)
|
||||
except Exception as e: # noqa: BLE001 —— 归档失败退回旧行为(覆盖),不阻断生成;告警便于排障
|
||||
print(f"[cheap-run] per-run 归档失败(退回旧行为=scaffold 覆盖,不阻断生成):{type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def clean_stale_evidence(game_id: str) -> dict:
|
||||
"""scaffold 后按【固定清单】清 evidence 残留(F0-c:取代旧红线③只清 verdict.json 一个文件)。
|
||||
|
||||
主清 _wg1-gen/<id>/evidence/(scaffold 不碰它、逐 run 累积):verdict/反馈/日志/截图/快照/报告全列清;
|
||||
再兜底清 amgen-<id>/evidence/(正常已被 scaffold rmSync,列此防不归档/异常路)。封「读回上一 run 反馈/绿
|
||||
verdict → 误归因/假绿」。best-effort:逐文件 unlink(missing_ok),任何异常只告警、不阻断。返回 {removed:[...]}。
|
||||
"""
|
||||
removed = []
|
||||
try:
|
||||
wg1_ev = wg1_game_dir(game_id) / "evidence"
|
||||
for name in _STALE_EVIDENCE_WG1:
|
||||
p = wg1_ev / name
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
removed.append(f"wg1/evidence/{name}")
|
||||
amgen_ev = game_dir(game_id) / "evidence"
|
||||
for name in _STALE_EVIDENCE_AMGEN:
|
||||
p = amgen_ev / name
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
removed.append(f"amgen/evidence/{name}")
|
||||
except Exception as e: # noqa: BLE001 —— 清理 best-effort,失败只告警、不阻断生成
|
||||
print(f"[cheap-run] evidence 清理清单化异常(忽略,不阻断生成):{type(e).__name__}: {e}", flush=True)
|
||||
if removed:
|
||||
print(f"[cheap-run] evidence 清理清单化:game={game_id} 清 {len(removed)} 项 {removed}", flush=True)
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
def _resolve_in_repo(rel: str) -> Path:
|
||||
"""把 repo 相对路径解析为绝对路径,校验不逃出 repo 根(对 tools.mjs resolveInRepo)。"""
|
||||
abs_p = (_REPO_ROOT / rel).resolve()
|
||||
@ -166,6 +280,69 @@ def write_file(game_id: str, path: str, content: str) -> dict:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
# ── edit_file 锚点容错(W-AXIS 波2:治「一字之差」5 连拒螺旋)──────────────────────────────
|
||||
# 精确匹配(逐字节)是首选、语义最稳;精确 0 命中时再走【空白归一回退】——只吸收行内空白/缩进差异(模型手抄
|
||||
# 锚点时最常见的偏差),归一后仍须【唯一】命中才接受;绝不做相似度阈值类模糊匹配(会改错地方=危险)。归一后仍
|
||||
# 找不到/不唯一 → 报错附【最近似片段 + 行号窗口】,帮模型下一次给准锚点,而不是只丢一句"没找到"(5 连拒的病根)。
|
||||
|
||||
def _norm_ws_line(ln: str) -> str:
|
||||
"""行内空白归一:去首尾空白 + 内部连续空白(空格/制表)压成单空格(' '.join(split) 天然如此)。空行→''。"""
|
||||
return " ".join(ln.split())
|
||||
|
||||
|
||||
def _locate_ws_normalized(content: str, old: str) -> dict:
|
||||
"""空白归一回退定位:content 与 old 按【整行序列】做行内空白/缩进归一后找命中(只定位不改)。
|
||||
返回 {status:'unique', start, klen} | {status:'ambiguous'|'notfound', hint}。
|
||||
"""
|
||||
content_lines = content.split("\n")
|
||||
old_lines = old.split("\n")
|
||||
norm_content = [_norm_ws_line(l) for l in content_lines]
|
||||
norm_old = [_norm_ws_line(l) for l in old_lines]
|
||||
k = len(norm_old)
|
||||
if k == 0 or all(l == "" for l in norm_old):
|
||||
# old 归一后无实锚(纯空白)——无法安全定位。
|
||||
return {"status": "notfound", "hint": (
|
||||
"edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。old 归一后无实质内容(纯空白)、无法定位;"
|
||||
"请复制文件里的原文当 old(含唯一上下文),或改用 write_file 整体覆盖。")}
|
||||
starts = [i for i in range(0, len(norm_content) - k + 1)
|
||||
if norm_content[i:i + k] == norm_old]
|
||||
if len(starts) == 1:
|
||||
return {"status": "unique", "start": starts[0], "klen": k}
|
||||
if len(starts) > 1:
|
||||
rows = "/".join(str(s + 1) for s in starts)
|
||||
return {"status": "ambiguous", "hint": (
|
||||
f"edit 的 old 忽略空白/缩进差异后仍命中 {len(starts)} 处(分别起于第 {rows} 行)、无法唯一定位;"
|
||||
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
|
||||
return {"status": "notfound", "hint": _edit_nearest_hint(content_lines, norm_content, norm_old)}
|
||||
|
||||
|
||||
def _edit_nearest_hint(content_lines: list, norm_content: list, norm_old: list) -> str:
|
||||
"""找与 norm_old 整行重合最多的同长窗口作【最近似片段】提示(仅诊断、不作匹配接受),附原文行号窗口。"""
|
||||
k = len(norm_old)
|
||||
best_i, best_score = -1, 0
|
||||
for i in range(0, max(0, len(norm_content) - k + 1)):
|
||||
score = sum(1 for j in range(k) if norm_old[j] and norm_content[i + j] == norm_old[j])
|
||||
if score > best_score:
|
||||
best_score, best_i = score, i
|
||||
win = k
|
||||
if best_i < 0 or best_score == 0:
|
||||
# 全无整行重合 → 退化:按首个非空 old 行做子串近似定位(单行窗口)。
|
||||
first = next((l for l in norm_old if l), "")
|
||||
for i, l in enumerate(norm_content):
|
||||
if first and first in l:
|
||||
best_i, best_score, win = i, 1, 1
|
||||
break
|
||||
if best_i < 0:
|
||||
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。确认 path 是否为你要改的文件、"
|
||||
"old 是否复制自该文件当前内容(可先 read_file 核对),或改用 write_file 整体覆盖。")
|
||||
end = min(len(content_lines), best_i + win)
|
||||
snippet = "\n".join(f"{best_i + 1 + off}| {content_lines[best_i + off]}"
|
||||
for off in range(0, end - best_i))
|
||||
return ("edit 未找到 old(逐字节精确、忽略空白/缩进后仍不命中)。"
|
||||
f"最接近的是第 {best_i + 1}-{end} 行(整行匹配 {best_score}/{win}):\n{snippet}\n"
|
||||
"若要改的就是这里,请复制上面【原文】(含其真实缩进/空白)当 old 重发;否则确认 path/old 来源。")
|
||||
|
||||
|
||||
def edit_file(game_id: str, path: str, old: str, new: str) -> dict:
|
||||
"""edit_file:增量修改——把 game 目录内某 L3 文件里出现一次的 old 串替换成 new,避免"改三行→重写整文件"。
|
||||
|
||||
@ -173,9 +350,12 @@ def edit_file(game_id: str, path: str, old: str, new: str) -> dict:
|
||||
"改一个 rng 小函数"被迫变成"重写 22KB",MiniMax 在超长 tool-call 参数上反复漏 path 触发熔断。
|
||||
edit_file 让小修表达为短参数替换,天然绕开漏参高发区。
|
||||
|
||||
锚点容错(W-AXIS 波2):精确匹配(逐字节)唯一命中即用;精确 0 命中时走空白归一回退(只吸收行内空白/缩进
|
||||
差异,归一后仍须唯一命中才接受,绝不相似度模糊匹配);仍找不到/不唯一 → 报错附最近似片段 + 行号窗口,帮
|
||||
模型下一次给准锚点(治审计实证的「一字之差」5 连拒螺旋)。
|
||||
|
||||
边界继承:读现文件走 game_dir 内解析(不走 read_file 的知识根 shadow,要改的是工程盘上的真文件),
|
||||
回写复用 write_file(继承 L1_FIXED 拒写 + games/amgen-<id>/ 越界拒写);write_whitelist 由 toolkit 层把守。
|
||||
old 必须在文件里唯一命中(0 次=没找到、>1 次=不唯一),否则拒改并给结构化提示(edit 自身失败面,纳入纠偏)。
|
||||
回写复用 write_file(继承 L1_FIXED 拒写 + game_dir 越界拒写);write_whitelist 由 toolkit 层把守。
|
||||
"""
|
||||
try:
|
||||
abs_p = _resolve_in_repo(path)
|
||||
@ -186,19 +366,27 @@ def edit_file(game_id: str, path: str, old: str, new: str) -> dict:
|
||||
return {"ok": False, "error": f"文件不存在,无法 edit:{path}(先用 write_file 建它,再 edit 增量改)"}
|
||||
content = abs_p.read_text(encoding="utf-8")
|
||||
cnt = content.count(old)
|
||||
if cnt == 0:
|
||||
return {"ok": False, "error": (
|
||||
"edit 未找到待替换串 old(逐字节精确匹配,含缩进/换行)。"
|
||||
"请复制文件里的原文当 old(可 read_file 核对),或改用 write_file 整体覆盖。")}
|
||||
if cnt > 1:
|
||||
return {"ok": False, "error": (
|
||||
f"edit 的 old 串在文件里出现 {cnt} 次、不唯一,无法定位。"
|
||||
"请把 old 加长到含唯一上下文(多带几行),锚定到唯一位置。")}
|
||||
new_content = content.replace(old, new, 1)
|
||||
matched_by = "exact"
|
||||
if cnt == 1:
|
||||
new_content = content.replace(old, new, 1) # 精确唯一命中(首选、最稳)
|
||||
else:
|
||||
# cnt == 0:精确没找到 → 空白归一回退(只吸收行内空白/缩进差异,仍须唯一命中)。
|
||||
loc = _locate_ws_normalized(content, old)
|
||||
if loc["status"] != "unique":
|
||||
return {"ok": False, "error": loc["hint"]} # 归一后仍非唯一命中 → 带最近似片段/行号窗口提示
|
||||
i, k = loc["start"], loc["klen"]
|
||||
cl = content.split("\n")
|
||||
new_content = "\n".join(cl[:i] + new.split("\n") + cl[i + k:]) # 按整行替换命中窗口,落 new 原样
|
||||
matched_by = "ws-normalized"
|
||||
# 回写经 write_file:继承 L1_FIXED 拒写 + game_dir 越界拒写(edit 不新开绕过面)。
|
||||
r = write_file(game_id, path, new_content)
|
||||
if r.get("ok"):
|
||||
r["message"] = "edit 已应用(替换 1 处)"
|
||||
r["message"] = ("edit 已应用(替换 1 处)" if matched_by == "exact"
|
||||
else "edit 已应用(old 精确没匹配上,按行内空白/缩进归一后唯一命中 1 处;已落 new 原样)")
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
@ -402,7 +402,19 @@ async def _cheap_middlewares_factory(user_id: str, agent_id: str, session_id: st
|
||||
|
||||
# 收口采集(cost/trace/repairs 跨进程回收):reply 收尾写 evidence/service-run-summary.json,供 T3 driver 组 result-out。
|
||||
collector = _get_collector_cls()(game_id=game_id, breaker=breaker, repair=repair, tracer=tracer)
|
||||
return [collector, tracer, repair, breaker]
|
||||
# W-AXIS 波1 真相层:逐 turn 全文落 amgen-<gameId>/turns.jsonl(模型文本/工具全参/工具返回/门续修反馈)。
|
||||
# observe-only、best-effort,默认开(CHEAP_TURNS_ENABLED=0 关);接线失败/关 → None,不加入列表(现有行为字节不变)。
|
||||
# RepairMiddleware 在单次 reply 内 mid-reply 注入 name=gate 续修,turns 中间件每见新一轮 ModelCallStart 扫
|
||||
# agent.state.context 收下——故放在 repair 之后无碍(它只读 context 尾部、不拦事件)。
|
||||
mws = [collector, tracer, repair, breaker]
|
||||
try:
|
||||
import cheap_turns_sink # noqa: PLC0415 —— 顶层零 agentscope,工厂内惰性建中间件
|
||||
turns_mw = cheap_turns_sink.build_turns_middleware(game_id, trace_id=game_id)
|
||||
if turns_mw is not None:
|
||||
mws.append(turns_mw) # 观测旁路,置于最内层(只读事件、原样 yield,顺序不影响拦截语义)
|
||||
except Exception as e: # noqa: BLE001 —— 真相层接线失败绝不阻断生成
|
||||
print(f"[cheap-service] turns 真相层中间件接线失败(降级不落 turns,不阻断生成):{type(e).__name__}: {e}", flush=True)
|
||||
return mws
|
||||
|
||||
|
||||
def build_cheap_app(*, title: str = SERVICE_TITLE) -> "FastAPI":
|
||||
|
||||
@ -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 # 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")
|
||||
@ -271,10 +277,18 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
|
||||
from cheap_genre_route import route_genre # noqa: PLC0415
|
||||
|
||||
scaffold_template, genre = route_genre(brief)
|
||||
if scaffold_template:
|
||||
print(f"[cheap-driver] game={game_id} 品类路由命中:genre={genre} template={scaffold_template}", flush=True)
|
||||
scaffold_desc = None
|
||||
write_whitelist = None
|
||||
if scaffold_template:
|
||||
# 2026-07-08 创始人纠偏:规范 = 文件位置(工程规范) + 绝对通用 plumbing(已由 cheap_run._L1_FIXED 锁
|
||||
# host-config/game/index/entry/main 的 boot 链与装配),**不把模型限死到只改几个文件**——只改几个文件游戏
|
||||
# 做不好玩、也切不中 brief 主题(浏览器验收实测:写锁 core.js 致「榫卯」brief 出「陶艺」游戏,主题漂)。
|
||||
# 故 create 路给 per-genre 完整可玩起点(scaffold_template)+ 一句话描述引导(scaffold_desc),但**不加写锁**:
|
||||
# game-logic/core/render/assets 全放开,模型自由把它做成好玩且切题的游戏。render.js 整写截断类失败靠 harness
|
||||
# 门(check 的 ESM 导出对账 + stripCode 假阴性已修)兜住并回喂续修,不靠锁模型的手。
|
||||
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template)
|
||||
print(f"[cheap-driver] game={game_id} 品类路由命中:genre={genre} template={scaffold_template} "
|
||||
f"(per-genre 起点+引导,不写锁)", flush=True)
|
||||
# 失控兜底轮数(与三闸 150 对齐;成本上界=max_repairs=6 优雅终止,而非轮数闸;放开防单 POST 多续修被 EXCEED_MAX_ITERS 提前切断)。
|
||||
max_iters = genconfig.get("budget", "cheap_max_iters", 150)
|
||||
max_tokens = genconfig.get("model", "max_tokens", 16000)
|
||||
@ -304,6 +318,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
|
||||
ctx_cfg = config.build_context_config()
|
||||
agent_body = {
|
||||
"name": "cheap-writer",
|
||||
# create 路统一用通用 create prompt + per-genre scaffold_desc 引导(模型自由改全部游戏文件,不锁)。
|
||||
"system_prompt": build_system_prompt(game_id, scaffold_desc),
|
||||
"context_config": ctx_cfg.model_dump(mode="json"),
|
||||
"react_config": {"max_iters": max_iters},
|
||||
@ -334,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,
|
||||
@ -378,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 参数同义)。
|
||||
@ -389,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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -86,6 +86,15 @@ def _persist_brief_forensics(game_id: str, brief: str, source: str = "job.brief"
|
||||
_rec(f"brief 落盘异常(已忽略,不影响生成):{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# fix①-B(重实):missing-param 类 stuck 续修时给 agent 的贴脸补参反馈(比默认 schema 校验错更明确,并催改用 edit_file 短载荷)。
|
||||
_MALFORMED_RESUME_FEEDBACK = (
|
||||
"【重要·纠偏】你刚才反复漏了工具调用的必填参数(通常是 path),调用被拒并触发了熔断。"
|
||||
"重发工具调用时【必须一次带全参数】:write_file 要 {path, content}、edit_file 要 {path, old, new}——path 绝不能省。"
|
||||
"小改强烈建议用 edit_file(只发变化的几行、参数短、最不易漏 path),别对整个 game-logic.js 用 write_file"
|
||||
"(内容越长越容易把 path 漏掉)。现在:先 read_file 看清你要改的文件路径,再带【完整参数】重发修改,然后 check → build → finish。"
|
||||
)
|
||||
|
||||
|
||||
def _bypass_state() -> AgentState:
|
||||
"""无人值守跳过工具 ASK(否则 FunctionTool 默认 check_permissions 返回 ASK 卡死;对 studio.py:_bypass_state)。"""
|
||||
return AgentState(permission_context=PermissionContext(mode=PermissionMode.BYPASS))
|
||||
@ -217,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)
|
||||
@ -224,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
|
||||
@ -254,13 +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",
|
||||
system_prompt=system_prompt or build_system_prompt(game_id, scaffold_desc), # modify 传 build_modify_system_prompt;扩模板传 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(), # 历史压缩
|
||||
@ -276,7 +306,23 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
|
||||
for attempt in range(max_resumes + 1):
|
||||
attempts = attempt + 1
|
||||
_rec(f"resume attempt {attempts}/{max_resumes + 1} → writer.reply …")
|
||||
await writer.reply(_user_msg(kick))
|
||||
try:
|
||||
await writer.reply(_user_msg(kick))
|
||||
except Tier2CircuitBreak as _cb:
|
||||
# fix①-B(重实·resume 层,2026-07-08 n=5 收敛环坐实 heritage 死圈复现)——
|
||||
# 根因:MiniMax 长参数下漏 write_file/edit_file 必填 path,schema 校验拒 → 连撞 stuck 阈值 → 终态杀、九门没跑。
|
||||
# 先前 on_acting 版无效(_agent.py:1376 校验先于 Step3 工具执行/on_acting、畸形调用直接 return 不进钩子);
|
||||
# 改挂此处:missing-param 类 stuck 不当终态,还有 resume 预算就带【贴脸补参反馈】续修——与 gate-fail resume
|
||||
# 同机制、跨 reply memory 保留=原地续修、能真正触达 agent。只清 stuck 连击计数(不动 spent_rmb/tool_calls,防误清预算);
|
||||
# 非 missing-param 的 stuck(反复 check 同红线 / build 同编译错=真死圈)/ budget / 其它熔断照旧向上抛(终态、不放行)。
|
||||
_missing_param = _cb.kind == "stuck" and "is a required property" in (_cb.reason or "")
|
||||
if _missing_param and attempt < max_resumes and not getattr(breaker, "budget_soft_tripped", False):
|
||||
_rec(f"熔断 stuck(漏必填参 path)→ fix①-B 带补参反馈续修(attempt {attempts}):{(_cb.reason or '')[:70]}")
|
||||
breaker._fail_repeat = 0 # 只清 stuck 连击计数(下一 attempt 干净起,不动 spent_rmb/tool_calls)
|
||||
breaker._last_fail_sig = None
|
||||
kick = _MALFORMED_RESUME_FEEDBACK
|
||||
continue
|
||||
raise # 其它熔断 / 预算耗尽 / 非漏参 stuck → 终态,交外层 except 记 breaker_tripped
|
||||
# ① 真 finish:CLI 回喂对齐(W-S1 单③,对账发现六④)——收敛判据从「check+build 绿」升为
|
||||
# 「九门绿」:finish 后跑九门收口,门未全绿且轮数/预算有余时带 C6 反馈 resume 续修,
|
||||
# 使 lab 复验口径与生产 RepairMiddleware(finish 点拦截→跑门→judge→回喂)行为同型。
|
||||
@ -389,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/正则/断言(违反创始人红线)。
|
||||
@ -412,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。"""
|
||||
|
||||
537
cheap-worker/cheap_turns_sink.py
Normal file
537
cheap-worker/cheap_turns_sink.py
Normal file
@ -0,0 +1,537 @@
|
||||
"""cheap_turns_sink.py — 便宜档生成链路逐 turn 全文落盘(W-AXIS 波1 · 真相层 F0-a)。
|
||||
|
||||
【这份解决什么问题】
|
||||
诊断档 §2.3 F0-a 坐实:trace.jsonl 只落 TraceStep 事件骨架——模型推理/回复文本被碎成逐 delta
|
||||
分散在成千行里、工具返回文本(ToolResultTextDeltaEvent.delta)在 observability.trace.to_trace_step
|
||||
的 observe 段被整段丢弃、门反馈(RepairMiddleware 注入 / CLI kick)根本不走 trace 事件。于是
|
||||
「模型每轮写了什么、harness 每轮回了什么」磁盘上物理没有这份数据,历史结论只能建在聚合数字上。
|
||||
|
||||
【本模块补的真相层】
|
||||
一个便宜档专属、observe-only 的中间件 CheapTurnsMiddleware,挂 on_reply 嗅探同一条 AgentScope
|
||||
事件流,把逐 delta 事件【按一轮 ReAct(一次模型调用 + 其工具调用 + 工具返回)聚合成整段】,
|
||||
连同【harness→模型的输入 / 门续修反馈】一起逐 turn 落 amgen-<gid>/turns.jsonl(人读全文、
|
||||
可归档、可归因)。与 trace.jsonl / OTLP 三者并存互不影响:trace.jsonl 给成本对账机读、
|
||||
OTLP 给 Tempo、turns.jsonl 给人读链路。
|
||||
|
||||
【两条 harness→模型输入都要收(两条生成路各有一条)】
|
||||
· CLI 路(cheap_studio):外层 resume 循环每 attempt 重新 writer.reply(kick),门反馈就是那个 kick
|
||||
(name="user")——在 on_reply 起点读 input_kwargs["inputs"] 收得到。
|
||||
· Service 路(cheap_service_app + RepairMiddleware):单次 reply 内于 finish 点 mid-reply 注入
|
||||
UserMsg(name="gate", ...),不在 inputs 里——每见新一轮 ModelCallStartEvent 就扫 agent.state.context
|
||||
尾部新增的 name="gate" 消息收下(去重防重复落)。
|
||||
|
||||
【best-effort 铁律(与 collector / breaker / trace sink 同款)】
|
||||
on_reply 只读事件、原样 yield;写盘 / 聚合 / 读 context 全程吞异常,只告警、绝不抛、绝不阻断生成主链。
|
||||
默认开(真相层是本波地基);env CHEAP_TURNS_ENABLED=0 一键关(纯增量旁路,关开关即回,现有行为字节不变)。
|
||||
|
||||
【单文件上限 + 轮转 + 密钥脱敏】
|
||||
长 run(如烧到 180K token 的死圈)turns.jsonl 会很大——超单文件上限即把当前文件轮转成
|
||||
turns.<n>.jsonl 另起,防单文件撑爆。落盘前对所有文本做 new-api key / Bearer / token 类脱敏
|
||||
(工具若回显凭据,不泄进盘)。
|
||||
|
||||
【惰性 import 红线】顶层零 agentscope 依赖:纯落盘/聚合/脱敏件(CheapTurnsWriter / _TurnAggregator /
|
||||
_redact)在顶层,可脱离 agentscope 单测;真正 subclass MiddlewareBase 的中间件类只在
|
||||
build_turns_middleware() 里惰性定义(镜像 cheap_service_app._get_collector_cls),对齐工厂惰性红线。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
# ── 开关 / 上限 env(全大写 CHEAP_ 前缀,与 genconfig / OTLP 风格一致)──
|
||||
ENV_ENABLED = "CHEAP_TURNS_ENABLED" # 默认开;"0"/"false"/"no" 关(纯增量旁路)
|
||||
ENV_MAX_BYTES = "CHEAP_TURNS_MAX_BYTES" # 单文件上限(字节),超即轮转;默认 32MB
|
||||
|
||||
# 单文件默认上限:32MB。单局 turns.jsonl 正常几百 KB~数 MB,死圈长 run 才可能逼近;超即轮转另起。
|
||||
_DEFAULT_MAX_BYTES = 32 * 1024 * 1024
|
||||
|
||||
# 单字段文本上限(防单个工具返回把一行 JSON 撑成几 MB):工具返回按 read_file 200KB 截断上界给到 256KB,
|
||||
# 模型文本/思考/参数各给 128KB——正常远够(保「全文」),只挡病态超大,超出留 …[截断 N 字节] 标记不静默丢。
|
||||
_FIELD_TEXT_CAP = 256 * 1024
|
||||
_MODEL_TEXT_CAP = 128 * 1024
|
||||
|
||||
|
||||
# ── 事件类型分组(以类名字符串判,不强依赖 import 具体事件类;与 observability.trace 同口径)──
|
||||
_REASONING_TEXT_EVENTS = {"TextBlockStartEvent", "TextBlockDeltaEvent", "TextBlockEndEvent"}
|
||||
_THINKING_EVENTS = {"ThinkingBlockStartEvent", "ThinkingBlockDeltaEvent", "ThinkingBlockEndEvent"}
|
||||
_TOOLCALL_EVENTS = {"ToolCallStartEvent", "ToolCallDeltaEvent", "ToolCallEndEvent"}
|
||||
_TOOLRESULT_EVENTS = {
|
||||
"ToolResultStartEvent", "ToolResultTextDeltaEvent", "ToolResultDataDeltaEvent", "ToolResultEndEvent",
|
||||
}
|
||||
|
||||
# ── 一次性告警去重(同 tag 只 print 一次,避免长 run 刷屏)──
|
||||
_WARNED: set[str] = set()
|
||||
_WARN_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _warn_once(tag: str, msg: str) -> None:
|
||||
"""同一 tag 的告警只落一次(best-effort,绝不抛)。"""
|
||||
with _WARN_LOCK:
|
||||
if tag in _WARNED:
|
||||
return
|
||||
_WARNED.add(tag)
|
||||
print(f"[cheap-turns] {msg}", flush=True)
|
||||
|
||||
|
||||
def reset_warned_for_test() -> None:
|
||||
"""仅测试用:清告警去重集(让各用例互不串)。"""
|
||||
with _WARN_LOCK:
|
||||
_WARNED.clear()
|
||||
|
||||
|
||||
def turns_enabled() -> bool:
|
||||
"""真相层落盘是否开(默认开;env CHEAP_TURNS_ENABLED ∈ {0,false,no,off} 关)。"""
|
||||
v = os.environ.get(ENV_ENABLED)
|
||||
if v is None:
|
||||
return True
|
||||
return v.strip().lower() not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def _max_bytes() -> int:
|
||||
"""单文件上限(env CHEAP_TURNS_MAX_BYTES 覆盖;非法值回落默认 32MB)。"""
|
||||
raw = os.environ.get(ENV_MAX_BYTES)
|
||||
if raw and raw.strip():
|
||||
try:
|
||||
n = int(raw.strip())
|
||||
if n > 0:
|
||||
return n
|
||||
except ValueError:
|
||||
_warn_once("bad-max-bytes", f"CHEAP_TURNS_MAX_BYTES 非法({raw!r}),回落默认 {_DEFAULT_MAX_BYTES}")
|
||||
return _DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""当前时刻 ISO8601(落盘 ts;取不到返空串,best-effort)。"""
|
||||
try:
|
||||
return datetime.now().isoformat(timespec="milliseconds")
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# 密钥脱敏 —— 落盘前把 new-api 凭据 / Bearer / token 类字面量抹掉(工具回显凭据不泄进盘)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
_REDACTED = "***REDACTED***"
|
||||
|
||||
# sk- 前缀密钥(OpenAI 兼容 / new-api 常见形态)。
|
||||
_RE_SK = re.compile(r"sk-[A-Za-z0-9_\-]{6,}")
|
||||
# Bearer <token>。
|
||||
_RE_BEARER = re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._\-]{8,}")
|
||||
# 带键名的赋值:api_key/apikey/token/secret/password/authorization = "值" 或 : 值。
|
||||
_RE_LABELED = re.compile(
|
||||
r'(?i)("?(?:api[_-]?key|apikey|access[_-]?token|auth(?:orization)?|token|secret|password)"?\s*[:=]\s*"?)'
|
||||
r"([^\s\"',}]{6,})"
|
||||
)
|
||||
|
||||
|
||||
def _redact(text: Optional[str]) -> str:
|
||||
"""把文本里的凭据类字面量脱敏(best-effort:任何异常回退原文,绝不因脱敏丢内容/抛)。
|
||||
|
||||
覆盖三类:sk- 前缀密钥、Bearer token、带键名的赋值;另外若进程 env 有 NEWAPI_KEY,
|
||||
连它的字面量一起抹(最强一层——即便工具原样回显了当前生成用的真实凭据也不落盘)。
|
||||
"""
|
||||
if not text:
|
||||
return text or ""
|
||||
try:
|
||||
out = str(text)
|
||||
# 进程真实凭据字面量(最强,先抹):env 里那把 key 若出现在文本中,整条替换。
|
||||
real_key = os.environ.get("NEWAPI_KEY")
|
||||
if real_key and len(real_key) >= 8 and real_key in out:
|
||||
out = out.replace(real_key, _REDACTED)
|
||||
out = _RE_SK.sub(_REDACTED, out)
|
||||
out = _RE_BEARER.sub(lambda m: m.group(1) + _REDACTED, out)
|
||||
out = _RE_LABELED.sub(lambda m: m.group(1) + _REDACTED, out)
|
||||
return out
|
||||
except Exception: # noqa: BLE001 —— 脱敏失败宁可回退原文也不丢内容/不抛
|
||||
return str(text)
|
||||
|
||||
|
||||
def _cap(text: str, cap: int) -> str:
|
||||
"""单字段文本上限:超出截断并留 …[截断 N 字节] 标记(不静默丢,保可诊断)。"""
|
||||
if text is None:
|
||||
return ""
|
||||
if len(text) <= cap:
|
||||
return text
|
||||
dropped = len(text) - cap
|
||||
return text[:cap] + f"\n…[截断 {dropped} 字节]"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# CheapTurnsWriter —— 逐 turn 记录落 amgen-<gid>/turns.jsonl(单文件上限 + 轮转)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class CheapTurnsWriter:
|
||||
"""把逐 turn 记录(dict)追加写到 turns.jsonl 的落盘件(单文件上限 → 轮转 turns.<n>.jsonl)。
|
||||
|
||||
契约:write_record(rec: dict) -> None。JSONL 追加写、每条一行,写失败只告警绝不抛(best-effort)。
|
||||
轮转:每次写前若现文件已达上限,把它 rename 成 turns.1.jsonl / turns.2.jsonl … 再从空文件续写,
|
||||
防单文件被病态长 run 撑爆(归档时整个 amgen 目录连同轮转分片一起归档)。
|
||||
"""
|
||||
|
||||
def __init__(self, output_path: "Path | str", *, max_bytes: Optional[int] = None) -> None:
|
||||
self._path = Path(output_path)
|
||||
self._max_bytes = max_bytes if (max_bytes and max_bytes > 0) else _max_bytes()
|
||||
self._rotated = 0
|
||||
self._lock = threading.Lock() # Service 长驻并发多局各自 writer,单 writer 内写串行保序
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc: # noqa: BLE001 —— 预建失败写时再试
|
||||
_warn_once("mkdir", f"预建 turns.jsonl 目录失败(写时再试):{self._path.parent} — {exc}")
|
||||
|
||||
def _maybe_rotate(self) -> None:
|
||||
"""现文件达上限 → rename 成 turns.<n>.jsonl 另起(best-effort,失败只告警继续写原文件)。"""
|
||||
try:
|
||||
if self._path.exists() and self._path.stat().st_size >= self._max_bytes:
|
||||
self._rotated += 1
|
||||
rotated = self._path.with_name(f"{self._path.stem}.{self._rotated}{self._path.suffix}")
|
||||
self._path.rename(rotated)
|
||||
print(f"[cheap-turns] turns.jsonl 达上限({self._max_bytes} 字节)→ 轮转 {rotated.name}", flush=True)
|
||||
except Exception as exc: # noqa: BLE001 —— 轮转失败不阻断写(继续写原文件)
|
||||
_warn_once("rotate", f"turns.jsonl 轮转失败(继续写原文件):{exc}")
|
||||
|
||||
def write_record(self, rec: dict) -> None:
|
||||
"""追加写一条 turn 记录(JSONL);写失败只告警绝不抛(observe-only 不咬生成)。"""
|
||||
try:
|
||||
with self._lock:
|
||||
self._maybe_rotate()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(rec, ensure_ascii=False)
|
||||
with self._path.open("a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception as exc: # noqa: BLE001 —— 一条写失败不连累后续、不阻断生成
|
||||
_warn_once("write", f"turns.jsonl 写失败(best-effort,不阻断生成,path={self._path}):{exc}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# _TurnAggregator —— 把逐 delta 事件聚合成整段 turn(纯逻辑,脱 agentscope 单测)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _evt_type(evt: Any) -> str:
|
||||
"""事件类型名:优先 type().__name__,dict 形态兜底取 'type' 键(与 observability.trace 同口径)。"""
|
||||
name = type(evt).__name__
|
||||
if name == "dict":
|
||||
t = evt.get("type") if isinstance(evt, dict) else None
|
||||
return str(t) if t else name
|
||||
return name
|
||||
|
||||
|
||||
def _evt_get(evt: Any, key: str, default: Any = None) -> Any:
|
||||
"""兼容对象属性 / dict 键取值(事件可能是 typed 对象,也可能是已序列化 dict)。"""
|
||||
if isinstance(evt, dict):
|
||||
return evt.get(key, default)
|
||||
return getattr(evt, key, default)
|
||||
|
||||
|
||||
def _msg_name(msg: Any) -> str:
|
||||
"""取 Msg.name(区分 harness kick=user / 门续修=gate);取不到返空串。"""
|
||||
return str(_evt_get(msg, "name", "") or "")
|
||||
|
||||
|
||||
def _msg_text(msg: Any) -> str:
|
||||
"""把一个 Msg 的 content 抽成纯文本:str 直取;list(块)拼各块 text;取不到返空串(best-effort)。"""
|
||||
try:
|
||||
content = _evt_get(msg, "content", None)
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for blk in content:
|
||||
if isinstance(blk, dict):
|
||||
if blk.get("type") in (None, "text") and blk.get("text"):
|
||||
parts.append(str(blk.get("text")))
|
||||
else:
|
||||
t = getattr(blk, "text", None)
|
||||
if t:
|
||||
parts.append(str(t))
|
||||
return "\n".join(parts)
|
||||
return str(content)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
def _as_msg_list(inputs: Any) -> list:
|
||||
"""把 on_reply input_kwargs['inputs'] 归一成 Msg 列表(单条/列表/None 都吃)。"""
|
||||
if inputs is None:
|
||||
return []
|
||||
if isinstance(inputs, (list, tuple)):
|
||||
return list(inputs)
|
||||
return [inputs]
|
||||
|
||||
|
||||
class _TurnAggregator:
|
||||
"""把一条 reply 的逐事件流聚合成「逐 turn 整段记录」并经 emit 落盘(纯逻辑,可脱 agentscope 单测)。
|
||||
|
||||
turn 边界 = 一次模型调用:ModelCallStartEvent 起新 turn(先冲上一 turn),其间累积模型思考/文本/
|
||||
工具调用参数,ModelCallEnd 记 token;工具返回(在 ModelCallEnd 之后、下一 ModelCallStart 之前的
|
||||
acting 段)累积进【当前仍未冲的】这一 turn;ReplyEndEvent 冲最后一 turn。
|
||||
|
||||
harness→模型输入(kick / 门续修反馈)另走 note_reply_inputs / scan_context_gate,按文件追加序即时落
|
||||
(内容 hash 去重防重复)。所有记录带统一信封 {gid, traceId, seq, ts, kind, ...}。
|
||||
"""
|
||||
|
||||
def __init__(self, gid: str, trace_id: str, emit: Callable[[dict], None]) -> None:
|
||||
self._gid = gid
|
||||
self._trace_id = trace_id
|
||||
self._emit = emit
|
||||
self._seq = 0
|
||||
self._turn_idx = 0
|
||||
self._cur: Optional[dict] = None
|
||||
self._seen_input: set[int] = set() # 已落 harness/gate 输入的内容 hash(去重)
|
||||
|
||||
# ── 统一信封 + 落盘 ──
|
||||
def _write(self, kind: str, body: dict) -> None:
|
||||
rec = {"gid": self._gid, "traceId": self._trace_id, "seq": self._seq,
|
||||
"ts": _now_iso(), "kind": kind}
|
||||
rec.update(body)
|
||||
self._seq += 1
|
||||
try:
|
||||
self._emit(rec)
|
||||
except Exception as exc: # noqa: BLE001 —— emit(落盘)失败已在 writer 内兜;此处再兜一层防御
|
||||
_warn_once("emit", f"turn 记录 emit 失败(best-effort):{exc}")
|
||||
|
||||
# ── harness→模型输入(两条路)──
|
||||
def note_reply_inputs(self, inputs: Any) -> None:
|
||||
"""on_reply 起点:把触发本次 reply 的输入(CLI kick / Service 首个 kick)落成 harness_input/gate_feedback。"""
|
||||
for msg in _as_msg_list(inputs):
|
||||
name = _msg_name(msg)
|
||||
text = _msg_text(msg)
|
||||
kind = "gate_feedback" if name == "gate" else "harness_input"
|
||||
self._emit_input(kind, name, text)
|
||||
|
||||
def scan_context_gate(self, context: Any) -> None:
|
||||
"""每见新一轮 ModelCallStart 扫 agent.state.context 尾部的 name='gate' 续修注入(Service mid-reply 路)。"""
|
||||
try:
|
||||
for msg in (context or []):
|
||||
if _msg_name(msg) == "gate":
|
||||
self._emit_input("gate_feedback", "gate", _msg_text(msg))
|
||||
except Exception: # noqa: BLE001 —— 读 context best-effort,失败忽略
|
||||
pass
|
||||
|
||||
def _emit_input(self, kind: str, name: str, text: str) -> None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
h = hash((kind, text))
|
||||
if h in self._seen_input:
|
||||
return
|
||||
self._seen_input.add(h)
|
||||
self._write(kind, {"name": name, "text": _cap(_redact(text), _FIELD_TEXT_CAP)})
|
||||
|
||||
# ── 事件聚合 ──
|
||||
def ingest_event(self, evt: Any) -> None:
|
||||
"""吃一个 AgentEvent(对象/序列化 dict)→ 累积进当前 turn / 冲 turn(全 best-effort)。"""
|
||||
try:
|
||||
et = _evt_type(evt)
|
||||
if et == "ModelCallStartEvent":
|
||||
self._flush()
|
||||
self._start_turn(_evt_get(evt, "model_name"))
|
||||
return
|
||||
if et in _THINKING_EVENTS:
|
||||
delta = _evt_get(evt, "delta")
|
||||
if delta:
|
||||
self._ensure_turn()["thinking"] += str(delta)
|
||||
return
|
||||
if et in _REASONING_TEXT_EVENTS:
|
||||
delta = _evt_get(evt, "delta")
|
||||
if delta:
|
||||
self._ensure_turn()["text"] += str(delta)
|
||||
return
|
||||
if et in _TOOLCALL_EVENTS:
|
||||
self._on_tool_call(et, evt)
|
||||
return
|
||||
if et in _TOOLRESULT_EVENTS:
|
||||
self._on_tool_result(et, evt)
|
||||
return
|
||||
if et == "ModelCallEndEvent":
|
||||
cur = self._cur
|
||||
if cur is not None:
|
||||
cur["tokensIn"] = int(_evt_get(evt, "input_tokens", 0) or 0)
|
||||
cur["tokensOut"] = int(_evt_get(evt, "output_tokens", 0) or 0)
|
||||
return
|
||||
if et in ("ReplyEndEvent",):
|
||||
self._flush()
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 —— 聚合任何异常只告警,绝不抛、绝不咬生成
|
||||
_warn_once("ingest", f"turn 事件聚合失败(best-effort 丢该事件):{exc}")
|
||||
|
||||
def _start_turn(self, model_name: Any) -> None:
|
||||
self._turn_idx += 1
|
||||
self._cur = {
|
||||
"turn": self._turn_idx,
|
||||
"modelName": str(model_name) if model_name else None,
|
||||
"thinking": "", "text": "",
|
||||
"toolCalls": {}, # tool_call_id -> {"name","args"}
|
||||
"toolResults": {}, # tool_call_id -> {"name","text","state"}
|
||||
"tokensIn": None, "tokensOut": None,
|
||||
}
|
||||
|
||||
def _ensure_turn(self) -> dict:
|
||||
"""无当前 turn(极少数事件早于首个 ModelCallStart)时惰性起一个,保证不漏内容。"""
|
||||
if self._cur is None:
|
||||
self._start_turn(None)
|
||||
return self._cur # type: ignore[return-value]
|
||||
|
||||
def _on_tool_call(self, et: str, evt: Any) -> None:
|
||||
tcid = str(_evt_get(evt, "tool_call_id", "") or "")
|
||||
if not tcid:
|
||||
return
|
||||
cur = self._ensure_turn()
|
||||
slot = cur["toolCalls"].setdefault(tcid, {"name": None, "args": ""})
|
||||
name = _evt_get(evt, "tool_call_name")
|
||||
if name and not slot["name"]:
|
||||
slot["name"] = str(name)
|
||||
delta = _evt_get(evt, "delta")
|
||||
if delta:
|
||||
slot["args"] += str(delta)
|
||||
|
||||
def _on_tool_result(self, et: str, evt: Any) -> None:
|
||||
tcid = str(_evt_get(evt, "tool_call_id", "") or "")
|
||||
if not tcid:
|
||||
return
|
||||
cur = self._ensure_turn()
|
||||
slot = cur["toolResults"].setdefault(tcid, {"name": None, "text": "", "state": None})
|
||||
name = _evt_get(evt, "tool_call_name")
|
||||
if name and not slot["name"]:
|
||||
slot["name"] = str(name)
|
||||
if et == "ToolResultTextDeltaEvent":
|
||||
delta = _evt_get(evt, "delta")
|
||||
if delta:
|
||||
slot["text"] += str(delta) # ← 补上 to_trace_step 丢掉的工具返回文本
|
||||
elif et == "ToolResultEndEvent":
|
||||
state = _evt_get(evt, "state")
|
||||
if state is not None:
|
||||
slot["state"] = str(getattr(state, "value", state))
|
||||
|
||||
def _flush(self) -> None:
|
||||
"""冲当前 turn:有实质内容才落(纯空 turn 不落),把 toolCalls/toolResults dict 转有序 list + 脱敏 + 截断。"""
|
||||
cur = self._cur
|
||||
self._cur = None
|
||||
if cur is None:
|
||||
return
|
||||
has_content = bool(
|
||||
cur["thinking"].strip() or cur["text"].strip()
|
||||
or cur["toolCalls"] or cur["toolResults"]
|
||||
or cur["tokensIn"] or cur["tokensOut"]
|
||||
)
|
||||
if not has_content:
|
||||
return
|
||||
body: dict = {"turn": cur["turn"]}
|
||||
if cur["modelName"]:
|
||||
body["modelName"] = cur["modelName"]
|
||||
if cur["thinking"].strip():
|
||||
body["thinking"] = _cap(_redact(cur["thinking"]), _MODEL_TEXT_CAP)
|
||||
if cur["text"].strip():
|
||||
body["text"] = _cap(_redact(cur["text"]), _MODEL_TEXT_CAP)
|
||||
if cur["toolCalls"]:
|
||||
body["toolCalls"] = [
|
||||
{"id": tcid, "name": v["name"], "args": _cap(_redact(v["args"]), _FIELD_TEXT_CAP)}
|
||||
for tcid, v in cur["toolCalls"].items()
|
||||
]
|
||||
if cur["toolResults"]:
|
||||
body["toolResults"] = [
|
||||
{"id": tcid, "name": v["name"], "state": v["state"],
|
||||
"text": _cap(_redact(v["text"]), _FIELD_TEXT_CAP)}
|
||||
for tcid, v in cur["toolResults"].items()
|
||||
]
|
||||
if cur["tokensIn"] is not None or cur["tokensOut"] is not None:
|
||||
body["tokens"] = {"in": cur["tokensIn"] or 0, "out": cur["tokensOut"] or 0}
|
||||
self._write("model_turn", body)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""对外冲当前 turn(中间件在 ModelCallStart 处先冲上一 turn、再落门反馈、再起新 turn,保时间序)。"""
|
||||
self._flush()
|
||||
|
||||
def close(self) -> None:
|
||||
"""收口:冲最后一 turn(ReplyEnd 缺失/异常退出时兜底)。"""
|
||||
self._flush()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# 中间件工厂 —— 惰性 subclass MiddlewareBase(顶层不 import agentscope,对齐工厂惰性红线)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
_TURNS_MW_CLS = None
|
||||
|
||||
|
||||
def _get_turns_middleware_cls():
|
||||
"""惰性定义并缓存 CheapTurnsMiddleware(subclass MiddlewareBase;顶层不 import agentscope 保惰性红线)。"""
|
||||
global _TURNS_MW_CLS
|
||||
if _TURNS_MW_CLS is not None:
|
||||
return _TURNS_MW_CLS
|
||||
from agentscope.middleware import MiddlewareBase # noqa: PLC0415
|
||||
|
||||
class CheapTurnsMiddleware(MiddlewareBase):
|
||||
"""便宜档真相层中间件:只挂 on_reply,observe-only 嗅探事件流→逐 turn 全文落 turns.jsonl。
|
||||
|
||||
与 collector/breaker 同款「读事件、原样 yield」姿势,绝不拦截/修改事件、绝不抛。只 override
|
||||
on_reply(框架按 override 检测只把本类接进 on_reply 洋葱,不参与 reasoning/acting/model_call)。
|
||||
"""
|
||||
|
||||
def __init__(self, writer: CheapTurnsWriter, *, gid: str, trace_id: str) -> None:
|
||||
self._agg = _TurnAggregator(gid, trace_id, writer.write_record)
|
||||
self._seen_model_call = False # 见过首个 ModelCallStart(之后每轮扫 context 收 gate 注入)
|
||||
|
||||
async def on_reply(self, agent, input_kwargs, next_handler):
|
||||
# ① reply 起点:落触发本次 reply 的输入(CLI 每 attempt 的 kick / Service 首个 kick)。
|
||||
try:
|
||||
self._agg.note_reply_inputs(
|
||||
input_kwargs.get("inputs") if isinstance(input_kwargs, dict) else None)
|
||||
except Exception: # noqa: BLE001 —— 输入落盘 best-effort,失败不影响事件透传
|
||||
pass
|
||||
try:
|
||||
async for evt in next_handler(**input_kwargs):
|
||||
# ② 每见新一轮 ModelCallStart:按时间序【先冲上一 turn → 再落门续修(它在时间上晚于上一 turn)
|
||||
# → 再起新 turn】。故先 flush() 冲上一 turn,再扫 agent.state.context 收 Service 路 mid-reply
|
||||
# 注入的 name=gate 续修(内容 hash 去重防重复),最后 ingest 起新 turn(其内部 flush 已 no-op)。
|
||||
try:
|
||||
if _evt_type(evt) == "ModelCallStartEvent":
|
||||
self._agg.flush()
|
||||
self._agg.scan_context_gate(getattr(getattr(agent, "state", None), "context", None))
|
||||
self._agg.ingest_event(evt)
|
||||
except Exception: # noqa: BLE001 —— 嗅探纯旁路,任何异常不连累事件透传
|
||||
pass
|
||||
yield evt # 原样透传(observe-only,不改不拦)
|
||||
finally:
|
||||
# 收口:冲最后一 turn(即便 ReplyEnd 缺失 / 异常退出也不漏最后一轮)。
|
||||
try:
|
||||
self._agg.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
_TURNS_MW_CLS = CheapTurnsMiddleware
|
||||
return _TURNS_MW_CLS
|
||||
|
||||
|
||||
def build_turns_middleware(game_id: str, *, trace_id: Optional[str] = None,
|
||||
writer: Optional[CheapTurnsWriter] = None):
|
||||
"""构造便宜档真相层中间件(默认开;关或接线失败 → 返回 None,调用方据 None 不加入 middleware 列表)。
|
||||
|
||||
落盘路径 = amgen-<game_id>/turns.jsonl(与 trace.jsonl / evidence 同目录,随产物一起归档/定位)。
|
||||
best-effort:关(CHEAP_TURNS_ENABLED=0)/ 建 writer 失败 / import 失败一律返回 None,绝不抛、绝不阻断生成。
|
||||
|
||||
:param game_id: 后端 gameId(产物目录键;与 scaffold/trace 一致)。
|
||||
:param trace_id: 贯穿 traceId(缺省用 game_id,与 tracer.trace_id 同源)。
|
||||
:param writer: 仅测试注入(内存/临时 writer);生产恒 None → 落 amgen-<gid>/turns.jsonl。
|
||||
:return: CheapTurnsMiddleware 实例 或 None(关/失败)。
|
||||
"""
|
||||
if not turns_enabled():
|
||||
return None
|
||||
try:
|
||||
if writer is None:
|
||||
import cheap_run # noqa: PLC0415 —— 惰性 import(顶层不牵产物路径解析)
|
||||
writer = CheapTurnsWriter(cheap_run.game_dir(game_id) / "turns.jsonl")
|
||||
cls = _get_turns_middleware_cls()
|
||||
return cls(writer, gid=str(game_id), trace_id=str(trace_id or game_id))
|
||||
except Exception as exc: # noqa: BLE001 —— 接线失败降级 None(不落 turns,不阻断生成)
|
||||
_warn_once("build", f"真相层 turns 中间件接线失败(降级不落 turns,不阻断生成):{type(exc).__name__}: {exc}")
|
||||
return None
|
||||
@ -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;终局『漏太多结束』是否可达待亲验)"
|
||||
}
|
||||
]
|
||||
}
|
||||
199
cheap-worker/hard_genre_batch.py
Normal file
199
cheap-worker/hard_genre_batch.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""hard_genre_batch.py — 便宜档【5 已覆盖品类】n=5 重测基线批跑(W-AXIS 收尾)。
|
||||
|
||||
2026-07-09 换轴后重锚:验收权威 = accepted = 机械九门预筛 ∧ 独立模型玩法判定(glm-5.2 看真玩证据),
|
||||
verdictPass 只是预筛地板、finished 只是模型自称收敛。本脚本作废换轴前那批全绿的污染数字(旧口径把
|
||||
「九门 pass」当验收通过),在新链路(turns.jsonl 真相层 + per-run 归档 + 三层归因)与新语义下重测。
|
||||
|
||||
品类 = 5 个【有 per-genre 模板】的已覆盖品类(heritage/trpg/sim-business/puzzle/narrative);idle/action
|
||||
无模板品类不入本轮(归 W-GENRE)。每品类各跑 n=5 局(distinct gid `hard-<genre>-r<round>`,绝不同 gid 重跑
|
||||
洗数字——同 gid 会触发归档覆盖)。串行跑(本机 CPU+chrome 负载,别开并发)。
|
||||
|
||||
跑法(分级防无人值守烧钱):
|
||||
冒烟 n=1×5: cheap-worker/.venv/bin/python cheap-worker/hard_genre_batch.py 1 1
|
||||
全量补 r2-5: cheap-worker/.venv/bin/python cheap-worker/hard_genre_batch.py 4 2
|
||||
参数:argv[1]=本次跑几轮(默认 1) argv[2]=起始轮号(默认 1);gid 后缀 = r<轮号>。
|
||||
env:NO_PROXY(网关直连绕代理) + NEWAPI_KEY(_bootstrap 自动从凭据档解析)。
|
||||
每局逐行追加 results/wax-baseline.jsonl(断点续跑不丢已完成局);累计成本超 ¥200 硬停(PARTIAL)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import cheap_studio # noqa: E402
|
||||
from cheap_genre_route import route_genre # noqa: E402 brief→per-genre 黄金模板路由(对齐生产 create 路)
|
||||
from cheap_roles import SCAFFOLD_DESC_BY_TEMPLATE # noqa: E402 per-genre 起点一句话描述
|
||||
from worker import genconfig # noqa: E402 与生产同读 genconfig(max_tokens 单一源)
|
||||
|
||||
# max_tokens 与生产 create 路(cheap_service_driver)同源 genconfig.model.max_tokens(2026-07-09 调 32K:16K 是自设限、
|
||||
# 实测 M3 单轮能输出 17000+ tokens,inline thinking 吃额度致大文件整写截断——给 thinking+代码留足)。别用 run_studio 的 16K 默认。
|
||||
_MAXTOK = genconfig.get("model", "max_tokens", 16000)
|
||||
|
||||
# 累计成本硬停线(¥):无人值守跑 25 局,任何一轮累计越线立即停机 PARTIAL(单局另有 ¥10 软/¥15 硬 breaker,此为跨局兜底)。
|
||||
_HARD_STOP_RMB = 200.0
|
||||
|
||||
# 结果逐行落盘(断点续跑不丢):每完成一局即 append 一行 JSON。
|
||||
_JSONL = Path(__file__).resolve().parent / "results" / "wax-baseline.jsonl"
|
||||
|
||||
# 5 个【已覆盖品类】brief——各命中一个 per-genre 黄金模板(route_genre 关键词确定性路由),取自批跑器既有 brief 池:
|
||||
# narrative/trpg/heritage 用同题 brief(HARD 池);puzzle/sim-business 用跨主题 brief(XTHEME 池,该两类同题无 brief)。
|
||||
# 路由核对:文字冒险/分支→story、掷骰→trpg、非遗→feiyi、华容道/解谜→puzzle、经营/奶茶店→shop。
|
||||
COVERED_BRIEFS = [
|
||||
("narrative", "一个分支选择推进的互动文字冒险:每屏一段剧情+2-3个选项,选择影响走向,最终3个不同结局,有结局图鉴"),
|
||||
("trpg", "一个掷骰战斗的地牢爬塔:每层遇一个敌人,点击掷骰(可见骰点)按攻防结算,击败进下一层,失败结算,每3层升级选天赋"),
|
||||
("heritage", "一个还原传统榫卯木工的非遗小游戏:按工序节拍(选料→画线→凿卯→修整)逐步操作,每步有节奏判定,完成得成品评分"),
|
||||
("puzzle", "一个数字华容道解谜:滑动数字块把它们按序归位,规则逐关递进,卡壳给提示,通关计步数炫耀成绩"),
|
||||
("sim-business", "一个经营奶茶店:顾客排队点单,按配方调对饮品上对得分,收银攒钱升级解锁新品与自动出杯设备"),
|
||||
]
|
||||
|
||||
|
||||
def _row_total_cost(row: dict) -> float:
|
||||
"""一局的总成本 = 生成 costRmb + 判定 judge.costRmb(判定用独立档,与生成台账隔离,累计兜底要两者都算)。"""
|
||||
gen = row.get("costRmb") or 0.0
|
||||
j = row.get("judge") or {}
|
||||
jc = j.get("costRmb") or 0.0
|
||||
try:
|
||||
return float(gen) + float(jc)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _prior_cumulative() -> float:
|
||||
"""读已落盘 JSONL 里所有历史局的累计成本(跨多次调用续跑时,¥200 兜底要含之前轮次已花)。"""
|
||||
if not _JSONL.is_file():
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for line in _JSONL.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
total += _row_total_cost(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
async def _run_one(genre, brief, port, cdp, round_no):
|
||||
"""跑一局并返回批次账行(串行,无 semaphore)。gid=hard-<genre>-r<round>,distinct、绝不同 gid 重跑。"""
|
||||
gid = f"hard-{genre}-r{round_no}"
|
||||
t0 = time.time()
|
||||
# 对齐生产 create 路:brief→route_genre 选 per-genre 黄金模板作起点 + scaffold_desc 描述引导。
|
||||
scaffold_template, routed_genre = route_genre(brief)
|
||||
# 不写锁(2026-07-08/09 创始人纠偏,W-AXIS 波2 拆 reskin 管路):game-logic/core/render/assets 全放开,
|
||||
# 只锁绝对通用 plumbing=L1_FIXED;写坏靠 harness 门 + 独立判定兜、不靠限死模型的手。
|
||||
write_whitelist = None
|
||||
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None
|
||||
try:
|
||||
summary = await cheap_studio.run_studio(
|
||||
gid, brief, run_gates=True, port=port, cdp_port=cdp,
|
||||
scaffold_template=scaffold_template, scaffold_desc=scaffold_desc,
|
||||
write_whitelist=write_whitelist, genre=routed_genre, max_tokens=_MAXTOK)
|
||||
except Exception as e: # noqa: BLE001 harness 级异常也原样计入基线(失败也是基线的一部分)
|
||||
return {"genre": genre, "gid": gid, "round": round_no,
|
||||
"accepted": False, "verdictPass": None, "finished": False, "ok": False,
|
||||
"judge": None, "fail": f"{type(e).__name__}: {e}", "wallSec": round(time.time() - t0, 1)}
|
||||
v = summary.get("verdict") or {}
|
||||
rich = summary.get("richness") or {}
|
||||
layer = summary.get("failureLayer") or {} # classify_cheap_failure_layer 返回 {layer,reason,failedGates}
|
||||
j = summary.get("judge") or {} # apply_gameplay_judge 写入(仅 run_gates∧finished 时有)
|
||||
return {
|
||||
"genre": genre, "gid": gid, "round": round_no,
|
||||
"template": scaffold_template or "_template(通用)",
|
||||
# ── 三个验收信号分开记(2026-07-09 新语义,别混):
|
||||
"accepted": bool(summary.get("accepted")), # ★最终权威 = 预筛 ∧ 独立模型玩法判定
|
||||
"verdictPass": v.get("pass"), # 预筛(机械九门),只是地板不是验收
|
||||
"finished": bool(summary.get("finished")), # 模型自称收敛(check+build 绿),不是验收
|
||||
"ok": bool(summary.get("ok")), # run_studio 收口 ok(blocking 下 = accepted)
|
||||
# ── 判定段(verdict/rejectClasses/degraded/costRmb + 诊断):
|
||||
"judge": {
|
||||
"ran": j.get("ran"), "verdict": j.get("verdict"),
|
||||
"rejectClasses": j.get("rejectClasses"), "degraded": j.get("degraded"),
|
||||
"costRmb": j.get("costRmb"), "reason": j.get("reason"),
|
||||
"model": j.get("model"), "frames": j.get("frames"),
|
||||
} if j else None,
|
||||
# ── 失败归因三层(driver_contract/mechanical/gameplay/none)+ 归档指针:
|
||||
"failedGates": v.get("failedGates"),
|
||||
"failureLayer": layer.get("layer"),
|
||||
"failureReason": layer.get("reason"),
|
||||
"archivedPriorRunTo": summary.get("archivedPriorRunTo"), # distinct gid 首跑恒 None
|
||||
"attempts": summary.get("attempts"),
|
||||
"breaker": summary.get("breaker"),
|
||||
"costRmb": summary.get("costRmb"), # 生成成本(不含判定)
|
||||
"richness": rich.get("score"), "richMax": rich.get("max"),
|
||||
"wallSec": round(time.time() - t0, 1),
|
||||
}
|
||||
|
||||
|
||||
def _mark(r: dict) -> str:
|
||||
"""一局的达标标记(accepted 口径):
|
||||
✅ accepted=True(最终权威过)
|
||||
⚠️判定拒 预筛过(verdictPass=True)但判定拒(accepted=False)——机械门放行、独立判定逮住的坏游戏
|
||||
finished-only 模型自称收敛(finished=True)但预筛未过(旧 FALSE-PASS 改名,新语义)
|
||||
❌ 未收敛/harness 挂
|
||||
"""
|
||||
if r.get("accepted"):
|
||||
return "✅"
|
||||
if r.get("verdictPass") is True:
|
||||
return "⚠️判定拒"
|
||||
if r.get("finished"):
|
||||
return "finished-only"
|
||||
return "❌"
|
||||
|
||||
|
||||
async def main():
|
||||
n_rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 1
|
||||
start_round = int(sys.argv[2]) if len(sys.argv) > 2 else 1
|
||||
_JSONL.parent.mkdir(exist_ok=True)
|
||||
cumulative = _prior_cumulative()
|
||||
print(f">>> W-AXIS n=5 重测基线:本次跑 {n_rounds} 轮(r{start_round}..r{start_round + n_rounds - 1})× {len(COVERED_BRIEFS)} 品类,"
|
||||
f"串行;已落盘历史累计成本 ¥{cumulative:.2f}(硬停线 ¥{_HARD_STOP_RMB:.0f})")
|
||||
session_rows = []
|
||||
stopped = False
|
||||
for round_no in range(start_round, start_round + n_rounds):
|
||||
if stopped:
|
||||
break
|
||||
for cat_i, (genre, brief) in enumerate(COVERED_BRIEFS):
|
||||
# 串行:每局固定 port(同 index),前一局 chrome 已收才起下一局。
|
||||
r = await _run_one(genre, brief, 4340 + cat_i * 2, 9242 + cat_i * 2, round_no)
|
||||
session_rows.append(r)
|
||||
# 逐行落盘(断点续跑不丢)。
|
||||
with _JSONL.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
cumulative += _row_total_cost(r)
|
||||
j = r.get("judge") or {}
|
||||
print(f"{_mark(r):>13s} r{round_no} {genre:14s} tpl={r.get('template','?')} "
|
||||
f"accepted={r.get('accepted')} verdictPass={r.get('verdictPass')} finished={r.get('finished')} "
|
||||
f"judge={j.get('verdict')}/{j.get('rejectClasses')} degraded={j.get('degraded')} "
|
||||
f"layer={r.get('failureLayer')} attempts={r.get('attempts')} "
|
||||
f"genCost=¥{r.get('costRmb')} judgeCost=¥{j.get('costRmb')} wall={r.get('wallSec')}s "
|
||||
f"累计=¥{cumulative:.2f} {('FAIL='+r['fail']) if r.get('fail') else ''}")
|
||||
# ¥200 跨局硬停:立即停机,已完成局全在 JSONL,报 PARTIAL。
|
||||
if cumulative > _HARD_STOP_RMB:
|
||||
print(f"\n!!! 累计成本 ¥{cumulative:.2f} 越硬停线 ¥{_HARD_STOP_RMB:.0f} → 停机 PARTIAL(已完成 {len(session_rows)} 局本次)")
|
||||
stopped = True
|
||||
break
|
||||
|
||||
# ── 本次汇总(accepted 口径为主,verdictPass/finished 并列对照)──
|
||||
n = len(session_rows)
|
||||
acc = sum(1 for r in session_rows if r.get("accepted"))
|
||||
vp = sum(1 for r in session_rows if r.get("verdictPass") is True)
|
||||
fin = sum(1 for r in session_rows if r.get("finished"))
|
||||
deg = sum(1 for r in session_rows if (r.get("judge") or {}).get("degraded"))
|
||||
print("\n========== 本次汇总(新验收语义)==========")
|
||||
print(f">>> 真实率(accepted 最终权威):{acc}/{n} = {acc / n:.0%}" if n else ">>> 无完成局")
|
||||
if n:
|
||||
print(f">>> 对照:预筛过(verdictPass) {vp}/{n}={vp / n:.0%} | 收敛(finished) {fin}/{n}={fin / n:.0%} | 判定 degraded {deg}/{n}")
|
||||
print(f">>> 差额 = 预筛过但判定拒 {vp - acc} 局(机械门放行、独立判定逮住);全 finished-only={fin - vp} 局(收敛但预筛未过)")
|
||||
print(f">>> 累计成本(本次+历史)= ¥{cumulative:.2f}")
|
||||
# 本次聚合另存一份(便于取用);逐局真相仍以 JSONL 为准。
|
||||
out = _JSONL.parent / f"wax-baseline-r{start_round}-{start_round + n_rounds - 1}.json"
|
||||
out.write_text(json.dumps(session_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f">>> 本次结果 → {out};逐局全量 → {_JSONL}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
87
cheap-worker/hard_genre_xtheme.py
Normal file
87
cheap-worker/hard_genre_xtheme.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""hard_genre_xtheme.py — 轻A 换皮鲁棒性【跨主题】验证(it5 的 brief 与模板同题=偏软,本轮换主题真考换皮)。
|
||||
|
||||
it5 的 5 个 brief 恰与模板品类同题(heritage=榫卯、feiyi 模板也=榫卯),换皮近乎恒等、验不出"跨主题换皮"能力。
|
||||
本脚本给每个路由品类换一个【不同主题】的 brief(仍命中同一 per-genre 模板+写锁),真考:模型能否只写 game-logic
|
||||
把一款「榫卯」模板换皮成「剪纸」、把「点客」换皮成「奶茶店」等——render/core 锁死下,靠数据位驱动出不同主题。
|
||||
|
||||
跑:cheap-worker/.venv/bin/python cheap-worker/hard_genre_xtheme.py(NO_PROXY + 凭据档自动解析 NEWAPI_KEY)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import cheap_studio # noqa: E402
|
||||
from cheap_genre_route import route_genre # noqa: E402
|
||||
from cheap_roles import SCAFFOLD_DESC_BY_TEMPLATE # noqa: E402
|
||||
|
||||
# 跨主题 brief——每个仍路由到同一 per-genre 模板(关键词命中),但主题换了,真考换皮。
|
||||
XTHEME_BRIEFS = [
|
||||
# 非遗:榫卯→剪纸(仍命中 feiyi:非遗/剪纸)
|
||||
("feiyi-jianzhi", "一个还原传统剪纸技艺的非遗小游戏:按刻纸工序(起稿→折纸→刻镂→揭裱)逐步操作,每步有稳度节奏判定,完成得窗花成品评分"),
|
||||
# TRPG:奇幻地牢→赛博骰战(仍命中 trpg:掷骰/地下城)
|
||||
("trpg-cyber", "一个赛博朋克掷骰爬塔:每层地下城遇一个黑客守卫,点击掷骰(可见骰点)按攻防结算,击败进下一层,失败结算,每3层升级选芯片天赋"),
|
||||
# 经营:通用点客→奶茶店(仍命中 shop:经营/奶茶店/顾客/点单)
|
||||
("shop-milktea", "一个经营奶茶店:顾客排队点单,按配方调对饮品上对得分,收银攒钱升级解锁新品与自动出杯设备"),
|
||||
# 解谜:通用→数字华容道(仍命中 puzzle:解谜/华容道)
|
||||
("puzzle-huarong", "一个数字华容道解谜:滑动数字块把它们按序归位,规则逐关递进,卡壳给提示,通关计步数炫耀成绩"),
|
||||
# 剧情:通用→校园悬疑(仍命中 story:互动小说/分支/多结局)
|
||||
("story-campus", "一个校园悬疑互动小说:每屏一段剧情+2-3个选项,选择影响走向,揭开三个不同真相结局,有结局图鉴"),
|
||||
]
|
||||
|
||||
|
||||
async def _run_one(sem, tag, brief, port, cdp):
|
||||
async with sem:
|
||||
gid = f"xt-{tag}"
|
||||
t0 = time.time()
|
||||
scaffold_template, routed_genre = route_genre(brief)
|
||||
# 2026-07-08 创始人纠偏:不写锁,全放开(只锁 L1_FIXED 通用 plumbing)。
|
||||
write_whitelist = None
|
||||
scaffold_desc = SCAFFOLD_DESC_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None
|
||||
try:
|
||||
summary = await cheap_studio.run_studio(
|
||||
gid, brief, run_gates=True, port=port, cdp_port=cdp,
|
||||
scaffold_template=scaffold_template, scaffold_desc=scaffold_desc,
|
||||
write_whitelist=write_whitelist, genre=routed_genre)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"tag": tag, "gid": gid, "template": scaffold_template, "locked": write_whitelist is not None,
|
||||
"ok": False, "fail": f"{type(e).__name__}: {e}", "wallSec": round(time.time() - t0, 1)}
|
||||
v = summary.get("verdict") or {}
|
||||
rich = summary.get("richness") or {}
|
||||
return {
|
||||
"tag": tag, "gid": gid, "template": scaffold_template or "_template(通用)",
|
||||
"locked": write_whitelist is not None,
|
||||
"ok": bool(summary.get("ok")), "verdictPass": v.get("pass"),
|
||||
"failedGates": v.get("failedGates"), "attempts": summary.get("attempts"),
|
||||
"costRmb": summary.get("costRmb"), "richness": rich.get("score"),
|
||||
"wallSec": round(time.time() - t0, 1),
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
sem = asyncio.Semaphore(2)
|
||||
tasks = [_run_one(sem, tag, brief, 4380 + i * 2, 9282 + i * 2)
|
||||
for i, (tag, brief) in enumerate(XTHEME_BRIEFS)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
print("\n\n========== 轻A 跨主题换皮验证 ==========")
|
||||
passed = 0
|
||||
for r in results:
|
||||
vp = r.get("verdictPass") is True
|
||||
passed += 1 if vp else 0
|
||||
mark = "✅" if vp else ("⚠️FALSE-PASS" if r.get("ok") else "❌")
|
||||
print(f"{mark} {r['tag']:16s} tpl={r.get('template')} lock={r.get('locked')} "
|
||||
f"verdictPass={r.get('verdictPass')} failedGates={r.get('failedGates')} "
|
||||
f"attempts={r.get('attempts')} rich={r.get('richness')}/12 "
|
||||
f"cost={r.get('costRmb')} wall={r.get('wallSec')}s {('FAIL='+r['fail']) if r.get('fail') else ''}")
|
||||
print(f">>> 跨主题换皮达标(九门 verdict.pass):{passed}/{len(results)} 过门率={passed/len(results):.0%}")
|
||||
out = Path(__file__).resolve().parent / "results" / "hard-genre-xtheme.json"
|
||||
out.parent.mkdir(exist_ok=True)
|
||||
out.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f">>> 结果 → {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
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"
|
||||
|
||||
|
||||
154
cheap-worker/results/wax-baseline-r1-1.json
Normal file
154
cheap-worker/results/wax-baseline-r1-1.json
Normal file
@ -0,0 +1,154 @@
|
||||
[
|
||||
{
|
||||
"genre": "narrative",
|
||||
"gid": "hard-narrative-r1",
|
||||
"round": 1,
|
||||
"template": "_template-story",
|
||||
"accepted": true,
|
||||
"verdictPass": true,
|
||||
"finished": true,
|
||||
"ok": true,
|
||||
"judge": {
|
||||
"ran": true,
|
||||
"verdict": "accept",
|
||||
"rejectClasses": [],
|
||||
"degraded": false,
|
||||
"costRmb": 0.05651,
|
||||
"reason": null,
|
||||
"model": "glm-5.2",
|
||||
"frames": 6
|
||||
},
|
||||
"failedGates": [],
|
||||
"failureLayer": "none",
|
||||
"failureReason": "九门未见失败门",
|
||||
"archivedPriorRunTo": null,
|
||||
"attempts": 1,
|
||||
"breaker": null,
|
||||
"costRmb": 0.7167,
|
||||
"richness": 7,
|
||||
"richMax": 12,
|
||||
"wallSec": 382.8
|
||||
},
|
||||
{
|
||||
"genre": "trpg",
|
||||
"gid": "hard-trpg-r1",
|
||||
"round": 1,
|
||||
"template": "_template-trpg",
|
||||
"accepted": true,
|
||||
"verdictPass": true,
|
||||
"finished": true,
|
||||
"ok": true,
|
||||
"judge": {
|
||||
"ran": true,
|
||||
"verdict": "accept",
|
||||
"rejectClasses": [],
|
||||
"degraded": false,
|
||||
"costRmb": 0.06215,
|
||||
"reason": null,
|
||||
"model": "glm-5.2",
|
||||
"frames": 6
|
||||
},
|
||||
"failedGates": [],
|
||||
"failureLayer": "none",
|
||||
"failureReason": "九门未见失败门",
|
||||
"archivedPriorRunTo": null,
|
||||
"attempts": 1,
|
||||
"breaker": null,
|
||||
"costRmb": 1.0491,
|
||||
"richness": 7,
|
||||
"richMax": 12,
|
||||
"wallSec": 304.8
|
||||
},
|
||||
{
|
||||
"genre": "heritage",
|
||||
"gid": "hard-heritage-r1",
|
||||
"round": 1,
|
||||
"template": "_template-feiyi",
|
||||
"accepted": true,
|
||||
"verdictPass": true,
|
||||
"finished": true,
|
||||
"ok": true,
|
||||
"judge": {
|
||||
"ran": true,
|
||||
"verdict": "accept",
|
||||
"rejectClasses": [],
|
||||
"degraded": false,
|
||||
"costRmb": 0.14706,
|
||||
"reason": null,
|
||||
"model": "glm-5.2",
|
||||
"frames": 6
|
||||
},
|
||||
"failedGates": [],
|
||||
"failureLayer": "none",
|
||||
"failureReason": "九门未见失败门",
|
||||
"archivedPriorRunTo": null,
|
||||
"attempts": 1,
|
||||
"breaker": null,
|
||||
"costRmb": 1.443,
|
||||
"richness": 10,
|
||||
"richMax": 12,
|
||||
"wallSec": 577.4
|
||||
},
|
||||
{
|
||||
"genre": "puzzle",
|
||||
"gid": "hard-puzzle-r1",
|
||||
"round": 1,
|
||||
"template": "_template-puzzle",
|
||||
"accepted": true,
|
||||
"verdictPass": true,
|
||||
"finished": true,
|
||||
"ok": true,
|
||||
"judge": {
|
||||
"ran": true,
|
||||
"verdict": "accept",
|
||||
"rejectClasses": [],
|
||||
"degraded": false,
|
||||
"costRmb": 0.07192,
|
||||
"reason": null,
|
||||
"model": "glm-5.2",
|
||||
"frames": 6
|
||||
},
|
||||
"failedGates": [],
|
||||
"failureLayer": "none",
|
||||
"failureReason": "九门未见失败门",
|
||||
"archivedPriorRunTo": null,
|
||||
"attempts": 1,
|
||||
"breaker": null,
|
||||
"costRmb": 3.6155,
|
||||
"richness": 9,
|
||||
"richMax": 12,
|
||||
"wallSec": 433.0
|
||||
},
|
||||
{
|
||||
"genre": "sim-business",
|
||||
"gid": "hard-sim-business-r1",
|
||||
"round": 1,
|
||||
"template": "_template-shop",
|
||||
"accepted": false,
|
||||
"verdictPass": false,
|
||||
"finished": true,
|
||||
"ok": false,
|
||||
"judge": {
|
||||
"ran": false,
|
||||
"verdict": null,
|
||||
"rejectClasses": null,
|
||||
"degraded": null,
|
||||
"costRmb": null,
|
||||
"reason": "预筛(机械九门)未过,不跑玩法判定",
|
||||
"model": null,
|
||||
"frames": null
|
||||
},
|
||||
"failedGates": [
|
||||
"H_progress"
|
||||
],
|
||||
"failureLayer": "gameplay",
|
||||
"failureReason": "玩法层失败(H_progress):机械与判卷合约均未见问题,玩法本身未达真进展/终局",
|
||||
"archivedPriorRunTo": null,
|
||||
"attempts": 4,
|
||||
"breaker": null,
|
||||
"costRmb": 12.045,
|
||||
"richness": 11,
|
||||
"richMax": 12,
|
||||
"wallSec": 876.1
|
||||
}
|
||||
]
|
||||
25
cheap-worker/results/wax-baseline.jsonl
Normal file
25
cheap-worker/results/wax-baseline.jsonl
Normal file
@ -0,0 +1,25 @@
|
||||
{"genre": "narrative", "gid": "hard-narrative-r1", "round": 1, "template": "_template-story", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.05651, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 0.7167, "richness": 7, "richMax": 12, "wallSec": 382.8}
|
||||
{"genre": "trpg", "gid": "hard-trpg-r1", "round": 1, "template": "_template-trpg", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.06215, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.0491, "richness": 7, "richMax": 12, "wallSec": 304.8}
|
||||
{"genre": "heritage", "gid": "hard-heritage-r1", "round": 1, "template": "_template-feiyi", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.14706, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.443, "richness": 10, "richMax": 12, "wallSec": 577.4}
|
||||
{"genre": "puzzle", "gid": "hard-puzzle-r1", "round": 1, "template": "_template-puzzle", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.07192, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 3.6155, "richness": 9, "richMax": 12, "wallSec": 433.0}
|
||||
{"genre": "sim-business", "gid": "hard-sim-business-r1", "round": 1, "template": "_template-shop", "accepted": false, "verdictPass": false, "finished": true, "ok": false, "judge": {"ran": false, "verdict": null, "rejectClasses": null, "degraded": null, "costRmb": null, "reason": "预筛(机械九门)未过,不跑玩法判定", "model": null, "frames": null}, "failedGates": ["H_progress"], "failureLayer": "gameplay", "failureReason": "玩法层失败(H_progress):机械与判卷合约均未见问题,玩法本身未达真进展/终局", "archivedPriorRunTo": null, "attempts": 4, "breaker": null, "costRmb": 12.045, "richness": 11, "richMax": 12, "wallSec": 876.1}
|
||||
{"genre": "narrative", "gid": "hard-narrative-r2", "round": 2, "template": "_template-story", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.06422, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 0.5554, "richness": 10, "richMax": 12, "wallSec": 161.6}
|
||||
{"genre": "trpg", "gid": "hard-trpg-r2", "round": 2, "template": "_template-trpg", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.12879, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 2.6101, "richness": 7, "richMax": 12, "wallSec": 337.2}
|
||||
{"genre": "heritage", "gid": "hard-heritage-r2", "round": 2, "template": "_template-feiyi", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.06972, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.1078, "richness": 10, "richMax": 12, "wallSec": 314.1}
|
||||
{"genre": "puzzle", "gid": "hard-puzzle-r2", "round": 2, "template": "_template-puzzle", "accepted": false, "verdictPass": false, "finished": true, "ok": false, "judge": {"ran": false, "verdict": null, "rejectClasses": null, "degraded": null, "costRmb": null, "reason": "预筛(机械九门)未过,不跑玩法判定", "model": null, "frames": null}, "failedGates": ["E_live", "G_input", "H_progress"], "failureLayer": "driver_contract", "failureReason": "判卷驱动器合约失败:真玩停在菜单类 phase=menu(判卷驱动器没起局进游玩)——取证接口没对齐,通常不是玩法坏", "archivedPriorRunTo": null, "attempts": 2, "breaker": null, "costRmb": 10.9643, "richness": 9, "richMax": 12, "wallSec": 789.2}
|
||||
{"genre": "sim-business", "gid": "hard-sim-business-r2", "round": 2, "template": "_template-shop", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.07256, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 2, "breaker": null, "costRmb": 7.8666, "richness": 10, "richMax": 12, "wallSec": 936.3}
|
||||
{"genre": "narrative", "gid": "hard-narrative-r3", "round": 3, "template": "_template-story", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.05411, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.1093, "richness": 9, "richMax": 12, "wallSec": 330.4}
|
||||
{"genre": "trpg", "gid": "hard-trpg-r3", "round": 3, "template": "_template-trpg", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["broken", "hollow"], "degraded": false, "costRmb": 0.06465, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 2, "breaker": null, "costRmb": 1.5238, "richness": 9, "richMax": 12, "wallSec": 330.2}
|
||||
{"genre": "heritage", "gid": "hard-heritage-r3", "round": 3, "template": "_template-feiyi", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.06404, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.5699, "richness": 10, "richMax": 12, "wallSec": 370.5}
|
||||
{"genre": "puzzle", "gid": "hard-puzzle-r3", "round": 3, "template": "_template-puzzle", "accepted": false, "verdictPass": false, "finished": true, "ok": false, "judge": {"ran": false, "verdict": null, "rejectClasses": null, "degraded": null, "costRmb": null, "reason": "预筛(机械九门)未过,不跑玩法判定", "model": null, "frames": null}, "failedGates": ["H_progress"], "failureLayer": "gameplay", "failureReason": "玩法层失败(H_progress):机械与判卷合约均未见问题,玩法本身未达真进展/终局", "archivedPriorRunTo": "/Users/lili/Project/games-development-ai/game-runtime/games/_amgen-archive/hard-puzzle-r3/20260709-085256", "attempts": 4, "breaker": null, "costRmb": 11.4059, "richness": 8, "richMax": 12, "wallSec": 942.5}
|
||||
{"genre": "sim-business", "gid": "hard-sim-business-r3", "round": 3, "template": "_template-shop", "accepted": false, "verdictPass": false, "finished": true, "ok": false, "judge": {"ran": false, "verdict": null, "rejectClasses": null, "degraded": null, "costRmb": null, "reason": "预筛(机械九门)未过,不跑玩法判定", "model": null, "frames": null}, "failedGates": ["A_boot"], "failureLayer": "mechanical", "failureReason": "机械门失败(A_boot):装载/异常/掌帧/渲染层坏死(黑屏/抛错/定帧)", "archivedPriorRunTo": null, "attempts": 2, "breaker": null, "costRmb": 13.369, "richness": 9, "richMax": 12, "wallSec": 736.5}
|
||||
{"genre": "narrative", "gid": "hard-narrative-r4", "round": 4, "template": "_template-story", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.05023, "reason": null, "model": "glm-5.2", "frames": 5}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 0.6924, "richness": 9, "richMax": 12, "wallSec": 180.8}
|
||||
{"genre": "trpg", "gid": "hard-trpg-r4", "round": 4, "template": "_template-trpg", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.06098, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.9305, "richness": 9, "richMax": 12, "wallSec": 285.8}
|
||||
{"genre": "heritage", "gid": "hard-heritage-r4", "round": 4, "template": "_template-feiyi", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["broken", "hollow", "off_brief"], "degraded": false, "costRmb": 0.04581, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.4408, "richness": 8, "richMax": 12, "wallSec": 300.4, "judgeInstrumentNote": {"kind": "judge-image-blind", "note": "网关丢图假阴;判定档切 M3 后同证据重判=accept,盘上已订正", "eyesOnValid": true, "rejudgedVerdict": "accept", "rejudgedModel": "MiniMax-M3", "rejudgedAt": "2026-07-10"}}
|
||||
{"genre": "puzzle", "gid": "hard-puzzle-r4", "round": 4, "template": "_template-puzzle", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.0268, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 2.7289, "richness": 9, "richMax": 12, "wallSec": 314.8}
|
||||
{"genre": "sim-business", "gid": "hard-sim-business-r4", "round": 4, "template": "_template-shop", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["hollow"], "degraded": false, "costRmb": 0.14978, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.3087, "richness": 11, "richMax": 12, "wallSec": 332.5}
|
||||
{"genre": "narrative", "gid": "hard-narrative-r5", "round": 5, "template": "_template-story", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["hollow", "off_brief"], "degraded": false, "costRmb": 0.12619, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 0.5375, "richness": 6, "richMax": 12, "wallSec": 375.4, "judgeInstrumentNote": {"kind": "judge-image-blind", "note": "网关丢图假阴;判定档切 M3 后同证据重判=accept,盘上已订正", "eyesOnValid": true, "rejudgedVerdict": "accept", "rejudgedModel": "MiniMax-M3", "rejudgedAt": "2026-07-10"}}
|
||||
{"genre": "trpg", "gid": "hard-trpg-r5", "round": 5, "template": "_template-trpg", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["degraded"], "degraded": true, "costRmb": 0.05717, "reason": "判定输出缺 hollow 字段(无法确认地板,fail-closed)", "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.88, "richness": 10, "richMax": 12, "wallSec": 319.6, "judgeInstrumentNote": {"kind": "rejudged-cap-artifact", "note": "cap1500 截断误拒;同证据重判=accept(质量口径计 accept);盘上已订正", "rejudgedVerdict": "accept"}}
|
||||
{"genre": "heritage", "gid": "hard-heritage-r5", "round": 5, "template": "_template-feiyi", "accepted": true, "verdictPass": true, "finished": true, "ok": true, "judge": {"ran": true, "verdict": "accept", "rejectClasses": [], "degraded": false, "costRmb": 0.1458, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.3141, "richness": 8, "richMax": 12, "wallSec": 332.6}
|
||||
{"genre": "puzzle", "gid": "hard-puzzle-r5", "round": 5, "template": "_template-puzzle", "accepted": false, "verdictPass": null, "finished": false, "ok": false, "judge": null, "failedGates": null, "failureLayer": "none", "failureReason": "九门未跑出 verdict(无 guards),无失败可归因", "archivedPriorRunTo": null, "attempts": 2, "breaker": {"kind": "timeout", "reason": "单步静默超时(相邻事件间隔 >420.0s,疑单步卡死)"}, "costRmb": 11.0151, "richness": null, "richMax": null, "wallSec": 1130.4}
|
||||
{"genre": "sim-business", "gid": "hard-sim-business-r5", "round": 5, "template": "_template-shop", "accepted": false, "verdictPass": true, "finished": true, "ok": false, "judge": {"ran": true, "verdict": "reject", "rejectClasses": ["broken", "hollow", "off_brief"], "degraded": false, "costRmb": 0.05135, "reason": null, "model": "glm-5.2", "frames": 6}, "failedGates": [], "failureLayer": "none", "failureReason": "九门未见失败门", "archivedPriorRunTo": null, "attempts": 1, "breaker": null, "costRmb": 1.5973, "richness": 10, "richMax": 12, "wallSec": 286.7, "judgeInstrumentNote": {"kind": "judge-image-blind", "note": "网关丢图假阴;判定档切 M3 后同证据重判=accept,盘上已订正", "eyesOnValid": true, "rejudgedVerdict": "accept", "rejudgedModel": "MiniMax-M3", "rejudgedAt": "2026-07-10"}}
|
||||
112
cheap-worker/tests/test_cheap_failure_layer.py
Normal file
112
cheap-worker/tests/test_cheap_failure_layer.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""test_cheap_failure_layer.py — W-AXIS 波1 失败三层归因单测(driver_contract/mechanical/gameplay/none)。
|
||||
|
||||
覆盖 gate_judge 的两件归因(诊断档 §2.2 判卷合约反噬的对症):
|
||||
1. classify_cheap_failure_layer:verdict → 三层归因(供 run-summary/批次账;成功/未跑出裁决=none);
|
||||
2. _feedback_layer_lead + _render_cheap_feedback:续修回喂顶部按真实层次措辞(治「合约缺口误当玩法坏死」)。
|
||||
|
||||
跑法:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_failure_layer.py -q
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CW = os.path.dirname(_HERE)
|
||||
_REPO_ROOT = os.path.dirname(_CW)
|
||||
_GEN_WORKER = os.path.join(_REPO_ROOT, "tier2", "gen-worker")
|
||||
for _p in (_CW, _GEN_WORKER):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from worker.gate_judge import ( # noqa: E402
|
||||
classify_cheap_failure_layer,
|
||||
build_cheap_feedback_obj,
|
||||
_feedback_layer_lead,
|
||||
_render_cheap_feedback,
|
||||
)
|
||||
|
||||
|
||||
def _h(pass_=False, *, before=0, after=0, phase="menu"):
|
||||
return {"pass": pass_,
|
||||
"checks": [{"path": "score", "op": "increased", "before": before, "after": after, "pass": pass_}],
|
||||
"latch": {"pass": False, "advisory": True, "phaseNow": phase}}
|
||||
|
||||
|
||||
# ── classify:四态 ────────────────────────────────────────────────────────────
|
||||
def test_classify_none_when_no_verdict():
|
||||
assert classify_cheap_failure_layer(None)["layer"] == "none"
|
||||
assert classify_cheap_failure_layer({})["layer"] == "none"
|
||||
assert classify_cheap_failure_layer({"pass": False, "guards": {}})["layer"] == "none"
|
||||
|
||||
|
||||
def test_classify_none_when_all_pass():
|
||||
v = {"pass": True, "guards": {"A_boot": {"pass": True}, "H_progress": {"pass": True}}}
|
||||
assert classify_cheap_failure_layer(v)["layer"] == "none"
|
||||
|
||||
|
||||
def test_classify_mechanical_takes_priority():
|
||||
# D_render(黑屏)失败即机械层,即便 H_progress 也挂(根因在机械)。
|
||||
v = {"pass": False, "guards": {"D_render": {"pass": False, "bright": 2}, "H_progress": _h(phase="menu")}}
|
||||
r = classify_cheap_failure_layer(v)
|
||||
assert r["layer"] == "mechanical"
|
||||
assert "D_render" in r["reason"]
|
||||
|
||||
|
||||
def test_classify_driver_contract_menu_stuck():
|
||||
# H_progress 挂 + latch phaseNow=菜单类 → 判卷驱动器进不去菜单,合约层(不是玩法坏)。
|
||||
v = {"pass": False, "guards": {"H_progress": _h(phase="menu")}}
|
||||
r = classify_cheap_failure_layer(v)
|
||||
assert r["layer"] == "driver_contract"
|
||||
assert "菜单" in r["reason"]
|
||||
|
||||
|
||||
def test_classify_gameplay_score_increased_non_menu():
|
||||
# score 真涨(after>before)、phase 已在 play、无回退考卷 → 玩法层(真玩驱动起来了但到不了终局)。
|
||||
v = {"pass": False, "guards": {"H_progress": _h(before=0, after=216, phase="play")}}
|
||||
assert classify_cheap_failure_layer(v)["layer"] == "gameplay"
|
||||
|
||||
|
||||
def test_classify_driver_contract_fallback_zero_increment(tmp_path):
|
||||
# selectionBasis=回退 + 零增量 → 合约层(考卷驱不动本游戏),需 staged play-spec 提供 selectionBasis。
|
||||
import json
|
||||
(tmp_path / "play-spec.json").write_text(json.dumps({
|
||||
"driver": {"type": "key-cycle", "selectionBasis": "state 无 targets/target 键 → 按键类回退 → key-cycle 族"}
|
||||
}), encoding="utf-8")
|
||||
v = {"pass": False, "guards": {"H_progress": _h(before=5, after=5, phase="play")}}
|
||||
r = classify_cheap_failure_layer(v, staged_dir=tmp_path)
|
||||
assert r["layer"] == "driver_contract"
|
||||
|
||||
|
||||
# ── 回喂反馈按真实层次措辞 ────────────────────────────────────────────────────
|
||||
def test_feedback_lead_mechanical():
|
||||
v = {"pass": False, "guards": {"D_render": {"pass": False, "bright": 2}}}
|
||||
obj = build_cheap_feedback_obj(v, game_id="g")
|
||||
lead = _feedback_layer_lead(obj)
|
||||
assert lead and "机械门" in lead
|
||||
assert _render_cheap_feedback(obj).startswith("【失败层次·机械门】")
|
||||
|
||||
|
||||
def test_feedback_lead_driver_contract_menu():
|
||||
v = {"pass": False, "guards": {"H_progress": _h(phase="menu")}}
|
||||
obj = build_cheap_feedback_obj(v, game_id="g")
|
||||
lead = _feedback_layer_lead(obj)
|
||||
assert lead and "判卷驱动器合约" in lead
|
||||
# 续修全文顶部横幅点明「不是你的玩法写坏」——治合约缺口被误当玩法坏死。
|
||||
assert "不是你的玩法写坏" in _render_cheap_feedback(obj)
|
||||
|
||||
|
||||
def test_feedback_lead_gameplay():
|
||||
v = {"pass": False, "guards": {"H_progress": _h(before=0, after=216, phase="play")}}
|
||||
obj = build_cheap_feedback_obj(v, game_id="g")
|
||||
lead = _feedback_layer_lead(obj)
|
||||
assert lead and "玩法层" in lead
|
||||
|
||||
|
||||
def test_feedback_lead_none_when_no_items():
|
||||
assert _feedback_layer_lead({}) is None
|
||||
assert _feedback_layer_lead({"failedGates": []}) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
126
cheap-worker/tests/test_cheap_run_archive.py
Normal file
126
cheap-worker/tests/test_cheap_run_archive.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""test_cheap_run_archive.py — W-AXIS 波1 per-run 归档 + evidence 清理清单化单测。
|
||||
|
||||
覆盖 cheap_run 的两件真相层落地(脱 agentscope,系统 Python 即可跑):
|
||||
1. archive_prior_run:首跑无产物 → None;有上一 run → 把 amgen-<id>/ 与 _wg1-gen/<id>/ 整体【移】进
|
||||
带时间戳归档位(原地清空、归档留全),返回指针;开关关 → None 且原地不动。
|
||||
2. clean_stale_evidence:按固定清单清 _wg1-gen/<id>/evidence/ 残留(verdict-feedback 等全列),
|
||||
兜底清 amgen-<id>/evidence/service-run-summary.json;缺文件不报错。
|
||||
|
||||
用 monkeypatch 把 cheap_run 的目录根指向临时沙箱,绝不碰真实 game-runtime/games。
|
||||
跑法:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_run_archive.py -q
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CW = os.path.dirname(_HERE)
|
||||
_REPO_ROOT = os.path.dirname(_CW)
|
||||
_GEN_WORKER = os.path.join(_REPO_ROOT, "tier2", "gen-worker")
|
||||
for _p in (_CW, _GEN_WORKER):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import cheap_run # noqa: E402
|
||||
|
||||
|
||||
def _sandbox(monkeypatch, tmp_path):
|
||||
"""把 cheap_run 的目录根重定向到临时沙箱(game_dir/wg1_game_dir/_ARCHIVE_DIR 全随之改)。"""
|
||||
games = tmp_path / "games"
|
||||
monkeypatch.setattr(cheap_run, "_GAMES_DIR", games)
|
||||
monkeypatch.setattr(cheap_run, "_WG1_DIR", games / "_wg1-gen")
|
||||
monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", games / "_amgen-archive")
|
||||
return games
|
||||
|
||||
|
||||
def _seed_prior_run(gid):
|
||||
"""造一个「上一 run」的产物:amgen-<id>/{trace.jsonl,turns.jsonl} + _wg1-gen/<id>/evidence/verdict.json。"""
|
||||
amgen = cheap_run.game_dir(gid)
|
||||
(amgen / "src").mkdir(parents=True, exist_ok=True)
|
||||
(amgen / "trace.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
(amgen / "turns.jsonl").write_text('{"kind":"model_turn"}\n', encoding="utf-8")
|
||||
wg1_ev = cheap_run.wg1_game_dir(gid) / "evidence"
|
||||
wg1_ev.mkdir(parents=True, exist_ok=True)
|
||||
(wg1_ev / "verdict.json").write_text('{"pass":true}', encoding="utf-8")
|
||||
return amgen, wg1_ev
|
||||
|
||||
|
||||
# ── 归档 ─────────────────────────────────────────────────────────────────────
|
||||
def test_archive_first_run_returns_none(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
assert cheap_run.archive_prior_run("t-first") is None # 无上一 run 可归档
|
||||
|
||||
|
||||
def test_archive_moves_amgen_and_wg1(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
gid = "t-move"
|
||||
amgen, wg1_ev = _seed_prior_run(gid)
|
||||
ptr = cheap_run.archive_prior_run(gid)
|
||||
assert ptr is not None
|
||||
arch = Path(ptr)
|
||||
# 原地清空(移走,不复制)。
|
||||
assert not amgen.exists(), "amgen-<id>/ 应被移走"
|
||||
assert not cheap_run.wg1_game_dir(gid).exists(), "_wg1-gen/<id>/ 应被移走"
|
||||
# 归档留全(trace/turns/verdict 都在归档位)。
|
||||
assert (arch / "amgen" / "trace.jsonl").exists()
|
||||
assert (arch / "amgen" / "turns.jsonl").exists()
|
||||
assert (arch / "wg1" / "evidence" / "verdict.json").exists()
|
||||
|
||||
|
||||
def test_archive_twice_distinct_slots(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
gid = "t-twice"
|
||||
_seed_prior_run(gid)
|
||||
p1 = cheap_run.archive_prior_run(gid)
|
||||
_seed_prior_run(gid) # 再造一次上一 run
|
||||
p2 = cheap_run.archive_prior_run(gid)
|
||||
assert p1 and p2 and p1 != p2, "两次归档必须落不同槽位(同秒加 -n 后缀)"
|
||||
|
||||
|
||||
def test_archive_disabled_noop(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("CHEAP_ARCHIVE_ENABLED", "0")
|
||||
gid = "t-off"
|
||||
amgen, _ = _seed_prior_run(gid)
|
||||
assert cheap_run.archive_prior_run(gid) is None
|
||||
assert amgen.exists(), "归档关时原地不动(退回旧覆盖行为)"
|
||||
|
||||
|
||||
# ── evidence 清理清单化 ──────────────────────────────────────────────────────
|
||||
def test_clean_stale_evidence_removes_checklist(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
gid = "t-clean"
|
||||
wg1_ev = cheap_run.wg1_game_dir(gid) / "evidence"
|
||||
wg1_ev.mkdir(parents=True, exist_ok=True)
|
||||
# 造全清单残留 + 一个不在清单的文件(不该被清)。
|
||||
for name in ("verdict.json", "verdict-feedback.json", "game-log.json",
|
||||
"first-paint.png", "after-play.png"):
|
||||
(wg1_ev / name).write_text("x", encoding="utf-8")
|
||||
(wg1_ev / "keep-me.txt").write_text("keep", encoding="utf-8")
|
||||
amgen_ev = cheap_run.game_dir(gid) / "evidence"
|
||||
amgen_ev.mkdir(parents=True, exist_ok=True)
|
||||
(amgen_ev / "service-run-summary.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
out = cheap_run.clean_stale_evidence(gid)
|
||||
# 关键残留元凶 verdict-feedback.json 必被清(旧红线③不清它=残留混杂根因)。
|
||||
assert not (wg1_ev / "verdict-feedback.json").exists()
|
||||
assert not (wg1_ev / "verdict.json").exists()
|
||||
assert not (wg1_ev / "game-log.json").exists()
|
||||
assert not (wg1_ev / "first-paint.png").exists()
|
||||
assert not (amgen_ev / "service-run-summary.json").exists()
|
||||
# 清单外文件保留。
|
||||
assert (wg1_ev / "keep-me.txt").exists()
|
||||
assert "wg1/evidence/verdict-feedback.json" in out["removed"]
|
||||
|
||||
|
||||
def test_clean_stale_evidence_missing_ok(monkeypatch, tmp_path):
|
||||
_sandbox(monkeypatch, tmp_path)
|
||||
# evidence 目录都不存在 → 不报错、removed 空。
|
||||
out = cheap_run.clean_stale_evidence("t-none")
|
||||
assert out == {"removed": []}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
@ -53,14 +53,15 @@ def test_port_pool_derives_distinct_pairs():
|
||||
A.release_ports(p3)
|
||||
|
||||
|
||||
def test_tools_factory_returns_six_tools_bound_to_external_game_id(tmp_path, monkeypatch):
|
||||
def test_tools_factory_returns_seven_tools_bound_to_external_game_id(tmp_path, monkeypatch):
|
||||
import json
|
||||
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
|
||||
p = tmp_path / "_cheap-sessions" / "s1.json"
|
||||
p.parent.mkdir(parents=True)
|
||||
p.write_text(json.dumps({"external_game_id": "70012"}), encoding="utf-8") # create 路:仅映射
|
||||
tools = asyncio.run(A._cheap_tools_factory("u1", "a1", "s1"))
|
||||
assert len(tools) == 6, "便宜档六工具(read/list/write/check/build/finish)"
|
||||
# 便宜档七工具(read/list/write/edit/check/build/finish);edit_file 是 fix①-A(2026-07-08)加的增量改工具。
|
||||
assert len(tools) == 7, "便宜档七工具(read/list/write/edit/check/build/finish)"
|
||||
|
||||
|
||||
def test_resolve_write_whitelist_i1_fail_closed():
|
||||
@ -82,7 +83,9 @@ def test_middlewares_factory_soft_budget_and_failsafe_caps(tmp_path, monkeypatch
|
||||
p.write_text(json.dumps({"external_game_id": "70020"}), encoding="utf-8")
|
||||
mws = asyncio.run(A._cheap_middlewares_factory("u1", "a1", "s2"))
|
||||
names = [type(m).__name__ for m in mws]
|
||||
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware", "RepairMiddleware", "CircuitBreakerMiddleware"]
|
||||
# W-AXIS 波1:真相层 CheapTurnsMiddleware 追加在末位(observe-only,默认开);breaker 仍在 idx=3。
|
||||
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware", "RepairMiddleware",
|
||||
"CircuitBreakerMiddleware", "CheapTurnsMiddleware"]
|
||||
breaker = mws[3]
|
||||
assert breaker.soft_budget is True, "决策①:cheap 软预算切 soft"
|
||||
assert breaker.rmb_hard_limit == 10.0, "cheap ¥10(soft 档软目标,不再是硬地板)"
|
||||
|
||||
@ -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"}
|
||||
|
||||
250
cheap-worker/tests/test_cheap_turns_sink.py
Normal file
250
cheap-worker/tests/test_cheap_turns_sink.py
Normal file
@ -0,0 +1,250 @@
|
||||
"""test_cheap_turns_sink.py — W-AXIS 波1 真相层逐 turn 全文落盘单测(TDD:聚合 / 脱敏 / 轮转 / 开关)。
|
||||
|
||||
覆盖 cheap_turns_sink 的纯逻辑件(脱 agentscope,系统 Python 即可跑):
|
||||
1. _redact:new-api key / Bearer / 带键名赋值 / 进程 env 真实凭据 全脱敏;
|
||||
2. _TurnAggregator:一轮 ReAct 事件流聚合成整段 model_turn(模型文本 + 工具全参 + 工具返回文本 + token);
|
||||
3. 门反馈/harness 输入两条路(note_reply_inputs / scan_context_gate)落 gate_feedback/harness_input + 去重;
|
||||
4. CheapTurnsWriter:JSONL 落盘 + 单文件上限轮转 turns.<n>.jsonl;
|
||||
5. turns_enabled 开关(默认开;CHEAP_TURNS_ENABLED=0 关)、build_turns_middleware 关时返回 None。
|
||||
|
||||
跑法:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_turns_sink.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CW = os.path.dirname(_HERE)
|
||||
_REPO_ROOT = os.path.dirname(_CW)
|
||||
_GEN_WORKER = os.path.join(_REPO_ROOT, "tier2", "gen-worker")
|
||||
for _p in (_CW, _GEN_WORKER):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import cheap_turns_sink as cts # noqa: E402
|
||||
|
||||
|
||||
# ── 一:密钥脱敏 ─────────────────────────────────────────────────────────────
|
||||
def test_redact_sk_bearer_labeled():
|
||||
assert "sk-ABCDEF123456" not in cts._redact("凭据 sk-ABCDEF123456 已用")
|
||||
assert cts._REDACTED in cts._redact("凭据 sk-ABCDEF123456 已用")
|
||||
out = cts._redact('headers={"Authorization":"Bearer tok_9f8e7d6c5b4a"}')
|
||||
assert "tok_9f8e7d6c5b4a" not in out and cts._REDACTED in out
|
||||
out2 = cts._redact('api_key=deadbeefcafebabe0123')
|
||||
assert "deadbeefcafebabe0123" not in out2 and cts._REDACTED in out2
|
||||
|
||||
|
||||
def test_redact_env_real_key(monkeypatch):
|
||||
monkeypatch.setenv("NEWAPI_KEY", "REALKEY-abcdef1234567890")
|
||||
out = cts._redact("工具回显了 REALKEY-abcdef1234567890 这条")
|
||||
assert "REALKEY-abcdef1234567890" not in out and cts._REDACTED in out
|
||||
|
||||
|
||||
def test_redact_none_and_plain():
|
||||
assert cts._redact(None) == ""
|
||||
assert cts._redact("普通游戏代码 createGame({plugins})") == "普通游戏代码 createGame({plugins})"
|
||||
|
||||
|
||||
# ── 二:一轮 ReAct 事件聚合成 model_turn ─────────────────────────────────────
|
||||
def _collect(events, *, inputs=None, context=None):
|
||||
"""把事件序列喂聚合器,返回落下的记录列表(emit 收集器)。"""
|
||||
recs = []
|
||||
agg = cts._TurnAggregator("g-demo", "g-demo", recs.append)
|
||||
if inputs is not None:
|
||||
agg.note_reply_inputs(inputs)
|
||||
for e in events:
|
||||
if cts._evt_type(e) == "ModelCallStartEvent":
|
||||
agg.flush()
|
||||
if context is not None:
|
||||
agg.scan_context_gate(context)
|
||||
agg.ingest_event(e)
|
||||
agg.close()
|
||||
return recs
|
||||
|
||||
|
||||
def test_aggregate_one_round_full_text():
|
||||
events = [
|
||||
{"type": "ModelCallStartEvent", "model_name": "MiniMax-M3"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "我先读手册"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "再写 game-logic"},
|
||||
{"type": "ToolCallStartEvent", "tool_call_id": "t1", "tool_call_name": "write_file"},
|
||||
{"type": "ToolCallDeltaEvent", "tool_call_id": "t1", "delta": '{"path":"src/game-logic.js",'},
|
||||
{"type": "ToolCallDeltaEvent", "tool_call_id": "t1", "delta": '"content":"createGame(){}"}'},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1200, "output_tokens": 800},
|
||||
{"type": "ToolResultStartEvent", "tool_call_id": "t1", "tool_call_name": "write_file"},
|
||||
{"type": "ToolResultTextDeltaEvent", "tool_call_id": "t1", "delta": '{"ok":true,"bytes":512}'},
|
||||
{"type": "ToolResultEndEvent", "tool_call_id": "t1", "tool_call_name": "write_file", "state": "success"},
|
||||
{"type": "ReplyEndEvent", "reply_id": "r1"},
|
||||
]
|
||||
recs = _collect(events)
|
||||
turns = [r for r in recs if r["kind"] == "model_turn"]
|
||||
assert len(turns) == 1, f"应聚合成一条 model_turn,实得 {recs}"
|
||||
t = turns[0]
|
||||
# 模型文本聚合全(不再碎成逐 delta)。
|
||||
assert t["text"] == "我先读手册再写 game-logic"
|
||||
# 工具调用:名 + 全参(argsDelta 聚合)。
|
||||
assert t["toolCalls"][0]["name"] == "write_file"
|
||||
assert '"path":"src/game-logic.js"' in t["toolCalls"][0]["args"]
|
||||
assert '"content":"createGame(){}"' in t["toolCalls"][0]["args"]
|
||||
# 工具返回文本(to_trace_step 丢的那段,这里补上)+ 状态。
|
||||
assert t["toolResults"][0]["text"] == '{"ok":true,"bytes":512}'
|
||||
assert t["toolResults"][0]["state"] == "success"
|
||||
# token。
|
||||
assert t["tokens"] == {"in": 1200, "out": 800}
|
||||
|
||||
|
||||
def test_two_rounds_split_into_two_turns():
|
||||
events = [
|
||||
{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第一轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第二轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 2, "output_tokens": 2},
|
||||
{"type": "ReplyEndEvent"},
|
||||
]
|
||||
turns = [r for r in _collect(events) if r["kind"] == "model_turn"]
|
||||
assert [t["turn"] for t in turns] == [1, 2]
|
||||
assert turns[0]["text"] == "第一轮" and turns[1]["text"] == "第二轮"
|
||||
|
||||
|
||||
def test_empty_turn_not_written():
|
||||
# 只有 ModelCallStart 无任何内容 → 不落空 turn。
|
||||
turns = [r for r in _collect([
|
||||
{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "ReplyEndEvent"},
|
||||
]) if r["kind"] == "model_turn"]
|
||||
assert turns == []
|
||||
|
||||
|
||||
# ── 三:门反馈 / harness 输入两条路 ───────────────────────────────────────────
|
||||
def test_harness_input_and_gate_feedback():
|
||||
# CLI 路:kick(name=user)→ harness_input;门续修反馈同理(CLI 用 name=user 的 kick 承载 feedback)。
|
||||
recs = _collect(
|
||||
[{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "改"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ReplyEndEvent"}],
|
||||
inputs=[{"name": "user", "content": "请按 brief 造游戏"}],
|
||||
)
|
||||
kinds = [r["kind"] for r in recs]
|
||||
assert "harness_input" in kinds
|
||||
hi = next(r for r in recs if r["kind"] == "harness_input")
|
||||
assert hi["text"] == "请按 brief 造游戏"
|
||||
|
||||
|
||||
def test_gate_feedback_from_context_and_dedup():
|
||||
# Service 路:mid-reply 注入 name=gate 续修,每见 ModelCallStart 扫 context;同内容只落一次。
|
||||
ctx = [{"name": "user", "content": "kick"},
|
||||
{"name": "gate", "content": "以下真玩验收门未通过,请修 H_progress"}]
|
||||
recs = _collect(
|
||||
[{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第一轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ModelCallStartEvent", "model_name": "m"}, # 第二次 MCS 再扫一遍 context
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第二轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ReplyEndEvent"}],
|
||||
context=ctx,
|
||||
)
|
||||
gate_recs = [r for r in recs if r["kind"] == "gate_feedback"]
|
||||
assert len(gate_recs) == 1, "同内容门续修只落一次(去重)"
|
||||
assert "H_progress" in gate_recs[0]["text"]
|
||||
|
||||
|
||||
def test_gate_feedback_ordered_after_prior_turn():
|
||||
# 时间序(真实注入序):门续修在【第一轮结束后】才注入 context,故第二轮 ModelCallStart 才扫到。
|
||||
# 手动驱动以复现「先冲上一 turn → 再落 gate → 再起新 turn」的中间件顺序(_collect 静态 context 无法表达)。
|
||||
recs = []
|
||||
agg = cts._TurnAggregator("g", "g", recs.append)
|
||||
ctx = [] # 起手 context 无 gate(第一轮时判卷还没跑)
|
||||
for e in [{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第一轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1}]:
|
||||
if cts._evt_type(e) == "ModelCallStartEvent":
|
||||
agg.flush(); agg.scan_context_gate(ctx)
|
||||
agg.ingest_event(e)
|
||||
ctx.append({"name": "gate", "content": "门未通过"}) # 第一轮后 RepairMiddleware 注入门续修
|
||||
for e in [{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "第二轮"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ReplyEndEvent"}]:
|
||||
if cts._evt_type(e) == "ModelCallStartEvent":
|
||||
agg.flush(); agg.scan_context_gate(ctx)
|
||||
agg.ingest_event(e)
|
||||
agg.close()
|
||||
seq_kinds = [r["kind"] for r in recs]
|
||||
# 期望序:model_turn(第一轮) → gate_feedback → model_turn(第二轮)
|
||||
i_turn1 = seq_kinds.index("model_turn")
|
||||
i_gate = seq_kinds.index("gate_feedback")
|
||||
assert i_turn1 < i_gate, "门续修须落在上一 turn 之后(时间序正确)"
|
||||
assert i_gate < seq_kinds.index("model_turn", i_gate), "门续修须在下一 turn 之前"
|
||||
|
||||
|
||||
def test_input_redacted():
|
||||
recs = _collect(
|
||||
[{"type": "ModelCallStartEvent", "model_name": "m"},
|
||||
{"type": "TextBlockDeltaEvent", "delta": "x"},
|
||||
{"type": "ModelCallEndEvent", "input_tokens": 1, "output_tokens": 1},
|
||||
{"type": "ReplyEndEvent"}],
|
||||
inputs=[{"name": "user", "content": "key=sk-SECRET1234567 造游戏"}],
|
||||
)
|
||||
hi = next(r for r in recs if r["kind"] == "harness_input")
|
||||
assert "sk-SECRET1234567" not in hi["text"] and cts._REDACTED in hi["text"]
|
||||
|
||||
|
||||
# ── 四:落盘 + 轮转 ──────────────────────────────────────────────────────────
|
||||
def test_writer_appends_jsonl():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "turns.jsonl"
|
||||
w = cts.CheapTurnsWriter(p)
|
||||
w.write_record({"kind": "model_turn", "turn": 1, "text": "a"})
|
||||
w.write_record({"kind": "model_turn", "turn": 2, "text": "b"})
|
||||
lines = p.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0])["turn"] == 1 and json.loads(lines[1])["turn"] == 2
|
||||
|
||||
|
||||
def test_writer_rotates_on_cap():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "turns.jsonl"
|
||||
w = cts.CheapTurnsWriter(p, max_bytes=200) # 极小上限逼轮转
|
||||
for i in range(20):
|
||||
w.write_record({"kind": "model_turn", "turn": i, "pad": "x" * 40})
|
||||
rotated = list(Path(td).glob("turns.*.jsonl"))
|
||||
assert rotated, "超单文件上限应轮转出 turns.<n>.jsonl"
|
||||
# 轮转分片 + 现文件的行数总和 = 写入条数(不丢记录)。
|
||||
total = 0
|
||||
for f in [p] + rotated:
|
||||
total += len(f.read_text(encoding="utf-8").strip().splitlines())
|
||||
assert total == 20
|
||||
|
||||
|
||||
def test_writer_write_failure_is_silent(monkeypatch):
|
||||
# 落盘失败(路径不可写)只告警、绝不抛(best-effort 铁律)。
|
||||
w = cts.CheapTurnsWriter("/nonexistent-root-xyz/deep/turns.jsonl")
|
||||
w.write_record({"kind": "model_turn", "turn": 1}) # 不抛即通过
|
||||
|
||||
|
||||
# ── 五:开关 ─────────────────────────────────────────────────────────────────
|
||||
def test_enabled_default_on_and_off(monkeypatch):
|
||||
monkeypatch.delenv(cts.ENV_ENABLED, raising=False)
|
||||
assert cts.turns_enabled() is True
|
||||
for off in ("0", "false", "no", "off"):
|
||||
monkeypatch.setenv(cts.ENV_ENABLED, off)
|
||||
assert cts.turns_enabled() is False
|
||||
monkeypatch.setenv(cts.ENV_ENABLED, "1")
|
||||
assert cts.turns_enabled() is True
|
||||
|
||||
|
||||
def test_build_middleware_none_when_disabled(monkeypatch):
|
||||
monkeypatch.setenv(cts.ENV_ENABLED, "0")
|
||||
assert cts.build_turns_middleware("g1") is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
@ -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.1
|
||||
version: 1.6.3
|
||||
---
|
||||
|
||||
你是 A-model 游戏生成 agent。目标产物 = 一款**能跑能玩、有内容、不易同质化的高质小游戏**(多文件 LittleJS),落在 ⟦G⟧/。
|
||||
@ -27,13 +27,13 @@ version: 1.6.1
|
||||
【先设计后写码:决定这游戏好不好玩(关键,别跳过)】
|
||||
能跑 ≠ 好玩。动手写码前,先据 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,6 +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 / write_file / list_dir / check / build / finish 六个工具;**没有 edit_file** —— 改文件用 write_file 整体覆盖。
|
||||
- 你有 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 读手册,别直接写码;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。**
|
||||
现在开始:**先 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.1 # 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
|
||||
|
||||
@ -30,5 +30,6 @@
|
||||
| [2026-06-28-002-tier2-n5收敛环-go-no-go](../plans/2026-06-28-002-feat-tier2-n5收敛环-go-no-go-plan.md) | 切片二 runbook(**终局 2026-07-04:r4 4/5 收敛、decide_n5=KEEP_留观、fable 终判 go**;剩=留观观测+archetype 清账,见作战清单 W-S2) |
|
||||
| [2026-06-30-便宜档M3-生产cutover](../plans/2026-06-30-便宜档M3-生产cutover-plan.md) | 切片一 M3 尾(**S0–S6 证据侧全收口 2026-07-04**;只余放行+S5a=决策包⑤) |
|
||||
| [2026-07-04-prompt治理-四道闸CI接线-工单](../plans/2026-07-04-prompt治理-四道闸CI接线-工单-plan.md) | prompt 治理机器门接线工单(2026-07-04 新排,即刻可派;步骤 0 消费面对账先行) |
|
||||
| [2026-07-09-001-生成线验收换轴三波](../plans/2026-07-09-001-feat-生成线验收换轴三波-plan.md) | W-AXIS 换轴执行版(创始人 2026-07-09 拍:三波连打+模型判定升阻断;SoT 修订已同批回写质量模型裁定三/图说护城河段/验收门 §2.4;人审版=brainstorms 同日诊断档;**待双评审后开波 0/1**) |
|
||||
|
||||
新建设计一律走 [`feature-design-doc`](../../.agents/skills/feature-design-doc.md):先查注册表、frontmatter 申报 `topic/status/sot-impact`(docs-gate G6 强制),收口时蒸馏进 SoT 后从本板移除。
|
||||
|
||||
@ -161,7 +161,7 @@ flowchart TB
|
||||
|
||||

|
||||
|
||||
这张图是整本图集里**最该第一个读**的一张:它把前面那些分门别类的领域图,拼成了一条从「创作者敲下一句话」一直跑到「数据回流让 feed 重排」的完整系统链。它用八条横向泳道——创作者、game-studio、aigc + SAA、compliance、project + feed、玩家、telemetry、ad + trade——各占一条道,让你能顺着一个编号(①到⑮,外加钱流的 Ⓐ 到 Ⓓ)从头走到尾,看清每一步落在哪个域、交给谁、产出什么。主链是这样跑的:创作者一句话(①)经 studio 提交(②),aigc 建任务(③)进入 SAA 十六节点编排(④),九门验收通过后 emit 出包(⑤)回写成 project 草稿(⑥);创作者提交发布(⑦)触发 compliance 锁风门裁决(⑧),只有 pass 才让游戏 PUBLISHED 入 feed(⑨);玩家在竖屏游戏流里刷到(⑩)、即点即玩并互动(⑪);这些行为经埋点上报进 telemetry 聚合(⑬)、算出 0–100 的质量分(⑭),再把 feed 重新排序(⑮),把更好的游戏推回到玩家眼前——这条绿色的回流箭头,正是把一条线性的价值链弯成飞轮的地方。下半部还叠了一条与主链并联的钱流:玩家试玩时触发游戏内广告曝光(Ⓐ),经 ad 模块计费落账(Ⓑ),由 trade 按 `source_ref` 对账分账(Ⓒ),最终结进创作者钱包(Ⓓ)。
|
||||
这张图是整本图集里**最该第一个读**的一张:它把前面那些分门别类的领域图,拼成了一条从「创作者敲下一句话」一直跑到「数据回流让 feed 重排」的完整系统链。它用八条横向泳道——创作者、game-studio、aigc + SAA、compliance、project + feed、玩家、telemetry、ad + trade——各占一条道,让你能顺着一个编号(①到⑮,外加钱流的 Ⓐ 到 Ⓓ)从头走到尾,看清每一步落在哪个域、交给谁、产出什么。主链是这样跑的:创作者一句话(①)经 studio 提交(②),aigc 建任务(③)进入 SAA 十六节点编排(④),九门验收通过后 emit 出包(⑤)回写成 project 草稿(⑥);创作者提交发布(⑦)触发 compliance 锁风门裁决(⑧),只有 pass 才让游戏 PUBLISHED 入 feed(⑨);玩家在竖屏游戏流里刷到(⑩)、即点即玩并互动(⑪);这些行为经埋点上报进 telemetry 聚合(⑬)、算出 0–100 的质量分(⑭),再把 feed 重新排序(⑮),把更好的游戏推回到玩家眼前——这条绿色的回流箭头,正是把一条线性的价值链弯成飞轮的地方。下半部还叠了一条与主链并联的钱流:玩家试玩时触发游戏内广告曝光(Ⓐ),经 ad 模块计费落账(Ⓑ),由 trade 按 `source_ref` 对账分账(Ⓒ),最终结进创作者钱包(Ⓓ)。(2026-07-09 勘误:主链里「九门验收通过后 emit 出包」这一环,九门自此降为「未见明显死」的机械预筛地板,出包放行还须叠加独立模型对真玩证据的玩法判定,详见质量 SoT《游戏质量与爆火能力》裁定三。)
|
||||
|
||||
新人能从这张图看出几件单看任何一张领域图都看不全的事。其一,**判断「系统有没有缝」,就是看相邻泳道的交接处对不对得上**——出包能不能落成草稿、草稿能不能提审、裁决能不能控制入流、互动能不能回流成排序,任何一处接不上,闭环就断了;这张图把所有交接点都画在了一条线上,据图就能逐个核对。其二,**合规门是整条链的上线总闸**:图上那条红线写着「pass 才入流」,游戏造得再好,过不了锁风门也进不了 feed,这是平台不碰红线的硬约束。其三,**现状必须诚实地读**,不能因为画出来了就当全是实的:图里用橙色虚线小标点出了两处现状桩——feed 的游标分页现在恒返第一页、Redis 候选集还是增长期形态,广告侧的 `provider` 还是 mock、真广告 SDK 要等渠道落地才接;提现打款那一格更标了红线,缺渠道时必须 fail-fast、绝不发假钱。其四,右上角那个虚线框是 **tier2 富游戏档**——它走的是 AgentScope 自治加 Phaser 无头的更深 AI 介入档,**0 号 spike 已过 accept、核心代码已落**(验证走 n=5 收敛环),产品化排期待定;它**不在这条现行链上跑同一条装载路**,换的只是「游戏内容怎么造出来」,造出来之后仍旧汇进同一条上线与发行的公共件。把这些边界一眼标清,正是为了让据图做评审的人,既能顺着主链确认闭环跑得通,又不会把桩误当成已经建好的现行。
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ date: 2026-06-24
|
||||
|
||||
# agentic 生成运行时架构
|
||||
|
||||
> **定位**:绘境AI 游戏生成的统一运行时架构,单一事实源。把"谁来生成"(agent 框架)和"生成什么、怎么验收、怎么落库"(平台契约)彻底分开:平台只认一层 adapter,框架在 adapter 背后可换、可并存、可水平扩容。本文收敛原先散在十余份图说与详设里的设计,采用 2025 年已收敛的业界标准作 adapter 底座,自研只保留两块主流空白——生成专属的断点续跑、确定性验收门。
|
||||
> **定位**:绘境AI 游戏生成的统一运行时架构,单一事实源。把"谁来生成"(agent 框架)和"生成什么、怎么验收、怎么落库"(平台契约)彻底分开:平台只认一层 adapter,框架在 adapter 背后可换、可并存、可水平扩容。本文收敛原先散在十余份图说与详设里的设计,采用 2025 年已收敛的业界标准作 adapter 底座,自研只保留两块主流空白——生成专属的断点续跑、分层验收门(机械预筛 + 独立模型玩法判定,2026-07-09 修订)。
|
||||
>
|
||||
> **给谁看**:判断"整个生成架构是不是想要的那个"的创始人;照此实现的工程师。
|
||||
>
|
||||
@ -18,7 +18,7 @@ date: 2026-06-24
|
||||
|
||||
## 一、命题与定位:可插拔的 agent 平台
|
||||
|
||||
绘境AI 的游戏生成统一由 **AgentScope 一套自治 agent 框架**编排,按 **AI 参与深度**分三档(Tier0/1/2):级别越高,AI 写进游戏的内容与代码越多。三档都不从零写——平台预建工程脚手架、备好玩法模板,AI 在**玩法模板加工程模板**上按档位深浅填数值、写逻辑、写表现层;最低档也是能上线、能挂广告或内购的真游戏,无法形成商业闭环的超休闲玩具不做。引擎按表现复杂度选(轻量档 LittleJS 增强发行版、最富档 Phaser),但引擎只是实现、不是分档的轴——分档的轴只有 AI 参与深度这一条。无论哪档,产物都过九门兜底真玩判定。
|
||||
绘境AI 的游戏生成统一由 **AgentScope 一套自治 agent 框架**编排,按 **AI 参与深度**分三档(Tier0/1/2):级别越高,AI 写进游戏的内容与代码越多。三档都不从零写——平台预建工程脚手架、备好玩法模板,AI 在**玩法模板加工程模板**上按档位深浅填数值、写逻辑、写表现层;最低档也是能上线、能挂广告或内购的真游戏,无法形成商业闭环的超休闲玩具不做。引擎按表现复杂度选(轻量档 LittleJS 增强发行版、最富档 Phaser),但引擎只是实现、不是分档的轴——分档的轴只有 AI 参与深度这一条。无论哪档,产物都过分层验收:九门机械预筛兜「未见明显死」的地板,独立模型玩法判定裁「真的可玩」(2026-07-09 起,裁定见[游戏质量与爆火能力](游戏质量与爆火能力.md)裁定三)。
|
||||
|
||||
生产编排现阶段只押 AgentScope 一套;SAA(Spring AI Alibaba)、dify、coze 等别的框架降到最低优先级,留作后期"验证框架可插拔"的适配目标——等 AgentScope 这条做扎实,再把它们一一适配进来,用这件事证明下面这条可插拔原则不是空话,而不是现在并行养两套编排。
|
||||
|
||||
@ -26,7 +26,7 @@ date: 2026-06-24
|
||||
|
||||
这条原则不是空谈"将来好换",它有具体落点:后期把 SAA、dify 适配进来时,固定协议钉在那里,适配就只是给它们各接一个被治理的 adapter、各包一个执行后端,而不是推倒重来——把它们适配进来这件事本身,就是对这条可插拔原则的验证。
|
||||
|
||||
**两块不外包的自研——这是护城河。** 采标准能解决绝大多数适配问题(见 §二),但有两件事没有主流对等物,必须自建:一是**确定性验收门**——在没有人、也没有看图模型的情况下,用纯代码机器判定一局游戏"真的可玩",判定权与"出题的和被考的绝不能是同一只模型"这条纪律不让步;二是**生成专属的断点续跑语义**——一次自治生成跑到哪、能否按平台口径续上,各框架自造、互不通用。这两块是平台真正的工程纵深,别人短期补不上。
|
||||
**两块不外包的自研——这是护城河。** 采标准能解决绝大多数适配问题(见 §二),但有两件事没有主流对等物,必须自建:一是**分层验收门**——机械真玩预筛(CDP 真浏览器九门,纯代码)判「未见明显死」的地板,独立模型对真玩证据链(截图/录屏/日志/取证状态时间线)的玩法判定裁「真的可玩」,两层相与才放行。2026-07-09 修订:原口径「在没有人、也没有看图模型的情况下,用纯代码机器判定真的可玩」被当日五路 harness 审计证伪——机械断言只证得「有东西在涨」,秒死/刷分/死锁都能空心过门(判定归属与裁定见同目录[游戏质量与爆火能力](游戏质量与爆火能力.md)裁定三)。护城河的实体因此讲准确:是取证契约、真玩证据链与金标校准资产这套别人短期补不上的东西,不是「零模型」的判定纯度;「出题的和被考的绝不能是同一只模型」这条纪律原样不让步——判定模型独立于生成模型,只吃运行证据、不吃生成 agent 的自报。二是**生成专属的断点续跑语义**——一次自治生成跑到哪、能否按平台口径续上,各框架自造、互不通用。这两块是平台真正的工程纵深,别人短期补不上。
|
||||
|
||||
---
|
||||
|
||||
@ -46,7 +46,7 @@ date: 2026-06-24
|
||||
| 沙箱(固定端口 CDP) | 隔离的 build + run 环境 | **microVM 抽象**(E2B Firecracker / 阿里云 AgentRun·AIO Sandbox) | **抽象为 SandboxDriver,近期只承诺 Local/Docker/薄沙箱**(现状 = Local workdir + 端口段错开、已 accept,**非"采 microVM"**);microVM 云端多租户才需,CDP/成本/数据主权待验、AgentRun 厂商锁死(见 §二补③) |
|
||||
| 水平扩容(K-槽 Semaphore + 进程内端口槽) | 长跑 agent 并发 | **durable-execution**(范式:Temporal / DB queue+lock / MessageBus) | **现状非反模式**:K-槽已参数化(线程池+Semaphore(K)+K 错开端口槽+单测守);durable-execution 跨机扩容才需——独立选型(≥3 候选)、先旁路 tier2、不动在产 SAA(见 §二补③) |
|
||||
| prompt / model 配置 | 配置注册 + 热改 | ~~**Langfuse 式**(registry + 运行时热取 + 缓存 TTL + config-as-data)~~ | **2026-07-01 反转**:改采 yudao 配置中心版本化 ⊕ Nacos 下发 ⊕ AgentScope per-POST 热重载(Langfuse 式退历史备选,详见 ADR-4);受治理配置走 yudao 审批发版、运营开关热改 |
|
||||
| 验收门(judge 纯代码、禁 LLM 自评、出题≠被考) | 确定性验收 | **无主流对等** | **自研 —— 护城河,必须自建** |
|
||||
| 验收门(机械预筛纯代码 + 独立模型玩法判定、禁自评、出题≠被考;2026-07-09 分层修订,裁定见质量模型档) | 分层验收(证据链 + 校准资产) | **无主流对等** | **自研 —— 护城河,必须自建** |
|
||||
| checkpoint / 续跑 | 生成专属续跑 | **无跨框架标准** | **自研合理** |
|
||||
|
||||
收敛后的架构是"采标准 + 瘦身自研":adapter 退化成三个标准端点加两块自研。
|
||||
@ -60,7 +60,7 @@ date: 2026-06-24
|
||||
│ · MCP server ← 工具(write / build / run_gates / finish)+ 资产/模板 │
|
||||
│ · OTel GenAI ← trace(invoke_agent / chat / execute_tool) │
|
||||
│ · 〔自研〕生成 checkpoint / 续跑语义 │
|
||||
│ · 〔自研·护城河〕确定性验收门(judge 禁自评 + per-tier verdict) │
|
||||
│ · 〔自研·护城河〕分层验收门(预筛+模型判定 · 禁自评 + verdict) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
↕ 标准端点(框架只要会说 A2A+MCP+OTel 即插入)
|
||||
agent 框架:AgentScope(主框架,三档统一编排) / SAA·dify·coze(最低优先级,远期适配验证可插拔)
|
||||
@ -101,7 +101,7 @@ date: 2026-06-24
|
||||
| durable-execution(跨机) | Temporal(MIT)/ DBOS(OSS) | 范式 · future-state(跨机才需) | 扩容 | 跨机 run 调度/续跑是否真超出 Agent Service durable session |
|
||||
| microVM 沙箱(远期) | E2B(via E2BWorkspace)/ 阿里云 AgentRun | future-state | 沙箱 | E2B 数据出境 / AgentRun 数据不出境·FC 锁·定价;Chrome+CDP under gVisor |
|
||||
|
||||
护城河两块,标准都不提供、必自研:确定性验收门 + fail-closed 预算硬闸。归属须分清:MCP / goose / AGENTS.md ∈ AAIF;A2A ∈ Linux Foundation;OTel GenAI ∈ OpenTelemetry 独立仓。独立的 `agentscope-runtime` 仓已归档,其工具沙箱 / Agent-as-a-Service / 可观测能力已并入 agentscope 2.0.3(`workspace` / `app` / `event`)——做沙箱与部署用 `agentscope[full]` 一个包,不装那个归档仓(本仓 [`agentscope-2.0-facts.md`](../../../../.agents/knowledge/agentscope-2.0-facts.md) 早有此结论)。
|
||||
护城河两块,标准都不提供、必自研:分层验收门(机械预筛 + 模型判定) + fail-closed 预算硬闸。归属须分清:MCP / goose / AGENTS.md ∈ AAIF;A2A ∈ Linux Foundation;OTel GenAI ∈ OpenTelemetry 独立仓。独立的 `agentscope-runtime` 仓已归档,其工具沙箱 / Agent-as-a-Service / 可观测能力已并入 agentscope 2.0.3(`workspace` / `app` / `event`)——做沙箱与部署用 `agentscope[full]` 一个包,不装那个归档仓(本仓 [`agentscope-2.0-facts.md`](../../../../.agents/knowledge/agentscope-2.0-facts.md) 早有此结论)。
|
||||
|
||||
### 二补三 · build-vs-buy ADR 决策矩阵
|
||||
|
||||
@ -198,8 +198,8 @@ agentscope 2.0.3 内置 Workspace 三后端(源码实证 `LocalWorkspace` / `Doc
|
||||
### 3.1 划线五原则
|
||||
|
||||
1. **契约固定、实现可插拔。** 固定的是"形状"——接口签名、状态 schema、verdict schema;可插拔的是"形状背后由谁去满足它"。SAA 实现一遍任务协议,AgentScope 再实现一遍;LittleJS 实现一套探针,Phaser 再实现一套。
|
||||
2. **契约不做成 skill。** skill 本质是"喂给 agent 的指令 + 资源包",自身不执行代码,agent 可读可不读。把硬约束写成 skill,等于降格成提示。固定协议必须落在机器可校验的载体上:JSON Schema、`.d.ts` 接口、Java interface、纯代码的 judge 判定。skill 只承载"怎么用某个引擎写游戏"这类 playbook,不承载"完成与否谁说了算"这类纪律。
|
||||
3. **门探针劈两半。** "门要从引擎读哪几样"(canvas 选择器、帧计数源、boot 就绪信号、输入注入、可观测 state)是引擎无关的抽象,该固定;"怎么从某个具体引擎把这几样读出来"是 per-engine 的实现,该可插拔。判 pass / fail 的逻辑永远待在固定层、由纯代码产出,探针只负责"读"和"驱动",绝不负责"判"。
|
||||
2. **契约不做成 skill。** skill 本质是"喂给 agent 的指令 + 资源包",自身不执行代码,agent 可读可不读。把硬约束写成 skill,等于降格成提示。固定协议必须落在机器可校验的载体上:JSON Schema、`.d.ts` 接口、Java interface、schema 化的 judge 判据(机械预筛纯代码;模型玩法判定的输入证据与输出裁决同样是固定 schema——判定可换模型,契约不动)。skill 只承载"怎么用某个引擎写游戏"这类 playbook,不承载"完成与否谁说了算"这类纪律。
|
||||
3. **门探针劈两半。** "门要从引擎读哪几样"(canvas 选择器、帧计数源、boot 就绪信号、输入注入、可观测 state)是引擎无关的抽象,该固定;"怎么从某个具体引擎把这几样读出来"是 per-engine 的实现,该可插拔。判 pass / fail 的逻辑永远待在固定层、由纯代码产出,探针只负责"读"和"驱动",绝不负责"判"——本条约束的是**机械预筛探针层**;L1 终判 = 预筛 ∧ 独立模型玩法判定(2026-07-09),后者是证据判读层、不受"纯代码"限定,其输入证据与输出裁决的契约固定见原则 2 与质量模型档裁定三。
|
||||
4. **多语言走 MCP / shell-out。** 单写 agent 跑在 Python,但探针是 node/CDP、构建是 esbuild、某些能力包可能是别的语言。跨语言不靠在 Python 里硬塞,走两条标准通道:MCP 协议天生跨进程跨语言;或 skill 目录放任意语言脚本、SKILL.md 指示 agent 用内置 Bash shell-out 去跑。
|
||||
5. **单一实现时不冻接口(rule-of-three)。** 一个接口只有一套真实现时把形状钉死,必被那唯一一套实现反向决定,等第二套落地才发现抽错。所以第一套实现先定 v1 directional(方向性草案 + 留演进位),真正冻结推迟到第二套实现落地。这条直接影响 A6 / A7 的定级——它们现在只有 LittleJS 一套真实现,不该被当成已冻结的协议。
|
||||
|
||||
@ -382,9 +382,9 @@ tier2(最高深度档)的运行时按 AgentScope 2.0.3 的真实对象结构落
|
||||
|
||||
### 5.4 三层校验与九门
|
||||
|
||||
验收分三层,处置力度递减。**L1 硬约束**管编译、启动、运行错误——boot 不起、跑着抛异常、画面死;判据全是确定性信号(构建日志、浏览器 console、CDP 错误捕获、九门探针),零 LLM 参与,必须循环逼到解决为止。**L2 设计符合**管玩法与关卡实现对不对、UI 缺组件、品类约定有没有违反;靠确定性的设计符合度信号,尽量解决而非死循环;它的拒发权不是天生的,走 observe→enforce——先只观测积累信号,在真实数据上证明判得准,才赋予拒发权。**L3 效果**管特效、美观、好不好玩;只用 M3 多模态视觉软检打分,绝不解决、绝不阻塞拒发,产出只进质量趋势、告警、给人工终审减负。L3 死活不当门有两条硬理由:扩确定性门去自动判"好不好玩"只会得到能被刷的代理指标(精心做的打砖块和"摆三块砖点一下就赢"的退化品会一起全绿,门即废);让纯 LLM 当玩家裁判则踩 Goodhart——模型进了验收当裁判,会学会优化成"让裁判说好"而非真好。所以"好不好玩"最终归人工终审。迭代上初期只焊死 L1,L2 / L3 渐进,绝不让效果问题阻塞真问题。
|
||||
验收分三层,处置力度递减。**L1 硬约束**管编译、启动、运行错误——boot 不起、跑着抛异常、画面死;判据全是确定性信号(构建日志、浏览器 console、CDP 错误捕获、九门探针),零 LLM 参与,必须循环逼到解决为止。**L2 设计符合**管玩法与关卡实现对不对、UI 缺组件、品类约定有没有违反;靠确定性的设计符合度信号,尽量解决而非死循环;它的拒发权不是天生的,走 observe→enforce——先只观测积累信号,在真实数据上证明判得准,才赋予拒发权。**L3 效果**管特效、美观、好不好玩;只用 M3 多模态视觉软检打分,绝不解决、绝不阻塞拒发,产出只进质量趋势、告警、给人工终审减负。L3 死活不当门有两条硬理由:扩确定性门去自动判"好不好玩"只会得到能被刷的代理指标(精心做的打砖块和"摆三块砖点一下就赢"的退化品会一起全绿,门即废);让纯 LLM 当玩家裁判则踩 Goodhart——模型进了验收当裁判,会学会优化成"让裁判说好"而非真好。所以"好不好玩"最终归人工终审。迭代上初期只焊死 L1,L2 / L3 渐进,绝不让效果问题阻塞真问题。(2026-07-09 修订:L2 里「玩法真实性」一项的 enforce 已生效——五路审计坐实机械代理指标被刷穿〔score-only 空心 pass 五案在册〕,正是本段预言的「能被刷的代理指标」;独立模型玩法判定以布尔裁决升为阻断,金标正反例复验加创始人抽玩承担「在真实数据上证明判得准」的义务,细则见[游戏质量与爆火能力](游戏质量与爆火能力.md)裁定三。「纯 LLM 当玩家裁判踩 Goodhart」的告诫仍有效,防线=判定只吃运行证据不吃生成 agent 自报、金标校准、判定模型可换、阻断开关可回退;L3 效果〔美观/特效〕仍永不阻塞。)
|
||||
|
||||
L1 的主体是九门,每门在真浏览器里真玩一局取确定性证据:A_boot(能否 boot,轮询 boot 信号)、B_uncaught(有无未捕获异常,监听 console 与 CDP 错误)、C_frame(帧在不在推进,前后取帧号差大于零)、D_render(画面有无真实内容,截图回读亮像素阈值)、E_live(画面在不在变,对多帧取哈希去重)、F_wiring(逻辑真调引擎还是空桩,查引擎 API 调用有没有出现)、G_input(注入输入后状态有无变化)、H_progress(核心机制有无真进展,driver 驱动加玩后断言、含 latch 终态)、I_control(控制响应跟不跟手,输入到反应的时序在阈值内)。九门里只有 H_progress 落 L2,其余落 L1;E_live 与 H_progress 是条件门(没有 driver 时降为 advisory),其余是硬门。九门全绿等于机制地板通过、可发——把"做完了"钉在客观证据上,绝不让模型自评。
|
||||
L1 的主体是九门,每门在真浏览器里真玩一局取确定性证据:A_boot(能否 boot,轮询 boot 信号)、B_uncaught(有无未捕获异常,监听 console 与 CDP 错误)、C_frame(帧在不在推进,前后取帧号差大于零)、D_render(画面有无真实内容,截图回读亮像素阈值)、E_live(画面在不在变,对多帧取哈希去重)、F_wiring(逻辑真调引擎还是空桩,查引擎 API 调用有没有出现)、G_input(注入输入后状态有无变化)、H_progress(核心机制有无真进展,driver 驱动加玩后断言、含 latch 终态)、I_control(控制响应跟不跟手,输入到反应的时序在阈值内)。九门里只有 H_progress 落 L2,其余落 L1;E_live 与 H_progress 是条件门(没有 driver 时降为 advisory),其余是硬门。九门全绿等于机械预筛地板通过;放行还需独立模型玩法判定(2026-07-09,质量模型档裁定三)——「做完了」依旧绝不由生成模型自评。
|
||||
|
||||
现有九门是为低档的 LittleJS 建的,探针钩子硬编码了 LittleJS 专属取法。tier2 复用的是"真玩判定加零自评"这套哲学、**不是**这套代码,探针要为 Phaser 整套重写——canvas 选择器、帧源、boot 信号、输入注入与 state 读取四条钩子各自重写,不是参数化一键切。沙箱底座曾被列为 spike 的前置阻断项:agentscope-runtime 独立仓已归档、能力并入 2.0.3,原打算先小验 agentscope 2.0.3 内置 Workspace 的 Docker 后端能否托管这套 CDP 探针(它是 MCP-gateway 工具沙箱、不直接暴露 CDP),够就用、不够回落自建 Docker 薄沙箱复用现有 harness。**但 2026-06-28 收敛环 runbook 已解除这道前置**:实测证明 harness 能在 in-process(本地 runner)直接跑起来,收敛环就走本地 runner、不再卡等 DockerWorkspace 托管结论;跨机 / Docker 沙箱选型降为 go/no-go 之后的 Phase B 项、不阻塞收敛。原"这个结论必须在生成迭代开跑前出来、否则 spike 跑不起来"的判断已被证伪。
|
||||
|
||||
@ -394,7 +394,7 @@ L1 的主体是九门,每门在真浏览器里真玩一局取确定性证据:A_b
|
||||
|
||||
spike 第二段放开让 agent 自产或扩展 driver 时,会撞上一个 Goodhart 雷:**写者不能写判自己游戏的那张卷子**——agent 既造游戏又造判它的 driver,会把 driver 写成只走它走过的路、只读不暴露真实力的字段,然后全绿假绿。隔离办法是拆开写卷人与判卷人,走三层:平台先按 schema 加白名单编译(非白名单或越界字段直接拒)、再交一个不向着被评对象、专找漏洞的独立 adversarial 评审(查它是否覆盖真实玩家路径、是否读了作弊字段)、三层全过才入验收。配套采两个指标:自产 driver 被独立评审打回的比例、driver 被修改的次数(防靠反复改 driver 逃避卡死探测)。
|
||||
|
||||
> **本节是运行时视角的九门与三层校验,确定性验收的护城河命题(机器判、禁自评、出题≠被考、per-tier verdict 元协议)即落在此节与 §三 A5**;另有一层"对外开闸放行"的 6 道门(D12 控制平面、GP9 合规先行、落库观测三门、首局体验门、G0/G1 前置)坐在九门机制地板之上,是独立 SoT,见 [`验收门.md`](验收门.md),其基准靶集见 [`WG1基准.md`](WG1基准.md)。
|
||||
> **本节是运行时视角的九门与三层校验,分层验收的护城河命题(机械预筛机器判、玩法判定独立模型、禁自评、出题≠被考、per-tier verdict 元协议)即落在此节与 §三 A5**;另有一层"对外开闸放行"的 6 道门(D12 控制平面、GP9 合规先行、落库观测三门、首局体验门、G0/G1 前置)坐在九门机制地板之上,是独立 SoT,见 [`验收门.md`](验收门.md),其基准靶集见 [`WG1基准.md`](WG1基准.md)。
|
||||
> **与质量四层塔的轴区分(2026-07-02 质量 canonical 接管,防混)**:本节的"三层校验"(L1 硬约束 / L2 设计符合 / L3 效果)按**处置力度**分层——必须解决 / 尽量解决 / 只评分不阻塞;它与 [`游戏质量与爆火能力.md`](游戏质量与爆火能力.md) canonical 的**四层质量塔**(L1 机制可玩 / L2 内容丰富 / L3 留存结构 / L4 传播钩子)**共用 L1–L3 字母、但轴完全不同**——后者按**质量维度**分层。两者别对齐字母:三层校验的 L1(机器硬门)确实约等于质量塔的 L1(机制可玩,唯一拒发权),但三层校验的 L3(效果视觉软检)对应的是质量塔的 L2(内容丰富,LLM 评)。质量塔是品类 rubric、tier2 反馈契约、数据飞轮留存口径的共同上位标准,九门 / 富三门 / 首局门的**判据 SoT 仍是本节与 [`验收门.md`](验收门.md),质量 canonical 只裁消费口径**;开闸六门里与质量相关的 **D11 就绪分,权重管辖已移交质量轨**。
|
||||
> **现 / 建(2026-06-24 spike 后)**:低档九门现行已落。tier2 侧——三层校验框架、Phaser 探针(九门 + 富游戏三门:三联动/经济/latch)、L3 视觉软检 observe-only 接线**已落并在 feie-005 全过**;spike 收敛补的四道契约机器门(`validate_datatable` 数据表 schema+DAG+可达性、`LOCKED_PLATFORM_FILES` 锁平台文件、`validate_play_scene` 表现层契约、finish 门)也已落。待:自产 driver 三层隔离(spike 第二段)、偏脆的文本契约门做成结构化校验。
|
||||

|
||||
@ -468,7 +468,7 @@ SAA 那一侧的能力面是另一套形态,但它现已降为最低优先级、
|
||||
|
||||
复用边界看两个维度:来源(官方现成,即 AgentScope 2.0.3 自带,还是自建)乘以共享(各档公共,还是 tier2 私有)。一个关键的交叉结论是——**公共组件等于自建与共享的交集**(九门、三层校验、CDP、计费、送审、feed、trace),它们是团队自建、被各档共用的,**不是官方现成能力**;"框架里有"不等于"公共组件"。每个自建项还标出它用哪种 AgentScope 扩展点落地:验收门是框架外的确定性断言、硬熔断三件是挂在洋葱上的 Middleware、角色 prompt 与模型路由是纯数据的 Prompt 热配置、skill 目录经 Workspace 的 add_skill 注入、工作记忆与经验召回是 AgentState 的 context 加经验库。把载体类型标清楚,是为了让"换引擎等于换 id 和 version、trace 始终按 id/version 归因"这条目标态有落点,也防止把本该热配的东西硬编码进机制。
|
||||
|
||||
一次诚实的对地基现成度的上修:有几样早稿打算自建的能力,其实官方已有现成件,该删掉自建、改用官方——token 计量用模型响应自带的用量、结构化输出用模型层能力、可观测 trace 用官方 TracingMiddleware(纯 OTel)、经验召回用现成的记忆框架、多 agent 总线用 Agent Team。**这里要纠一处早稿错**:早稿还列了"软预算控制用官方 `ReplyBudgetControlMiddleware`",但 0 号 spike 期核验官方当时无此类(2.0.3 已补进原生 token 制、仍保自建 ¥ 闸,见 §15)——这一项不属"改用官方",反而归下面"必须自建"(tier2 已用自建 `on_system_prompt` + 订阅模型调用结束事件的硬熔断实现)。官方确实没有、必须自建或引第三方的是:RAG 检索管线、评测模块、成品级的框架互通、以及两块护城河——确定性验收门(框架不提供)和强制 fail-closed 的预算硬闸。
|
||||
一次诚实的对地基现成度的上修:有几样早稿打算自建的能力,其实官方已有现成件,该删掉自建、改用官方——token 计量用模型响应自带的用量、结构化输出用模型层能力、可观测 trace 用官方 TracingMiddleware(纯 OTel)、经验召回用现成的记忆框架、多 agent 总线用 Agent Team。**这里要纠一处早稿错**:早稿还列了"软预算控制用官方 `ReplyBudgetControlMiddleware`",但 0 号 spike 期核验官方当时无此类(2.0.3 已补进原生 token 制、仍保自建 ¥ 闸,见 §15)——这一项不属"改用官方",反而归下面"必须自建"(tier2 已用自建 `on_system_prompt` + 订阅模型调用结束事件的硬熔断实现)。官方确实没有、必须自建或引第三方的是:RAG 检索管线、评测模块、成品级的框架互通、以及两块护城河——分层验收门(框架不提供;机械预筛 + 模型判定)和强制 fail-closed 的预算硬闸。
|
||||
|
||||
配置分三类,决定一样东西改了要不要发版。硬代码机制类(确定性门断言、四道熔断、基线硬门强制、循环与记忆的组装压缩机制、权限硬上限、那几条铁律)随代码走、不外置。版本化安全与构建配置类(依赖锁、引擎版本、构建 profile、启用的沙箱类型、权限上限只可降不可升)审计留痕、不热改。热配置策略类(prompt 文本、各 agent 的模型路由、few-shot、skills、max_iters、熔断阈值、新增门的 observe/enforce 档、上下文预算、压缩阈值、枚举内的 RAG 源、记忆模式)改配置不重新部署服务代码。但这里的"不发版"只指不重部署代码:其中影响生产质量/安全的**重要配置**(关键 agent、关键玩法模板、prompt 正文、生成门阈值)的发布仍须走**配置发布流程审批**、通过才生效,本质是一次**受治理的配置发版**(后期阶段做、复用 yudao,见 §5.2 管理面);只有非关键热配置才即时生效。一条颗粒度澄清:新增或删除一类内容等于改枚举、是硬代码;已有类里增减取值(比如多挂一个 RAG 源)是可配置。三类门也同此分:基线确定性硬门永远强制、不可关,新增确定性门走 observe→enforce(默认只观测,达标率稳了才赋拒发权),M3 视觉软检永远只观察、绝不放行也绝不拒发。
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ date: 2026-07-02
|
||||

|
||||
|
||||
> **生效状态**:本模型经 Codex + Opus 双评审后,由创始人 2026-07-02 以决策包⑦一次拍定生效——四层定义与权力分配、档位观测线、rubric 计分制、平衡门判法五裁定、「收窄 A–E」历史化、富三门档位适用与断言分界、D11 权重管辖移交,七项俱批;档内数值草案即生效值,观测线区间待首批品类件回填分布后锁成单值(§10)。2026-07-03 通用底座增补第 12 条口径(「核心操作非无脑」,L2 满分升至 12),图 1 底部明细带按 L2 7 / L3 3 / L4 2 分组列全。设计留痕 = `docs/agent-specs/2026-07-02-游戏质量与爆火能力-设计.md`。
|
||||
>
|
||||
> **2026-07-09 修订(创始人当日拍板)**:L1 的证据构成升级——机械门降位为「未见明显死」预筛,独立模型对真玩证据的玩法判定升为阻断,合称 L1 双证据口径(裁定三,§3)。依据是当日五路 harness 审计坐实的三处病根:机械门的玩法语义已空心化(现存五份 verdict 仅凭 score 单调上升即 pass、终态闭环降 advisory)、判卷合约失败被误标为玩法失败、链路真相层缺失(诊断档 = `docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md`)。修订面 = §2 表格与权力宪法第一条、§3 裁定三、§9 防 Goodhart 第二条;**L2–L4 分数一律非阻塞的纪律不变**——升级的是 L1 自身的证据,不是把丰富度分数变成门。
|
||||
|
||||
质量不是一个分,是四层递进的塔:机器门保「能玩」(L1,质量分层内唯一拒发权),LLM 评「有料」(L2),结构要求保「想再玩」(L3)与「想传出去」(L4)的前提;玩家真实数据是 L3/L4 的最终裁判,并回流校准 L2–L4 评分与 D11 权重。本档立标准、判定归属与数值档位,是 W-GENRE 品类件 rubric、tier2 富三门反馈契约、数据飞轮留存口径的共同上位标准。它不重定义九门/富三门/首局门的具体判据(判据 SoT 仍是[验收门](验收门.md),本档只裁消费口径),不建留存/传播数据的采集管道(归 telemetry 线与[数据飞轮](数据飞轮.md)回流环),不替 agent 调平衡数值,不写各品类的完整 rubric(归 W-GENRE 品类件),不造统一「质量总分/爆火分」或任何自动爆火门,也不改开闸六门的治理设计(仅接管其中 D11 就绪分的权重管辖)。
|
||||
|
||||
@ -22,14 +24,14 @@ date: 2026-07-02
|
||||
|
||||
| 层 | 回答 | 判定者 | 量纲 | 权力 | 消费者 |
|
||||
|---|---|---|---|---|---|
|
||||
| **L1 机制可玩** | 能玩、玩得通吗 | 机器真玩(九门 + 富游戏三门 + 首局门),零 LLM | 布尔(过/不过) | **质量分层内唯一拒发权** | 生成环放行、生产复验 |
|
||||
| **L1 机制可玩** | 能玩、玩得通吗 | 双证据:机器真玩预筛(九门 + 富游戏三门 + 首局门)**∧** 玩法地板判定(便宜档 = 独立模型看真玩证据,出题 ≠ 被考;tier2 现 = 富三门双路,见裁定三落地范围) | 布尔(过/不过) | **质量分层内唯一拒发权** | 生成环放行、生产复验 |
|
||||
| **L2 内容丰富** | 有料、耐玩吗 | LLM 验证 agent 按品类 rubric,非阻塞 | 逐条 0/1 + 分组小计 | 观测 + 批次验收线,不拦单款 | 品类件验收、批次观测、回流分析 |
|
||||
| **L3 留存结构** | 想再玩的结构前提在吗 | 生成时 rubric 留存组;上线后玩家真数据 | rubric 分;运营 0–100 | 观测;真数据经 quality_score 影响分发 | feed 排序、数据飞轮 |
|
||||
| **L4 传播钩子** | 想让别人看的结构前提在吗 | 生成时 rubric 传播组;上线后 share/remix 漏斗 | rubric 分;漏斗率 | 观测;爆款筛选归运营 | remix 网络效应、爆款签约(P-IPX-06) |
|
||||
|
||||
层间纪律三条,是整个模型的权力宪法。
|
||||
|
||||
**质量侧的发布权只在 L1。** 一款游戏能不能进 feed,质量分层内由确定性证据说了算,L2–L4 任何分数都不拦发布。这是既有防 Goodhart 红线(效果层「绝不放行也绝不拒发」)在整个模型上的推广:LLM 评分与结构评分都是软信号,软信号一旦拿到拒发权,评分尺立刻变成被优化的目标。发布链上另有治理拒发权(GP9 合规、D12 控制平面),在本模型之外、不受本档约束。
|
||||
**质量侧的发布权只在 L1。** 一款游戏能不能进 feed,质量分层内由 L1 说了算,L2–L4 任何分数都不拦发布。L1 自身的证据构成 2026-07-09 起为双份:机械真玩预筛是地板(挡明显死:起不来、抛异常、不渲染、不掌帧),独立模型对真玩证据的玩法判定是裁决(能玩到终局吗、核心操作有真决策吗、切 brief 的题吗)。这样改的原因写在裁定三:机械断言曾被当成「可玩」的完整证据,实测它只证得了「有东西在涨」,秒死、刷分、死锁、永不结束的游戏都能空心过门——把发布权全押在它上面,恰好造出了另一种 Goodhart(游戏围着盲驱动器的盲区设计)。原防 Goodhart 红线本身不动:LLM 的丰富度/留存/传播**评分**仍是软信号、永不拿拒发权;玩法判定不是评分——它裁的是**地板的有无**(玩得通吗/有决策层吗/切题吗),不是**程度的高低**(丰富、耐玩、好玩到什么程度——那些永远归 L2 软信号),与机械预筛同属 L1,受金标校准与人工抽检约束。发布链上另有治理拒发权(GP9 合规、D12 控制平面),在本模型之外、不受本档约束。
|
||||
|
||||
**推荐权在真数据。** 玩家行为聚合成运营质量分(0–100)回灌 feed 决定「值不值得推」;生成侧 rubric 分只影响批次改进与回流分析,不直接改 feed 排序。
|
||||
|
||||
@ -43,10 +45,12 @@ date: 2026-07-02
|
||||
|
||||
判据本体在[验收门](验收门.md),本档不重抄:九门 = 客观健康门 A_boot/B_uncaught/C_frame/D_render/E_live 加 driver 依赖门 F_wiring/G_input/H_progress/I_control,真玩驱动、CDP 探针、零 LLM;富游戏三门 = tier2 多系统档专属(三联动/经济/latch);首局门 = H 门的 additive 派生超集(可玩 ≤2s/首反馈即时/60s 品类闭环),FAIL 不推翻 H。定稿时点的现状:便宜档 M1 三品类 15/15 达标;tier2 收敛环 conditional,卡在经济门与 H_progress——判法与反馈的解法见 §8。
|
||||
|
||||
**裁定一(消费口径):driven 时九门全量 AND 为现行 L1 口径,「收窄 A–E」历史化。** 验收门 §2.4 曾记「生成环消费从九门全过收窄成只认 A–E,G/H/I 降参考,F_wiring 改 VLM 判」——那是 gamedef 时代 driver 缺失、G/H/I 不可靠时的消费策略。现行便宜档在门跑前自动生成 play-spec 使 driven=true、九门全部致命,cheap 与 tier2 的判据代码都认九门全量与富门,M1 达标 15/15 正是这个口径。据此裁定:driven 时九门全量 AND 为现行 L1 口径;undriven 时 E_live/H_progress 自动降 advisory 的分级保留在 harness(已实装);「收窄 A–E」条款自本档定稿起历史化,消除文档-代码漂移,存量引用面随收口逐一改注,此后再发现的引用面一律改该处引用、不回改本裁定。F_wiring 保持机器硬门;VLM 视觉判定归 L3 视觉软检,永远软。
|
||||
**裁定一(消费口径):driven 时九门全量 AND 为机械层取值口径,「收窄 A–E」历史化;2026-07-09 起该口径的通过 = 预筛通过,不再单独构成 L1 通过(见裁定三)。** 验收门 §2.4 曾记「生成环消费从九门全过收窄成只认 A–E,G/H/I 降参考,F_wiring 改 VLM 判」——那是 gamedef 时代 driver 缺失、G/H/I 不可靠时的消费策略。现行便宜档在门跑前自动生成 play-spec 使 driven=true、九门全部致命,cheap 与 tier2 的判据代码都认九门全量与富门,M1 达标 15/15 正是这个口径。据此裁定:driven 时九门全量 AND 为机械层现行取值口径(自裁定三起它构成 L1 的预筛半,另一半 = 独立模型玩法判定);undriven 时 E_live/H_progress 自动降 advisory 的分级保留在 harness(已实装);「收窄 A–E」条款自本档定稿起历史化,消除文档-代码漂移,存量引用面随收口逐一改注,此后再发现的引用面一律改该处引用、不回改本裁定。F_wiring 保持机器硬门;VLM 视觉**效果**评分归 L3 视觉软检,永远软——它与裁定三的玩法判定(布尔裁决、阻断)是两物,别混。
|
||||
|
||||
**裁定二(富三门档位适用):富游戏三门只随「多系统富游戏」档位走**(现为 tier2 经营类)。便宜档经营品类的进度判定 = occupied 反应族驱动 + score 递增断言,不引入 tier2 经济平衡门(盈利/破产双路判)——单循环游戏没有「经济死局」问题,把它强推到便宜档,是给 L1 加不属于该档位的判据。这里要分清「门」与「断言」两个层次:play-spec 里经 H 门校验的 per-game 真玩断言(score 递增,以及游戏含消费扣减时的 currency 扣减断言)是便宜档 L1 的正当判据,照常使用;本裁定挡的是门级判据跨档,不是禁用某类断言(断言与 rubric 的分界见 §4 规范五)。两档判据差异是档位事实,不是待统一的债。
|
||||
|
||||
**裁定三(2026-07-09,L1 双证据口径):机械门降位为预筛,独立模型玩法判定升为阻断,两者相与才是 L1 通过。** 依据是当日五路 harness 审计:九门里唯一带玩法语义的 H_progress 实测退化成「score 单调上升」,终态闭环 latch 在「有进展」时一律降 advisory,现存五份 pass verdict(hard-heritage/hard-trpg/hard-idle-sim/c2v-1/c2v-3)全部仅凭 score↑ 过门——机械断言证得了「有东西在涨」,证不了「人能玩通」;而系统提示为让盲驱动器能跑,曾教模型把计分结构围着验收器的盲区设计(「两层奖励」条款),机械门独裁的后果正是它自己被 Goodhart。据此裁定四条:**其一**,机械真玩链(九门/富三门/首局门)语义降位为「未见明显死」的预筛地板,driven 时仍按裁定一全量 AND 取值,但其通过不再单独构成 L1 通过,verdict 的对外语义随之改名(预筛通过 ≠ 验收通过);**其二**,新增独立模型玩法判定,输入 = brief 加真玩证据链(首帧/局中/局末截图或录屏、游戏日志、取证状态时间线),产出 = 布尔裁决加逐条理由,阻断放行,理由原文回喂续修——判定者与生成者绝不同源(出题 ≠ 被考纪律原样保留),且判定只吃运行证据、不吃生成 agent 的自报。**玩法判定只裁地板布尔,分界钉死**:它的三类拒绝 = broken(真人玩不通/到不了终局)、hollow(空壳——核心循环不存在决策层,判「有没有」、不判「做得多好」)、off-brief(不切题);凡「多丰富/多耐玩/多好玩」的**程度**问题一律归 L2 rubric 非阻塞——同一维度在两层各管一半:L1 判定裁「有无」(如:决策层存在吗),L2 rubric 评「高低」(如:rubric 第 12 条评决策做得多有层次),谁想把某条 rubric 升成门,仍须回本档改 SoT 并过创始人,近义措辞不构成升门依据;**其三**,判定失败或评不出一律 fail-closed:不放行、标 degraded、入人工复核队列,人可放行——绝不静默降级成自动通过;**其四**,判定的校准纪律与 rubric 同构:每品类金标正反例复验、漂移超线先复采样再回退,创始人抽玩是最终校准锚,判定阻断带配置开关可整体回退到修订前口径。**落地范围**:模型判定先在便宜档生效(执行 = W-AXIS 换轴 plan 波 2);tier2 的玩法地板现由富三门双路真玩承担——其经济双路(赢得了且输得了且都到终态)正是「地板布尔」的机械对位物——tier2 是否叠加模型判定,随便宜档校准结果另议,在此之前 §2 表行的「模型判定」对 tier2 读作「富三门双路」。失败归因随本裁定分三层——判卷驱动器合约失败(取证不完整)、机械门失败(代码/运行缺陷)、玩法判定失败(设计缺陷)——回喂反馈必须按真实层次措辞,不得再把合约缺口说成「玩法坏死」。
|
||||
|
||||
## 4 L2 内容丰富:rubric 体系
|
||||
|
||||

|
||||
@ -69,7 +73,7 @@ date: 2026-07-02
|
||||
|
||||
**规范四:判定形态。** 纯 LLM、非阻塞、逐条给理由;绝不写成代码校验、不进九门、不进脚手架(创始人红线)。评分调用失败/超时/解析失败一律降级为「本次未评出」,不抛、不阻断生成、不进达标判定——非阻塞语义自带降级,也把错杀面封为零(评分再错也不拦游戏)。每款一次评分调用,成本占比可忽略(便宜档 <¥0.1 量级)。
|
||||
|
||||
**规范五:与 L1 断言的分界。** `sim-business-game-design` skill §10 曾注「4 条可机检」,这四条不同质,分两类处置:纯 code-presence 类(即时反馈/下一个解锁/音反馈——代码里存在调用 ≠ 体验成立)按红线软化为 LLM 评分维度,不落项目代码;经 H 门 assertAfterPlay 校验的 per-game 真玩断言(可见成长的 score/资源递增断言、经营的 currency 扣减断言)属 L1 机制可玩,保留为硬断言、不软化——把真玩硬证据降成软评分会削 L1 的拒发权,方向恰好反了。同名两物并存且分工明确:L1 断言判「机制上真发生」,L2 rubric 判「体验上成立、有层次」;skill §10 注记照此改口。
|
||||
**规范五:与 L1 断言的分界。** `sim-business-game-design` skill §10 曾注「4 条可机检」,这四条不同质,分两类处置:纯 code-presence 类(即时反馈/下一个解锁/音反馈——代码里存在调用 ≠ 体验成立)按红线软化为 LLM 评分维度,不落项目代码;经 H 门 assertAfterPlay 校验的 per-game 真玩断言(可见成长的 score/资源递增断言、经营的 currency 扣减断言)属 L1 机制可玩,保留为硬断言、不软化——把真玩硬证据降成软评分会削 L1 的拒发权,方向恰好反了。同名两物并存且分工明确:L1 断言判「机制上真发生」,L2 rubric 判「体验上成立、有层次」;skill §10 注记照此改口。2026-07-09 起这套分类学加入第三员——L1 模型玩法判定,判「作为游戏成立的**地板有无**」(broken/hollow/off-brief 三类拒绝,见裁定三):同一维度可以同时出现在 L1 判定与 L2 rubric,前者裁有无、后者评高低(例:决策层——L1 判定裁「有没有真决策」,rubric 第 12 条评「决策做得多好」),近义措辞不使 rubric 条目获得门权。
|
||||
|
||||
**规范六:消费点。** 分组小计消费于品类件验收与批次观测(档位观测线,§7)和回流分析(§10);逐条理由消费于品类件迭代——哪条维度长期不成立,就是品类设计 skill 或脚手架的改进信号。
|
||||
|
||||
@ -139,7 +143,7 @@ flowchart LR
|
||||
|
||||
量纲纪律:既有双量纲锁死不动——生成质量分(0–1,aigc 域)与运营质量分(0–100,telemetry→feed)不可互相回灌(`contracts/README.md` 双量纲红线);rubric 分(0–N)是生成侧局部量纲,只在品类验收、批次观测、回流分析消费,禁止换算回灌进前两者;**不新造统一「质量总分/爆火分」**。D11 就绪分(0–100,开闸治理观测件)的权重(playability 0.5/firstPlay 0.25/stability 0.15/efficiency 0.1)自本档起归质量轨管辖:放量前的起步值仍由生产 cutover plan 落定(其部署步已在修 efficiency 维 BUDGET_RMB 与便宜档实际成本的错配,该项列为质量轨接管后的首个复核件);放量后第一轮校准见 §10。
|
||||
|
||||
防 Goodhart 三条,贯穿全模型:**L2–L4 分数一律非阻塞**——任何丰富/留存/传播分都不得升为拒发门,升门动议须回本档改 SoT 并过创始人;**真玩与真实玩家数据是仅有的两种「硬」证据**——设计侧一切 LLM 评分都是软信号;**「好玩」的终审归人**(审核台)。rubric 被 Goodhart 或评分漂移的兜底与此同构:非阻塞设计把错杀面封为零,金标锚定 ±1 回退管住漂移,放量后真数据校准是最终纠偏——若 rubric 分与留存相关性为零或负,重审 rubric 维度,回滚面只在 rubric 文本与评分 prompt,不伤门与代码。
|
||||
防 Goodhart 三条,贯穿全模型:**L2–L4 分数一律非阻塞**——任何丰富/留存/传播分都不得升为拒发门,升门动议须回本档改 SoT 并过创始人;**真玩与真实玩家数据是仅有的两种「硬」证据,且真玩证据必须被判读**——设计侧一切 LLM 评分都是软信号;机械断言对真玩证据的解读同样会空心(2026-07-09 实证:score-only pass 五案),所以 L1 的判读自裁定三起是「机械预筛 ∧ 独立模型判定」双份,单独哪一份都不许自称完整硬证据;**「好玩」的终审归人**(审核台与创始人抽玩)。rubric 被 Goodhart 或评分漂移的兜底与此同构:非阻塞设计把错杀面封为零,金标锚定 ±1 回退管住漂移,放量后真数据校准是最终纠偏——若 rubric 分与留存相关性为零或负,重审 rubric 维度,回滚面只在 rubric 文本与评分 prompt,不伤门与代码。
|
||||
|
||||
## 10 生效、校准与升级路径
|
||||
|
||||
|
||||
@ -76,7 +76,7 @@ flowchart TD
|
||||
这三组门的取舍理由,可以浓缩成三条核心决策:
|
||||
|
||||
1. **D12 和 GP9 为什么必须硬阻塞?** 因为这两件事不做,后果都不可逆。后台的生成 worker 是**全局串行**的(一次只处理一个任务),不做控制平面,单个用户的并发请求就能把它压垮,所有人都生成不了;不做合规先行,违规内容会直接生成出来进入玩家信息流,这是法务红线,一旦发生无法回收。
|
||||
2. **那 3 道落库门为什么第一版只观测、不拦截?** 因为底层那套"九门 harness"(在真实浏览器里跑游戏、用九道确定性检查判定能不能玩的测试夹具)已经是挡住坏游戏的**硬地板**了。这三道落库门是更上一层的质量观测,第一版若一上来就生效拦截,反而会**误杀好游戏**——所以先只记录数据,等积累够了再考虑收紧。(这里"九门硬地板"说的是**开闸放行**那一侧:一款游戏要进信息流,仍按完整九门要求。生成环迭代生成时的消费口径曾在 2026-06-20 收窄为"只认五道客观健康门 A–E",但已于 2026-07-02 由质量轨裁定回到 **driven 时九门全量 AND**、收窄 A–E 归为历史方案——沿革详见 §2.4。)
|
||||
2. **那 3 道落库门为什么第一版只观测、不拦截?** 因为底层那套"九门 harness"(在真实浏览器里跑游戏、用九道确定性检查判定能不能玩的测试夹具)已经是挡住坏游戏的**硬地板**了。这三道落库门是更上一层的质量观测,第一版若一上来就生效拦截,反而会**误杀好游戏**——所以先只记录数据,等积累够了再考虑收紧。(这里"九门硬地板"说的是**开闸放行**那一侧:一款游戏要进信息流,须过完整九门机械预筛,且 2026-07-09 起还须过独立模型玩法判定——L1 双证据口径,裁定见质量 SoT《游戏质量与爆火能力》裁定三。生成环消费口径的沿革:2026-06-20 曾收窄为"只认五道客观健康门 A–E",2026-07-02 由质量轨裁定回到 **driven 时九门全量 AND**,2026-07-09 该口径整体降位为预筛——详见 §2.4。)
|
||||
3. **真实计费为什么不在这一批?** 见 §1.2。
|
||||
|
||||
### 1.2 真实计费扣退为什么被拆出去(随 M4)
|
||||
@ -157,6 +157,8 @@ flowchart TD
|
||||
### 2.4 生成环消费九门的口径:硬门收窄成客观健康门 A–E(创始人 2026-06-22 历史回收判定捡回)
|
||||
|
||||
> **历史化注记(2026-07-02,质量轨裁定)**:本节记述的"生成环把九门收窄成只认 A–E 客观健康门",是 gamedef 时代 driver 常缺、G/H/I 三门不可靠时的消费策略。现行便宜档在门跑前自动生成 play-spec 使 driven=true,cheap 与 tier2 的判据代码都按九门全量与富游戏门取 AND(M1 达标 bake-off 15/15 正是这个口径);undriven 时 E_live/H_progress 自动降 advisory 的分级仍保留在 harness。因此 **driven 时九门全量 AND 为现行 L1 口径,"收窄 A–E"归为历史方案、不再是待选路线**;判定归属见同目录现行 canonical《游戏质量与爆火能力》(裁定 L1-a)。以下原文按历史记录保留,不删。
|
||||
>
|
||||
> **再修订(2026-07-09,创始人拍板)**:九门全量 AND 的语义再降一级——它仍是机械层的取值口径,但其通过自此只构成「未见明显死」的**预筛**,不再单独等于 L1 通过;L1 通过 = 机械预筛 ∧ 独立模型玩法判定(依据 = 当日五路 harness 审计:H_progress 实测退化为 score 单调上升、latch 有进展即降 advisory,五份空心 pass verdict 在册)。裁定与四条细则见《游戏质量与爆火能力》裁定三,本档判据本体(九门各门怎么判)不动——动的又一次只是消费侧语义。
|
||||
|
||||
前面几节把九门 harness 讲成一道统一的"硬地板",这个说法在"开闸放行"这个语境里仍然成立——一款游戏要进信息流,确实要先过这套真玩检测。但要把一件 2026-06-20 拍下的口径变化讲清楚,否则架构档会和现行真相脱节:**生成环(SAA 管线)在迭代生成时怎么"消费"九门的裁决,已经从"九门全过才算成功"收窄成了"只认五道客观健康门 A–E"。** 这是九门收窄后、gamedef 路实测生成质量从 all-9 口径下的约 50% 做到 91.7% 的直接原因之一——实测样本 n=12:关闭 thinking 时过 8/12,启用 Anthropic 原生 thinking 分离协议后过 11/12(即 91.7%);创始人 2026-06-20 拍板以 n=12/91.7% 宣告达标(n≥30 只多给方差信息,不改"thinking-on 更好且过门"的结论)。gamedef 路虽已判错误路线废弃,但"九门收窄成 A–E 客观门"这条验收消费策略引擎无关、对现行 A-model 真 src/ 同样适用。
|
||||
|
||||
|
||||
91
docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md
Normal file
91
docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
topic: cheap-gen-harness-direction-diagnosis
|
||||
status: draft(待创始人拍方向)
|
||||
sot-impact: 本档不改 SoT;若换轴获批,判定语义与判卷合约的变更须另立设计档申报 agentic运行时架构图说 的 sot-impact
|
||||
上级: docs/architecture/架构/生成引擎/agentic运行时架构图说.md
|
||||
---
|
||||
|
||||
# 生成线 harness 方向性诊断与换轴方向(2026-07-09)
|
||||
|
||||
2026-07-09 凌晨创始人提出方向性质疑:harness 与 agent 设计有方向性错误,各种伪装成功伪装失败,模型能力被限制被误导,过去的结论建立在错误结果上,且从未有人做过真实链路的端到端验证。本档是对这一质疑的取证与裁定:五路独立审计(harness 约束机制清单、真实链路端到端还原、验证信号真伪、知识层对标 TapTap、微信/抖音/QQ 行业方案调研)+ 主会话亲手复核四项要害证据。
|
||||
|
||||
## 一、结论
|
||||
|
||||
质疑主体成立,但病根与直觉不同。**不在「知识层薄」——知识层其实厚且路子对**(薄提示+模型自读知识库,与 TapTap 同构);**真正的方向性错误有三处,互相咬合成一个自我蒙蔽的循环**:
|
||||
|
||||
1. **判定语义膨胀**:机械信号被系统当成「可玩」事实。九门唯一的玩法语义门 H_progress 实测退化为「score 涨了就行」;终态闭环(latch)在「有进展」时一律降级 advisory。结果是「点哪都加分、永不结束」的游戏能过全部九门。与此同时真正懂玩法的 LLM 丰富度评审被设计成非阻塞旁路,richness=0 或 null 照样 pass——**判定权威给了不懂玩法的机械门,懂玩法的模型判断靠边站**。
|
||||
2. **判卷合约反噬生成**:盲驱动器判卷要求游戏暴露私有内省合约(`state().targets` 键名锁死、phase 命名、score 键)。合约没对上,失败反馈却措辞成「玩法坏死/终态不可达/空壳」,既误导了模型的续修方向,也误导了我们的根因结论;系统提示甚至教模型用「两层奖励」围着盲驱动器的盲区设计计分——**验证器的局限反向塑造了游戏设计与失败归因**。
|
||||
3. **真相层缺失**:trace.jsonl 只落事件骨架(工具名/参数增量/成败/token 数),模型文本、工具返回、门反馈**一个字都不落盘**;失败 run 的产物被同 gameId 重跑直接覆盖;同一 evidence 目录混着不同 run 的 verdict 与 verdict-feedback。「没人端到端读过链路」不是偶然失职——**磁盘上物理没有这份数据**。所有历史结论都建立在聚合数字上,于是每次事故只能催生又一层兜底补丁(现存 14+ 层事故驱动补丁、约 36 个约束机制),层层叠成创始人说的「不成体系」。
|
||||
|
||||
## 二、证据(全部可复核,标注来源)
|
||||
|
||||
### 2.1 判定语义膨胀(审计三 + 主会话亲验)
|
||||
|
||||
- 九门 A/B/C/D/F(装载/无异常/掌帧/真像素渲染/真接线)是真实机械信号,E/G/H/I 是半启发式。放行判据 `gate_judge.py:382` = `pass && guards 非空`。
|
||||
- **亲验实锤**:`_wg1-gen/hard-trpg/evidence/verdict.json` 顶层 `pass=true`,`guards.H_progress` 仅凭 `{"path":"score","op":"increased","before":0,"after":216}` 通过,`latch:{pass:false, advisory:true, reason:"未驱动到终态但有真进展(限时/无快速失败态类→advisory)"}`。同构的 score-only pass 现存至少五份:hard-heritage / hard-trpg / hard-idle-sim / c2v-1 / c2v-3,其中 hard-heritage 与 c2v-3 连 firstPlay 的 firstFeedback=False 都照样 pass(首局门非阻塞)。降级机制在 `play.cdp.cjs:1150-1161`。
|
||||
- 创始人点名的三类假阳(秒死/存档刷分/死锁,AGENTS.md §6.13、commit ec276b1b)与该机制严丝合缝:score 单调上升即可过,不问是否靠刷、不问能否结束。
|
||||
- 唯一质量判断 `cheap_verify.py` 是纯 LLM judge,合规(不违「模型能力不写代码校验」红线),但被三重声明钉成「不进 verdict、不改 ok、不动达标」——xtheme-v1-prefix 批里 rich=0 与 rich=null 的游戏全部 verdictPass=true。
|
||||
- 静态 check 六类里语法(node --check)与未定义标识符(esbuild 作用域分析)是真实信号;红线八条(禁裸 Math.random/Date.now 等)靠自制 stripCode 正则,已坐实假阴事故:字符串内 `//`(如赛博文案 'JACK IN // FLOOR')被当注释误抹真代码 → 误报红线缺失 → **模型明明写对却被反复拒到熔断烧钱**(commit 0be0f61c,2026-07-08 23:27 才修)。系统提示为此教模型「check 按去注释后的纯文本正则命中,把裸调封装进函数内部照样被拦」——教模型绕正则,而不是写对代码。
|
||||
|
||||
### 2.2 判卷合约反噬(审计二)
|
||||
|
||||
- 8 份留存失败反馈(verdict-feedback.json)里 H_progress 8/8 挂:4 例 phaseNow=menu(**驱动器进不去菜单**),3 例进了 play 但 score 键没接上。反馈文本却统一措辞「无真实进展/终态不可达/空壳」。
|
||||
- 干净的证伪案:xt-puzzle-huarong turn1 被拒(「终态不可达/空壳」),turn2 模型的 finish summary 原文是「menu/over 按钮加入 targets(occupied:true)让 tap-targets 驱动可导航;phase 名改 'game...'」——**只是把按钮暴露进 targets、改了 phase 名就过,游戏本体(804 行真 15-puzzle,行优先归位判定、3×3→5×5 关卡递进)一直是好的**。
|
||||
- 系统提示(contracts/prompts/04-config/cheap-system.md:30)明文:「九门自动验收会盲点 occupied:true 目标、判 score 涨,所以做成两层奖励——基础分(保盲驱动器能跑通过门)+技巧分」。游戏计分结构为判卷器而设计。
|
||||
- resume 后模型为了自证,去读判卷器源码 play.cdp.cjs、读自己的 trace.jsonl、自写 smoke 测试:xt-puzzle-huarong 一局烧到 ¥11.48 / input 180,292 tokens / 19 分钟;同品类干净一次过(xt-trpg-cyber)只要 ¥0.79——**14 倍成本方差全是 harness 摩擦,与品类难度无关**。
|
||||
|
||||
### 2.3 真相层缺失(审计二 + 主会话亲验)
|
||||
|
||||
- **亲验实锤**:amgen-xt-puzzle-huarong/trace.jsonl 每行只有 `{"ext":{"raw":{"event":"ToolResultTextDeltaEvent"},...}}` 类事件骨架,全文件 grep ERROR/PASS/FAIL 仅 5 处(argsDelta 碎片),模型推理文本与门反馈文本零落盘。
|
||||
- 失败 run 无归档:重跑同 gid 直接覆盖 amgen-<gid>/ 与 _wg1-gen/<gid>/;现存 10 条 trace 全是通过 run,it4-baseline 的失败链路、xtheme-v1-prefix 的熔断链路(trpg-cyber ¥13.84 / puzzle 7 次 ¥10.09)已被抹掉。
|
||||
- evidence 目录卫生:hard-heritage 目录里 verdict.json(07-09 00:20,pass=True)与 verdict-feedback.json(07-08 17:06,passed=False,init 字符串不同=不同代码)并存——红线③只清 verdict.json 与 service-run-summary.json,不清反馈残留。
|
||||
- OTLP sink 默认 no-op(CHEAP_OTLP_ENDPOINT 缺省即不发),观测线没有第二处留存。
|
||||
|
||||
### 2.4 约束面全景(审计一)
|
||||
|
||||
系统提示 10,814 字符 / 92 句,强制与禁止词密度约每句一个(必须×12、绝不×11、别×30、钉死×6、红线×9);7 个工具、框架原生 Planning/Team/Bash/Edit/Glob/Grep/Read/Write 全被 monkeypatch 关闭;熔断经济学 = ¥10 软停 + ¥15 硬地板 + 6 次续修 + 150 步 + stuck 4 连同签名。约 36 个约束机制、14+ 层事故补丁,每层注释都自认「兜底/纵深/fail-open」——**层数本身就是「主判定不可信」的证据**。写锁(20fb8754「换皮写锁」)已被创始人当夜纠偏全撤(22da3464,同时 max_tokens 16K→512K),但 reskin 机器(build_reskin_system_prompt、白名单管路)休眠在代码里待清理;唯一正当的白名单残存 = modify 路「只改玩法」的语义收窄。
|
||||
|
||||
### 2.5 被推翻的两个历史结论(审计二)
|
||||
|
||||
- **「真实率 ~80% 是 MiniMax-M3 天花板」不成立**。它是三个口径的混淆(死圈修复 plan 实测真过门率 39%、跑到门率 83%;bake-off-7 是 3 个简单街机品类 n=2 的 100%;AGENTS.md 的 80% 是 MVP 目标值),且在 stripCode 假阴修复前测得(污染)。修复+per-genre 起点后 xtheme / it5-lockcore / r1 三批各 5/5 全过,模型没变——**过门率随工具移动,不随模型**。(诚实边界:近批 n=1/品类,不能反推「已稳定 95%+」,只能判「80% 不是模型天花板」。)
|
||||
- **「主导根因=玩法坏死」大半是误标**。多数失败真因是判卷驱动器合约缺口(进不去菜单/认不出终局/score 键没对上),小补丁即过;真坏死(heritage 黑屏渲染没接线)是少数,且已被 per-genre scaffold 修掉。
|
||||
|
||||
### 2.6 模型真实能力重估(审计二)
|
||||
|
||||
五套通过产物亲读:L1 五文件字节全同(固定 plumbing),game-logic/core/render 五款 md5 各异——**是「模板骨架+模型自著内容」,不是字节换皮**。story-campus 377 行真分支叙事(8 节点/4 结局/双属性轴/结局图鉴)、heritage 310 行节拍火候玩法、puzzle 804 行真解谜。工程质量整体良好,几乎无死代码。M3 在被解开限制后的表现,支持创始人「llm 能力很强,一直被限制被误导」的判断。
|
||||
|
||||
## 三、哪些资产是对的(不推倒)
|
||||
|
||||
知识层:薄入口+read_file 自取知识库的架构(cheap_roles.py:4 铁律)、6 份品类设计 skill(~933 行)、11 份插件 api.d.ts、❌→✅ 幻觉速查表、6 套黄金脚手架、「先设计后写码+⑨否决项」的设计纪律——这些与 TapTap/行业同构,是对的地基。真浏览器 CDP 九门里 A/B/C/D/F 五门是真实机械信号,该留作地板。tier2 的双路经济门(赢得了且输得了且都到终态)是结构性可玩地板的正确样板。LLM 丰富度评审本身合规且已接线,只是权威位置放错。¥ 硬地板 fail-closed 的成本封顶正确。撤写锁+放开 max_tokens 的纠偏方向正确。
|
||||
|
||||
行业对照(审计五):TapTap/微信 CodeBuddy/抖音/QQ 四家收敛的最小结构 = (半)通用 coding agent + 规则/知识文件 + 真实编译/预览/真机验证 + 人在环。**没有一家用代码启发式判玩法;没有一家用正则拒改 agent 输出**;约束要么进知识库(软),要么进工具权限层 Deny/Ask/Allow(CodeBuddy),不进语义层。我们知识层已同构,自造的私有物恰好是出问题的两处:判定语义与判卷合约。
|
||||
|
||||
## 四、换轴方向(三波七件,待拍)
|
||||
|
||||
**波 1 · 真相层(先行,其余一切验证的前提)**
|
||||
① 链路全文落盘:模型文本、工具返回、门反馈逐 turn 留存;per-run 归档(带 attempt 序号),永不同 gid 覆盖;evidence 目录卫生(残留反馈/快照全清)。
|
||||
② 判卷驱动器合约失败与游戏失败分开归因:反馈文本按真实层次措辞(「驱动器无法导航:菜单按钮未暴露进 targets」≠「玩法空壳」),失败分类进批次账。
|
||||
|
||||
**波 2 · 判定语义重置(§6.13 的产品化)**
|
||||
③ 九门 pass 语义降级为「未见明显死」的机械预筛;验收 = 机械门绿 **且** 模型判定过——把 LLM judge 从非阻塞旁路升为阻断权威,并从「读源码」扩到「看运行」(录屏/连拍截图喂视觉模型 + agent 按 brief 试玩判定:能玩到终局吗、决策有代价吗、像不像 brief 要的游戏)。终态可达按品类做成 tier2 式双路门。
|
||||
④ 拆真正误导模型的残笼:清理休眠 reskin 机器;stripCode 类正则红线换 esbuild/AST 词法层判定;删「两层奖励围着盲驱动器设计」的提示文案(判卷改为 agent 试玩后此合约整体可退役);edit_file 锚点容错(行号/模糊匹配),消 5 连拒螺旋。
|
||||
|
||||
**波 3 · 知识层补强(对标清单的三个真缺口)**
|
||||
⑤ 类型硬门:tsc 对 api.d.ts 做真类型检查(TapTap 的 LSP 门对等物)。
|
||||
⑥ examples 索引 + recipes 场景库(现有 5 份设计范式是「什么好玩」,补「场景怎么实现」)。
|
||||
⑦ 暗资产接线:6 个模型不可见插件、11 份 PLUGIN.md、引擎能力摘要,接进模型面并建防漂移对账。
|
||||
|
||||
**收尾:n=5 重测基线**——波 1/2 落地后按品类重跑,作废全部污染数字,重锚真实率与单局成本;所有下过的相关结论(容量、成本、品类难度)以新基线为准。
|
||||
|
||||
## 五、风险与验证
|
||||
|
||||
- 视觉模型+agent 试玩升为阻断权威后,单局验收成本上升(多一次模型判定)。对冲:机械预筛先挡明显死,判定只对过筛者跑;且当前 14 倍成本方差主要来自 harness 摩擦,拆笼后净成本预期下降而非上升。判定模型的假阳假阴同样存在——用 n=5 收敛环+创始人抽玩校准它,而不是回退到机械门。
|
||||
- 判卷合约(targets)退役是行为面变更:现存过门游戏依赖它,需保留读取兼容或一次性重判。
|
||||
- 换轴范围只及便宜档与共享 verdict 语义;tier2 自治环与双路门不动(它是样板)。
|
||||
- 验证方式:每项落地都以「同一失败案例在新链路下能否一眼下钻到真根因」为验收(拿 xt-puzzle-huarong turn1 与 heritage 黑屏两案回放);重测基线用真浏览器亲玩+截图为硬证,遵 §6.13。
|
||||
|
||||
## 六、证据索引
|
||||
|
||||
commit:0be0f61c(stripCode 假阴)/ 22da3464(撤写锁+512K)/ 20fb8754(写锁引入,已撤)/ 88d3a237(死菜单假阳→加 played)/ 9d463f08(whack-mole E_live 假阴)/ 8b491d13(latch 词表)/ ec276b1b(§6.13 立规)。
|
||||
文件:play.cdp.cjs:1150-1191(latch 降级与判定主体)/ gate_judge.py:365,382 / tools.mjs:130-134,275,302-427(静态门)/ cheap_verify.py + cheap_studio.py:424-441(LLM judge 非阻塞)/ cheap-system.md:30(两层奖励)、:65(正则语义)/ cheap_roles.py:4(知识自取铁律)/ cheap_run.py:113-134(read_file 沙箱)/ _wg1-gen/{hard-trpg,hard-heritage,hard-idle-sim,c2v-1,c2v-3}/evidence/verdict.json(score-only pass)/ amgen-*/trace.jsonl(骨架化)/ results/hard-genre-*.json(批次账)。
|
||||
审计执行:2026-07-09 五路只读审计代理(opus)+ 主会话四项亲验;审计报告要点已并入本档,原始报告在会话内。
|
||||
61
docs/plans/2026-07-09-001-feat-生成线验收换轴三波-plan.md
Normal file
61
docs/plans/2026-07-09-001-feat-生成线验收换轴三波-plan.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
date: 2026-07-09
|
||||
topic: cheap-gen-acceptance-axis-shift-plan
|
||||
status: 草稿(待双评审;方向与验收权威已由创始人 2026-07-09 拍板)
|
||||
上级: docs/plans/2026-06-25-生成引擎统一执行计划-AgentScope三档-plan.md
|
||||
sot-impact: 已回写——质量模型 SoT 裁定三、运行时图说护城河段、验收门 §2.4 再修订、AGENTS.md §3.1(同工作树,与本 plan 同批提交);本 plan 只执行、不再改 SoT
|
||||
关联: cheap-worker/ + game-runtime/tools/amodel-gen/tools.mjs + game-runtime/games/_wg1-gen/_shared/play.cdp.cjs + tier2/gen-worker/worker/gate_judge.py + contracts/prompts/04-config/cheap-system.md @ d3baf86e
|
||||
---
|
||||
|
||||
# 生成线验收换轴三波 · 执行计划(W-AXIS)
|
||||
|
||||
人审版(方向诊断)= `docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md`;判定归属的权威文本 = 质量模型 SoT 裁定三。本 plan 是执行版:波 0(并行勘误)+ 三波主体 + 收尾重测,波内可派单、波间有依赖。创始人已拍:三波连打、模型判定升为阻断;每波收口按 §6.13 真浏览器验收,报可提交状态等指令。执行位 = opus(工单六要素即下表各波),主会话验真终审。
|
||||
|
||||
## 波 0 · 污染引用面定点勘误(小,半天;与波 1 并行)
|
||||
|
||||
- **目标**:被 2026-07-09 审计推翻的两个结论(「~80%=MiniMax 天花板」「主导根因=玩法坏死」)及「九门 pass=验收通过」旧语义,在**策展层**的引用点全部勘误;留痕层(docs/plans、docs/brainstorms、agent-specs 历史档)一律不改——它们是带日期的快照,git 即历史。
|
||||
- **边界**:只改策展层 = `.agents/**`、`docs/architecture/**`、`docs/mvp/**`、`AGENTS.md`(已改)、auto-memory(已勘误两档);不重写历史,不删数据文件(results/*.json、_wg1-gen 产物全保留)。
|
||||
- **输入**:grep 关键词族(「80%」「天花板」「玩法坏死」「九门.*(通过|达标|放行)」「verdictPass」),诊断档 §2.5 的证据。
|
||||
- **产出**:勘误点清单(文件:行 → 改法)+ 逐点修正(有日期的勘误注记,不抹原文);`.agents/skills/cheap-model-game-generation.md` §11 等生成线 skill 的结论段对齐新口径。
|
||||
- **验收**:清单里每点给 diff;再跑同一 grep,策展层无残留旧口径(历史档命中属预期,标注即可)。
|
||||
- **回滚**:纯文档,git revert 即回。
|
||||
|
||||
## 波 1 · 真相层(先行,其余波的验证前提)
|
||||
|
||||
- **目标**:从此每个生成 run 的完整链路可读、可归档、可归因——「模型每轮写了什么、harness 每轮回了什么」不再是物理缺失的数据。
|
||||
- **边界**:只动 cheap-worker 落盘/归档层与 evidence 卫生;不改门判定、不改 prompt、不动 tier2。
|
||||
- **输入**:现 trace sink(事件骨架)、Service 事件流、`_write_session_cfg`/collector 收口点;诊断档 §2.3 的 F0-a/b/c 三缺口。
|
||||
- **产出**:①逐 turn 全文落盘(模型文本、工具调用全参、工具返回文本、门反馈/续修注入文本),落 `amgen-<gid>/turns.jsonl`,带单文件上限与轮转,密钥类字段脱敏;②per-run 归档——重跑同 gid 前把上一 run 的 amgen 目录与 evidence 整体移入带时间戳的归档位,批次账每 run 记归档指针;③evidence 清理清单化——scaffold 时按固定清单清残留(verdict-feedback/world-snapshot/play-report 等全列,不再只清两个文件);④失败归因三层字段(driver 合约失败/机械失败/玩法判定失败)进 verdict 消费层与批次账,回喂反馈按真实层次措辞。
|
||||
- **验收**:拿两个历史案回放——xt-puzzle-huarong「合约缺口被误标空壳」案与 heritage「黑屏」案:新链路下从批次账出发 ≤3 步到达逐 turn 全文与正确归因层;跑一局真生成,turns.jsonl 里能原文看到门反馈与模型响应。
|
||||
- **回滚**:落盘/归档是纯增量旁路,关开关即回;归因字段只加不减。
|
||||
|
||||
## 波 2 · 判定语义重置(依赖波 1;SoT 裁定三的代码兑现)
|
||||
|
||||
- **目标**:验收 = 机械预筛 ∧ 独立模型玩法判定;误导模型的残笼拆除。
|
||||
- **边界**:cheap 路 verdict 消费层、cheap_verify 升级、cheap-system prompt 清洗、amodel-gen 静态门实现替换;九门 harness 判据本体不动(动消费语义,与 2026-06-22 收窄同一条边界纪律);tier2 双路门不动。
|
||||
- **输入**:质量模型 SoT 裁定三(四条细则:预筛降位/判定契约/fail-closed/校准纪律);波 1 的证据链;现 cheap_verify LLM judge 接线位。
|
||||
- **产出**:①判定器——输入 brief + 真玩证据链(first-paint/after-play/局中连拍截图、game-log、取证状态时间线),输出布尔裁决 + 逐条理由(schema 固定),阻断放行,理由原文进续修反馈;判定模型走 new-api 可配置(默认便宜多模态档,单局判定成本目标 ≤¥0.3,草案值随首批校准),评不出 fail-closed 标 degraded;degraded 的落点(内测期口径,防孤儿设计):不建独立队列 UI——落批次账 + result_out 标 degraded、按生成失败回调(用户可重试),人工复核从批次账捞、放行走现有审核台通道,独立复核队列显式 defer 至量起来再立;阻断带 genconfig 开关(整体回退位);②ok 语义切换——`ok = 预筛 ∧ 判定`:verdict 物理保留 `pass` 字段(语义收窄为「预筛通过」,不改名不删),新增 `accepted`(= 预筛 ∧ 判定)与 `judge` 段(裁决/理由/degraded),字段只加不减;**跨模块影响面同批核清**——game-cloud 回调消费(GateVerdict/status 分类)与 OTLP 观测(gen_task_total{status}、gen_gate_fail_total{gate})需新增「玩法判定失败」类目与 degraded 态,防观测线静默漏计,核对结论列入本波验收;③拆残笼——清休眠 reskin 机器(build_reskin_system_prompt 与 create 路白名单管路,保 modify 路语义收窄)、红线八条的判定从 stripCode 正则换 AST/词法级事实(esbuild/acorn 通道已有)、prompt 清洗(「两层奖励」改纯游戏设计语并删「围盲驱动器」「绕正则」措辞;删已失效的恐吓式熔断警告)、edit_file 锚点容错(空白归一/近似定位提示);④判定校准包——每品类金标正反例(含审计在册的五份空心 pass 作反例种子),漂移复采样纪律同 rubric 规范三。
|
||||
- **验收**:现存五份 score-only pass 全部进金标标注(创始人/主会话亲玩定标)——判定器对标注为反例的必须判不过、对标注为正例的必须判过,**不预设五份全是反例**;任何样本不得仅凭预筛通过自动验收,判定评不出的 fail-closed 入人工复核;跑 n=3 冒烟,判定理由与创始人抽玩结论一致 ≥2/3;一局含字符串 `//` 的游戏红线判定不再假阴;edit 锚点 5 连拒案例在容错下 ≤2 次收敛;全程真浏览器截图硬证。
|
||||
- **回滚**:判定阻断开关回退到修订前口径;prompt 走 registry 版本回退;AST 红线与正则实现可并跑对照一批再切。
|
||||
|
||||
## 波 3 · 知识层补强(与波 2 尾部可并行)
|
||||
|
||||
- **目标**:补对标 TapTap 清单的三个真缺口,把「存在但没喂」的资产接进模型面。
|
||||
- **边界**:只动知识资产与 check 的类型检查通道;不动插件实现;不新建品类模板(idle/action 归 W-GENRE)。
|
||||
- **输入**:知识层审计盘点表(诊断档 §三);littlejs.d.ts(277KB 现货)、11 份 PLUGIN.md、6 个暗插件、ENGINE-CAPABILITIES.md。
|
||||
- **产出**:①类型硬门——tsc 对游戏源 × api.d.ts/受控面 d.ts 做真类型检查,先 advisory 跑一批校准噪声再转阻断,报错原文进 check 反馈;②examples 索引(「要做 X → 读 Y」)+ 3–5 篇高频 recipes(命中矩形/计时器/资产回退/场景机/结算演出);③暗资产接线——6 个未注入插件逐个裁定(注入+文档化,或明确移出库面),PLUGIN.md 进模型可读指针,引擎能力摘要蒸馏 ≤3KB 版;④防漂移对账门——插件目录 vs prompt 白名单 vs host-config 注入清单三方一致性检查进 CI。
|
||||
- **验收**:tsc 门对金标语料零误伤——金标 = `cheap-worker/fixtures/golden-samples`(现 2 款)+ `game-runtime/games` 下 6 套 `_template*` 脚手架与 2 款 `_fewshot*` 良品,清单随工单固化(评审更正:此前「amodel-gen 29 金标」为误记,该处只有单测无金标);新对账门在 CI 红绿可证;抽一局生成,模型 read_file 命中新索引/recipes(turns.jsonl 佐证)。
|
||||
- **回滚**:tsc 门 advisory→阻断分两步,阻断前可停在 advisory;知识文件纯增量。
|
||||
|
||||
## 收尾 · n=5 重测基线(依赖波 1+2)
|
||||
|
||||
- **目标**:作废全部污染数字,在新链路、新语义下重锚便宜档真实率与单局成本;所有容量/成本/品类难度类结论以新基线为准。
|
||||
- **边界**:只测便宜档 5 已覆盖品类,纯度量、不改代码;idle/action 无模板品类不入本轮(归 W-GENRE)。
|
||||
- **输入**:波 1 的真相层(turns.jsonl/归档/归因)+ 波 2 的新验收语义与判定器;批跑脚本沿用 hard_genre_batch 族。
|
||||
- **产出**:5 品类 × n=5(按 tier2-validation 口径),每局四件套证据 + 判定理由 + 归因层;真浏览器亲玩抽验;基线报告回写进度总账与质量模型 §7 实测列。
|
||||
- **验收**:创始人抽玩 ≥3 款与判定结论一致;基线报告过主会话终审。
|
||||
- **回滚**:纯度量任务无回滚面(N/A)——数字不达预期就是结论本身,作废重跑即可。
|
||||
|
||||
## 风险与依赖
|
||||
|
||||
判定器自身会假阳假阴——靠金标校准+抽玩+阻断开关兜,绝不回退到机械门独裁;判定成本上升被拆笼后的重试下降对冲(审计:14 倍成本方差主要是 harness 摩擦);targets 取证合约本计划保留(归因修正后它只是取证接口,不再是隐性验收目标),整体退役(agent 亲手试玩驱动)列为判定 v2 的后续评估项,不在本三波。
|
||||
164
docs/plans/2026-07-10-001-feat-生成线验收v2-测试agent替EGH-plan.md
Normal file
164
docs/plans/2026-07-10-001-feat-生成线验收v2-测试agent替EGH-plan.md
Normal file
@ -0,0 +1,164 @@
|
||||
---
|
||||
date: 2026-07-10
|
||||
topic: cheap-gen-acceptance-v2-tester-agent-plan
|
||||
status: 双评审收敛终稿待创始人批(初审 Codex 10 项+Opus 8 项全修入;差量复核 Opus 5/5 FIXED+3 小项、Codex 3 FIXED+2 PARTIAL+1 矛盾,余项已全部修入;方向由创始人 2026-07-09 拍板「拆着杀」、判定档 2026-07-10 拍板切 MiniMax-M3)
|
||||
上级: docs/plans/2026-07-09-001-feat-生成线验收换轴三波-plan.md
|
||||
sot-impact: 申报——质量模型 SoT(游戏质量与爆火能力.md)裁定一/二/三与 §2 表 L1 行、验收门.md §2.4 与 F_wiring 归类句与首局门段、agentic运行时架构图说.md 分层验收与 C5/C6 段;主修订处逐字文案见附录 A,次级引用面按附录 A 末表在波 0 内落并对照验收;评审通过前 SoT 不动
|
||||
关联: cheap-worker/(cheap_studio/cheap_verify/cheap_run/cheap_gates/cheap_service_driver/cheap_service_app/cheap_modify/cheap_roles/result_out/hard_genre_batch/hard_genre_xtheme)+ tier2/gen-worker/worker/gate_judge.py + game-runtime/games/_wg1-gen/_shared/play.cdp.cjs + game-runtime/tools/amodel-gen/(gen/tools)+ contracts/prompts/04-config/cheap-system.md + tier2/config/generation.yaml + game-cloud ReadinessScorer/GenMetrics(读侧)@ d3baf86e
|
||||
---
|
||||
|
||||
# 生成线验收 v2 · 测试 agent 替 E/G/H 执行计划(W-AXIS-V2)
|
||||
|
||||
W-AXIS 三波(2026-07-09)把验收权威从机械门移交给了独立模型判定,但判定的证据仍由旧机械链生产:tap-targets 驱动器按游戏自报的 `_forensicsView` 契约去玩,E_live/G_input/H_progress 三门再按同一契约读数。n=5 重测基线(25 局,`cheap-worker/results/wax-baseline.jsonl`)与随后的逐局破案证明,这条证据生产线本身就是最大的失败源。本计划执行创始人 2026-07-09 晚拍板的「拆着杀」:契约无关的机械门降为地板保留,依赖契约的门连同驱动器与取证契约整体退役,由一个**测试 agent**(视觉引导真玩 + 玩法地板判定合一)接管验收证据的生产与裁决;修复侧不新建角色——现有 writer 续修回路就是修复 agent,换的只是喂给它的反馈来源。判定与测试员模型档 = MiniMax-M3(创始人 2026-07-10 拍板,动因与边界修正见附录 A)。
|
||||
|
||||
双评审(Codex 10 项、Opus 8 项,2026-07-10)的全部发现已修入本稿,其中三处硬伤直接改写了初稿的口径:机械地板从「五门」修正为「四门投影」(F_wiring 的期望前缀集唯一来源就是 play-spec,拆契约后它必然坍缩,不能再称契约无关);生产 Service 路与 A11 修改链纳入波 1(初稿只接了 CLI 批跑路);回退机制从复用 `judge.blocking` 改为独立的 `acceptance.mode` 三态开关(布尔开关承担不了「判定是否阻断」与「验收器版本选择」两种语义)。
|
||||
|
||||
## 1 为什么换:三组已核实数字
|
||||
|
||||
**基线失败的多数出自验收仪器自身缺陷,不是游戏差。** n=5 基线 25 局,操作口径 11 局失败,其中判定 cap 截断误拒 1 局、判定图像盲假阴 3 局、取证契约缺陷坑杀好游戏 2 局、判定边界偏严 1 局——仪器与 harness 归因 6~7/11。真游戏缺陷只有 2 局(boot 空指针、局内点击全哑),生成线 stall 1 局。质量口径修正后真实率 18/25 = 72%,而失败集中的 puzzle/sim-business 两品类,每局失败都烧到 ¥10+ 软停——因为契约仪表盘骗的不止是门,还有模型自己:它收到的门反馈与它的代码对不上,只能反复续修直到熔断。
|
||||
|
||||
**逐局破案坐实「一份固定代码玩几十种游戏」的结构病。** 五局失败全部下钻到代码行:hard-puzzle-r2 与 hard-sim-business-r1 是同一个冻结闭包缺陷——模型把 `phase`/`targets` 算在 `_forensicsView()` 外层、state 闭包之外,host 只调一次,导出永远定格 boot 值;两局的模型都在注释里写明白了这个坑该怎么防,然后放错了一层。游戏本体真浏览器一拍就点开,驱动器、三门、续修反馈三方全在读一块失真的仪表盘。hard-puzzle-r3 是真不可玩(`hitTile` 四参函数只传三参,局内点击全部静默吞掉),hard-sim-business-r3 是 boot 空指针但门反馈无栈无行号修不动,hard-puzzle-r5 是生成 stall。品类失败集中的原因由此清楚:puzzle/sim-business 的进展链条最深(菜单→观察→解题→计分、排队→点单→配方→交付),取证契约面最大,出错概率与杀伤都最大;轻叙事品类点一下就有进展,契约面小,全过。这与创始人既有红线「模型能力的事绝不写成代码校验」同构——E/G/H 正是在用代码回答「这游戏玩起来对不对」。
|
||||
|
||||
**Spike 考卷证明测试 agent 方向成立,且量化了它的弱点。** 用 10 局已知真相的基线游戏做考卷(spike 资产随波 0 入 `spikes/playtest-agent/`),M3 当测试员:已知真相 9 局判对 7 局;3 局真坏局全部拦住、未出现假阳——trpg-r3 报「战斗场景缺掷骰按钮和面板无法操作」与人工亲眼的空壳一致,puzzle-r2 报出 `8undefine` 渲染错,而这张局内画面旧管线从未见过(旧驱动器从没进过 play)。2 局假阴(sim-business-r2、heritage-r4)经人工按视觉坐标复点裁决:游戏命中完全正常(红茶→顾客,serve 日志立现、score 0→7→14),是测试员接地失误后把自己的失手归因成游戏缺陷;heritage-r4 另有 14 步预算装不下四道工序节拍的成分。结论:未见假阳是这套验收的第一性优势;假阴以重测成本为代价、可通过预算与二掷确认压低;「失手后错误归因」是 M3 级测试员的头号风险,必须由设计约束(§3)而非模型自觉来治。
|
||||
|
||||
## 2 目标形态:v2 验收 = 四门地板 ∧ 测试 agent 裁决
|
||||
|
||||
**机械地板 = A_boot / B_uncaught / C_frame / D_render 四门的显式投影,权威字段独立。** 四门共同特征是契约无关——读装载标志、未捕获异常、引擎帧号、canvas 像素,不需要游戏配合任何自报接口,零成本、确定性、抓真死(基线里 boot 空指针正是 A 门拦的)。落地形态是一个**投影函数与权威字段**:消费层从 `verdict.guards` 显式抽取四门求与,记 `floor` 段(`{pass, gates}`),所有预筛消费点一律读它;harness 原生 `verdict.pass` 保持 harness 语义不动(driven 时九门全量 AND、undriven 时七门硬集),**不原地改义**——初稿把「五门」与 `verdict.pass` 混为一谈,是评审纠正的第一处硬伤。
|
||||
|
||||
**F_wiring 降观测,不入地板。** 它的判据 = 命中 `spec.expectedEngineCallPrefixes`,而该期望集唯一来源是 play-spec(`cheap_run._build_play_spec` 按驱动器族写死);拆掉 play-spec 后 F 会静默坍缩成「任意引擎调用即过」,不再证「真接线」。验收门 §2.4 本就把 F 归为 driver 依赖门,初稿称其契约无关是事实错误。「真接线」的关切移交两处:check 静态层(波 2 增补源码级插件调用存在断言——现 check 只有结构存在门与红线词法扫,无此断言,须新增而非「现有」)与测试员的观察项(玩的过程中音效/计分/表现是否真实发生);F 门结果照落 verdict 供观测,一个版本窗口后各消费面停止读取(verdict.json 的 harness 输出本身永不删改,tier2 与历史复跑照旧)。
|
||||
|
||||
**E_live / G_input / H_progress / I_control 四门退役**:E/G/H 依赖 `_forensicsView` 状态导出与驱动器真玩,I 依赖 per-game controlCheck 契约,全部随契约拆除(降观测一个版本窗口,后摘)。
|
||||
|
||||
**测试 agent = 真玩与玩法地板判定合一,持 L1 判定半的阻断权。** 输入只有 brief 与浏览器;它按人类可见的证据(截图 + 运行日志尾)视觉引导地玩——点它看见的,不是契约导出的;产出三样:①行动转写(每步动作/理由/落点)与全程截图序列,即验收证据链,落 `evidence/playtest/`——**v2 起 gameplay 硬证据的唯一落点在此**,harness 的 first-paint/after-play 在 undriven 下只是 boot 屏,降为装载观测,§6.13 亲眼验收、审核台与后端 trace 的截图指针随波 1 迁移;②布尔裁决,三类拒绝语义原样继承裁定三(broken/hollow/off_brief,判「有无」不判「多好」),外加图像通道自检(看不到图 fail-closed);③修复反馈,**现象与推测强制分离**(「我在第 N 步点 (x,y) 后画面未变」是现象;「可能是事件未绑定」是推测,必须标注)——这是对 spike 三次错误归因失效模式的直接约束,续修反馈模板只引用现象段。丰富度 LLM 评分(L2,非阻塞)不动,继续读源码走旁路。
|
||||
|
||||
**统一验收编排器,CLI 与 Service 与 modify 三路共用。** 生产线的验收权威不在 cheap_studio(那是批跑/实验路),而在 Service 生产路(`cheap_service_driver` 判定接线、`cheap_service_app`→`cheap_gates` 收口)与 A11 修改链(`cheap_modify` 确定性修改与模块重生成,现按九门全量验收)。波 1 交付一个编排器函数:输入 gid/brief/evidence 上下文,输出 `floor ∧ playtest` 裁决 + 修复反馈 + 记账,三条路全部改调它——创建与修改不允许两套验收标准。
|
||||
|
||||
**修复回路复用现有 writer resume。** 续修反馈的来源从门反馈模板换成「测试员现象报告 + 四门地板结果」;测试员与写手在会话与角色上物理隔离(测试员看不到源码与生成对话,写手拿不到测试员系统提示),「只吃运行证据、不吃自报」的纪律原样保留。
|
||||
|
||||
**模型与成本。** 测试员与判定档同一配置(genconfig `judge.model`,现 MiniMax-M3;出题≠被考的边界修正随附录 A 落 SoT)。spike 实测 ¥0.1~0.25/轮(8~14 步含截图),设计预算上限 ¥0.5/轮、单局验收(含 fail 二掷)≤¥1.5,对照旧链路契约困死单局烧 ¥10+ 的账,经济上净赚。测试员模型档是显式质量旋钮:M3 是下限起步,换更强档只改配置。
|
||||
|
||||
**阻断、回退与灰度 = `acceptance.mode` 三态(genconfig 新键),`judge.blocking` 退回单一语义。** 初稿想复用 `judge.blocking` 当回退位,评审证伪:该布尔在代码里的语义是「判定结果是否阻断」(False = ok 仍取预筛、判定照跑仅观测),拨它回不到「判定器读驱动器证据」的 W-AXIS 态;且波 2 拆除 play-spec/驱动器后,旧判定器的证据(driver 真玩截图、状态时间线)物理不存在,任何开关都造不出来。v2 采用:
|
||||
|
||||
- `acceptance.mode = v2`(目标态):`accepted = floor ∧ playtest`,测试员阻断;
|
||||
- `acceptance.mode = shadow`(波 1 灰度窗口):测试员照跑照落证据,`accepted` 仍取旧口径——新旧并跑对照的合法形态;
|
||||
- `acceptance.mode = v1`(回退态):W-AXIS 口径(driven 九门 + 判定器读驱动器证据)。**时序约束:v1 仅在 play-spec/驱动器仍挂载的波 1~波 2 前有效;波 2 拆除后 v1 不可达,回退 = 版本化整体回退(prompt registry 版本 + git revert 波 2 提交),这是有意的单向门,写进波 2 验收前的放行检查。**
|
||||
- `judge.blocking` 保留原语义(玩法裁决是否阻断)且缺省 true,不再兼职版本选择;配置键命名空间 `judge.*` 不改名(`playtest` 是 summary 字段名,与配置键不对齐是刻意的)。
|
||||
|
||||
**fail-closed 语义不变。** 测试员评不出、看不到图、步数预算尽而无裁决,一律 degraded 不放行,落批次账与 result_out,人工复核从批次账捞(维持 W-AXIS 波 2 的 degraded 落点口径,不建独立队列)。
|
||||
|
||||
## 3 测试员设计要求:spike 实证转为约束
|
||||
|
||||
以下八件写进波 1 的实现验收标准,不留给模型自觉:
|
||||
|
||||
1. **坐标协议钉死**:测试员回路自截自用——`Emulation.setDeviceMetricsOverride` 390×844、DPR=1,截图像素与逻辑坐标 1:1,模型报的 tap 坐标即 CSS 坐标直接派发;与 harness 的 DPR2 取证截图互不掺和。协议(动作 JSON schema、退出码、超时、evidence 目录布局)以 `playtest/1` schema 落档于实现文件头注。
|
||||
2. **坐标尺**:截图上烧 50/100px 网格与轴刻度,模型照尺读数报坐标(无尺时 M3 偏 160~230px,有尺仍偏 60~90px——必要不充分,还需 3/4)。
|
||||
3. **落点回显**:每次 tap 后页面放亮黄圆环标记,下一帧截图可见上一步落点,照偏差自校正(narrative-r1 案:加回显后 12 步真玩通到结局卡)。
|
||||
4. **runner 侧硬提示**:同一坐标连点 ≥2 次即确定性注入警告文本逼换点,由 runner 代码保证,不靠提示词自觉。
|
||||
5. **反早退驳回**:fail 裁决若独立落点 <3 处且非图像通道故障,驳回一次逼继续测。
|
||||
6. **预算随进展扩展**:基础步数预算(缺省 14)在有进展证据(得分/关卡/日志推进)时自动扩到上限(缺省 24);两值进 genconfig。
|
||||
7. **fail 二掷确认**:测试员判 fail 且四门地板全绿时,重开一局再测一掷,两掷同 fail 才落 fail;二掷证据落 `evidence/playtest/roll-2/`,seed 由 runner 显式生成并记录进转写(禁隐式时钟种子),每掷前清 localStorage 与存档,保证两掷独立。pass 一掷即过,不加成本。
|
||||
8. **图像通道自检**:裁决 JSON 强制自报「本轮是否真实看到截图」;看不到 → fail-closed degraded,归因「测试员图像通道故障」而非游戏缺陷(继承判定器 07-09 盲检修的教训;字段名在 playtest schema 内统一,与旧判定器 judge.json 的 `imagesSeen` 是两套工具的两个字段,不强行对齐)。
|
||||
|
||||
## 4 字段与迁移协议(run-summary / verdict / trace 三层)
|
||||
|
||||
新增字段一次钉死,旧字段双写一个版本窗口,消费者逐个点名:
|
||||
|
||||
| 层 | 新字段 | 语义 | 旧字段(双写/并列一个版本窗口,窗口后**停止消费**——verdict.json 的 harness 原生输出永不删改) |
|
||||
|---|---|---|---|
|
||||
| verdict 消费层 | `floor` 段 `{pass, gates:{A,B,C,D}}` | 四门投影,预筛权威 | `pass` 照产照落(harness 原生,不改义,消费面停读)、E/G/H/I/F 结果(降观测) |
|
||||
| run-summary | `acceptanceVersion`(取值随 `acceptance.mode`:v2/shadow/v1)、`playtest` 段(裁决/拒类/现象清单/degraded/成本/rolls) | 测试员裁决权威;**shadow 行与 v2 行靠 acceptanceVersion 区分,防波 3 基线对账串数** | `judge` 段(双写窗口) |
|
||||
| result_out/trace | `trace.playtest`(additive) | 后端消费 | `trace.gameplayJudge`(双写窗口) |
|
||||
| 批账 | `floorPass` 列 + `accepted` + `acceptanceVersion` 列(每行必带) | 报表口径 | `verdictPass` 列(窗口内并列打印仅供对照,表头注明 harness 原生语义,**不作判定输入**) |
|
||||
|
||||
消费者清单与改点:`cheap_studio._prefilter`、`apply_gameplay_judge` 的 prefilter 入参、`result_out.prefilter_pass/_build_trace/_map_failure_reason`、`hard_genre_batch`、`hard_genre_xtheme`、`worker/gate_judge.judge_cheap_verdict`(续修触发器)、game-cloud 读侧 `GenMetrics`(gen_gate_fail 归因加 playtest 类目)与 `ReadinessScorer`(见 §5 首局维)。failureLayer 归因沿用三层语义,`driver_contract` 层随契约退役自然消亡(存量保留历史语义),新增 `tester_degraded`。
|
||||
|
||||
## 5 首局体验门与 D11 首局维的去向(被拆契约的直接下游,同波裁定)
|
||||
|
||||
现首局门是 H 门的 additive 派生(可玩 ≤2s / 首反馈即时 / 60s 品类闭环),证据全部来自驱动器真玩;D11 就绪分的首局维读 `gatespec.driver + H_progress`。契约拆除后两者失去证据来源,不能含糊成「观测漏计」。裁定:**三断言由测试员转写接管**——转写天然带时间戳与动作序列,首次有效交互时刻、首个可感知反馈、闭环达成时刻都可从转写机械提取,波 1 在 playtest 段落 `firstPlay:{playableAtMs, firstFeedbackMs, loopClosed}` 三字段;D11 首局维与 `ReadinessScorer.scorePlayability` 同波改读 `trace.playtest`(Java 侧小改,与 GenMetrics 归因补齐合并为一张随波 1 的跨仓工单,不再是「事后可选优化」)。首局门作为独立门名退役,语义并入 playtest 证据。
|
||||
|
||||
## 6 波次
|
||||
|
||||
### 波 0 · SoT 修订与 spike 资产入仓(半天;评审过即落)
|
||||
|
||||
- **目标**:v2 的判定归属先在权威文本上成立,证据资产可检索。
|
||||
- **边界**:只动附录 A 列明的策展层文本与 `spikes/`、在飞板;不动代码;留痕层不改。
|
||||
- **输入**:附录 A(主修订处逐字终文 + 次级引用面清单);scratchpad 的 spike 三件。
|
||||
- **产出**:①主修订四处按附录 A1~A4 逐字落;②次级引用面(质量 SoT §2 表 L1 行、裁定二中依赖 occupied 驱动/score 断言的判定句、规范五的断言分界句、验收门首局门段与 F 归类句、运行时图说 C5/C6 与 driven 段)按附录 A5 表逐处修,修订注记统一「已裁定、随 W-AXIS-V2 波 1/2 兑现」——**SoT 写裁定与生效条件,不把未来态写成当前事实**;③spike 资产入 `spikes/playtest-agent/`(脚本 + 考卷终榜 + README:复跑方法、真相表、结论);④在飞板登记。
|
||||
- **验收**:docs-gate 七检全绿;主修订处 `git diff` 与附录 A 逐字对账;次级引用面按 A5 表逐处人工对照,rg 复扫无「九门=验收」「五门」残留口径;spikes/README 按考卷真相表可复核。
|
||||
- **回滚**:纯文档,git revert。
|
||||
|
||||
### 波 1 · 测试 agent 生产化 + 三路统一接线(3~5 天;主体)
|
||||
|
||||
- **目标**:spike 原型变成生产件,CLI/Service/modify 三路共用一个验收编排器,shadow 灰度可用。
|
||||
- **边界**:新增件(playtest 回路 + 编排器)+ 三路接线点 + 字段迁移(§4)+ 跨仓读侧小改(§5);不拆 play-spec/驱动器(波 2)、不动 prompt 正文(波 2)、不动 tier2 判据。
|
||||
- **输入**:spike 脚本(§3 前五件已实证);cheap_verify 判定器基建(`_NewapiVisionModel` 计费、fail-closed、真相层落盘、genconfig 旋钮);play.cdp.cjs 的 CDP 会话与起服编排;§4 迁移协议。
|
||||
- **产出**:①`game-runtime/games/_wg1-gen/_shared/playtest.cdp.cjs` 生产版——§3 八件全实现,`playtest/1` 协议落头注,证据落 `evidence/playtest/roll-N/`;②`cheap-worker/cheap_verify.py` 新增 `run_playtest()`(子进程封装/超时/成本记账/fail-closed/`playtest.json` 真相层)与**四门投影函数**(floor 段的唯一产地);③**统一验收编排器**——`accepted = floor ∧ playtest`(mode=v2)/旧口径(mode=shadow/v1),修复反馈拼装(只引现象段);④三路接线:`cheap_studio`、`cheap_service_driver`+`cheap_service_app`/`cheap_gates`、`cheap_modify`(确定性修改与模块重生成)全部改调编排器;⑤§4 字段迁移与全部预筛消费点同批切读 floor 投影——**判定语义单轨,无双语义窗口**(含 `hard_genre_xtheme`、`gate_judge` 续修触发器);批账的 `verdictPass` 列窗口内并列打印**仅供对照**(表头注明 = harness 原生 driven/undriven 语义),不作任何判定输入——「双写窗口」只存在于报表列与 trace 旧段,不存在于判定语义;⑥§5 firstPlay 三字段 + 跨仓工单(ReadinessScorer/GenMetrics);⑦单测(fake 测试员覆盖 §3 八件与 fail-closed 各路径、投影函数、mode 三态)+ 真跑验收。
|
||||
- **验收**:pytest 全量绿;**10 局考卷用生产件重跑**:已知真相判对 ≥8/9、真坏局零放行,假阴经二掷+预算扩展 ≤1/6(sb2 与 heritage-r4 应翻为 pass);**Service 真续修 E2E 一局**(create→写→shadow 并跑→v2 切换→测→裁→回喂→收敛)与 **modify 回归一局**(create→modify→v2 验收),全链真浏览器截图硬证;shadow 模式下新旧口径对照表落批次账。
|
||||
- **回滚**:mode 拨回 v1(驱动器仍挂载,W-AXIS 口径完整可达);新增件全部旁路,不触旧链。
|
||||
|
||||
### 波 2 · 契约退役与提示清洗(1~2 天;依赖波 1 验收过)
|
||||
|
||||
- **目标**:play-spec/驱动器/取证契约退出便宜档验收链,提示与工具面按真实加载链清洗到零残留,模型不再为死契约写代码。
|
||||
- **边界**:cheap 路验收消费层、play-spec 生成层、prompt 全加载链、amodel-gen check;九门 harness 判据代码不删(降观测,保 tier2 与历史复跑);tier2 双路门不动。
|
||||
- **输入**:波 1 的编排器与考卷回归;prompt 真实加载链清单(见产出③);`_template*` 脚手架。
|
||||
- **产出**:①验收消费——四门地板为唯一机械取值,E/G/H/I/F 结果降观测字段(照落 verdict 供对照,一个版本窗口后各消费面停止读取;harness 输出不删);增补 check 源码级插件调用存在断言(词法层,接替 F_wiring 的真接线护栏);②play-spec 生成与 tap-targets 驱动器从 cheap 验收链退役:`cheap_gates.ensure_play_spec` 调用摘除、`cheap_service_driver._read_driver_type` 改读 playtest、`gen.mjs` Node 遗留路的 play-spec 自动产同批摘、golden-spec 对照工具(auto_vs_golden/bake_off)改挂测试员或显式封存,逐件裁定入清单;③**prompt 全加载链清洗**(评审补全的清单,缺一即残留):`contracts/prompts/04-config/cheap-system.md`(registry 正文)、`cheap_roles.py` 内置回退正文(registry 读取失败的 fallback,必须与 registry 同步改)、`cheap_roles.py` modify 路的契约切片、品类设计 skills 里教 targets/occupied 的段落(`.agents/skills/sim-business-game-design.md` 等)、`_template*` 内为 E/G/H 服务的注释与结构——删 `_forensicsView`「自动验收契约」全部教学与 targets/occupied 语义、删两步成单围驱动器设计的教导;`_forensicsView` 降级为可选调试接口,check 摘除「必须实现」拦截(保留 handleTap 输入契约与 ctx.log 日志纪律——它们服务真人与测试员,不是死契约);④批跑与报表——`hard_genre_batch`/`hard_genre_xtheme` 结账口径切 floorPass+playtest 语义。
|
||||
- **验收**:同一批游戏新旧口径并跑对照(观测窗口),无一例「旧口径拦住的真坏局新口径放行」;prompt 清洗按加载链清单逐项给 diff 与动因,`rg -n "forensicsView|occupied|targets.*occupied|play-spec"` 在 prompt/skills/模板面零残留(代码与历史档命中属预期,标注);n=3 冒烟全链绿;**波 2 放行检查:确认 v1 回退窗口自此关闭,回退=版本化整体回退,创始人知悉**。
|
||||
- **回滚**:prompt 走 registry 版本回退 + cheap_roles 内置正文同批 revert;play-spec 生成器代码保留一个版本窗口,重挂 + mode=v1 即回 W-AXIS 口径(本窗口内);窗口后走 git revert。
|
||||
|
||||
### 波 3 · 重测基线与金标校准(1 天+;依赖波 2)
|
||||
|
||||
- **目标**:v2 口径下的真实率基线与测试员校准锚。
|
||||
- **输入**:波 1 考卷回归数据;`cheap-worker/fixtures/judge-golden/` 金标种子(expectReject 待创始人定标)。
|
||||
- **产出**:①n=5×5 品类重测(零重跑纪律、逐行 JSONL、¥ 硬停线沿用),报告与 07-09 基线并排(口径差异显式标注);②金标扩容——10 局考卷真相表并入金标(含 2 假阴案作测试员反例),创始人亲玩定标窗口;③测试员观测面——假阴率/二掷翻案率/步数分布/成本分布进批次账,作为换测试员模型档的决策数据。
|
||||
- **验收**:基线报告过主会话终审(逐行核账 + 抽 3 局亲眼);金标复验通过(测试员对标注反例必须 fail、正例必须 pass);双差品类(puzzle/sim-business)真实率相对 07-09 基线不降(它们契约税最重,v2 应受益最大——不达即回查)。
|
||||
- **回滚**:基线是测量不是变更;测量期发现缺陷按波 1/2 回滚位处理。
|
||||
|
||||
## 7 风险与对策
|
||||
|
||||
- **测试员假阴**(实测 2/6,二掷+预算扩展后目标 ≤1/6):fail-closed 方向安全,代价是重测/续修成本;波 3 观测面持续量化,超线即升测试员模型档(配置项,零代码)。
|
||||
- **M3 同源残余盲区**(测试员与生成同模型):地板布尔判「有无」使同源风险最小化,spike 已实证 M3 抓自家缺陷(8undefine 案);金标反例复验 + 创始人抽玩是校准锚。
|
||||
- **错误归因污染续修**:现象/推测强制分离 + 现象必须带步号与落点坐标;续修反馈模板只引用现象段。
|
||||
- **时延**:一轮 8~24 步 × M3 每步 10~40s ≈ 2~10 分钟,与生成本身(5~6 分钟)同量级;二掷只对 fail 触发;步数与超时在 genconfig 可调。
|
||||
- **供给**:M3 走 MiniMax Direct 一手通道;图像通道自检 fail-closed 兜故障,degraded 不放行。
|
||||
- **单向门**:波 2 关闭的是**配置级回退**(`acceptance.mode=v1` 自此不可达);代码级仍可版本化整体回退(重挂 play-spec + revert 波 2 提交),但那是一次变更而非拨开关——非严格不可逆,是计划内唯一需要创始人知悉后放行的降级换挡点,已列波 2 放行检查。
|
||||
|
||||
## 8 明确不做(本计划边界)
|
||||
|
||||
不动 tier2 富三门双路(裁定三落地范围原句保留:tier2 是否叠加模型判定随便宜档校准另议);不做「好玩程度」评价(L2 rubric 职责,非阻塞纪律不变);不建独立人工复核队列 UI(维持批次账 + 审核台通道);不为测试员新建模型供给通道(用现网关配置);不重写九门 harness 代码(降位不删码)。
|
||||
|
||||
## 附录 A · SoT 修订(波 0 落;A1~A4 为逐字终文,A5 为次级引用面清单)
|
||||
|
||||
生效语气约定:A1~A4 终文里的「2026-07-10 起 / v2 起」一律指**裁定生效**;工程兑现以波 1/2 收口为准,波 0 落档时每处修订注记统一附「已裁定,随 W-AXIS-V2 波 1/2 兑现」——SoT 陈述裁定与生效条件,不把未完成的工程写成当前事实。
|
||||
|
||||
**A1 质量模型 SoT(游戏质量与爆火能力.md)裁定三,四处修订(终文):**
|
||||
|
||||
1. 「其一」句末追加:「2026-07-10 再修订(W-AXIS-V2):便宜档机械预筛收敛为契约无关四门投影(A_boot/B_uncaught/C_frame/D_render,消费层从 guards 显式抽取,权威字段 floor,不复用 harness 原生 verdict.pass);F_wiring 的期望前缀集源自 play-spec,随契约退役降观测,『真接线』关切移交 check 静态层与测试员观察;E_live/G_input/H_progress/I_control 退出便宜档验收取值(判据代码保留、降观测)。依据是 2026-07-10 基线逐局破案:取证契约实现缺陷在 25 局里坑杀 2 局好游戏、误导续修每局烧 ¥10+,契约面最大的品类失败最集中(执行与证据 = W-AXIS-V2 plan)。」
|
||||
2. 「其二」句判定输入改为(替换「brief 加真玩证据链(首帧/局中/局末截图或录屏、游戏日志、取证状态时间线)」):「brief 加真玩证据链;v2 起证据由测试 agent 亲手真玩产生(视觉引导逐步操作的全程截图序列、行动转写、运行日志),取证状态时间线随 `_forensicsView` 契约退役不再作判定输入——判定与真玩合一于测试 agent,其裁决即玩法判定半。三类拒绝与『判有无不判多好』分界不变。」
|
||||
3. 「其二」句「判定者与生成者绝不同源(出题≠被考纪律原样保留)」改为:「出题≠被考的实质 = 判定只吃运行证据、不吃生成 agent 的自报,且测试员与写手会话隔离;判定模型允许与生成同源(2026-07-10 创始人拍板切 MiniMax-M3)——地板布尔判『有无』使同源盲区风险最小,同源模型抓自家缺陷已有实证(spike 考卷 8undefine 案);残余风险由金标正反例复验与创始人抽玩兜底。动因:glm-5.2 唯一供给通道为二手中转,图像支持按池轮换、2026-07-09 整日全盲,判定层可用性不押注二手供给。」
|
||||
4. 失败归因三层句「判卷驱动器合约失败(取证不完整)」加注:「(v2 起该层随契约退役自然消亡,存量归因保留历史语义;新增 tester_degraded = 测试员评不出,fail-closed 待人工)」。
|
||||
|
||||
**A2 裁定一末句追加(终文):**「2026-07-10 起『driven 时九门全量 AND』口径仅存于 tier2 与历史复跑;便宜档机械取值 = 从 verdict.guards 显式抽取的契约无关四门投影(G/I 在 undriven 下恒 skip-pass、E/H 降 advisory、F 期望集随 play-spec 退役,均不进取值)——四门投影是消费层函数,不等于也不改写 harness 原生 verdict.pass(见裁定三 2026-07-10 修订)。」
|
||||
|
||||
**A3 验收门.md §2.4 追加段(终文):**「便宜档消费口径 v2(2026-07-10,W-AXIS-V2):机械取值 = A_boot/B_uncaught/C_frame/D_render 四门投影(消费层 floor 字段),E_live/G_input/H_progress/I_control 与 F_wiring 降观测——F 的期望前缀集源自 play-spec,契约退役后不再具证真力,本节前文『F_wiring 属 driver 依赖门』的归类由此坐实;验收 = 四门 ∧ 测试 agent 真玩裁决(证据落 evidence/playtest/,harness 截图在 undriven 下为 boot 屏、仅作装载观测)。九门判据代码不删,tier2 与历史复跑照旧。首局门三断言随驱动器退役由测试员转写接管(firstPlay 三字段),独立门名退役。执行与字段协议 = W-AXIS-V2 plan。」
|
||||
|
||||
**A4 agentic运行时架构图说.md 分层验收段(终文):**「便宜档分层验收 2026-07-10 起为:契约无关四门预筛(A/B/C/D 投影)∧ 测试 agent 真玩裁决(视觉引导真玩 + 玩法地板判定合一,broken/hollow/off_brief 三类拒绝,fail-closed);E/G/H/I/F 降观测,play-spec 与 `_forensicsView` 取证契约退役。指针:质量模型 SoT 裁定三、W-AXIS-V2 plan。」
|
||||
|
||||
**A5 次级引用面(波 0 逐处修,注记「已裁定、随波 1/2 兑现」):**
|
||||
|
||||
| 文件 | 处 | 修订要点 |
|
||||
|---|---|---|
|
||||
| 游戏质量与爆火能力.md | §2 表 L1 行 | 「机器真玩预筛(九门+富三门+首局门)」→ 便宜档=四门投影、tier2=九门+富三门;首局门语义并入测试员转写 |
|
||||
| 游戏质量与爆火能力.md | 裁定二 | 「便宜档经营品类的进度判定 = occupied 反应族驱动 + score 递增断言」句改为历史口径注记(该判定手段随契约退役,进度证据改由测试员转写承担);门级判据不跨档的本旨不动 |
|
||||
| 游戏质量与爆火能力.md | 规范五(断言与 rubric 分界) | play-spec 断言相关表述加 v2 注记(断言载体退役,分界原则保留:机械可判的归地板/转写,程度评价归 rubric) |
|
||||
| 验收门.md | 九门条目 E/G/H/I/F 段、首局门段 | 各加一句 v2 便宜档降观测/退役注记,指针 A3 |
|
||||
| agentic运行时架构图说.md | C5/C6、driven 段 | play-spec 自动生成与 driven 判定描述加「便宜档 v2 退役,tier2 保留」注记 |
|
||||
| 游戏质量与爆火能力.md | §2 门类型消歧段、§3 总述句 | 「两套门」消歧与「九门=A..E+driver 依赖门」总述各加便宜档 v2 取值注记(四门投影,指针 A2) |
|
||||
| 验收门.md / 质量 SoT | 首局时间口径处(可玩≤2s/首反馈/60s 闭环) | 断言语义迁移至测试员转写 firstPlay 三字段的注记(指针本 plan §5) |
|
||||
| agentic运行时架构图说.md | §六 A11 调整回路「三断言机器门」句 | modify 验收随统一编排器切 v2(三断言证据来源迁移),加注记指针本 plan 波 1④ |
|
||||
|
||||
## 附录 B · Spike 证据清单(波 0 随资产入仓)
|
||||
|
||||
- 考卷终榜:已知真相 9 局判对 7,真坏 3/3 全拦、零假阳(trpg-r3 空壳理由与人工一致、puzzle-r2 报出旧管线从未见过的 8undefine、puzzle-r3 拦对),真好 4/6 过;2 假阴经人工视觉复点平反(sim-business-r2:红茶→顾客 serve 立现 score 0→7→14)。
|
||||
- 失效模式三案(测试员失手后错误归因):narrative-r1 v2 版、puzzle-r3 理由段、sim-business-r2——§3 第 2/3/4/7 条设计要求的直接依据。
|
||||
- 成本:考卷 10 局共 15.2 万入/0.85 万出 tokens ≈ ¥2;单轮 ¥0.1~0.25。
|
||||
- 资产:`spikes/playtest-agent/{playtest-agent-spike.cjs, playtest-exam.jsonl, transcripts/, README.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');
|
||||
|
||||
15
game-runtime/package-lock.json
generated
15
game-runtime/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"typescript": "^5.4.5",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
},
|
||||
@ -508,6 +509,20 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"typescript": "^5.4.5",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
7
game-runtime/src/core/api.d.ts
vendored
7
game-runtime/src/core/api.d.ts
vendored
@ -301,6 +301,13 @@ export interface PluginContext {
|
||||
* 集成段=LittleJS 真实句柄背书;**引擎 import 只在集成段 host,不进插件 impl(保引擎可换 + 满足门1 真接线)**。
|
||||
*/
|
||||
getEngine(): EngineCapabilities | null;
|
||||
/**
|
||||
* 受控日志:游戏/插件在 init / 场景切换 / 得分 / 出错处记语义事件,写 host 侧 `window.__gameLog` 供诊断。
|
||||
* 运行时由 host 附加在 boot.ctx 上(tools.mjs 的 CTX_METHODS 已含 'log';系统提示与 littlejs-game-dev skill
|
||||
* 均以 `ctx.log('tag', 信息)` 形态教学)。此前本接口漏声明它,致 tsc 类型门对每个用 ctx.log 的真实游戏误报
|
||||
* 「Property 'log' does not exist」——补声明使类型面与运行时契约一致(W-AXIS 波3)。
|
||||
*/
|
||||
log(tag: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
9
game-runtime/src/core/game-host.d.ts
vendored
9
game-runtime/src/core/game-host.d.ts
vendored
@ -52,6 +52,15 @@ export interface GameInstance {
|
||||
render(g: CanvasRenderingContext2D): void; // g=mainContext(boot 注入的同一面)
|
||||
onInput?(ev: { type: string; x?: number; y?: number; key?: string }): void; // 可选输入入口
|
||||
destroy(): void; // 卸载(幂等)
|
||||
// ── 便宜档 L3 输入/取证契约(L1 game.js wrapper 与九门驱动器据此派发/取证)──────────────
|
||||
// 系统提示与 littlejs-game-dev skill 均把这三项当硬契约教(handleTap 主输入、handleKey 键盘、_forensicsView
|
||||
// 可测性红线),check() 也结构性强制其存在;此前 GameInstance 漏声明它们,致 tsc 类型门把每个真实游戏返回的
|
||||
// 这三项误报为「excess property」。补为可选成员使类型面与运行时契约一致(W-AXIS 波3;presence 由 check 强制、
|
||||
// 非 tsc 职责,故声明为可选、只消除误报不放松结构门)。
|
||||
handleTap?(x: number, y: number): void; // 点击主输入(L1 在 pointerdown 时直达调用)
|
||||
handleKey?(key: string): void; // 键盘输入(L1 在 keydown 时调用;方向键/空格类)
|
||||
_forensicsView?(): { state(): Record<string, unknown>; measures?(): Record<string, unknown> }; // 取证面:state() 至少 { phase, score }
|
||||
_handleAction?(action: any): void; // 可选直接命令入口(host.do(action) e2e 辅助:action 形态由游戏自定义,故 any)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
@ -102,3 +102,80 @@ test('④ 合规 ctx 写法零报', () => {
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.equal(errs.length, 0, JSON.stringify(errs));
|
||||
});
|
||||
|
||||
// ───────── ⑤ 字符串内的 // 不 desync stripCode(2026-07-08 trpg-cyber 假阴性根治回归) ─────────
|
||||
|
||||
test('⑤ 字符串内含 // 不吞后续真代码(赛博主题回归)', () => {
|
||||
// 赛博主题实测:'> JACK IN // FLOOR 01' 字符串里的 //,旧 stripCode 行注释先剥、吞掉闭合引号 →
|
||||
// 引号失衡 → 后续字符串正则跨行匹配 → 级联误抹真代码(实测抹 169 行,连 bundle.tick/handleTap 一起抹)
|
||||
// → check 误报红线缺失 → 模型明明写对却被反复拒 → thrash 到步数顶熔断失败。回归:字符串后的真
|
||||
// Math.random 仍须命中且报对行号(证明字符串内 // 未 desync 后续码)。
|
||||
const src = [
|
||||
'export function createGame(){', // 1
|
||||
" const banner = '> JACK IN // FLOOR 01';", // 2 // 在字符串内
|
||||
' const r = Math.random();', // 3 真违规,须命中
|
||||
' return { _forensicsView(){}, handleTap(){} };', // 4
|
||||
'}', // 5
|
||||
].join('\n');
|
||||
const errs = _bannedLineErrors(src, 'game-logic.js');
|
||||
assert.ok(hasBanned(errs, 'Math\\.random'), `字符串内 // 不应吞掉后续真代码:${JSON.stringify(errs)}`);
|
||||
assert.ok(errs.some((e) => e.includes('第 3 行')), `Math.random 应报第 3 行:${JSON.stringify(errs)}`);
|
||||
});
|
||||
|
||||
test('⑤ 字符串内的 // 后接注释形态的文本零误杀', () => {
|
||||
// 双引号串含 //,且串内出现禁用 API 名(Date.now);既不该吞后续码、串内 API 名也不该误报。
|
||||
const src = [
|
||||
'export function createGame(){', // 1
|
||||
' const url = "sys://net/Date.now() 只是文案 // 提示";', // 2 串内 // + API 名
|
||||
' const t = ctx.time.nowMs();', // 3 合规
|
||||
' return { _forensicsView(){}, handleTap(){} };', // 4
|
||||
'}', // 5
|
||||
].join('\n');
|
||||
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)}`);
|
||||
});
|
||||
|
||||
102
game-runtime/tools/amodel-gen/tests/tsc-advisory.test.mjs
Normal file
102
game-runtime/tools/amodel-gen/tests/tsc-advisory.test.mjs
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* tsc-advisory.test.mjs — amodel-gen tscAdvisory() 类型门(W-AXIS 波3)防回归单测
|
||||
* owner:便宜档 W-AXIS 波3 | 跑法:node --test tools/amodel-gen/tests/tsc-advisory.test.mjs(cwd 任意)
|
||||
*
|
||||
* 【守的不变量】
|
||||
* ① advisory 有牙齿:真实类型 bug(把数字当函数调)必被抓到 findings。
|
||||
* ② 干净游戏零误伤:合法 game-logic(用 ctx.log/handleTap/_forensicsView 运行时契约)→ 0 findings。
|
||||
* ③ off 开关:CHEAP_TSC_GATE=off → available:false、findings 恒空(门可停)。
|
||||
* ④ 永不阻断 + 永不抛:任何输入 tscAdvisory 都返回对象、不抛(fail-open 只在本通道自身);
|
||||
* formatTscAdvisory 恒产字符串、含「不阻断」标记。
|
||||
* 本测直接建临时 game 目录读盘(tscAdvisory 经 gameDir(id) 取 src/),测后清理。
|
||||
*/
|
||||
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tscAdvisory, formatTscAdvisory, GAME_RUNTIME } from '../tools.mjs';
|
||||
|
||||
const GAMES_DIR = join(GAME_RUNTIME, 'games');
|
||||
const mkGame = (id, logic) => {
|
||||
const dir = join(GAMES_DIR, `amgen-${id}`, 'src');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'game-logic.js'), logic, 'utf8');
|
||||
return id;
|
||||
};
|
||||
const cleanup = (id) => rmSync(join(GAMES_DIR, `amgen-${id}`), { recursive: true, force: true });
|
||||
|
||||
// 合法游戏:用运行时契约成员(ctx.log 三参 / handleTap / _forensicsView)——.d.ts 已补声明,不应误报。
|
||||
const CLEAN = `'use strict';
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
const { sceneFsm } = plugins;
|
||||
let ticks = 0;
|
||||
return {
|
||||
init(boot) { this.ctx = boot.ctx; this.ctx.log('init', 'ok', { w: viewport.w }); },
|
||||
update(dt) { bundle.tick(dt); ticks += 1; },
|
||||
render(g) { g.fillRect(0, 0, viewport.w, viewport.h); },
|
||||
destroy() {},
|
||||
handleTap(x, y) { if (x > 0 && y > 0) sceneFsm.transition('play'); },
|
||||
_forensicsView() { return { state() { return { phase: sceneFsm.current(), score: ticks }; } }; },
|
||||
};
|
||||
}`;
|
||||
|
||||
// 带真实类型 bug:把数字当函数调(TS2349)——语法门/作用域门都放行,只有 tsc 抓得到。
|
||||
const BUGGY = `'use strict';
|
||||
export function createGame({ plugins, bundle, viewport }) {
|
||||
const cap = 5;
|
||||
cap();
|
||||
return {
|
||||
init(boot) { this.ctx = boot.ctx; },
|
||||
update(dt) { bundle.tick(dt); },
|
||||
render(g) {},
|
||||
destroy() {},
|
||||
handleTap(x, y) {},
|
||||
_forensicsView() { return { state() { return { phase: 'menu', score: 0 }; } }; },
|
||||
};
|
||||
}`;
|
||||
|
||||
const ids = [];
|
||||
const uid = (t) => { const id = `tsctest-${t}-${process.pid}`; ids.push(id); return id; };
|
||||
after(() => { for (const id of ids) cleanup(id); });
|
||||
|
||||
test('① advisory 有牙齿:真实类型 bug(数字当函数调)被抓到', () => {
|
||||
const id = mkGame(uid('bug'), BUGGY);
|
||||
const adv = tscAdvisory(id);
|
||||
// tsc 未装时降级(available:false)——本仓 devDep 已装,故应 available:true 且抓到 TS2349。
|
||||
if (!adv.available) { assert.equal(adv.findings.length, 0); return; }
|
||||
assert.ok(adv.findings.some((f) => f.code === 2349), `应抓到 TS2349,实得:${JSON.stringify(adv.findings)}`);
|
||||
});
|
||||
|
||||
test('② 干净游戏零误伤:合法运行时契约成员不误报', () => {
|
||||
const id = mkGame(uid('clean'), CLEAN);
|
||||
const adv = tscAdvisory(id);
|
||||
if (!adv.available) return; // 未装 tsc:跳过(降级路由 ③④ 守)
|
||||
assert.equal(adv.findings.length, 0, `干净游戏应 0 发现,实得:${JSON.stringify(adv.findings)}`);
|
||||
});
|
||||
|
||||
test('③ off 开关:CHEAP_TSC_GATE=off → available:false 且 findings 恒空', () => {
|
||||
const id = mkGame(uid('off'), BUGGY);
|
||||
const prev = process.env.CHEAP_TSC_GATE;
|
||||
process.env.CHEAP_TSC_GATE = 'off';
|
||||
try {
|
||||
const adv = tscAdvisory(id);
|
||||
assert.equal(adv.mode, 'off');
|
||||
assert.equal(adv.available, false);
|
||||
assert.equal(adv.findings.length, 0);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CHEAP_TSC_GATE; else process.env.CHEAP_TSC_GATE = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('④ 永不抛 + 永不阻断:缺 src 目录也返回对象;format 恒含「不阻断」', () => {
|
||||
const adv = tscAdvisory(`tsctest-missing-${process.pid}`); // 无此游戏目录
|
||||
assert.equal(typeof adv, 'object');
|
||||
assert.ok(Array.isArray(adv.findings));
|
||||
// formatTscAdvisory 对任意结果都产字符串、标记「不阻断」(BUGGY 结果也一样)。
|
||||
const idB = mkGame(uid('fmt'), BUGGY);
|
||||
const line = formatTscAdvisory(tscAdvisory(idB));
|
||||
assert.equal(typeof line, 'string');
|
||||
assert.ok(line.includes('不阻断'), `advisory 输出须标记「不阻断」,实得:${line}`);
|
||||
assert.ok(line.startsWith('[check:tsc-advisory]'), '须带独立节前缀');
|
||||
});
|
||||
@ -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));
|
||||
@ -135,15 +136,46 @@ const BANNED = [
|
||||
|
||||
/** 去注释 + 字符串字面量,避免红线/结构正则误伤文档注释里的示例(如注释里写「禁用 Math.random()」)。
|
||||
* 抹除时把内容换成【等量空格、保留换行】——结果串与原文行号一一对应(红线报错据此定位到行),
|
||||
* 且长度不变、不误匹配(空格非 \w、非 `.`,既有的 matchAll/结构正则语义不变)。 */
|
||||
function stripCode(s) {
|
||||
const blank = (m) => m.replace(/[^\n]/g, ' '); // 非换行→空格、换行留
|
||||
return s
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank) // 块注释(可跨行)
|
||||
.replace(/\/\/[^\n]*/g, blank) // 行注释
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, blank) // 单引号串
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, blank) // 双引号串
|
||||
.replace(/`(?:[^`\\]|\\.)*`/g, blank); // 模板串(可跨行)
|
||||
* 且长度不变、不误匹配(空格非 \w、非 `.`,既有的 matchAll/结构正则语义不变)。
|
||||
*
|
||||
* 【为什么必须单遍扫描,不能用多条独立正则】(2026-07-08 n=5 收敛环 trpg-cyber 坐实的 check 假阴性根治):
|
||||
* 旧版 5 条独立 .replace 先剥行注释、再剥字符串——但注释与字符串会互相嵌套:字符串里的 `//`
|
||||
* (赛博主题 '> JACK IN // FLOOR 01' 这类)会被行注释正则误当注释、吞到行尾(含闭合引号)→ 引号失衡
|
||||
* → 后续字符串正则从游离引号起跨行匹配 → 级联误抹真代码(实测抹掉 169 行,连 bundle.tick/_forensicsView/
|
||||
* handleTap 一起抹)→ check 误报红线方法"缺失"→ 模型明明写对了却被反复拒 → thrash 到步数顶熔断、烧钱失败。
|
||||
* 单遍状态机逐字符扫,同一时刻只处于「码/行注释/块注释/单引/双引/模板」一种态,嵌套的 `//`、引号各归其态、
|
||||
* 绝不越界——这是「注释与字符串互嵌」这类词法问题唯一正确的解法。逐字节保长、换行保留(行号对齐不变)。
|
||||
*/
|
||||
export function stripCode(s) {
|
||||
const out = [];
|
||||
const n = s.length;
|
||||
let state = 'code'; // code | line | block | sq(单引) | dq(双引) | tpl(模板)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = s[i];
|
||||
const c2 = i + 1 < n ? s[i + 1] : '';
|
||||
if (state === 'code') {
|
||||
if (c === '/' && c2 === '/') { state = 'line'; out.push(' '); i += 1; continue; }
|
||||
if (c === '/' && c2 === '*') { state = 'block'; out.push(' '); i += 1; continue; }
|
||||
if (c === "'") { state = 'sq'; out.push(' '); continue; }
|
||||
if (c === '"') { state = 'dq'; out.push(' '); continue; }
|
||||
if (c === '`') { state = 'tpl'; out.push(' '); continue; }
|
||||
out.push(c); continue; // 正常码逐字节保留(正则字面量按码处理,与旧版同,不新增误伤面)
|
||||
}
|
||||
if (state === 'line') { // 行注释:遇换行收尾
|
||||
if (c === '\n') { state = 'code'; out.push('\n'); continue; }
|
||||
out.push(' '); continue;
|
||||
}
|
||||
if (state === 'block') { // 块注释:遇 */ 收尾,换行保留
|
||||
if (c === '*' && c2 === '/') { state = 'code'; out.push(' '); i += 1; continue; }
|
||||
out.push(c === '\n' ? '\n' : ' '); continue;
|
||||
}
|
||||
// 字符串三态(sq/dq/tpl):转义跳一对、遇匹配闭合退回码、换行保留(模板可跨行;单双引跨行容错)
|
||||
const quote = state === 'sq' ? "'" : state === 'dq' ? '"' : '`';
|
||||
if (c === '\\') { out.push(' '); out.push(c2 === '\n' ? '\n' : (c2 ? ' ' : '')); i += 1; continue; }
|
||||
if (c === quote) { state = 'code'; out.push(' '); continue; }
|
||||
out.push(c === '\n' ? '\n' : ' '); continue;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** 插件键 → api.d.ts 目录(host-config 注入的 canonical 键名;特殊:juice/save/palettePost/physics 名≠dir)。 */
|
||||
@ -235,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);
|
||||
@ -259,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(语法);
|
||||
@ -292,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,勿改名/勿默认导出)');
|
||||
@ -306,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)) {
|
||||
@ -396,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`;
|
||||
@ -545,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);
|
||||
|
||||
@ -28,7 +28,12 @@ model:
|
||||
baseline_opus: claude-opus-4-8 # 强基线 Opus(网关现【无此渠道】、真调 503;待 new-api 开通后改这里即接上)
|
||||
baseline_fable: claude-fable-5 # 强基线 Fable(同上,网关现无;列出不假设可用,build_baseline_model 取不到诚实报错)
|
||||
baseline_crosscheck: deepseek-v4-pro # 跨族 cross-check(创始人 2026-06-24 拍:网关无 Opus/Fable,证路当前用它;实测可调)
|
||||
max_tokens: 16000 # 输出上限(非上下文窗;build_model 硬约束 max_tokens > thinking_budget)
|
||||
max_tokens: 512000 # 输出上限(非上下文窗;build_model 硬约束 max_tokens > thinking_budget)。
|
||||
# 2026-07-09 创始人拍「统一 512K(后面可调)」——把 output 上限抬到不再是瓶颈。
|
||||
# 依据:16K 是自设限非模型限(实测 M3 单轮能输出 17000 tokens/finish_reason=stop、
|
||||
# accepts 40K 与 512K 均无报错);cheap 走 OpenAI inline thinking(thinking 吃额度),
|
||||
# 低上限 + 大文件整写易撞截断丢方法/导出(heritage 黑屏、thrash 病根之一)。512K 彻底消除
|
||||
# 输出截断顾虑;模型 finish_reason=stop 自然收尾、不会因上限高而空转多花(thinking_budget 8K 仍封顶思考)。
|
||||
thinking_enable: true # M3 是否开 thinking 分离(便宜档无此约束,只 M3 路用)
|
||||
thinking_budget: 8000 # M3 thinking 预算(必须 < max_tokens,否则 build_model 抛)
|
||||
max_retries: 2 # 网关偶发 502 突发的兜底重试次数(wg1 spike 实证有效)
|
||||
@ -90,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