Compare commits

...

12 Commits

Author SHA1 Message Date
lili
85f48228cd feat(cheap-gen): W-AXIS 波3 知识层补强+收尾——tsc 顾问门+recipes+暗资产接线+n=5 基线台账
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
- tsc 顾问门:typescript devDependency + tsc-advisory 测试档;顾问性(永不阻断),
  金标 10 款零误伤抽验
- recipes/ 五篇高频场景索引(命中矩形/计时器/资产回退/场景机/结算演出,「抄这段形态
  +头号病根」)+ littlejs-game-dev §0.5 卡壳就查接线
- 暗资产接线:plugin-capability-map(11 注入+1 取证+6 储备逐件裁定)+
  engine-capabilities-brief(≤3KB 蒸馏)+ api.d.ts/game-host.d.ts 补插件指针;
  三方一致性对账门 plugin-surface-gate.py(含 cwd 锚定修)挂 pre-commit+Gitea Actions
- hard_genre_batch 参数化(五品类 n=5 批跑驱动,checkpoint 续跑)
- n=5 基线台账入库(gitignore 例外 add -f,只入台账不入游戏产物):
  wax-baseline.jsonl 25 局逐局记录(含判定仪器订正批注:cap1500 重判/图像盲 M3 重判,
  只加字段零改值)——终数:操作口径 14/25=56%、质量口径 18/25=72%;
  品类 narrative/heritage 5/5、trpg 4/5、puzzle/sim-business 2/5(缺口逐局有名有姓,
  根因见 07-10 v2 plan §1);旧「80%/39%」口径正式作废存照

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00
lili
882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00
lili
dc89701fcc feat(cheap-gen): W-AXIS 波1 真相层——turns 全文落盘+per-run 归档+evidence 清单化+failureLayer
治「真相层缺失」病根(历史上模型文本/工具返回/门反馈零落盘、失败 run 被同 gid 重跑
覆盖,归因只能建在聚合数字上):

- cheap_turns_sink.py:逐 turn 全文落 amgen-<gid>/turns.jsonl(模型文本/工具全参/
  工具返回/门续修反馈);observe-only middleware、best-effort,接线失败绝不阻断生成;
  CLI 与 Service 双路接线(cheap_run/cheap_service_app)
- per-run 归档:同 gid 重跑前工程整体归档 _amgen-archive/<gid>-<ts>/,失败现场不再被覆盖
- evidence 清单化清理:按白名单保留,跨 run 残留(旧截图/旧 verdict)开跑即清,防证据串局
- failureLayer 三层归因:run-summary 落 generation/gate/gameplay 失败层字段,
  聚合报表可分「生成没跑完/机械门挂/玩法层拒」

主会话亲验:两局真跑归档 179KB、turns 含门反馈全文;pytest 新增三个测试档全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
5d0f351050 docs(cheap-gen): W-AXIS 波0 文档批——诊断档+SoT×4 修订+两份 plan+策展层勘误
2026-07-09 创始人方向性质疑经五路审计坐实,正式推翻两个历史结论(80% 天花板=口径混淆
+stripCode 污染;玩法坏死主因=判卷合约缺口误标),病根三条=判定语义膨胀/判卷合约反噬/
真相层缺失。本批为纯文档批:

- 诊断档 docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md(人审版)
- SoT 修订×4(双评审修入、docs-gate 绿):质量模型裁定三(L1 双证据=机械预筛∧独立模型
  玩法判定,fail-closed+金标校准)/图说护城河改「分层验收」/验收门 §2.4/AGENTS.md §3.1
- 执行版 plan×2:07-09 三波换轴 + 07-10 验收v2(测试agent替E/G/H,创始人已批,
  含附录A SoT 修订逐字终文与波0-3 工单拆分)
- 策展层定点勘误:tech-decisions/三份生成线 skill 追加 2026-07-09 勘误注记(过九门
  自此只算机械预筛),feature-design-doc 固化「人审版=brainstorm/SoT、执行版=plan」定位
- 在飞板登记 W-AXIS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
1c4d5838ec fix(cheap-gen): 撤写锁·全放开 + max_tokens 512K——创始人纠偏 + 浏览器验收坐实
创始人 2026-07-08/09 两次纠偏,各揪出一个写锁法漏的真根因:
① 浏览器验收:写锁 core.js 致「榫卯」brief 出「陶艺」游戏(主题漂)——规范应是文件位置
   + 绝对通用 plumbing(L1_FIXED 已锁 host-config/game/index/entry/main),不该把模型限死到
   只改几个文件(只改几个文件游戏做不好玩、切不中题)。→ 全撤 write_whitelist,game-logic/
   core/render/assets 全放开,模型自由做好玩切题游戏;截断类失败靠 harness 门兜不靠锁。
② max_tokens:实测 M3 单轮能输出 17000 tokens/finish_reason=stop、accepts 40K/512K 无报错
   ——16K 是自设限非模型限;cheap 走 OpenAI inline thinking(thinking 吃 output 额度)致大文件
   整写截断(heritage 黑屏/thrash 病根之一)。→ genconfig max_tokens 16K→512K(统一上限,可调)。

浏览器验收 it8(不锁+stripCode修复+足量token):heritage=榫卯工坊·直榫凳·选料→画线→凿卯→
修整(切题修好);action=撞了得分535·1771米·翻转30(坏死修好);trpg=已清9层3次升级(死锁没了+
内容更全);全 1-2 attempts 无 thrash。残余 idle-sim「↑NaN」(render.js:164 upgradeCost=NaN,
模型数值 bug,待修)。build_reskin_system_prompt 撤锁后未用(留待清理)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
8f2ca254ad docs(agents): §6.13 硬约束——工具输出只是参考不是事实,最终产物必须真浏览器验收
创始人 2026-07-08 定:任何工具/harness 判定(check.ok/九门 verdict/smoke/lint/bright 等)
只作参考、绝不当事实——会假阳也会假阴。当日 stripCode 遇字符串内 // 致 check 假阴性误报红线
缺失(模型写对却被反复拒到熔断)、九门也放行过秒死/存档刷分坏死游戏,双向坐实。故:每个最终
产物必须真浏览器打开、亲看亲玩、截图硬证验收;失败根因下钻到具体代码/数据/配置,不停在「工具说」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
69307df8a5 fix(cheap-gen): 修 check 假阴性——stripCode 字符串内 // 致级联误抹真代码
n=5 跨主题验证 trpg-cyber 失败根因坐实:赛博主题文案用 '> JACK IN // FLOOR 01' 这类
字符串内含 //。旧 stripCode 用 5 条独立正则(先剥行注释、再剥字符串),字符串里的 // 被
行注释正则误当注释、吞掉闭合引号 → 引号失衡 → 后续字符串正则跨行匹配 → 级联误抹 169 行真
代码(bundle.tick/_forensicsView/handleTap 一起抹)→ check 误报三红线缺失 → 模型明明写对
却被反复拒 → thrash 到步数硬顶熔断、烧 ¥13.84 失败。

根治:stripCode 改单遍左→右状态机(码/行注释/块注释/单引/双引/模板 六态),同一时刻只一种态、
嵌套的 // 与引号各归其态绝不越界——「注释与字符串互嵌」词法问题唯一正确解。逐字节保长、换行
保留(红线行号定位不变)。影响面广:任何 game-logic 字符串含 //(URL/路径/N//M/科技赛博文案)
都触发假阴性,部分历史失败率实为此 bug 冒充模型质量差。

验证:trpg-cyber check.ok false→true;heritage/narrative 无回归;amodel-gen 35/35 绿
(check-banned 加 2 条 //-in-string 回归守卫)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
c8a15db349 feat(cheap-gen): 轻A 参数化收敛——create 路 per-genre 换皮写锁,模型只写 game-logic
n=5 收敛环第一性坐实便宜档失败主根:模型被放任重写引擎文件(render/core),大文件
整写截断丢导出→黑屏(heritage 坏死)、自造楼层推进死锁(trpg)。根治=生成面收窄:
create 路 route 命中 per-genre 黄金模板时,write_whitelist 物理锁 render/core,模型
只据 brief 换皮 game-logic.js(+assets.js)。机制早存在(write_whitelist 只用于
modify、6 个 per-genre 完整可玩模板已建),只差 create 接线。

- cheap_roles: build_reskin_system_prompt(引擎锁死、只写 game-logic 语言,镜像
  build_modify)+ SCAFFOLD_DESC_BY_TEMPLATE(5 品类换皮描述)
- cheap_studio: run_studio 据 write_whitelist 自选换皮/通用提示
- cheap_service_driver / hard_genre_batch: create 路 route 命中即写锁 game-logic+assets

it5 验证:heritage bright 0→178812、phase menu→play(真修好);trpg H_progress
floor→6 登顶(死锁消失,生成码天赋分支补了 dealFloor);写锁逐字节生效(render/core
与模板相同);未锁对照组(idle/action,无品类模板)仍坏死=证明是锁起效。
route_genre 覆盖 4 类,idle-sim/action-physics 无模板=已知覆盖缺口(round-2)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
824b4eaba3 fix(cheap-gen): 重同步 cheap-system.md 正文——补 edit_file 提示欠账
热取源 04-config/cheap-system.md 停留在「六工具/没有 edit_file/改文件用 write_file
整体覆盖」,而内联 _SYSTEM_PROMPT 早已加 edit_file(七工具/小改优先 edit_file)。生产
热取到 stale 提示 → 逼模型整文件重写 → 大文件截断(便宜档死圈病根之一)。按「.md 正文
= _SYSTEM_PROMPT 逐字节对齐」铁律重同步正文,registry.yaml + .md frontmatter version
双升 1.6.1→1.6.2;test_roles hotload 不变量 2 测试转绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
9bd428040f fix(cheap-gen): 死圈修复 fix①-B 重实——改挂 resume 续修层(on_acting 版无效)
n=5 收敛环 iteration 3 坐实:上一版 fix①-B(on_acting 拦截畸形参数)日志 0 触发=无效。
源码核实根因:AgentScope _agent.py:1376 jsonschema.validate 失败 → _handle_error_tool_call
→ return(Step1),在 Step3 工具执行/on_acting 钩子之前,故畸形调用【根本不进 on_acting】,
上一版是死代码(单测 mock next_handler 吐错误、但线上错误不经它)。已 revert。

重实挂到真能触达 agent 的层:cheap_studio resume 续修循环 wrap writer.reply,catch
Tier2CircuitBreak——【仅 missing-param 类 stuck】(reason 含 'is a required property'=MiniMax 漏
必填 path 撞满阈值)不当终态杀,还有 resume 预算 + 未越 ¥ 软停就带贴脸补参反馈续修(与 gate-fail
resume 同机制、跨 reply memory 保留=原地续修)。只清 stuck 连击计数(_fail_repeat/_last_fail_sig,
不动 spent_rmb/tool_calls 防误清预算);非漏参 stuck(反复 check 红线/build 编译错=真死圈)/budget/
其它熔断照旧向上抛终态、不放行。整环验证随 n=5 re-run(iteration 4)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
lili
e4e4e23564 Revert "fix(cheap-gen): 死圈修复 fix①-B 便宜档畸形参数纠偏——n=5 收敛环坐实 heritage 死圈复现"
This reverts commit 26f94f1f0b9137b342c0d37c1f7583d0f03e4369.
2026-07-10 04:27:17 -07:00
lili
3c0c105aeb fix(cheap-gen): 死圈修复 fix①-B 便宜档畸形参数纠偏——n=5 收敛环坐实 heritage 死圈复现
n=5 收敛 loop iteration 2(难品类)复现死圈:heritage 局 stuck 终杀,签名=连续 4 次
write_file:'path' is a required property——MiniMax 长参数下漏 write_file 必填 path,agent
收默认校验错不理会、反复漏同参 → 撞满 stuck 阈值(4)→ 九门从未跑 ok=False。fix①-A 给了
edit 替代但没阻止 agent 选 write_file 漏 path,故 fix①-B(此前押"证据门")现证据到、实现。

修:CircuitBreakerMiddleware.on_acting 非软停态给「漏必填参」的 write 类工具结果追加一条比默认
校验错更贴脸的补参提示(催补 path / 改 edit_file 短载荷),让 agent 撞满 stuck 前出圈。
经既有 _augment_toolresponse(<system-reminder> 追加,agent 下轮读得到)+ on_acting 洋葱钩子,
符合设计不自造机制。cheap-only(enable_malformed_correction,cheap_budget 工厂置 True;tier2
默认 False、逐字节零行为变化);不重置 stuck 计数(仍漏照常熔断,上界由现有阈值封死、不新增无界重试)。

测:test_malformed_correction 4(cheap追加/tier2零变化/非漏参不追加/非write工具不进)+ tier2
回归 test_circuit_stuck_signature 5 + test_repair_middleware 5 = 14 passed。整环验证随 n=5 re-run。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:27:17 -07:00
62 changed files with 4705 additions and 90 deletions

View File

@ -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 + 增量窗口锚点 |
---

View File

@ -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

View File

@ -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/Pythonshell-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/Pythonshell-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《游戏质量与爆火能力》裁定三。

View File

@ -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 起点后 xthemeit5-lockcorer1 三批各 5/5、模型未变过门率随工具移动不随模型「多为 E/G/H 玩法坏死」大半是误标真因多为判卷驱动器合约缺口——进不去菜单认不出终局score 键没对上,小补丁即过,真坏死是少数且已被 per-genre scaffold 修掉故「80% 缺口=玩法正确性」不成立。诚实边界:只能判 80% 不是 MiniMax 天花板,不能反推已稳定 95%+。见质量 SoT《游戏质量与爆火能力》裁定三与同日诊断档 `docs/brainstorms/2026-07-09-生成线harness方向性诊断与换轴方向.md` §2.5。)

View 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 后处理注入引擎里有,但**非本档范围**,别用。

View File

@ -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** 给人和 AIAI 主靠它),**SVG / HTML** 给人(更重要,看图即懂边界与核心思想)。
## 何时用

View File

@ -55,7 +55,7 @@ node_type: skill
## 达标门口径:质量地板 vs 生产真实交付率(M1 U3 实证)
对照验证比的是「两路等价」;达标门(bake-off)换一个问题——「这条路生成出来的游戏,按品类稳不稳定够格上线」,按品类聚合九门过门率、逐品类 ≥80% 判。建这道门时,**算过门率的分母怎么定**是个会直接翻转结论的口径决策。
对照验证比的是「两路等价」;达标门(bake-off)换一个问题——「这条路生成出来的游戏,按品类稳不稳定够格上线」,按品类聚合九门过门率、逐品类 ≥80% 判。建这道门时,**算过门率的分母怎么定**是个会直接翻转结论的口径决策。2026-07-09 勘误:「按品类九门过门率 ≥80% 判达标」自此只度量机械预筛地板——九门 pass 降为「未见明显死」的预筛够不够格上线还须过独立模型玩法判定达标口径随之收窄下文分母口径收敛款为分母、rawPassRate 并报〕本身不受影响。见质量 SoT《游戏质量与爆火能力》裁定三。
一次生成会以两种性质完全不同的方式「不过」:

View File

@ -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`

View 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 会拦「注入了但白名单漏列」「白名单列了但没注入」「新插件目录没裁定」。

View 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

View 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**文件名没变 → 代码一行不用动**。

View 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:'开始'})——同源即可
},
});
// 输入:只写实例方法 handleTapL1 在 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)`

View 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 过的名。

View 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 驻留判终态可达)。

View 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 实则永不推进)。

View 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_DIRcheck API 静态门据它把 plugins.<>.<方法> 映到 api.d.ts 查方法名
四者任一漂移都会出真事故:白名单列了但没注入 模型写 plugins.x 运行时 undefinedboot ;注入了但白名单没列
模型不知道有这件能力白摆(诊断档 §四⑦点名的暗资产);目录新增一件却没人裁定它该注入还是入储备 悄悄成
第七个暗插件本门把这三方 + 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())

View File

@ -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

View File

@ -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)。

View File

@ -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. 知识累积机制(同样是硬约束)

View File

@ -57,13 +57,13 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
先设计后写码决定这游戏好不好玩关键别跳过
能跑 好玩动手写码前先据 brief **game-logic.js 顶部写一段设计注释块**8 判定句三个空填好 + 核心数值锚 + 的三段难度定下**轻量玩法设计**再实现设计只在心里 = 收尾自查与验收都无从对照
- **核心循环好玩第一因 · 必含一层玩家决策或技巧**写清玩家每次操作做**什么决策** 得什么即时反馈 怎么变强**铁律核心操作绝不能是点了自动结算的无脑点击**必须让真人有发挥三选一起步**匹配**顾客点指定菜上对的才给分/给多分**时机**在对的时刻操作 = 更多奖励**取舍/连击**连续做对叠 combo资源有限要权衡自检把玩家换成闭眼乱点若分数照样拿满这循环就是无趣的重设计
- **决策层与自动验收解耦工程现实别绕开**九门自动验收会盲点 `occupied:true` 目标 score 所以做成**两层奖励****基础分**任意有效操作都加一点保盲驱动器能跑通过门+ **技巧分**叠在玩家的匹配/时机/连击上真人靠它玩出爽感盲驱动器拿基础分过门真人靠技巧拿高分两不耽误**别为让驱动器满分把玩法做成无脑点也别不做对就零分让驱动器过不了门**
- **两层反馈基础反馈保上手技巧分给深度好玩的设计惯例****基础反馈**任意有效操作都给一点即时回应分数/音效/飘字让新手一上手就有正反馈不至于无所适从**技巧分**叠在玩家的匹配/时机/连击上做对越多越准回报越高让熟练玩家玩出掌控感与爽感两层叠着来随便玩也有反馈不劝退新手玩得好有额外回报给足深度**别把玩法做成怎么点都一样的无脑点那样技巧分名存实亡也别不做对就颗粒无收那样没有上手缓冲劝退新手**
- **资源环**经营/放置类必含进货 库存 售卖收钱 缺货补货的软币循环制造再赚的张力
- **34 级解锁阶梯**攒够阈值解锁新商品/区域/能力任意时刻都露出下一个锁
- **数值成长**产出/成本随级上升略带滚雪球感别平淡线性
- **音效清单**收钱/ 升级欢呼 / 解锁号角 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 个资源环进货+ 34 级解锁 + 基础数值/音效**商品 3 种起步****离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标后续·MVP 不做**别塞进首版hitstop/震屏**不在**禁项它们是单行 juice 调用不是系统结算/暴击/大额成单时刻标配一次 hitStop + shake好手感不算堆料**先出能玩的核心再谈丰富** **但钉死可玩核心= 一个有决策深度的核心机制做透不是一个浅机制 + 一堆 meta 元素解锁/升级/飘字要砍的是商品数 / 附加系统 / 离线雇员绝不砍核心循环那一层玩家决策见上核心循环铁律那是 MVP 的心脏砍了就只剩无趣骨架**
@ -91,8 +91,8 @@ A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4
- 判据 = 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.js4
# 让 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 绝对通用 plumbinggame-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 + 结局图鉴,全链通过九门)",
}
# ── C2acheap_roles 运行时热取配置 ─────────────────────────────────────────────
# 把 _SYSTEM_PROMPT 从「硬编码字符串」改成「运行时从 contracts/prompts 读 + mtime 缓存 + 回落内置」。

View File

@ -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/};两目录皆不存在(首跑) 返回 Noneno-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)}

View File

@ -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":

View File

@ -177,11 +177,15 @@ def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0:
"""
import cheap_run # noqa: PLC0415
import cheap_studio # noqa: PLC0415
from worker.gate_judge import judge_cheap_verdict # noqa: PLC0415
from worker.gate_judge import classify_cheap_failure_layer, judge_cheap_verdict # noqa: PLC0415
# C6 接线:带 game_id/staged 目录判(驱动器族/日志摘要口径与 Service 续修一致;此处只为 summary 归一,不再落 sidecar 也无妨——staged_dir 传入使口径同源)。
j = judge_cheap_verdict(verdict or {}, game_id=game_id,
staged_dir=cheap_run.wg1_game_dir(game_id))
# F0-归因分层(W-AXIS 波1):把九门失败归到三层(driver_contract/mechanical/gameplay)进 run-summary,
# 供 result_out/批次账消费与回喂措辞对齐真实层次;放行(passed)/未失败时 layer=none。
failure_layer = classify_cheap_failure_layer(verdict or {},
staged_dir=cheap_run.wg1_game_dir(game_id))
vb = cheap_studio._verdict_brief(verdict) if verdict else None
svc = svc or {}
repairs = svc.get("repairs")
@ -205,6 +209,8 @@ def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0:
}
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
# F0-归因分层:失败三层归因随 summary 透传(additive;成功/未失败为 layer=none,不误标坏死)。
summary["failureLayer"] = failure_layer
# WU2 §3.9:Service 侧在模型调用抛错时(new-api 非 2xx)按 one-api 惯例分辨,把 failureReason 写进收口 sidecar;
# 这里原样透传进 summary,result_out._map_failure_reason 据它把额度耗尽落成 quota_exhausted(而非含糊 llm_error)。
# 仅当 Service 明确判额度耗尽(quota_exhausted)才透传,凭据失效/其他仍走 llm_error(可重试),避免误标。
@ -249,7 +255,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
import cheap_otlp_sink # noqa: PLC0415 —— 面四三跳传播:取当前 context 的 W3C carrier(顶层零重依赖)
import cheap_run # noqa: PLC0415
import cheap_verify # noqa: PLC0415
from cheap_roles import build_system_prompt # 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)

View File

@ -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 promptscaffold_desc 引导起点,不写锁、模型自由改全部游戏文件);
# modify 路由调用方显式传 system_prompt=build_modify_system_promptwrite_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 …")
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")

View File

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

View File

@ -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 Tempoturns.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) -> NoneJSONL 追加写每条一行,写失败只告警绝不抛(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

View File

@ -17,7 +17,9 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent非阻塞·只报
"""
import asyncio
import base64
import json
import time
from pathlib import Path
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
@ -393,6 +395,567 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
return _degraded(f"judge 异常:{type(e).__name__}: {e}")
# ══════════════════════════════════════════════════════════════════════════════
# 玩法地板判定(W-AXIS 波2 · 质量模型 SoT 裁定三的代码兑现)
# ──────────────────────────────────────────────────────────────────────────────
# 与上面的丰富度评分是两物,别混:
# · 丰富度(verify_richness):评「有多丰富/多耐玩」的【程度】——非阻塞软信号,只观测、永不拒发(L2)。
# · 玩法地板(judge_gameplay_floor):裁「作为游戏成不成立」的【地板布尔】——阻断放行(L1 的判定半)。
# 裁定三钉死的分界:判定只裁三类【拒绝】的有无,不判程度高低:
# · broken —— 真人玩不通 / 到不了终局 / 起不来 / 卡死一屏 / 核心操作无响应;
# · hollow —— 空壳:核心循环没有决策层(点哪都一样、无输赢取舍、纯自动结算的挂机点击),判「有没有」不判「多好」;
# · off_brief —— 不切题:画面/玩法与 brief 要的游戏是两回事。
# 三条铁律(裁定三):
# ① 出题≠被考(2026-07-10 创始人修正边界):判定只吃运行证据不吃自报;判定模型允许 M3(生成同源)——
# 地板只裁「有无」不裁品味、同源盲区风险最小,且 07-09 spike 实证 M3 当场抓出 M3 自己写的渲染 bug;
# 同源残余风险由金标正反例+创始人抽玩定标兜底。修正动因=glm-5.2 唯一通道(闲鱼二手中转)图像支持
# 按池轮换、07-09 整日全盲,判定层可用性不能押注二手供给。SoT 修订随验收 v2 双评审落档。
# ② fail-closed:判定失败/评不出/无证据 → 不放行、标 degraded(绝不静默降级成自动通过)。
# ③ 校准与阻断开关:金标正反例复验 + 创始人抽玩定标;judge.blocking 开关默认 true,可一键整体回退到修订前口径。
# 判定模型走 new-api:生产档由 generation.yaml judge.model 定(现 MiniMax-M3,一手直连通道);
# 单局判定成本目标 ≤¥0.3(实测 4-6 帧约 ¥0.10,记账进 judge.costRmb)。
# 判定默认档(genconfig 不可达时的编译回落,生产以 YAML 为准):2026-07-10 随创始人决策切 M3。
# 07-09 探针实证 glm-5.2 与 MiniMax-M3 均能读图;glm-5.2 因二手供给整日全盲弃用。
_JUDGE_DEFAULT_MODEL = "MiniMax-M3"
# 三类拒绝的机读键 → 对外类名(off_brief 用下划线,与 LLM 输出的 camelCase offBrief 解耦)。
_FLOOR_CLASSES = (("broken", "broken"), ("hollow", "hollow"), ("offBrief", "off_brief"))
_FLOOR_SYSTEM = (
"你是轻量小游戏的【玩法地板判定 agent】。你不是打分器——只判「这款游戏作为一个游戏,地板成不成立」,"
"只裁【有没有】、绝不裁【做得多好】(丰富/耐玩/好玩的程度问题不归你,归另一套软评分)。"
"你只看运行证据(真玩截图 / 运行日志 / 取证状态时间线),绝不采信任何自报、承诺或源码里写了什么。"
"逐条判这三类【拒绝】是否成立:\n"
"· broken(坏死):真人根本玩不通 —— 起不来 / 一直卡在同一屏 / 核心操作点了没反应 / 到不了任何终局或结算。\n"
"· hollow(空壳):核心循环没有决策层 —— 点哪都一样、没有输赢、没有取舍、判错没有代价,纯自动结算的挂机点击。"
"只判「有没有决策层」,不判「决策做得多有层次」。\n"
"· off_brief(不切题):画面与玩法跟 brief 要的游戏是两回事(主题漂移 / 品类不符)。\n"
"任一类成立 → 整体判 reject;三类都不成立 → 判 accept。"
"证据不足以断定某一类是否成立时,该类按【成立(有问题)】算——地板判定 fail-closed,宁可错拦、不可放过坏游戏"
"(后面有人工复核与金标校准兜)。严格只输出 JSON,不要任何额外文字、不要 markdown 围栏。"
)
_FLOOR_OUTPUT_CONTRACT = (
"严格只输出以下 JSON(problem=true 表示「这一类有问题、应拒绝」,why 给一句中文依据;"
"imagesSeen=你在本条消息里【真实看到】的截图张数,整数——一张都看不到就如实填 0,"
"这个字段用于检测图像传输故障,绝不据它奖惩你):\n"
'{"imagesSeen":0,"broken":{"problem":false,"why":""},"hollow":{"problem":false,"why":""},'
'"offBrief":{"problem":false,"why":""},"verdict":"accept","notes":"整体一句话"}'
)
def _degraded_floor(reason: str) -> dict:
"""fail-closed 降级结果(裁定三②):评不出/无证据/调用失败 → 不放行、标 degraded。
accepted=False 地板判定的 fail-closed 语义,与丰富度的 score=None(非阻塞观测)完全不同:
这里 degraded 直接构成拒绝(rejectClasses=['degraded']),阻断放行,由人工复核兜
"""
return {"accepted": False, "verdict": "reject", "rejectClasses": ["degraded"],
"checks": [], "notes": None, "degraded": True, "reason": reason}
def parse_floor_judgment(text) -> dict:
"""纯函数:把判定 LLM 的 JSON 输出解析成结构化地板裁决(含 fail-closed 兜底)。可单测、零网络、零 agentscope。
成功 {
accepted: bool, # = 三类均无问题 且 LLM verdict 未判 reject(两者取交,任一拒即拒)
verdict: "accept"|"reject",
rejectClasses: [str], # 命中的拒绝类子集(broken/hollow/off_brief)
checks: [{class, problem, why}*3],
notes: str, degraded: False}
解析失败 / 非对象 / 缺任一类判定字段 _degraded_floor(...)(accepted=Falsefail-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) Nonejudge 段记 tokenscostRmb 留空,不伪造成本
"""
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: 本局 briefoff_brief 判定必需
model: 可注入的判定 LLM 客户端单测用 fake零网络None glm-5.2 多模态客户端
frames/state_text/log_text: 可直接注入的证据单测用绕文件 I/ONone 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-playfail-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}sfail-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=finishedverdict.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 缺省 Noneblocking genconfigevidence_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-closedblocking→不放行,observe 模式保预筛。
blk = True if blocking is None else bool(blocking)
try:
blk = _judge_cfg()["blocking"] if blocking is None else bool(blocking)
except Exception: # noqa: BLE001
pass
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
summary["judge"] = {"ran": False, "blocking": blk, "degraded": True,
"reason": f"判定接线异常fail-closed{type(e).__name__}: {e}"}
summary["accepted"] = False if blk else prefilter
summary["ok"] = False if blk else prefilter
try: # 接线异常路也尽力对齐盘上证据(ev 推导自身可能失败,再兜一层)
_writeback_verdict_json(
evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id),
summary["accepted"], summary["judge"])
except Exception: # noqa: BLE001
pass
return summary
if __name__ == "__main__":
# 便捷自跑python cheap-worker/cheap_verify.py <gameId> ["brief"] —— 对已有产物真跑一次评分。
import json

View File

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

View File

@ -0,0 +1,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 与生产同读 genconfigmax_tokens 单一源)
# max_tokens 与生产 create 路cheap_service_driver同源 genconfig.model.max_tokens2026-07-09 调 32K16K 是自设限、
# 实测 M3 单轮能输出 17000+ tokensinline 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())

View 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.pyNO_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())

View File

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

View File

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

View File

@ -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
}
]

View 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"}}

View 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"]))

View 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"]))

View File

@ -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 档软目标,不再是硬地板)"

View File

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

View File

@ -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"]))

View File

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

View File

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

View File

@ -1,6 +1,6 @@
---
id: config.cheap-system
version: 1.6.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 涨,所以做成**两层奖励**——**基础分**(任意有效操作都加一点,保盲驱动器能跑通过门)+ **技巧分**(叠在玩家的匹配/时机/连击上,真人靠它玩出爽感)。盲驱动器拿基础分过门、真人靠技巧拿高分,两不耽误。**别为「让驱动器满分」把玩法做成无脑点;也别「不做对就零分」让驱动器过不了门。**
- **两层反馈:基础反馈保上手、技巧分给深度(好玩的设计惯例)****基础反馈**——任意有效操作都给一点即时回应(分数/音效/飘字),让新手一上手就有正反馈、不至于无所适从;**技巧分**——叠在玩家的匹配/时机/连击上,做对越多越准回报越高,让熟练玩家玩出掌控感与爽感。两层叠着来:随便玩也有反馈、不劝退新手,玩得好有额外回报、给足深度。**别把玩法做成「怎么点都一样」的无脑点(那样技巧分名存实亡);也别「不做对就颗粒无收」(那样没有上手缓冲、劝退新手)。**
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **34 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 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 个资源环(进货)+ 34 级解锁 + 基础数值/音效,**商品 ≤3 种起步**。**离线收益 / 看广告位 / 雇员 / 多档 BGM / 6+ 商品 一律标「后续·MVP 不做」**、别塞进首版hitstop/震屏**不在**禁项——它们是单行 juice 调用不是系统:结算/暴击/大额成单时刻标配一次 hitStop + 轻 shake好手感不算堆料。**先出能玩的核心,再谈丰富。** **但钉死:「可玩核心」= 一个有决策深度的核心机制做透,不是一个浅机制 + 一堆 meta 元素(解锁/升级/飘字)。要砍的是商品数 / 附加系统 / 离线雇员;绝不砍核心循环那一层玩家决策(见上「核心循环」铁律)——那是 MVP 的心脏,砍了就只剩无趣骨架。**
@ -61,6 +61,7 @@ A 读手册1 + 按品类 5/6/7/8/9+ 读你的起点 game-logic.js4
- 判据 = 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。

View File

@ -176,7 +176,7 @@ prompts:
# 首版正文 = _SYSTEM_PROMPT 内置原文逐字节对齐(含 ⟦G⟧/⟦SCAFFOLD_DESC⟧ 占位符),默认行为字节不变;
# 调一条 prompt = 改 04-config/cheap-system.md 正文 + 升 version下次生成自动生效。
- id: config.cheap-system
version: 1.6.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.0W-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.0W-GENRE 品类路由并集(剧情+6/TRPG+7/非遗+8/解谜+9
stage: "04-config"
owner: WS2
file: 04-config/cheap-system.md

View File

@ -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 尾(**S0S6 证据侧全收口 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 后从本板移除。

View File

@ -161,7 +161,7 @@ flowchart TB
![图 8 端到端跨域全链泳道](assets/07-端到端全链泳道.svg)
这张图是整本图集里**最该第一个读**的一张:它把前面那些分门别类的领域图,拼成了一条从「创作者敲下一句话」一直跑到「数据回流让 feed 重排」的完整系统链。它用八条横向泳道——创作者、game-studio、aigc + SAA、compliance、project + feed、玩家、telemetry、ad + trade——各占一条道,让你能顺着一个编号(①到⑮,外加钱流的 Ⓐ 到 Ⓓ)从头走到尾,看清每一步落在哪个域、交给谁、产出什么。主链是这样跑的:创作者一句话(①)经 studio 提交(②),aigc 建任务(③)进入 SAA 十六节点编排(④),九门验收通过后 emit 出包(⑤)回写成 project 草稿(⑥);创作者提交发布(⑦)触发 compliance 锁风门裁决(⑧),只有 pass 才让游戏 PUBLISHED 入 feed(⑨);玩家在竖屏游戏流里刷到(⑩)、即点即玩并互动(⑪);这些行为经埋点上报进 telemetry 聚合(⑬)、算出 0100 的质量分(⑭),再把 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 聚合(⑬)、算出 0100 的质量分(⑭),再把 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 收敛环),产品化排期待定;它**不在这条现行链上跑同一条装载路**,换的只是「游戏内容怎么造出来」,造出来之后仍旧汇进同一条上线与发行的公共件。把这些边界一眼标清,正是为了让据图做评审的人,既能顺着主链确认闭环跑得通,又不会把桩误当成已经建好的现行。

View File

@ -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 传播钩子)**共用 L1L3 字母、但轴完全不同**——后者按**质量维度**分层。两者别对齐字母:三层校验的 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 第二段)、偏脆的文本契约门做成结构化校验。
![图 D1 · 三层校验全景](assets/t2-D-01-三层校验全景.svg)
@ -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 视觉软检永远只观察、绝不放行也绝不拒发。

View File

@ -9,6 +9,8 @@ date: 2026-07-02
![四层质量塔](assets/quality-01-四层质量塔.svg)
> **生效状态**:本模型经 Codex + Opus 双评审后,由创始人 2026-07-02 以决策包⑦一次拍定生效——四层定义与权力分配、档位观测线、rubric 计分制、平衡门判法五裁定、「收窄 AE」历史化、富三门档位适用与断言分界、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 第二条;**L2L4 分数一律非阻塞的纪律不变**——升级的是 L1 自身的证据,不是把丰富度分数变成门。
质量不是一个分,是四层递进的塔:机器门保「能玩」(L1,质量分层内唯一拒发权),LLM 评「有料」(L2),结构要求保「想再玩」(L3)与「想传出去」(L4)的前提;玩家真实数据是 L3/L4 的最终裁判,并回流校准 L2L4 评分与 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 分;运营 0100 | 观测;真数据经 quality_score 影响分发 | feed 排序、数据飞轮 |
| **L4 传播钩子** | 想让别人看的结构前提在吗 | 生成时 rubric 传播组;上线后 share/remix 漏斗 | rubric 分;漏斗率 | 观测;爆款筛选归运营 | remix 网络效应、爆款签约(P-IPX-06) |
层间纪律三条,是整个模型的权力宪法。
**质量侧的发布权只在 L1。** 一款游戏能不能进 feed,质量分层内由确定性证据说了算,L2L4 任何分数都不拦发布。这是既有防 Goodhart 红线(效果层「绝不放行也绝不拒发」)在整个模型上的推广:LLM 评分与结构评分都是软信号,软信号一旦拿到拒发权,评分尺立刻变成被优化的目标。发布链上另有治理拒发权(GP9 合规、D12 控制平面),在本模型之外、不受本档约束。
**质量侧的发布权只在 L1。** 一款游戏能不能进 feed,质量分层内由 L1 说了算,L2L4 任何分数都不拦发布。L1 自身的证据构成 2026-07-09 起为双份:机械真玩预筛是地板(挡明显死:起不来、抛异常、不渲染、不掌帧),独立模型对真玩证据的玩法判定是裁决(能玩到终局吗、核心操作有真决策吗、切 brief 的题吗)。这样改的原因写在裁定三:机械断言曾被当成「可玩」的完整证据,实测它只证得了「有东西在涨」,秒死、刷分、死锁、永不结束的游戏都能空心过门——把发布权全押在它上面,恰好造出了另一种 Goodhart(游戏围着盲驱动器的盲区设计)。原防 Goodhart 红线本身不动:LLM 的丰富度/留存/传播**评分**仍是软信号、永不拿拒发权;玩法判定不是评分——它裁的是**地板的有无**(玩得通吗/有决策层吗/切题吗),不是**程度的高低**(丰富、耐玩、好玩到什么程度——那些永远归 L2 软信号),与机械预筛同属 L1,受金标校准与人工抽检约束。发布链上另有治理拒发权(GP9 合规、D12 控制平面),在本模型之外、不受本档约束。
**推荐权在真数据。** 玩家行为聚合成运营质量分(0100)回灌 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 口径,「收窄 AE」历史化。** 验收门 §2.4 曾记「生成环消费从九门全过收窄成只认 AE,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(已实装);「收窄 AE」条款自本档定稿起历史化,消除文档-代码漂移,存量引用面随收口逐一改注,此后再发现的引用面一律改该处引用、不回改本裁定。F_wiring 保持机器硬门;VLM 视觉判定归 L3 视觉软检,永远软
**裁定一(消费口径):driven 时九门全量 AND 为机械层取值口径,「收窄 AE」历史化;2026-07-09 起该口径的通过 = 预筛通过,不再单独构成 L1 通过(见裁定三)。** 验收门 §2.4 曾记「生成环消费从九门全过收窄成只认 AE,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(已实装);「收窄 AE」条款自本档定稿起历史化,消除文档-代码漂移,存量引用面随收口逐一改注,此后再发现的引用面一律改该处引用、不回改本裁定。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 体系
![rubric 计分制与 F-4 平衡门五裁定](assets/quality-02-rubric计分与平衡门.svg)
@ -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
量纲纪律:既有双量纲锁死不动——生成质量分(01,aigc 域)与运营质量分(0100,telemetry→feed)不可互相回灌(`contracts/README.md` 双量纲红线);rubric 分(0N)是生成侧局部量纲,只在品类验收、批次观测、回流分析消费,禁止换算回灌进前两者;**不新造统一「质量总分/爆火分」**。D11 就绪分(0100,开闸治理观测件)的权重(playability 0.5/firstPlay 0.25/stability 0.15/efficiency 0.1)自本档起归质量轨管辖:放量前的起步值仍由生产 cutover plan 落定(其部署步已在修 efficiency 维 BUDGET_RMB 与便宜档实际成本的错配,该项列为质量轨接管后的首个复核件);放量后第一轮校准见 §10。
防 Goodhart 三条,贯穿全模型:**L2L4 分数一律非阻塞**——任何丰富/留存/传播分都不得升为拒发门,升门动议须回本档改 SoT 并过创始人;**真玩与真实玩家数据是仅有的两种「硬」证据**——设计侧一切 LLM 评分都是软信号;**「好玩」的终审归人**(审核台)。rubric 被 Goodhart 或评分漂移的兜底与此同构:非阻塞设计把错杀面封为零,金标锚定 ±1 回退管住漂移,放量后真数据校准是最终纠偏——若 rubric 分与留存相关性为零或负,重审 rubric 维度,回滚面只在 rubric 文本与评分 prompt,不伤门与代码。
防 Goodhart 三条,贯穿全模型:**L2L4 分数一律非阻塞**——任何丰富/留存/传播分都不得升为拒发门,升门动议须回本档改 SoT 并过创始人;**真玩与真实玩家数据是仅有的两种「硬」证据,且真玩证据必须被判读**——设计侧一切 LLM 评分都是软信号;机械断言对真玩证据的解读同样会空心(2026-07-09 实证:score-only pass 五案),所以 L1 的判读自裁定三起是「机械预筛 ∧ 独立模型判定」双份,单独哪一份都不许自称完整硬证据;**「好玩」的终审归人**(审核台与创始人抽玩)。rubric 被 Goodhart 或评分漂移的兜底与此同构:非阻塞设计把错杀面封为零,金标锚定 ±1 回退管住漂移,放量后真数据校准是最终纠偏——若 rubric 分与留存相关性为零或负,重审 rubric 维度,回滚面只在 rubric 文本与评分 prompt,不伤门与代码。
## 10 生效、校准与升级路径

View File

@ -76,7 +76,7 @@ flowchart TD
这三组门的取舍理由,可以浓缩成三条核心决策:
1. **D12 和 GP9 为什么必须硬阻塞?** 因为这两件事不做,后果都不可逆。后台的生成 worker 是**全局串行**的(一次只处理一个任务),不做控制平面,单个用户的并发请求就能把它压垮,所有人都生成不了;不做合规先行,违规内容会直接生成出来进入玩家信息流,这是法务红线,一旦发生无法回收。
2. **那 3 道落库门为什么第一版只观测、不拦截?** 因为底层那套"九门 harness"(在真实浏览器里跑游戏、用九道确定性检查判定能不能玩的测试夹具)已经是挡住坏游戏的**硬地板**了。这三道落库门是更上一层的质量观测,第一版若一上来就生效拦截,反而会**误杀好游戏**——所以先只记录数据,等积累够了再考虑收紧。(这里"九门硬地板"说的是**开闸放行**那一侧:一款游戏要进信息流,仍按完整九门要求。生成环迭代生成时的消费口径曾在 2026-06-20 收窄为"只认五道客观健康门 AE",但已于 2026-07-02 由质量轨裁定回到 **driven 时九门全量 AND**、收窄 AE 归为历史方案——沿革详见 §2.4。)
2. **那 3 道落库门为什么第一版只观测、不拦截?** 因为底层那套"九门 harness"(在真实浏览器里跑游戏、用九道确定性检查判定能不能玩的测试夹具)已经是挡住坏游戏的**硬地板**了。这三道落库门是更上一层的质量观测,第一版若一上来就生效拦截,反而会**误杀好游戏**——所以先只记录数据,等积累够了再考虑收紧。(这里"九门硬地板"说的是**开闸放行**那一侧:一款游戏要进信息流,须过完整九门机械预筛,且 2026-07-09 起还须过独立模型玩法判定——L1 双证据口径,裁定见质量 SoT《游戏质量与爆火能力》裁定三。生成环消费口径的沿革:2026-06-20 曾收窄为"只认五道客观健康门 AE",2026-07-02 由质量轨裁定回到 **driven 时九门全量 AND**,2026-07-09 该口径整体降位为预筛——详见 §2.4。)
3. **真实计费为什么不在这一批?** 见 §1.2。
### 1.2 真实计费扣退为什么被拆出去(随 M4)
@ -157,6 +157,8 @@ flowchart TD
### 2.4 生成环消费九门的口径:硬门收窄成客观健康门 AE(创始人 2026-06-22 历史回收判定捡回)
> **历史化注记(2026-07-02,质量轨裁定)**:本节记述的"生成环把九门收窄成只认 AE 客观健康门",是 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 口径,"收窄 AE"归为历史方案、不再是待选路线**;判定归属见同目录现行 canonical《游戏质量与爆火能力》(裁定 L1-a)。以下原文按历史记录保留,不删。
>
> **再修订(2026-07-09,创始人拍板)**:九门全量 AND 的语义再降一级——它仍是机械层的取值口径,但其通过自此只构成「未见明显死」的**预筛**,不再单独等于 L1 通过;L1 通过 = 机械预筛 ∧ 独立模型玩法判定(依据 = 当日五路 harness 审计:H_progress 实测退化为 score 单调上升、latch 有进展即降 advisory,五份空心 pass verdict 在册)。裁定与四条细则见《游戏质量与爆火能力》裁定三,本档判据本体(九门各门怎么判)不动——动的又一次只是消费侧语义。
前面几节把九门 harness 讲成一道统一的"硬地板",这个说法在"开闸放行"这个语境里仍然成立——一款游戏要进信息流,确实要先过这套真玩检测。但要把一件 2026-06-20 拍下的口径变化讲清楚,否则架构档会和现行真相脱节:**生成环(SAA 管线)在迭代生成时怎么"消费"九门的裁决,已经从"九门全过才算成功"收窄成了"只认五道客观健康门 AE"。** 这是九门收窄后、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 路虽已判错误路线废弃,但"九门收窄成 AE 客观门"这条验收消费策略引擎无关、对现行 A-model 真 src/ 同样适用。

View 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)+ 主会话四项亲验;审计报告要点已并入本档,原始报告在会话内。

View 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」)+ 35 篇高频 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 的后续评估项,不在本三波。

View 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}`

View File

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

View File

@ -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",

View File

@ -17,6 +17,7 @@
},
"devDependencies": {
"esbuild": "0.28.1",
"typescript": "^5.4.5",
"ws": "^8.21.0"
}
}

View File

@ -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;
}
/*

View File

@ -52,6 +52,15 @@ export interface GameInstance {
render(g: CanvasRenderingContext2D): void; // g=mainContextboot 注入的同一面)
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 波3presence 由 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
}
/**

View File

@ -11,7 +11,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { _bannedLineErrors } from '../tools.mjs';
import { _bannedLineErrors, _bootCtxLine, _getInputLine, lexTokens } from '../tools.mjs';
/** 是否报了某红线(按正则 source 关键片段匹配)。 */
const hasBanned = (errs, frag) => errs.some((e) => e.includes(frag));
@ -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)}`);
});

View 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:falsefindings 恒空(门可停)
* 永不阻断 + 永不抛:任何输入 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]'), '须带独立节前缀');
});

View File

@ -24,6 +24,7 @@ import {
import { resolve, dirname, relative, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { scanUndefinedIdentifiers, scanImportChain } from './check-undef.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -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);

View File

@ -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 怎么把游戏玩到终态。

View File

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

View File

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