feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
This commit is contained in:
lili 2026-07-28 20:15:23 -07:00
parent 686aaaa421
commit cbfd4d871b
880 changed files with 83156 additions and 848 deletions

View File

@ -48,7 +48,7 @@
| `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` 的人读台账 |
| `plugin-capability-map.md` | 插件能力图谱:11 件通用注入 + 1 件 puzzle L1 受保护注入 + 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 件①):分支选择+多结局图鉴范式 / 文本红线 / 可达性红线含取证契约 |

View File

@ -53,7 +53,7 @@ games/<your-game>/
│ ├── balance.js # 【内容】数值 / 关卡 / 价格 / 掉落表(纯数据 + 公式)
│ ├── render.js # 【绘制】把状态画到 g;导出视口 W/H 常量
│ ├── assets.js # 【资产声明】图键→文件名映射 + loadAssets(host 层,可用 Image)
│ └── audio-director.js # 【音频编排】BGM/音效调度(可选)
│ └── audio-director.js # 【普通游戏音频编排】BGM/音效调度(可选;标准 Match-3 禁写)
├── assets/
│ ├── manifest.json # 资产清单:file/role/bytes/sha256/source{tool,model,prompt}
│ ├── gen-ledger.md # 生成历史(prompt/字节/hash/替换记录)——改素材的索引
@ -74,7 +74,7 @@ games/<your-game>/
| 层 | 谁负责 | 具体 |
|---|---|---|
| **玩法身份 WHAT** | **你写** | `game-logic.js`(必)把玩法接到五法 + 调注入的插件 · `core.js` 状态机与规则 · `balance.js` 数值/关卡/内容 · `render.js` 画面编排与命中区布局 · 事件→表现映射(何时触发哪个 juice/音效) · `assets.js` 资产声明 |
| **玩法身份 WHAT** | **你写** | `game-logic.js`(必)把玩法接到五法 + 调注入的插件 · `core.js` 状态机与规则 · `balance.js` 数值/关卡/内容 · `render.js` 画面编排与命中区布局 · 事件→表现映射(普通游戏可触发 juice/音效;标准 Match-3 音频由 host 自动编排) · `assets.js` 资产声明 |
| **通用机制 HOW** | **调插件** | 碰撞 / 物理 / 手感 / 缓动 / 输入缓冲 / 粒子打击感 / 音频 / 存档 / 调色 / **计分胜负 / HUD-UI / 场景状态机 / 定时调度**(共 11 注入件,见 §4;另 runtime-probe 仅取证不注入) |
| **装载 + 取证** | **平台供给** | `bootGameHost` 引擎五回调装载 · 插件注入 · `boot.ctx.getEngine()` · runtime-probe 六锚取证 |
@ -90,7 +90,7 @@ games/<your-game>/
// src/game-logic.js —— 【你写这个】命名导出 createGame,零引擎 import
// 入参由 L1(game.js wrapper)替你摊平:扁平 { plugins, bundle, viewport },没有 opts.runtime 嵌套。
export function createGame({ plugins, bundle, viewport }) {
// 插件已扁平注入(L1 host-config 替你装好全 11 件),用到才解构:
// 插件已由 L1 host-config 扁平注入,用到才解构;标准 Match-3 不解构 audioMusic:
const { sceneFsm, sessionScore, hudUi, timerScheduler /* …按需 gamefeel/juice/collision/physics/audioMusic */ } = plugins;
const core = createCore(); // 你的纯逻辑(可内联进本文件)
@ -133,7 +133,7 @@ export function createGame({ plugins, bundle, viewport }) {
| **gamefeel** | 手感 | `easing`(13 曲线 in/out/inOut);内置 `gamefeel.inputBuffer{consume,peek}`;**工厂(免 new + 自动注入受控 time,#4)**:`gamefeel.createCoyoteTimer(opts)→{setGrounded,isAvailable,consume}`、`gamefeel.createComboWindow(opts)→{hit,getCount}` |
| **particles-juice** | 粒子 + 打击感 | `spawnEmitter(preset\|cfg,x,y)`(或 `burst(x,y)` 直发 burst 预设,#6/#10 母语别名);`hitStop(dur)`/`getTimeScale`;`shakeScreen`/`getShakeOffset`;`flashScreen`/`pulseScale`;`PRESETS`(burst/trail/drift)。**⚠️ `juice.render(g)` 必须在你的 render(g) 里每帧手调(非自动,#11)——忘了 → 粒子/打击感不显示;`step(dt)` 默认 autoStep 自驱(经 onFrame),要手调 step 须工厂传 `autoStep:false` 防双步进** |
| **palette-post** | 调色 + 后处理 | `rgbToHsl/hslToRgb/applyPaletteMap/shiftHsl`;`setPost/renderPost`(vignette/dither/scanline)或 **`applyPreset('retro'\|'crt'\|'soft'\|'dither'\|'none')` 一键观感(#14)**。换色色板(from/to)仍由你注入、插件不内置成品色 |
| **audio-music** | 程序化音频 | `loadSong`(载曲)`/play(intensity?)`(播已载曲;**入参是情绪强度 0..1、不是曲目名**,#9)`/stop/loop`;`setIntensity`;**`playSfx(name)`(#8 放开)**:预设 `blip/thud/chime` 或语义名 `click/hit/win/coin/jump/fail` 等(自动映射、未知兜底 blip) |
| **audio-music** | 程序化音频 | 普通游戏可用:`loadSong`(载曲)`/play(intensity?)`(播已载曲;**入参是情绪强度 0..1、不是曲目名**,#9)`/stop/loop`;`setIntensity`;`playSfx(name)`。**标准 Match-3 例外:该键不进入 L3,Writer 不写任何音频逻辑。** |
| **save-progress** | KV 持久化 | `get(key,default)/set/remove/clear`;`createMemoryAdapter`/`createLocalStorageAdapter` |
| **runtime-probe** | 启动取证(六锚) | `mark(anchor)/getRecords/toJSONL/verify/verifyChain` |
@ -148,6 +148,20 @@ export function createGame({ plugins, bundle, viewport }) {
**典型组合**:菜单→玩→结算 用 `scene-fsm`;分数与最高分用 `session-score`(best 接 `save-progress`);倒计时/刷新波次用 `timer-scheduler`;分数/按钮/血条画面用 `hud-ui`;手感打击用 `particles-juice` + `gamefeel`。
### 标准 Match-3:两件 Writer 能力 + 一件 host-only 能力
绑定 `match3.orthogonal-swap-v1` 时,puzzle L1 会在通用 11 件之外成对注入
`plugins.match3ProducerProfile` 与 `plugins.match3VisualTimeline`。普通 puzzle 和其它品类没有这两个键。
- 先读 `game-runtime/src/plugins/match3-producer-profile/api.d.ts`:棋盘 substrate、几何、格心、主题安全区和选中白环全部调用它,不能自算另一套坐标或自画 selection。
- 再读 `game-runtime/src/plugins/match3-visual-timeline/api.d.ts` 与 `PLUGIN.md`:第二击同步业务结算时调用 `begin()`,`update(dt)` 推进,`render(g)` 只读 `view().tiles`,`handleTap` 入口先用 `isLocked()` 拒绝锁定期额外输入,重玩调用 `reset()`。
- 标准消除效果由固定 host 在 L3 的 `render(g)` 完成后自动合成:L1 已把 `match3ProducerProfile.geometry()` 与冻结六色主题 palette 注入时间线。Writer **不得调用** `match3VisualTimeline.renderEffects()`,也不得另造匹配消失粒子、光环或碎片;只按 `view().tiles` 绘制主题棋子主体。
- 第三件 `match3AudioDirector` 只属于可信 host,绝不进入 `plugins`。它自动提供主题 BGM、交换、无效回滚、每轮 clear 的复合消除音、fall 落定音和带终局结果的 settle 音;声音严格跟随受保护视觉 transition 账,连锁不会在同步业务递归里提前叠播。
- 标准 Match-3 Writer **不得解构或调用 `audioMusic` / `playSfx`**,不得取 `getAudioContext()`、自行创建 `AudioContext`/oscillator/buffer source,也不得另写标准 cue、BGM 或连锁升调逻辑。菜单、首击选中和重玩在 v1 本就没有 cue,不要自行补齐;静音按钮、首手势解锁、循环和主题资产均由 host 负责。
- `beforeKinds/swappedKinds/afterFallKinds/finalKinds` 是 0-based 8×8 JSON 原语数组;`pair/runs/matchedCells` 是 1-based;`view` 的逻辑和视觉坐标也是 1-based,视觉坐标可为浮点。
- 业务事件仍必须在第二击同步调用栈内写完。时间线只播放 sealed 事务,不能补写事件、改业务盘面或重新算连锁;终局结果可同步 latch,但终局场景必须等 settle 完成。
- 禁止自己维护另一套 `animPhase/visualQueue` 代替受保护能力,也禁止给最终业务棋盘旧坐标闪白冒充消除。八轮以上会封存真实终盘并标记 `visualComplete:false`,不能因为视觉验收上限让已提交业务崩溃。
### ⚠️ M3 实测易犯的幻觉 API(❌别这么写 → ✅正确)
> **铁律:插件方法名一律以 `src/plugins/<name>/api.d.ts` 为准,别按"常见库套路"臆测。** 以下是 M3 即便读了 api.d.ts 仍反复臆测出的错名(HJ-AGI-003 saae 实测),逐条记牢:
@ -162,14 +176,14 @@ export function createGame({ plugins, bundle, viewport }) {
- ❌ 在 game-logic.js 取受控面用 `opts.ctx` / `opts.boot` → ✅ 受控面在**实例 `init(boot)` 的 `boot.ctx`**(`boot.ctx.time` / `boot.ctx.random` / `boot.ctx.getEngine()` / `boot.ctx.log()`);**输入不经 `boot.ctx.getInput`——写实例方法 `handleTap(x,y)`/`handleKey(key)`,L1 派发**;`createGame` 入参是扁平 `{plugins,bundle,viewport}`,**没有 ctx**。
- ❌ 修 bug 时图省事写 `Math.random()` / `Date.now()` / `setTimeout()` → ✅ `boot.ctx.random()` 或 `boot.ctx.random.next()` / `boot.ctx.random.range(a,b)`、`boot.ctx.time()` 或 `boot.ctx.time.nowMs()`、`timer-scheduler.after(ms,cb)`(#1:time/random 既可直接调用又保留方法,两形态都对;红线 check 必拦裸 Math.random/Date.now;**越改越要守,thrash 时尤其别引入**)。
- ✅ `sessionScore.add(n)` = `addScore(n)` 别名(#6 阶段一B:直觉名直接可用,两者等价);**其它方法名拿不准一律先 `read_file` 对应 api.d.ts**。
- ⚠️ **`host-config.js` 和 `game.js` 都是 L1 固定 plumbing,你不写**(amodel harness `write_file` 会拒)——**全 11 插件**(sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics)+ viewport(390×844)+ 工厂 wiring 已替你配好。**你只写 `game-logic.js`(必)+ core/render/balance/assets**。〔历史教训 HJ-AGI-003:曾让 agent 写 host-config + game.js 工厂,M3 反复破坏返回契约(漏 viewport 崩 `reading 'w'`)/ registerOrder / 取插件漏 .plugins 崩 `reading 'define'`,故把 wiring 全收进 L1 固定、L3 只写游戏逻辑收扁平 {plugins,bundle,viewport},结构性根除。〕
- ⚠️ **`host-config.js` 和 `game.js` 都是 L1 固定 plumbing,你不写**(amodel harness `write_file` 会拒)——通用能力、viewport(390×844)和工厂 wiring 已替你配好;标准 Match-3 还由 L1 装配 host-only 视觉 renderer 与音频 director,`audioMusic` 不进入其 L3。**你只写 `game-logic.js`(必)+ core/render/balance/assets**。〔历史教训 HJ-AGI-003:曾让 agent 写 host-config + game.js 工厂,M3 反复破坏返回契约(漏 viewport 崩 `reading 'w'`)/ registerOrder / 取插件漏 .plugins 崩 `reading 'define'`,故把 wiring 全收进 L1 固定、L3 只写游戏逻辑收扁平 {plugins,bundle,viewport},结构性根除。〕
- ❌ 在 game-logic.js 里写 `opts.runtime.plugins` / 把入参当 `opts` 处理 —— **新架构入参是扁平的 `{ plugins, bundle, viewport }`**(L1 的 game.js wrapper 已替你摊平 opts.runtime),写 `opts.runtime.*` 取到 undefined → boot 崩。✅ **直接 `export function createGame({ plugins, bundle, viewport }) { const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins; … }`**(plugins/bundle/viewport 都是入参、平级;无任何 opts/runtime 嵌套)。〔HJ-AGI-003:旧架构 M3 漏 `.plugins` 一层崩 `reading 'define'`,故把嵌套收进 L1 wrapper、L3 只见扁平件,结构性根除。〕
---
## 5. 资产:美术/音乐/音效放哪 + 改图去哪找
- **放置**:全部进 `assets/`(`.jpg` 美术、`.mp3` 音乐音效),并在 `assets/manifest.json` 登记每件 `file/role/bytes/sha256/source{tool,model,prompt}`。
- **放置**:普通游戏资产全部进 `assets/`(`.jpg` 美术、`.mp3` 音乐音效),并在 `assets/manifest.json` 登记每件 `file/role/bytes/sha256/source{tool,model,prompt}`。标准 Match-3 的主题音频由固定 L1 资产清单管理,Writer 不新增或读取音频文件。
- **声明**:`src/assets.js` 用 **键名间接引用**——`IMAGE_FILES = { itemSoda: 'item-soda.jpg', ... }` + 业务 id→键映射。代码里用 `assets.images.itemSoda`,不写死文件名。
- **加载**:`game.js` 的 `init` 里 `await loadAssets('./assets/')`;并行 `new Image()`,**单图失败 → 该键置 null + 记 missing,游戏照常启动**(render 走程序化/占位回退,绝不因缺图崩)。音频在首次手势后由 audio-director 懒加载解码。
- **改一张图(LLM 怎么找)**:① 查 `assets/manifest.json` 找到该 role 的行(含原 prompt/hash);② `mmx` 重新生成,覆盖同名文件;③ 更新 manifest 该行 `sha256/bytes/prompt`;④ **文件名没变 → `assets.js`/`render.js` 都不用动**;新增品类才往 `IMAGE_FILES` 加键。
@ -197,7 +211,7 @@ mmx music generate --prompt "<情绪/配器/场景>" --instrumental --bpm 92 --o
## 7. 红线清单(违任一条 = 不合格)
1. 游戏工厂 `game.js` **零引擎 import**;引擎 import 只活 `entry.js`。
2. 游戏代码零 `Date.now`/`performance.now`/`Math.random`/`setTimeout`/`setInterval`/`requestAnimationFrame`/`addEventListener`/`new AudioContext`——全经 `boot.ctx` 或插件。
2. 游戏代码零 `Date.now`/`performance.now`/`Math.random`/`setTimeout`/`setInterval`/`requestAnimationFrame`/`addEventListener`/`new AudioContext`——全经 `boot.ctx` 或插件;标准 Match-3 进一步禁止所有 L3 音频入口。
3. **通用机制能调插件就别自己写**;反过来,**玩法语义(规则/数值/胜负条件/状态名/美术)绝不塞进插件**——插件是 engine 级、零品类词。
4. `render(g)` 画在 host 注入的 `g`(mainContext);不自取 canvas。
5. 资产缺失必须容错降级,绝不连坐崩溃。

View File

@ -24,6 +24,30 @@
`runtime-probe`(取证不注入):只在收口取证六锚(启动耗时等),不进 `plugins` 面,生成 agent 用不到。
## 仅 puzzle L1 的受保护注入(3 件)
`match3-producer-profile` 不是第 12 个通用插件。它只在 `_template-puzzle/src/host-config.js` 收到显式
`match3.orthogonal-swap-v1` 时由 L1 实例化,以 `plugins.match3ProducerProfile` 给 Match-3 Writer 调用;普通 puzzle、
其它品类、通用 `_template`、`all-plugins` 与 cheap-system
白名单都不注入。该能力冻结 390×844 / 8×8 / 40px 棋格几何、472 像素 selection mask 和主题安全区,
同一实例再由 `__genHost.readProducerCapabilityProbe()` 进入可信 preflight。L3 `_forensicsView` 或页面全局自报不参与身份判断。
`match3-visual-timeline` 同样只在显式 `match3.orthogonal-swap-v1` 下创建,以
`plugins.match3VisualTimeline` 注入,并与 producer 一起进入 `registerOrder`。它根据标准 0-based 8×8 kindToken 快照
独立分配稳定 `pieceId`,封存 `Match3VisualTransaction/1`,推导消除、列压实与顶部补位,按固定阶段输出
1-based 视觉棋子并用 `isLocked()` 统一锁住第二击后的额外输入。它拥有独立 id/version/configHash,绝不修改
`match3ProducerProfile` 已校准的身份。`visualTimelineCapability` 已从 host-config 独立返回;可信 host probe 与 runner
visualDrain/Proof/3 接线属于后续 contract-first 实现线,本次不冒充已完成。
`match3-audio-director` 只在同一标准 Match-3 profile 下由 L1 以冻结 `Match3AudioTheme/1` 和同一份
visual timeline 实例创建。它进入 `registerOrder` 并以 `audioDirectorCapability` 单独返回给可信 host,但不写入
L3 `plugins`;标准 Match-3 的 `audioMusic` 同样从 L3 门面移除。导演独占包内 BGM、交换/回滚/逐轮消除/下落/终局
cue、master 静音与只读 probe,按 host-only transition 账调度,避免游戏自制音效、同步连锁叠音或双 BGM 图。
这类“品类专用且影响验收像素”的能力必须登记在机器门 `PROTECTED_INJECTION_DIRS`,不能为了通过目录门而塞进
通用 11 件,也不能降成储备。机器门同时核对唯一模板、`plugins` 注入、`registerOrder` 与各自能力返回键,
并拒绝其它官方模板出现同一目录或键。新增受保护品类能力时,必须同时写清唯一模板入口、Writer 调用键、可信读口和未注入范围。
## 未注入的能力储备(6 件·造好了但没摆上桌)
这 6 件都是通过测试的真插件(各有 `api.d.ts`/`impl.js`/`test`),但当前 5 个注入品类(经营/剧情/TRPG/解谜/非遗)全是
@ -39,4 +63,4 @@
| **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 会拦「注入了但白名单漏列」「白名单列了但没注入」「新插件目录没裁定」。
把任一储备提升为**通用注入** = 改 6 个 `_template*/src/host-config.js` 的 `plugins` 装配 + prompt 白名单(cheap-system.md)+ tools.mjs `PLUGIN_KEY_DIR`,三处同改、再跑 `plugin-surface-gate.py` 验三方对齐。品类专用受保护注入不走这条通用扩面路径,须登记 `PROTECTED_INJECTION_DIRS` 并只改其唯一 L1 模板。gate 会拦「注入了但白名单漏列」「白名单列了但没注入」「受保护品类件误进通用面」「新插件目录没裁定」。

View File

@ -70,6 +70,17 @@ description: "当为解谜/找茬/消除/记忆类 brief 产设计阶段玩法
- **错点惩罚(轻)**:点错 = 抖屏 + 断连击 + 短锁定 **0.3–0.5s**;**绝不倒扣到看不见涨分**(分数总体must上升,挫败=流失)。
- **双货币(变现预留)**:星星(过关挣,解锁章节)+ 提示券(稀缺,卡壳消耗,**经看广告或攒**)。看广告点位:卡壳要提示 · 失败续 10 秒 · 结算翻倍。包装成"福利",不打断顿悟爽点。
### 标准 Match-3 的动作可读性
相邻交换三消的核心反馈不是“分数涨了、盘面变了”,而是玩家能连续看懂本手因果:`交换 → 匹配停留 → 消失 → 下落/补位 → 连锁间隔 → 稳定`。无效交换必须前移后回退。缺任一主体阶段、直接跳终盘、只用粒子或闪屏代替棋子运动,都属于玩法体验不完整,不得在设计稿中写成“已有消除反馈”。
- 交换约 180–240ms;匹配停留约 80–140ms;消失约 160–220ms;下落/补位约 180–360ms;连锁间隔约 80–120ms。
- 第二击接受后锁棋盘输入,稳定后恢复;锁定期额外点击不能改盘面、分数、步数或再次启动结算。
- 每一轮连锁独立播放消失与下落,不能只画第一轮匹配或最后一轮终盘。
- 终局结果可以同步确定,但结算页必须等最后一轮稳定后出现,不能抢走最后一次消除。
- 标准局部消除效果由固定 host 自动合成:亮色核心、扩散环与八向碎片使用 L1 注入的主题 palette,Writer 不自制匹配消失粒子,也不调用 `match3VisualTimeline.renderEffects()`。
- 设计输出必须明确主题棋子如何做位移、缩放/透明消失、顶部补位入场、无效回滚、连锁强化和终局转场;音效和震动只作辅助,不能重复覆盖 host 已提供的标准消除效果。
---
## 4. 美术风格范式(可达 + mmx 可产 + 程序化兜底)
@ -98,10 +109,10 @@ description: "当为解谜/找茬/消除/记忆类 brief 产设计阶段玩法
---
## 6. 音乐 / 音效范式(audio-music 可达)
## 6. 音乐 / 音效范式
- **BGM**:轻快专注循环(思考不烦躁);可随关卡后期 setIntensity 提张力。
- **音效(顿悟感的一半,务必有)**:点中正确"叮"(click/chime)· 连击升调 · 点错闷响(thud)· 过关号角(win)· 星级揭晓(coin)。**即时音反馈是"解开了"的确认信号**——audioMusic `playSfx`,无声卡静默降级不崩。
- **普通 puzzle**:BGM 轻快专注循环;正确、错误、过关等即时反馈可经 `audioMusic` 提供,无声卡时静默降级不崩。
- **标准 Match-3**:主题 BGM、交换、无效回滚、每轮 clear 复合音、fall 落定音和 terminal 音全部由 host-protected `match3AudioDirector` 自动跟随视觉时间线播放。设计只需给出主题听感,不向 Writer 下发音频实现清单;Writer 不得使用 `audioMusic/playSfx`、自建 Web Audio 或另造连锁 cue。
- 红线:**宁可程序化音效,也别没有反馈音**(解开无声 = 顿悟感直接减半)。
---
@ -124,11 +135,12 @@ description: "当为解谜/找茬/消除/记忆类 brief 产设计阶段玩法
3. **数值骨架**(基分 / 连击倍率 / 过关奖励 / 错点惩罚 / 观察窗口与一关时长 —— 按 §3 区间给具体值,交 core/balance);
4. **卡壳兜底**(几秒算卡壳 / 提示形式);
5. **美术风格词**(一组,全资产共用)+ 谜面元素清单(格子/目标/干扰项,标 mmx 或程序化);
6. **音效清单**(正确/错误/连击/过关/星级)+ BGM 情绪;
6. **音效清单**(正确/错误/连击/过关/星级)+ BGM 情绪;标准 Match-3 只写主题听感与动作反馈目标,标明由 host director 自动提供,不给 Writer 音频实现任务;
7. **UI 布局**(线索横幅 / HUD / 连击飘字 / 结算面板,§5);
8. **插件选择清单**(常用:sceneFsm + sessionScore + hudUi + timerScheduler + audioMusic + juice,记忆类加 save 存最佳);
8. **插件选择清单**(普通 puzzle 常用:sceneFsm + sessionScore + hudUi + timerScheduler + audioMusic + juice,记忆类加 save 存最佳;标准 Match-3 不选 audioMusic);
9. **首 3 分钟脚本**(逐关:示范关→舒适关→首个顿悟关→内容预告);
10. **可点性可见声明**(§9:当前该点的目标靠什么视觉特征与干扰项区分、多步范式下一步怎么指引——设计阶段就写清,别等实现时才想)。
11. **动作转场声明**(标准 Match-3 必填:交换/消失/下落补位/连锁/稳定/无效回滚各自的可见反馈、时长与输入锁;其它多步谜题说明状态切换如何让玩家看懂因果)。
> **MVP-first 铁律(关乎 code agent 能否收敛)**:首版**只做可玩核心**——1 个谜题主范式 + 3–5 关阶梯 + 基分/连击 + 基础音效,谜面元素 ≤3 种属性维度。**每日谜题 / 图鉴 / 称号系统 / 无尽模式 / 提示券商店 一律标「后续·MVP 不做」**,别一稿堆满 —— 设计太满 → code agent 实现负担过重 → read-thrash 不收敛。**先出能玩的核心,再谈丰富。**
@ -155,7 +167,7 @@ description: "当为解谜/找茬/消除/记忆类 brief 产设计阶段玩法
**通用 8 条**(全品类同一张表,与 sim-business §10 同源;解谜视角的落点写在括号里):
1. **即时反馈**:每次正确点击有没有"飘字 + 音效 + 粒子"三件套?(无 → 必加)
1. **即时反馈**:每次正确点击有没有"飘字 + 音效 + 粒子"三件套?(无 → 必加;标准 Match-3 的消除粒子由 host 自动合成,Writer 不重复制作)
2. **可见成长**:分数/关数有没有肉眼可见地涨?(连击倍率让它涨得有戏剧性)
3. **下一个解锁**:任意时刻有没有"下一关引入什么/差几颗星解锁下一章"的钩子?
4. **30 秒爽点**:开局 30 秒内有没有连过两关 + 第一次连击奖励?

View File

@ -10,11 +10,12 @@
外加一份 tools.mjs 的 PLUGIN_KEY_DIR(check 的 API 静态门据它把 plugins.<键>.<方法> 映到 api.d.ts 查方法名)。
四者任一漂移都会出真事故:白名单列了但没注入 → 模型写 plugins.x → 运行时 undefined、boot 崩;注入了但白名单没列
→ 模型不知道有这件、能力白摆(诊断档 §四⑦点名的「暗资产」);目录新增一件却没人裁定它该注入还是入储备 → 悄悄成
第七个暗插件。本门把「这三方 + PLUGIN_KEY_DIR 必须对齐,且每个插件目录都被显式裁定为『注入/取证/储备』之一」编译
第七个暗插件。本门把「这三方 + PLUGIN_KEY_DIR 必须对齐,且每个插件目录都被显式裁定为『通用注入/受保护品类注入/取证/储备』之一」编译
成 CI 与 pre-commit 消费的退出码。
裁定台账(单一事实源 = 本门的三个常量,改这里即改裁定;人读依据见 .agents/skills/plugin-capability-map.md):
裁定台账(单一事实源 = 本门的四个分区,改这里即改裁定;人读依据见 .agents/skills/plugin-capability-map.md):
· INJECTED = 三方对齐后应得的 11 键(不写死键名——从三方源解析后取交集/并集对账,键名漂移由对账本身抓);
· PROTECTED_INJECTION_DIRS = 只由指定品类 L1 固定装配、不进入通用 all-plugins/prompt 面的受保护能力;
· PROBE_DIRS = 取证不注入(runtime-probe:只在收口取证、不进 plugins 面,api.d.ts 明示);
· RESERVE_DIRS = 未注入能力储备(6 件:当前 5 注入品类是 tap/回合制,这些是动作/群体/摇杆/组合件类,
归 W-GENRE 动作线或需复合装配,本波不注入。逐件依据见 plugin-capability-map.md)。
@ -22,9 +23,9 @@
判据:
1. 三方注入键集必须【完全相等】:prompt 白名单 == host-config 注入 == PLUGIN_KEY_DIR 键集 —— 否则硬失败并打差集。
2. 每个注入键经 PLUGIN_KEY_DIR 映到的目录必须真实存在(含 impl.js)—— 否则硬失败(注入了不存在的插件)。
3. 目录分区完备:每个真实插件目录(含 impl.js)必须恰好落在 {注入目录, PROBE_DIRS, RESERVE_DIRS} 之一 ——
3. 目录分区完备:每个真实插件目录(含 impl.js)必须恰好落在 {通用注入目录, PROTECTED_INJECTION_DIRS, PROBE_DIRS, RESERVE_DIRS} 之一 ——
多出未裁定的目录(新插件没决定注入/储备)硬失败;台账里的 PROBE/RESERVE 目录消失(插件被删/改名)硬失败。
4. 储备/取证目录绝不能出现在任何注入源里(储备件被误注入 → 必须先从 RESERVE_DIRS 挪走再注入)—— 否则硬失败。
4. 储备/取证/受保护品类目录绝不能出现在通用注入源里——否则硬失败。
零依赖:仅标准库(re/sys/pathlib),与同仓 docs-gate.py / rubric-sync-gate.py 同栈同风格。
@ -41,6 +42,39 @@ from pathlib import Path
# ── 裁定台账(单一事实源;人读依据见 .agents/skills/plugin-capability-map.md)──────────────────────
PROBE_DIRS = {"runtime-probe"} # 取证不注入(收口取证,不进 plugins 面)
PROTECTED_INJECTION_DIRS = {
# 只进 _template-puzzle L1;固定 Match-3 几何/selection mask/可信 probe,不进通用 all-plugins。
"match3-producer-profile",
# 只进标准 Match-3;独立封存视觉事务、固定阶段并统一输入锁,不进入通用插件面。
"match3-visual-timeline",
# 只进标准 Match-3;宿主独占单一音频图、主题 BGM、阶段 cue 与静音,不进入 L3。
"match3-audio-director",
}
# 受保护能力同时声明唯一官方模板、实例键、能力返回键和是否允许进入 L3 plugins;
# gate 会核对实例、注册偏序、返回读口与 host-only 边界。
PROTECTED_INJECTION_BINDINGS = {
"match3-producer-profile": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3ProducerProfile",
"match3.orthogonal-swap-v1",
"producerCapability",
True,
),
"match3-visual-timeline": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3VisualTimeline",
"match3.orthogonal-swap-v1",
"visualTimelineCapability",
True,
),
"match3-audio-director": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3AudioDirector",
"match3.orthogonal-swap-v1",
"audioDirectorCapability",
False,
),
}
RESERVE_DIRS = { # 未注入能力储备(逐件依据见 plugin-capability-map.md)
"auto-targeting", # 索敌策略族——动作/射击品类,归 W-GENRE 动作线
"swarm-steering", # 均匀网格+群体运动学——同屏千级实体,动作/idle-sim 品类
@ -170,22 +204,69 @@ def run(root: Path) -> int:
msgs.append(f"注入键 {k} 映到目录 src/plugins/{key_dir[k]}/ 不存在或缺 impl.js")
# 判据 4:储备/取证目录绝不能出现在任何注入源(键名≠目录名,按目录集判)。
reserve_probe = RESERVE_DIRS | PROBE_DIRS
bad_injected = injected_dirs & reserve_probe
non_generic = RESERVE_DIRS | PROBE_DIRS | PROTECTED_INJECTION_DIRS
bad_injected = injected_dirs & non_generic
if bad_injected:
msgs.append(f"储备/取证目录被误注入(须先从 RESERVE_DIRS/PROBE_DIRS 台账挪走再注入):{sorted(bad_injected)}")
msgs.append("储备/取证/受保护品类目录被误注入通用面"
f"(须先从对应台账挪走再注入):{sorted(bad_injected)}")
# 受保护能力只允许出现在登记的官方模板;全部进入 registerOrder 与独立能力返回键,
# 只有声明 expose_to_l3=True 的能力才能进入 plugins。
template_hosts = sorted((root / "game-runtime/games").glob("_template*/src/host-config.js"))
if set(PROTECTED_INJECTION_BINDINGS) != PROTECTED_INJECTION_DIRS:
msgs.append("PROTECTED_INJECTION_BINDINGS 与 PROTECTED_INJECTION_DIRS 不一致")
for plugin_dir, (relative_host, plugin_key, profile_id, return_key, expose_to_l3) in PROTECTED_INJECTION_BINDINGS.items():
expected_host = root / relative_host
try:
source = expected_host.read_text(encoding="utf-8")
except OSError:
msgs.append(f"受保护能力 {plugin_dir} 的唯一模板不存在:{relative_host}")
continue
plugins_match = re.search(r"const\s+plugins\s*=\s*\{([^}]*)\}", source, re.S)
register_match = re.search(r"const\s+registerOrder\s*=\s*\[([^]]*)\]", source, re.S)
injected_by_object = bool(plugins_match and plugin_key in plugins_match.group(1))
injected_by_guard = bool(re.search(
rf"plugins\.{re.escape(plugin_key)}\s*=\s*{re.escape(plugin_key)}\b", source,
))
is_l3_injected = injected_by_object or injected_by_guard
if plugin_dir not in source:
msgs.append(f"受保护能力 {plugin_dir} 未在唯一模板实例化:{relative_host}")
if expose_to_l3 and not is_l3_injected:
msgs.append(f"受保护能力 {plugin_dir} 未按 {plugin_key} 注入唯一模板 L3 plugins:{relative_host}")
if not expose_to_l3 and is_l3_injected:
msgs.append(f"host-only 受保护能力 {plugin_dir} 禁止注入 L3 plugins:{relative_host}")
registered_in_array = bool(register_match and plugin_key in register_match.group(1))
registered_by_guard = bool(re.search(
rf"registerOrder\.splice\([^;]*\b{re.escape(plugin_key)}\b", source,
))
if not (registered_in_array or registered_by_guard):
msgs.append(f"受保护能力 {plugin_dir} 未进入唯一模板 registerOrder:{relative_host}")
if not re.search(rf"{re.escape(return_key)}\s*:\s*{re.escape(plugin_key)}\b", source):
msgs.append(f"受保护能力 {plugin_dir} 未从唯一模板返回 {return_key}:{relative_host}")
if profile_id not in source or not re.search(
r"interactionProfileId\s*===\s*MATCH3_INTERACTION_PROFILE_ID", source,
):
msgs.append(f"受保护能力 {plugin_dir} 未按显式 profile 条件装配:{profile_id}")
unexpected = []
for candidate in template_hosts:
candidate_source = candidate.read_text(encoding="utf-8")
if candidate != expected_host and (plugin_dir in candidate_source or plugin_key in candidate_source):
unexpected.append(str(candidate.relative_to(root)))
if unexpected:
msgs.append(f"受保护能力 {plugin_dir} 出现在未登记官方模板:{unexpected}")
# 判据 3:目录分区完备——每个真实插件目录恰好落在 {注入, PROBE, RESERVE} 之一;NON_PLUGIN 豁免。
classified = injected_dirs | PROBE_DIRS | RESERVE_DIRS | NON_PLUGIN_DIRS
classified = injected_dirs | PROTECTED_INJECTION_DIRS | PROBE_DIRS | RESERVE_DIRS | NON_PLUGIN_DIRS
unclassified = dirs - classified
if unclassified:
msgs.append("发现未裁定的插件目录(新插件必须显式裁定:注入 / 取证 PROBE_DIRS / 储备 RESERVE_DIRS 三选一,"
msgs.append("发现未裁定的插件目录(新插件必须显式裁定:通用注入 / 受保护品类注入 / 取证 / 储备四选一,"
"并在 plugin-capability-map.md 写一句依据):")
msgs.append(f" {sorted(unclassified)}")
# 台账里登记的 PROBE/RESERVE 目录若已从盘上消失(删/改名)→ 台账过时,硬失败。
ghost = (PROBE_DIRS | RESERVE_DIRS) - dirs
ghost = (PROTECTED_INJECTION_DIRS | PROBE_DIRS | RESERVE_DIRS) - dirs
if ghost:
msgs.append(f"台账登记的取证/储备目录在盘上不存在(插件被删/改名?更新本门台账与 plugin-capability-map.md):{sorted(ghost)}")
msgs.append(f"台账登记的受保护注入/取证/储备目录在盘上不存在"
f"(插件被删/改名?更新本门台账与 plugin-capability-map.md):{sorted(ghost)}")
if msgs:
_fail(msgs)
@ -193,7 +274,8 @@ def run(root: Path) -> int:
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)} 个真实插件目录全部已裁定。")
f"{len(PROTECTED_INJECTION_DIRS)} 受保护品类注入 + {len(PROBE_DIRS)} 取证 + "
f"{len(RESERVE_DIRS)} 储备目录全登记在册,{len(dirs)} 个真实插件目录全部已裁定。")
return 0

View File

@ -37,6 +37,12 @@ jobs:
# --changed 模式要比对基线找改动条目,故拉全历史。
fetch-depth: 0
- name: 安装并校验 Prompt eval 依赖
run: |
python3 -m pip install --disable-pip-version-check -r contracts/prompts/requirements-eval.txt
python3 -m pip check
python3 -c "import cv2, PIL; from PIL import Image; print('Pillow', PIL.__version__, 'OpenCV', cv2.__version__, 'PNG', Image.registered_extensions().get('.png'))"
- name: 段B 真模型四道闸(改动条目 / 指定条目)
run: |
if [ -z "$NEWAPI_KEY" ]; then

4
cheap-worker/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# ── 便宜档生成运行时工作区:每生成任务一个 hash 目录,可重生成、不入库(2026-07-23)──
# tracked=0(已用 git ls-files 复核),纯运行时工作区(.mcp/skills/任务临时产物),故整目录忽略。
# 前导 / 锚定到本目录根,只忽略 cheap-worker/_cheap-service-workspaces/ 这一层,不波及别处同名目录。
/_cheap-service-workspaces/

View File

@ -43,6 +43,27 @@ def _read_game_brief(game_id: str) -> str:
pass
return ""
def _resolve_a11_template_route(source_project) -> tuple[str | None, str | None]:
"""只从源工程可信元数据恢复 templateRoute/genre;v3 禁止用 brief 或单独 genre 反推 profile。"""
sp = _as_dict(source_project)
template = sp.get("scaffoldTemplate")
genre = cheap_verify.GENRE_BY_TEMPLATE.get(template) if template else None
if template and genre in cheap_verify._V3_GENRES:
return str(template), str(genre)
return None, None
def _resolve_a11_origin_brief(source_project) -> tuple[str | None, str | None]:
"""从 base SourceProject 恢复不可变原题面;active v3 禁止依赖本机 evidence 残留。"""
sp = _as_dict(source_project)
brief = sp.get("originBrief")
brief_hash = sp.get("originBriefHash")
if (not isinstance(brief, str) or not brief.strip() or not isinstance(brief_hash, str)
or hashlib.sha256(brief.encode("utf-8")).hexdigest() != brief_hash):
return None, None
return brief, brief_hash
# A11 M4 模块重生成:behavior 在便宜档映射到玩法文件(KTD4)——只重写它,其余文件保持不变。
_BEHAVIOR_TARGET = "game-logic.js"
_TARGET_REL = "src/game-logic.js"
@ -286,7 +307,8 @@ def materialize_source_project(game_id: str, files) -> Path:
# ───────────────────────── ③ execute_deterministic_modify(集成) ─────────────────────────
def execute_deterministic_modify(game_id: str, source_project, modify_patch,
*, base_version_id=None, port: int = 4320, cdp_port: int = 9222) -> dict:
*, base_version_id=None, task_trace_id=None,
port: int = 4320, cdp_port: int = 9222) -> dict:
"""确定性执行集成:apply 改一处 → 落盘已知良源 → 重建 → 九门。复用 cheap_run 九门,不另造。
流程:apply_deterministic_modify → 若 ok:(缺 plumbing 才 scaffold 已知良 entry-bundle/index.html)→
@ -298,6 +320,15 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch,
{"status":"succeeded|failed", "manifest":[...], "verdict":{...}|None, "stage":"apply|scaffold|build|stage|play",
"error":str|None, "smokeOk":bool|None, "playOk":bool|None}。
"""
mode = str(cheap_verify._acceptance_v3_cfg().get("mode") or "v3_shadow")
task_binding_hash = None
if mode in ("v3", "v3_shadow"):
try:
task_binding_hash = cheap_verify.task_binding_hash_v3(task_trace_id)
except ValueError as exc:
return {"status": "failed", "manifest": [], "verdict": None,
"stage": "apply", "error": str(exc), "smokeOk": None, "playOk": None}
# 1. apply:确定性改一处(纯文本定位替换,零 LLM)。
applied = apply_deterministic_modify(source_project, modify_patch)
manifest = applied["manifest"]
@ -338,13 +369,65 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch,
sm = cheap_run.smoke(game_id, port=port, cdp_port=cdp_port) # boot 预检(smokeOk 供返回);其 state 不再喂 ensure_play_spec
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
verdict = pr.get("verdict")
# W-AXIS-V2 波1:create 与 modify 不允许两套验收标准——确定性改也过统一编排器(四门投影 ∧ 测试员真玩)。
# modify 同步执行(worker_service 懒绑),用 asyncio.run 跑 async 编排器(与 _default_rewrite 同款);
# brief 取原游戏 brief(改数值行为不变,验证「改后仍是同一款可玩游戏」);测试员端口自动派生(与九门 4320 分段)。
# create 与 modify 不允许两套验收标准:确定性改也调用 v3 唯一入口;仅显式历史模式回放 v1/v2。
# brief 恒取原游戏题面;若 v3 最终给 verified reject + repairEligible,才调用一次受限 writer,并沿 parentRun
# 复验同一 brief。同步执行路径用 asyncio.run 驱动异步验收。
import asyncio # noqa: PLC0415
_acc = asyncio.run(cheap_verify.run_acceptance(
{"verdict": verdict}, game_id=game_id, brief=_read_game_brief(game_id), verdict=verdict))
gates_pass = bool(_acc.get("accepted"))
template_route, genre = _resolve_a11_template_route(source_project)
origin_brief, origin_brief_hash = _resolve_a11_origin_brief(source_project)
brief = origin_brief if mode in ("v3", "v3_shadow") else _read_game_brief(game_id)
if mode in ("v3", "v3_shadow") and (not template_route or genre not in cheap_verify._V3_GENRES
or not origin_brief or not origin_brief_hash):
import cheap_studio # noqa: PLC0415
_acc = cheap_studio.apply_v3_entry_failure(
{"verdict": verdict},
"A11 sourceProject 缺可信 templateRoute/originBrief,禁止依赖本机 evidence 或修改指令猜原题面",
mode=mode)
gates_pass = False
elif mode in ("v3", "v3_shadow"):
import cheap_studio # noqa: PLC0415
acceptance_identity = cheap_verify.build_acceptance_v3_identity(
game_id, brief, genre=genre, template_route=template_route, repair_ordinal=0,
task_binding_hash=task_binding_hash)
_acc_v3 = asyncio.run(cheap_verify.run_acceptance_v3(cheap_studio.build_acceptance_v3_request(
game_id, brief, verdict, acceptance_identity=acceptance_identity,
idempotency_key=f"{game_id}:a11-deterministic-v3")))
_acc = cheap_studio.apply_acceptance_v3({"verdict": verdict}, _acc_v3)
decision = _acc_v3.get("decision") or {}
if cheap_verify.is_v3_repair_authorized(_acc_v3):
# 确定性改本身零 LLM;只有 v3 最终硬证拒绝时,才把同一产物交受限 writer 修一次。
try:
repair_identity = cheap_verify.build_acceptance_v3_identity(
game_id, brief, genre=acceptance_identity["genre"],
template_route=acceptance_identity["templateRoute"],
source_artifact_hash=_acc_v3["artifactHash"],
parent_acceptance_request_hash=acceptance_identity["acceptanceRequestHash"],
repair_ordinal=1, proof_profile_id=acceptance_identity["proofProfileId"],
proof_registry_version=acceptance_identity["proofRegistryVersion"],
task_binding_hash=acceptance_identity["taskBindingHash"],
)
repair_summary = _default_rewrite(
game_id, decision.get("repairFeedback") or "按 v3 硬证修复玩法闭环",
port=port, cdp_port=cdp_port,
acceptance_parent_run_id=_acc_v3.get("runId"), acceptance_repair_count=1,
acceptance_brief=brief, genre=genre, acceptance_identity=repair_identity,
acceptance_template_route=template_route,
acceptance_task_trace_id=task_trace_id)
except Exception as e: # noqa: BLE001 —— A11 执行器必须返回 failed 摘要,不能把 worker 主循环打崩
repair_summary = None
log(f"v3 单次 writer 修复异常(保留首轮 reject):game_id={game_id} {type(e).__name__}: {e}")
if isinstance(repair_summary, dict):
final_acceptance = repair_summary.get("acceptanceV3")
_acc = (cheap_studio.apply_acceptance_v3(repair_summary, final_acceptance, first_pass=_acc_v3)
if isinstance(final_acceptance, dict) else repair_summary)
verdict = repair_summary.get("verdictFull") or verdict
gates_pass = cheap_verify.is_v3_publishable(_acc.get("acceptanceV3") or {})
else:
_acc = asyncio.run(cheap_verify.run_acceptance(
{"verdict": verdict}, game_id=game_id, brief=brief, verdict=verdict))
gates_pass = bool(_acc.get("accepted"))
# M5 三结构断言:base→改后 真比对(断言①改动真生效 / 断言②非目标稳)+ 血缘(③ baseVersionId)。
# 断言②对 deterministic 是兜 apply bug(理应只改目标文件,真比对抓"是否顺手碰了非目标")。
@ -355,14 +438,16 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch,
status = "succeeded" if (gates_pass and a12_pass) else "failed"
err = None
if status != "succeeded":
err = "验收未通过(v2:四门∧测试员)" if not gates_pass else "三断言未通过:" + json.dumps(
err = "验收未通过(统一验收入口)" if not gates_pass else "三断言未通过:" + json.dumps(
{k: v for k, v in assertions.items() if isinstance(v, dict) and not v.get("pass")}, ensure_ascii=False)
log(f"确定性改重建+v2验收+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass} "
log(f"确定性改重建+统一验收+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass} "
f"mode={_acc.get('acceptanceVersion')}")
return {"status": status, "manifest": manifest, "verdict": verdict, "assertions": assertions,
"stage": "play", "error": err, "smokeOk": sm.get("ok"), "playOk": pr.get("ok"),
# v2 验收摘要 additive(供 worker_service 组 run-summary / trace):
"acceptance": {k: _acc.get(k) for k in ("acceptanceVersion", "floor", "playtest", "judge", "accepted")}}
# 统一验收摘要 additive;v3 完整封存对象保留在 acceptanceV3。
"acceptance": {k: _acc.get(k) for k in ("acceptanceVersion", "floor", "playtest", "judge", "accepted",
"acceptanceV3", "acceptanceV3FirstPass", "publishFrozen",
"firstPassAccepted", "repairAttempted", "acceptedAfterRepair")}}
# ───────────────────────── ④ execute_regenerate_modify(模块重生成 · 改玩法)─────────────────────────
@ -417,7 +502,11 @@ def _noop_prepare(game_id: str) -> dict:
return {"ok": True, "output": "game_dir 已由 execute_regenerate_modify 预备(scaffold+materialize base 源)"}
def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: int = 9222) -> dict:
def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: int = 9222,
acceptance_parent_run_id=None, acceptance_repair_count=0,
acceptance_brief=None, genre=None, acceptance_identity=None,
acceptance_template_route=None,
acceptance_task_trace_id=None) -> dict:
"""默认重写器:跑 cheap_studio.run_studio 的 modify 态(自定义 system prompt + kick + 写边界白名单 +
prepare=noop[game_dir 已就绪]),复用同一套 ReAct + resume 熔断 + 三层校验收口。返回 run-summary(含九门 verdict)。
@ -438,11 +527,18 @@ def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: i
return asyncio.run(cheap_studio.run_studio(
game_id, intent, system_prompt=sys_prompt, initial_kick=kick,
write_whitelist={_BEHAVIOR_TARGET}, prepare=_noop_prepare,
port=port, cdp_port=cdp_port))
port=port, cdp_port=cdp_port,
acceptance_parent_run_id=acceptance_parent_run_id,
acceptance_repair_count=acceptance_repair_count,
acceptance_brief=acceptance_brief, genre=genre,
acceptance_identity=acceptance_identity,
acceptance_template_route=acceptance_template_route,
acceptance_task_trace_id=acceptance_task_trace_id))
def execute_regenerate_modify(game_id: str, source_project, modify_patch,
*, base_version_id=None, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict:
*, base_version_id=None, task_trace_id=None, rewrite_fn=None,
port: int = 4320, cdp_port: int = 9222) -> dict:
"""模块重生成执行(改玩法):取 base 源 + intent → 有界单文件重写 game-logic.js → 九门 → 断言②非目标稳定 + 改动清单。
流程:取 intent(空→failed 不重写)→ 取 base 源 files(无源→failed)→ scaffold-if-needed + materialize
@ -463,6 +559,16 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch,
"summary":run-summary|None, "error":str|None}。status=succeeded ⟺ 九门过 ∧ 断言②非目标稳定。
manifest = {"file":"src/game-logic.js","kind":"behavior","intent":intent,"changed":bool,"untouchedStable":bool}。
"""
mode = str(cheap_verify._acceptance_v3_cfg().get("mode") or "v3_shadow")
task_binding_hash = None
if mode in ("v3", "v3_shadow"):
try:
task_binding_hash = cheap_verify.task_binding_hash_v3(task_trace_id)
except ValueError as exc:
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest("", changed=False, stable=None),
"verdict": None, "summary": None, "error": str(exc)}
# 1. 取 intent(改写意图);空 → failed,不重写(省一切重活)。
patch = _as_dict(modify_patch)
payload = patch.get("payload") if isinstance(patch.get("payload"), dict) else {}
@ -484,6 +590,16 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch,
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "base 源工程无 files(无可改源)"}
template_route, genre = _resolve_a11_template_route(sp)
origin_brief, origin_brief_hash = _resolve_a11_origin_brief(sp)
original_brief = origin_brief if mode in ("v3", "v3_shadow") else _read_game_brief(game_id)
if rewrite_fn is None and mode in ("v3", "v3_shadow") \
and (not template_route or genre not in cheap_verify._V3_GENRES
or not origin_brief or not origin_brief_hash):
return {"status": "failed", "stage": "intent",
"manifest": _regen_manifest(intent, changed=False, stable=None),
"verdict": None, "summary": None,
"error": "A11 sourceProject 缺可信 templateRoute/originBrief,禁止依赖本机 evidence 或修改指令猜原题面,不执行重生成"}
try:
# 3. prep:缺 plumbing 才 scaffold(补 L1 entry-bundle/index.html)→ materialize base 源覆盖 src/(同 deterministic 口径)。
@ -505,7 +621,18 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch,
# 5. 有界单文件重写 game-logic.js(默认 cheap_studio modify 态;rewrite_fn 可注入桩)。
fn = rewrite_fn or _default_rewrite
run_summary = fn(game_id, intent, port=port, cdp_port=cdp_port)
identity = None
if rewrite_fn is None and mode in ("v3", "v3_shadow"):
# 在默认 Writer 启动前冻结 ordinal=0 身份;后续 run_studio 只验证和透传,不重选 profile。
identity = cheap_verify.build_acceptance_v3_identity(
game_id, original_brief, genre=genre, template_route=template_route, repair_ordinal=0,
task_binding_hash=task_binding_hash)
run_summary = fn(
game_id, intent, port=port, cdp_port=cdp_port, genre=genre,
acceptance_brief=original_brief, acceptance_identity=identity,
acceptance_template_route=template_route,
acceptance_task_trace_id=task_trace_id,
)
run_summary = run_summary if isinstance(run_summary, dict) else {}
# 6. 断言②:重写后再算 hash,判目标变没变 + 非目标稳没稳。
@ -527,8 +654,15 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch,
vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None
# W-AXIS-V2 波1:_default_rewrite 走 run_studio(run_gates=True),收口已跑统一验收编排器,验收权威 = accepted
# (create/modify 同标准);run_summary 缺 accepted(注入桩/老路)→ 回落四门投影,不回落九门 verdict.pass(退役口径)。
_acc = run_summary.get("accepted")
gates_pass = bool(_acc) if _acc is not None else cheap_verify.project_floor(verdict_full)["pass"]
v3_payload = run_summary.get("acceptanceV3")
v3_marker = isinstance(v3_payload, dict) or run_summary.get("acceptanceVersion") in ("v3", "v3_shadow")
if v3_marker:
# v3 标记存在但完整 payload 缺失时必须 fail-closed,禁止回落旧 accepted/四门。
gates_pass = cheap_verify.is_v3_publishable(v3_payload if isinstance(v3_payload, dict) else {})
else:
legacy_accepted = run_summary.get("accepted")
gates_pass = (bool(legacy_accepted) if legacy_accepted is not None
else cheap_verify.project_floor(verdict_full)["pass"])
# 三断言 verdict(统一形态,与 deterministic 路一致):①changed ②非目标稳(含误伤清单)③血缘。
nontarget_changed = sorted(k for k in set(nontarget_before) | set(nontarget_after)
if nontarget_before.get(k) != nontarget_after.get(k))

View File

@ -41,9 +41,9 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
【工作目录已就绪】
- 我已把克隆起点拷到 ⟦G⟧/(⟦SCAFFOLD_DESC⟧)。你的活是据 brief **借插件能力把它改造成一款高质游戏**——别把它当"这档就该这么简单"的天花板。
- **L1 固定 plumbing 别动(write 会被拒)**:⟦G⟧/index.html、⟦G⟧/entry-bundle.js、⟦G⟧/src/game.js(薄 wrapper)、**⟦G⟧/src/host-config.js**(host 装配:已替你注入**全 11 件能力插件** + viewport 固定 390×844 + 工厂 wiring;你无需装配任何插件)。
- **L1 固定 plumbing 别动(write 会被拒)**:⟦G⟧/index.html、⟦G⟧/entry-bundle.js、⟦G⟧/src/game.js(薄 wrapper)、**⟦G⟧/src/host-config.js**(host 装配:已替你注入通用能力 + viewport 固定 390×844 + 工厂 wiring;标准 Match-3 另有 host-only 视觉 renderer 与音频 director;你无需装配任何插件)。
- **你只写 L3 游戏本体**:**⟦G⟧/src/game-logic.js(必写,游戏核心)** + 按需 core.js(纯逻辑)/ render.js(画面)/ balance.js(数值)/ assets.js(资产)。结构简单可把逻辑/画面集中在 game-logic.js 内;多系统/多场景再拆 core/render。把脚手架起点改造成 brief 要的**高质游戏**(多借插件能力做卖相与深度);**画面读 viewport.w/h(390×844 竖屏)**。
- **入口契约(钉死)**:game-logic.js 必须 `export function createGame({ plugins, bundle, viewport })` 返回 GameInstance 五法 init(boot)/update(dt)/render(g)/destroy()。**插件已扁平注入 plugins**,用到才解构:sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics。
- **入口契约(钉死)**:game-logic.js 必须 `export function createGame({ plugins, bundle, viewport })` 返回 GameInstance 五法 init(boot)/update(dt)/render(g)/destroy()。**插件已扁平注入 plugins**,普通游戏用到才解构 sceneFsm/sessionScore/hudUi/timerScheduler/save/gamefeel/juice/palettePost/audioMusic/collision/physics;标准 Match-3 的 L3 没有 audioMusic,也看不到 host-only director。
【先读手册(必须;别凭记忆猜 API)】
1. read_file('.agents/skills/littlejs-game-dev.md') —— code 层作业手册(结构/边界/红线/11 注入插件 API 速查)。
@ -63,7 +63,7 @@ _SYSTEM_PROMPT = """你是 A-model 游戏生成 agent。目标产物 = 一款**
- **资源环**(经营/放置类必含):「进货 → 库存 → 售卖收钱 → 缺货补货」的软币循环,制造「赚→进→卖→再赚」的张力;
- **3–4 级解锁阶梯**:攒够阈值解锁新商品/区域/能力,任意时刻都露出「下一个锁」;
- **数值成长**:产出/成本随级上升、略带滚雪球感,别平淡线性;
- **音效清单**:收钱「叮」/ 升级欢呼 / 解锁号角(经 plugins.audioMusic;宁可程序化也别没有反馈音)。
- **音效清单**:普通游戏写收钱「叮」/升级欢呼/解锁号角等反馈(经 plugins.audioMusic)。标准 Match-3 只写主题听感目标,不实现声音:BGM、交换、回滚、clear 复合音、fall 和 terminal cue 由 host 自动提供。
定完过一遍 **10 条好玩自检**(①即时反馈 ②可见成长 ③下一个解锁 ④30 秒内首次升级/解锁 ⑤数值滚雪球 ⑥情感锚〔萌角色/拥有物〕 ⑦放置回归惊喜 ⑧音反馈 ⑨**核心操作非无脑**〔每次主操作有真实的决策/技巧含量,不是点了自动结算〕 ⑩**难度有曲线**〔一局内铺垫→拉紧→收束,张力持续制造「差一点」;前 10 秒即玩即教、零阅读〕)——**⑨ 是否决项:⑨ 不命中,其余九条全中也只是「有元素的无趣游戏」**(实测一款 6/8 命中却不好玩,正死在 ⑨);⑨ 命中的前提下,其余命中越多越好玩。**⑨ 的判定句式(设计完对着念)**:「玩家在__时要判断__,判错则__」——三个空都填得出、且第三个空是真代价(少得分/丢单/断连击),⑨ 才算命中。正例:顾客点了豆浆,上对 +8、上错顾客皱眉扣耐心——有判断、错有代价。反例:点任意顾客都 +5、点错零损失——无判断无代价,只是点击计数器。
**这 10 条自检对所有品类通用(⑨ 恒为否决项)**(动作/消除/跑酷也照它要即时反馈 + 可见成长 + 音反馈);**经营/养成/放置/点客类**再按 sim-business 取资源环/解锁阶梯/客流节奏范式(⑨ 在经营类的标准填法=节奏与压力决策:优先服务谁/何时补货/囤货还是现金——单击可达、判错有真代价,模式清单见其 §1);**剧情/互动叙事类**再按 narrative-game-design 取分支选择/属性轴/结局图鉴范式(选项单击即推进、结局 latch 驻留;「选择有重量」本身就是 ⑨ 要的决策层——选哪个通向不同走向,绝不做选啥都一样的假分支);**TRPG/掷骰冒险类**再按 trpg-game-design 取掷骰可见/亮牌取舍/难度爬坡范式;**解谜类**再按 puzzle-game-design 取顿悟距离/规则递进/卡壳兜底范式(解谜的技巧分=看懂线索少捞错,同守上面基础反馈+技巧分两层)。
@ -75,17 +75,24 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
【红线(check 会拦,违反则 finish 被拒)】
- game-logic.js 必须 **export function createGame({ plugins, bundle, viewport })**(命名导出、别改名、别默认导出);**零引擎 import**(引擎经 boot.ctx.getEngine())。
- 游戏源(game-logic / core / render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler,音效经 plugins.audioMusic;**输入不调 ctx.getInput**(已收归 L1,见下「输入契约」)。
- 游戏源(game-logic / core / render)**零裸** Math.random / Date.now / setTimeout / requestAnimationFrame / new AudioContext / addEventListener —— 时间随机经 boot.ctx,定时经 timer-scheduler;普通游戏音效经 plugins.audioMusic;**输入不调 ctx.getInput**(已收归 L1,见下「输入契约」)。
- update(dt) 内**必须调 bundle.tick(dt)**(bundle 是 createGame 入参;否则插件 onFrame 不推进、timer 永不到期、一局不结束)。**dt 单位是「秒」(≈1/60)**——游戏内若用毫秒计时(倒计时 / spawn 间隔 / 顾客耐心,常量都是 ms 如 60000/1300),累加时务必「elapsedMs += dt * 1000」,漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局不结束。**timerScheduler.after(ms,cb)/every(ms,cb) 同样收毫秒(#7):3 秒写 after(3000)、别写 after(3);与 dt=秒相反,别混。**
- **入参是扁平的 { plugins, bundle, viewport }——没有 opts、没有 opts.runtime、没有 ctx**(L1 已替你摊平)。受控面 ctx 只在五法 **init(boot) 的 boot.ctx**;**调用形态**:时间 **ctx.time()** 或 ctx.time.nowMs()、随机 **ctx.random()** 或 ctx.random.next()/ctx.random.range(a,b)、画布 ctx.getContext2d()、引擎 ctx.getEngine()、日志 ctx.log(tag,msg)。**#1:ctx.time/ctx.random 既可直接调用(ctx.time()=毫秒、ctx.random()=[0,1))、又保留方法(.nowMs()/.elapsedMs()、.next()/.range(a,b)/.reseed())——两形态都对**。**但 nowMs/next 是 time/random 上的方法、不是 ctx 上的——别写 ctx.nowMs()(应 ctx.time() 或 ctx.time.nowMs())。**五法 = init(boot)/update(dt)/render(g)/destroy() + **输入方法 handleTap(x,y)**(见下「输入契约」)。
- **入参是扁平的 { plugins, bundle, viewport }——没有 opts、没有 opts.runtime、没有 ctx**(L1 已替你摊平)。受控面 ctx 只在五法 **init(boot) 的 boot.ctx**;**调用形态**:时间 **ctx.time()** 或 ctx.time.nowMs()、随机 **ctx.random()** 或 ctx.random.next()/ctx.random.range(a,b)、画布 ctx.getContext2d()、引擎 ctx.getEngine()、诊断日志 ctx.log(tag,msg)、业务事件 ctx.event(type,payload,message?)。**#1:ctx.time/ctx.random 既可直接调用(ctx.time()=毫秒、ctx.random()=[0,1))、又保留方法(.nowMs()/.elapsedMs()、.next()/.range(a,b)/.reseed())——两形态都对**。**但 nowMs/next 是 time/random 上的方法、不是 ctx 上的——别写 ctx.nowMs()(应 ctx.time() 或 ctx.time.nowMs())。**五法 = init(boot)/update(dt)/render(g)/destroy() + **输入方法 handleTap(x,y)**(见下「输入契约」)。
- **插件扁平注入:直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins(用到才解构)**;**绝不写 opts.runtime.plugins**(那是 L1 内部的,你见不到 → 写了就 undefined → boot 崩 reading 'define')。游戏只用插件、不 new、不 register。
- **可用插件键(全 11 已注入,用到才取)**:sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics。
- **可用插件键(全 11 已注入,用到才取)**:sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics
- 标准 Match-3 仍使用受保护 producer/timeline 接线,但 `audioMusic` 与 `match3AudioDirector` 均不进入 L3。
- **标准 Match-3 音频红线(check 会拦)**:绑定 `match3.orthogonal-swap-v1` / 解构 `match3ProducerProfile` 时,不得出现 `audioMusic`、`playSfx`、`getAudioContext`、`AudioContext`、oscillator、buffer source、合成器或自制 BGM/cue。可信 host 的 `match3AudioDirector` 会按受保护视觉 transition 自动播放主题 BGM、交换、无效回滚、每轮 clear 复合音、fall 落定音和带终局结果的 settle 音;v1 的菜单、首击和重玩本来就无 cue,不要补写。
- **插件方法名一律以 api.d.ts 为准、别臆测**(实测幻觉:scene-fsm 写 defineScenes〔应 define〕)。用到某插件先 read_file 它的 api.d.ts;skill「⚠️ 实测易犯的幻觉 API」逐条列 ❌→✅。
- **hudUi.drawButton(g, rect, opts) 回吐它绘制的命中矩形 { x, y, w, h }**:✅ 直觉写法就对——const btn = hudUi.drawButton(g, { x, y, w, h }, { label }); if (hudUi.pointInRect(px, py, btn)) 命中处理(drawButton 返回命中矩形,直接接住判命中即可)。命中判定走 hudUi.pointInRect(px, py, rect)(**只在 hudUi,collision 插件没有 pointInRect 方法**)。**第二参是 rect 对象 { x, y, w, h }、不是 label/坐标位置参**——别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态。**起局/交互按钮的命中矩形必须在状态进入点(scene onEnter)确定性建立、render 只画不赋值**:绝不让命中矩形只在 render 副作用里生成——并发渲染帧饥饿下 render 可能没跑过一次=矩形恒 null=菜单点不动、一局起不来;把按钮几何抽成纯函数,onEnter 调它设命中区、render 调它画,同一来源不漂移。
- **sceneFsm.define(name, handlers) 逐场景调一次——绝不单对象批量注册**:❌ sceneFsm.define({ menu: {...}, play: {...}, over: {...} }) → 第一参当成了场景名「[object Object]」、menu/play/over 全没真注册 → 后续 transition('play') 找不到目标态被忽略 → 卡菜单(实测合成/2048 类翻车点)。✅ **每个场景各调一次**:sceneFsm.define('menu', { onEnter(){...} }); sceneFsm.define('play', { onEnter(){...}, update(dt){...} }); sceneFsm.define('over', { onEnter(){...} });(define 第一参恒为场景名字符串、第二参才是回调表 { onEnter, onExit, update, render };**回调表里 update 只收 (dt)、render 只收 (g)、与五法同形——别臆造场景名/state 首参,绝不写 update(scene, dt)**;可链式 .define(...).define(...))。
- **画面读 viewport.w/h**(viewport 是入参;别硬编码 390×844)。
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产;init(boot) 里 const assets = boot.assets || {};render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**(shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 null(host 已容错)→ 务必走程序化回退、别崩。BGM/音效经 plugins.audioMusic(不在 boot.assets)。无 manifest/无美术 → 全程序化绘制即可。
- **关键事件用 ctx.log 记日志**:在 init/场景切换/得分/出错处 调 ctx.log('tag', 信息)。**ctx 来自 init 的入参 boot,即「const ctx = boot.ctx」——绝不是 boot.boot.ctx**(host 传入的 boot 已是 {ctx, mainContext, canvas, seed, assets},多套一层 .boot=undefined → ctx=null → 无输入、游戏点不动;check 会拦 .boot.ctx)。插件调用已自动记,你只补游戏语义事件。
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产;init(boot) 里 const assets = boot.assets || {};render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**(shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 null(host 已容错)→ 务必走程序化回退、别崩。普通游戏 BGM/音效经 plugins.audioMusic;标准 Match-3 的音频资产与播放由 host 管,L3 不读取。无 manifest/无美术 → 全程序化绘制即可。
- **ctx.log 只记诊断,ctx.event 才记业务结果**:init、调试和错误可用 `ctx.log('tag', 信息)`;选择生效、击杀翻层、工序完成、解谜通关、订单交付等 profile 结果必须在真实状态变化发生后调用 `ctx.event(type, payload, message?)`。payload 必须是普通 JSON 对象,message 只是可选的人读说明,验收不从 tag/message 猜结果。host 只放行当前模板 profile 的事件 type;type 不在白名单或 payload 非法时 `ctx.event` 返回 false。遇到 false 要修正事件名/字段,不能改受保护的 host-config,不能降级成 ctx.log,更不能为过门伪造未发生的事件。**ctx 来自 init 的入参 boot,即 `const ctx = boot.ctx`,绝不是 boot.boot.ctx**。
- **剧情 narrative.branching-story**:`narrative.choice-committed` `{choiceId,branchId,node:{before,after}}`;`narrative.ending-reached` `{endingId,branchId,outcome:'win'|'lose'}`;图鉴真实解锁后 `narrative.gallery-updated` `{unlockedCount}`。同一路径的 branchId 必须一致。
- **TRPG trpg.dice-tower**:`trpg.battle-entered` `{enemyId}`;`trpg.combat-resolved` `{encounterState:{before,after},outcome:'success'|'critical'|'failure',heroHp}`;`trpg.enemy-defeated` `{enemyId}`;真实翻层后 `trpg.floor-entered` `{floor:{before,after}}`。brief 要升级时再发 `trpg.level-increased` `{upgradeId,level:{before,after}}`;明确要天赋时再发 `trpg.talent-selected` `{upgradeId,talentId}`,随后 floor-entered 同带该 upgradeId。
- **非遗 heritage.ordered-craft**:每道真实工序各发一次 `heritage.step-completed` `{workId,stepId,stepIndex,totalSteps,judgement}`,同一 workId 的 stepIndex 必须完整覆盖 1..N(N=3..12);全部完成后发 `heritage.craft-completed` `{workId,totalSteps,orderedStepIds,allStepsCompleted:true}`,成品落地再发 `heritage.product-completed` `{workId,score,works}`。
- **解谜 puzzle.match-board**:合法移动后 `puzzle.move-applied` `{boardHash:{before,after}}`;达成目标 `puzzle.goal-reached` `{goalId}`;结算 `puzzle.level-completed` `{level,result}`;真实提示 `puzzle.hint-shown` `{hintId}`;进入下一关 `puzzle.level-entered` `{level:{before,after}}`。
- **经营 sim-business.recipe-fulfillment**:订单出现 `sim-business.order-presented` `{orderId,recipeId}`;玩家选配方 `sim-business.recipe-selected` `{orderId,recipeId}`;正确交付 `sim-business.order-served` `{orderId,recipeId,success:true,revenueDelta}`(revenueDelta>0);结算 `sim-business.settlement-recorded` `{served,currency}`(均>0)。同一订单三步的 orderId/recipeId 必须一致;brief 真要求解锁时再发 `sim-business.product-unlocked` `{productId}` 或 `sim-business.equipment-unlocked` `{equipmentId}`。
- **输入契约(钉死·根治"启动不了")**:输入订阅已由 L1(game.js wrapper)接管,**你绝不自己订阅输入**——不写 ctx.getInput、不自建 pendingClicks 队列、不在 update(dt) 里挑时机消费点击。你只写实例方法 **handleTap(x, y)**(点击主输入:经营点客 / 打地鼠 / 点按钮等;**menu/玩中/结算各 phase 的判定全写在它内部**),L1 会在每次 pointerdown 时**直达**调用它;键盘玩法(方向键 / 空格)写 **handleKey(key)**,L1 在 keydown 时调用。check 会拦「调了 getInput」与「一个输入方法都没暴露」。
【完成判据 + 停机纪律(重要)】
@ -95,7 +102,8 @@ A 读手册(1 + 按品类 5/6/7/8/9)+ 读你的起点 game-logic.js(4)
- 你有 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。"""
现在开始:**先 read_file 读手册与起点 game-logic.js,别直接写码。先写一版最小可玩核心 → 立即 check(趁文件小、红线好定位好小修,用 edit_file 修到 PASS)→ 再扩玩法**;核心玩法实现完、check+build 绿了、收尾自查过了就 finish。
"""
# 默认脚手架描述(create 路 = 通用 _template「点圆得分」起点)。扩模板:per-genre 黄金骨架传各自描述覆盖,

View File

@ -443,7 +443,8 @@ def _shell_env() -> dict:
return env
def scaffold(game_id: str, template: Optional[str] = None) -> dict:
def scaffold(game_id: str, template: Optional[str] = None,
interaction_profile_id: Optional[str] = None) -> dict:
"""scaffold:shell-out `node tools.mjs scaffold-saa <id> [template]`(clone 模板 + SAA 信封 __GameBundle)。返回 {ok, output}。
template=None(默认)→ 通用 _template(点圆得分起点);扩模板:传 per-genre 黄金骨架目录名
@ -453,6 +454,11 @@ def scaffold(game_id: str, template: Optional[str] = None) -> dict:
argv = ["node", str(_AMODEL_GEN / "tools.mjs"), "scaffold-saa", game_id]
if template:
argv.append(template)
elif interaction_profile_id is not None:
# CLI 第四位是模板;只给 profile 时显式保留默认模板占位。
argv.append("_template")
if interaction_profile_id is not None:
argv.append(str(interaction_profile_id))
r = subprocess.run(argv, cwd=str(_GAME_RUNTIME), capture_output=True, text=True,
timeout=90.0, check=False)
return {"ok": r.returncode == 0, "output": (r.stdout + r.stderr).strip()}
@ -469,15 +475,22 @@ def stage(game_id: str) -> dict:
return {"ok": False, "output": f"stage shell-out 异常:{e}"}
def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222) -> dict:
def smoke(game_id: str, port: int = 4320, cdp_port: int = 9222,
interaction_profile_id: Optional[str] = None) -> dict:
"""smoke:shell-out `node play.mjs smoke <id>`(SAA 信封 boot + 帧推进 + 抓 _forensicsView state)。
interaction_profile_id 只在调用方显式提供时追加为 CLI 第五参数;None 保持历史 argv 不变。
Match-3 generation-only 用它在页面创建前注入可信 profile,使固定 host-config 装配受保护 producer。
解析 play.mjs 末行 `[smoke] PASS/FAIL {json}` 拿 ok + state(state 供 ensure_play_spec 推断 driver 形态)。
返回 {ok, state, raw}。
"""
try:
argv = ["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)]
if interaction_profile_id is not None:
argv.append(str(interaction_profile_id))
r = subprocess.run(
["node", str(_AMODEL_GEN / "play.mjs"), "smoke", game_id, str(port), str(cdp_port)],
argv,
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=120,
env=_shell_env(), check=False,
)

View File

@ -508,6 +508,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
failed = cheap_studio.apply_v3_entry_failure(
_failed_summary(game_id, str(exc)), str(exc), mode=acceptance_mode)
return failed, cheap_run.game_dir(game_id)
interaction_profile_id = job.get("interactionProfileId")
# WU2 §3.7 F:从 §6.1 job 取 per-user token(后端 dispatchGeneric 对真实 member 装、系统/编排旁路留空);
# 空串归一为 None(缺失 → _resolve_key 回落全局 env key)。整条 job 由 worker_service 透传至此,故直接从 job 取。
@ -534,6 +535,17 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
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)
if interaction_profile_id is not None:
try:
# job 只能显式选择已冻结的 shadow 校准 profile;必须先于凭据、网络、归档和 scaffold。
cheap_verify.preflight_interaction_profile_v3(
interaction_profile_id, acceptance_mode,
genre=str(genre or ""), template_route=str(scaffold_template or ""))
except Exception as exc: # noqa: BLE001 不可信 profile 不得触发 Writer 或产物写入
reason = f"interaction profile 预检失败:{type(exc).__name__}: {exc}"
failed = cheap_studio.apply_v3_entry_failure(
_failed_summary(game_id, reason), reason, mode=acceptance_mode)
return failed, cheap_run.game_dir(game_id)
# 失控兜底轮数(与三闸 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)
@ -600,7 +612,13 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
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 interaction_profile_id is None:
# 普通 job 保持历史调用形状,既不选择 profile,也不向 scaffold 传空占位参数。
sc = await asyncio.to_thread(cheap_run.scaffold, game_id, scaffold_template)
else:
sc = await asyncio.to_thread(
cheap_run.scaffold, game_id, scaffold_template,
interaction_profile_id=interaction_profile_id)
if not sc["ok"]:
_fs = _failed_summary(game_id, f"scaffold 失败:{sc['output'][:300]}")
if archived_to:
@ -616,6 +634,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
game_id, brief, genre=str(genre or ""),
template_route=str(scaffold_template or ""), repair_ordinal=0,
task_binding_hash=task_binding_hash,
interaction_profile_id=interaction_profile_id,
reference_asset_record_ids=(
[receipt["recordId"] for receipt in reference_asset_generation_receipts]
if reference_asset_generation_receipts is not None else None),
@ -736,6 +755,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
repair_ordinal=1, proof_profile_id=acceptance_identity["proofProfileId"],
proof_registry_version=acceptance_identity["proofRegistryVersion"],
task_binding_hash=acceptance_identity["taskBindingHash"],
interaction_binding=acceptance_identity.get("interactionBinding"),
design_ref=acceptance_identity.get("designRef"),
reference_asset_record_ids=acceptance_identity.get("referenceAssetRecordIds"),
consumer_ref=acceptance_identity.get("consumerRef"),

View File

@ -347,9 +347,9 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
**不**跑内部九门 play —— 由调用方在生成与 play 之间注入金标 spec 再单独 play,把驱动器从对照变量里摘掉
(Codex C1:run_studio 内部已 play,对照需把生成与 play 拆开)。
interaction_profile_id(缺省 None=原行为):由可信 scaffold 固化进 Writer 不可写的 entry-bundle,普通浏览器
直接打开时据此装配受保护 producer;同时透传给 smoke runner 作为显式覆盖。标准 Match-3 生成批传
`match3.orthogonal-swap-v1`。它不写入 Writer 上下文,也不改变其它品类的默认入口或验收身份。
interaction_profile_id(缺省 None=原行为):只供 v3_shadow 校准路径显式选择。可信编排在 scaffold 前
交叉核对 profile/template/proof,随后固化进 Writer 不可写的 entry-bundle、验收 identity 与 smoke runner。
普通 puzzle 不传该字段,interactionBinding 保持 null,禁止按 brief 或模板自动猜测。
A11 M4 模块重生成复用本编排(同一 ReAct + resume + 熔断 + 三层校验收口,不另造),靠四个可选参切到 modify 态,
都默认 None=create 原行为(create 路零改动):
@ -396,6 +396,42 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
mode=acceptance_mode,
)
supplied_binding = ((acceptance_identity or {}).get("interactionBinding")
if isinstance(acceptance_identity, dict) else None)
supplied_profile_id = (supplied_binding.get("interactionProfileId")
if isinstance(supplied_binding, dict) else None)
if (interaction_profile_id is not None and supplied_profile_id is not None
and interaction_profile_id != supplied_profile_id):
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
"显式 interaction_profile_id 与 acceptanceIdentity.interactionBinding 不一致",
mode=acceptance_mode,
)
requested_profile_id = interaction_profile_id or supplied_profile_id
trusted_route = (acceptance_template_route or scaffold_template
or ((acceptance_identity or {}).get("templateRoute")
if isinstance(acceptance_identity, dict) else None))
trusted_genre = (genre
or ((acceptance_identity or {}).get("genre")
if isinstance(acceptance_identity, dict) else None)
or (cheap_verify.GENRE_BY_TEMPLATE.get(trusted_route) if trusted_route else None))
if requested_profile_id is not None:
try:
# 显式 profile 属于冻结校准身份,必须在 archive/scaffold/Writer 任一写操作前验明。
cheap_verify.preflight_interaction_profile_v3(
requested_profile_id, acceptance_mode,
genre=str(trusted_genre or ""), template_route=str(trusted_route or ""),
proof_profile_id=((acceptance_identity or {}).get("proofProfileId")
if isinstance(acceptance_identity, dict) else None))
except Exception as exc: # noqa: BLE001 交互身份不可信时不得先改盘
return apply_v3_entry_failure(
{"ok": False, "gameId": game_id, "brief": brief, "finished": False},
f"Writer 前 interaction profile 预检失败:{type(exc).__name__}: {exc}",
mode=acceptance_mode,
)
# 外部 canonical identity 是显式可信输入;其 binding 通过预检后,scaffold/smoke/repair 共用同一 profile。
interaction_profile_id = requested_profile_id
# 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)。
@ -416,8 +452,6 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
# v3 身份必须在 Writer 创建前由可信编排层冻结。templateRoute 是 profile 选择轴;genre 只能交叉核对,
# 绝不允许在验收阶段仅凭 genre 重新猜 profile。modify 路由调用方通过 acceptance_template_route
# 或完整 acceptance_identity 传入源项目可信路由,身份仅存本函数局部变量。
trusted_route = acceptance_template_route or scaffold_template
trusted_genre = genre or (cheap_verify.GENRE_BY_TEMPLATE.get(trusted_route) if trusted_route else None)
v3_identity = None
if v3_enabled:
try:
@ -440,6 +474,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
proof_profile_id=supplied.get("proofProfileId"),
proof_registry_version=supplied.get("proofRegistryVersion"),
task_binding_hash=supplied.get("taskBindingHash"),
interaction_binding=supplied.get("interactionBinding"),
# W-GOLD-LIVE 检查点 3a:外部身份的参照资产消费声明三字段原样透传重建,
# 供下方全等比对与消费对账闸消费;缺省 None/[]/null ≡ 未声明(旧行为不变)。
design_ref=supplied.get("designRef"),
@ -456,6 +491,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
game_id, v3_brief, genre=str(trusted_genre or ""),
template_route=str(trusted_route or ""), repair_ordinal=0,
task_binding_hash=expected_task_binding,
interaction_profile_id=interaction_profile_id,
reference_asset_record_ids=(
[receipt["recordId"] for receipt in reference_asset_generation_receipts]
if reference_asset_generation_receipts is not None else None),
@ -634,6 +670,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
repair_ordinal=1, proof_profile_id=v3_identity["proofProfileId"],
proof_registry_version=v3_identity["proofRegistryVersion"],
task_binding_hash=v3_identity["taskBindingHash"],
interaction_binding=v3_identity.get("interactionBinding"),
design_ref=v3_identity.get("designRef"),
reference_asset_record_ids=v3_identity.get("referenceAssetRecordIds"),
consumer_ref=v3_identity.get("consumerRef"),

View File

@ -1465,11 +1465,11 @@ _V3_CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "play-loo
# W-GOLD-LIVE 检查点 2:Python v3 编排产出 acceptance-request/3 —— 即 v2 字段集之上开口三个参照资产
# 可选字段(designRef/referenceAssetRecordIds/consumerRef,全 optional,旧调用方不传即旧行为)。canonical
# 校验(validate.py 语义层)钉死 /3 与 /2 同一条 proof registry 版本线(2026-07-15.v3 → proof-obligations.v2.json),
# 注册表版本与 schemaVersion 错配时 Writer 前身份必然校验失败,二者必须成对切换。interactionBinding 固定为
# null(Python 编排不走 Node /2 交互绑定路径,v3 schema 允许显式 null)。canonical hash 口径不变:仍是
# canonical 字段集的稳定 JSON SHA-256,仅字段集随 v3 只增不改。
# 注意:Node runner(playtest-v3.cdp.cjs validateProfileProvenance)当前只接受 /1 与 /2 请求,真浏览器路径
# 需后续检查点升 /3 契约后复通;本步离线编排与 canonical 校验不经过 runner。
# 注册表版本与 schemaVersion 错配时 Writer 前身份必然校验失败,二者必须成对切换。普通路径的
# interactionBinding 保持 null;只有可信调用方显式选择 v3_shadow 校准 profile 时,才生成并贯通
# InteractionBinding/1。canonical hash 口径不变:仍是 canonical 字段集的稳定 JSON SHA-256。
# Node runner(playtest-v3.cdp.cjs validateProfileProvenance)已接受 /1、/2、/3 请求;非空 binding
# 通过独立文件与五项显式参数进入真浏览器路径,普通路径仍以 null 维持原行为。
_V3_OBLIGATIONS_FILE = _V3_CONTRACT_DIR / "proof-obligations.v2.json"
_V3_OBLIGATIONS_SCHEMA = _V3_CONTRACT_DIR / "proof-obligation-registry.schema.json"
_V3_SINGLE_ROLL_SCHEMA = _V3_CONTRACT_DIR / "single-roll-fact-v3.schema.json"
@ -1478,6 +1478,9 @@ _V3_ACCEPTANCE_REQUEST_SCHEMA = _V3_CONTRACT_DIR / "acceptance-request-v3.schema
_V3_ACCEPTANCE_PROVENANCE_SCHEMA = _V3_CONTRACT_DIR / "acceptance-provenance-v3.schema.json"
_V4_ACCEPTANCE_PROVENANCE_SCHEMA = _V3_CONTRACT_DIR / "acceptance-provenance-v4.schema.json"
_REFERENCE_ASSET_RECEIPT_SCHEMA = _V3_CONTRACT_DIR / "reference-asset-verification-receipt.schema.json"
_INTERACTION_PROFILE_REGISTRY_FILE = _V3_CONTRACT_DIR / "interaction-profiles.v2.json"
_INTERACTION_PROFILE_REGISTRY_SCHEMA = _V3_CONTRACT_DIR / "interaction-profile-registry-v2.schema.json"
_INTERACTION_BINDING_SCHEMA = _V3_CONTRACT_DIR / "interaction-binding.schema.json"
# Registry/1 仅供旧 acceptance 身份与 provenance 对账兼容;新生成入口的冻结消费统一走
# ReferenceAssetRegistry/2 + release 锚定预检,不得把本兼容器当作生产资产读取入口。
_V3_REFERENCE_ASSET_REGISTRY_FILE = _V3_CONTRACT_DIR / "reference-asset-registry.initial.json"
@ -1487,7 +1490,7 @@ _V3_REFERENCE_ASSET_REGISTRY_FILE = _V3_CONTRACT_DIR / "reference-asset-registry
_REFERENCE_ASSET_POLICY_ID = "survivor-gold-v1"
_REFERENCE_ASSET_POLICY_MODE = "frozen_preflight"
_REFERENCE_ASSET_RELEASE_REF = "contracts/play-loop/reference-asset-release.initial.json"
_REFERENCE_ASSET_RELEASE_SHA256 = "5856d607dd973c64dbafc448ccff87cc825c917ede48b6072a52421569e6ccd0"
_REFERENCE_ASSET_RELEASE_SHA256 = "506e181f688f082ea34bd7515e4b1c9952b30a1caad1a5799972ba2d7a3d27a5"
_REFERENCE_ASSET_TRUSTED_ROOT = Path(__file__).resolve().parents[1]
_V3_RESULTS_DIR = Path(__file__).resolve().parent / "results" / "acceptance-v3"
_V3_GENRES = {"narrative", "trpg", "heritage", "puzzle", "sim-business"}
@ -1736,7 +1739,8 @@ def _validate_v3_instance_parts(schema_file: Path, instance: dict) -> tuple[list
def _canonical_acceptance_request(identity: dict) -> dict:
"""返回写盘 request 的固定字段;acceptanceRequestHash 本身不入包,避免自引用 hash。
v3 字段集 = v2 十四字段(含 interactionBinding,Python 编排恒为 null)+ 三个参照资产可选字段
v3 字段集 = v2 十四字段(含 interactionBinding,普通路径为 null、显式 shadow 校准可为对象)+
三个参照资产可选字段
(designRef/referenceAssetRecordIds/consumerRef,未声明时分别为 null/[]/null)。字段集只增不改,
canonical hash 口径不变(稳定 JSON SHA-256)。
"""
@ -1749,10 +1753,83 @@ def _canonical_acceptance_request(identity: dict) -> dict:
return {field: identity.get(field) for field in fields}
def _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof: dict) -> dict:
"""读取 canonical 交互注册表,并把显式 profile 与当前 proof 路由逐项交叉核对。"""
if not isinstance(interaction_profile_id, str) or not interaction_profile_id:
raise ValueError("interactionProfileId 必须是非空字符串")
try:
registry = json.loads(_INTERACTION_PROFILE_REGISTRY_FILE.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 注册表不可读时不得降级成普通 puzzle
raise ValueError(f"interaction profile 注册表不可读:{type(exc).__name__}: {exc}") from exc
registry_errors = _validate_v3_instance(_INTERACTION_PROFILE_REGISTRY_SCHEMA, registry)
if registry_errors:
raise ValueError("interaction profile 注册表非法:" + ";".join(registry_errors[:4]))
profiles = registry.get("profiles") if isinstance(registry.get("profiles"), dict) else {}
profile = profiles.get(interaction_profile_id)
if not isinstance(profile, dict) or profile.get("id") != interaction_profile_id:
raise ValueError(f"interactionProfileId 未在可信注册表中唯一登记:{interaction_profile_id}")
cross_checks = {
"genre": resolved_proof.get("genre"),
"templateRoute": resolved_proof.get("templateRoute"),
"proofProfileId": resolved_proof.get("proofProfileId"),
}
for field, expected in cross_checks.items():
if profile.get(field) != expected:
raise ValueError(
f"interactionProfileId 与当前 {field} 不一致:{profile.get(field)} != {expected}")
prompt = ((profile.get("actorProtocol") or {}).get("prompt")
if isinstance(profile.get("actorProtocol"), dict) else None)
if not isinstance(prompt, dict) or prompt.get("runtimeEligible") is not False:
raise ValueError("Match-3 shadow profile 必须保持 runtimeEligible=false")
return {"registry": registry, "profile": profile}
def _build_interaction_binding_v3(interaction_profile_id, task_binding_hash: str,
resolved_proof: dict) -> dict:
"""按 InteractionBinding/1 canonical 前缀与稳定 JSON 生成任务绑定。"""
resolved_interaction = _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof)
content = {
"interactionProfileId": interaction_profile_id,
"interactionRegistryVersion": resolved_interaction["registry"]["registryVersion"],
"taskBindingHash": task_binding_hash,
}
binding = {
"schemaVersion": "InteractionBinding/1",
**content,
"interactionBindingHash": hashlib.sha256(
b"interaction-binding/1\n" + _stable_json_bytes_v3(content)
).hexdigest(),
}
binding_errors = _validate_v3_schema_only(_INTERACTION_BINDING_SCHEMA, binding)
if binding_errors:
raise ValueError("interactionBinding 非法:" + ";".join(binding_errors[:4]))
return binding
def preflight_interaction_profile_v3(interaction_profile_id, acceptance_mode, *, genre: str,
template_route: str, proof_profile_id=None) -> dict | None:
"""在 scaffold/Writer 前验证显式 profile;仅 v3_shadow 校准路径允许启用。"""
if interaction_profile_id is None:
return None
if acceptance_mode != "v3_shadow":
raise ValueError("显式 interactionProfileId 仅允许 v3_shadow 校准路径")
resolved_proof = _v3_resolve_registry(
genre, "", template_route=template_route, proof_profile_id=proof_profile_id)
resolved_interaction = _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof)
return {
"interactionProfileId": interaction_profile_id,
"interactionRegistryVersion": resolved_interaction["registry"]["registryVersion"],
"proofProfileId": resolved_proof["proofProfileId"],
"genre": resolved_proof["genre"],
"templateRoute": resolved_proof["templateRoute"],
}
def build_acceptance_v3_identity(game_id: str, brief: str, *, genre: str, template_route: str,
source_artifact_hash=None, parent_acceptance_request_hash=None,
repair_ordinal: int = 0, proof_profile_id=None,
proof_registry_version=None, task_binding_hash=None,
interaction_profile_id=None, interaction_binding=None,
design_ref=None, reference_asset_record_ids=None,
consumer_ref=None) -> dict:
"""Writer 前冻结验收身份;profile 只能由可信 templateRoute 选中,修复链不得重选。
@ -1791,6 +1868,20 @@ def build_acceptance_v3_identity(game_id: str, brief: str, *, genre: str, templa
genre, brief, template_route=template_route, proof_profile_id=proof_profile_id,
proof_registry_version=proof_registry_version,
)
if interaction_profile_id is not None and interaction_binding is not None:
raise ValueError("interaction_profile_id 与 interaction_binding 不能同时提供")
if interaction_profile_id is not None:
canonical_interaction_binding = _build_interaction_binding_v3(
interaction_profile_id, task_binding_hash, resolved)
elif interaction_binding is not None:
if not isinstance(interaction_binding, dict):
raise ValueError("interactionBinding 必须是对象或 null")
canonical_interaction_binding = _build_interaction_binding_v3(
interaction_binding.get("interactionProfileId"), task_binding_hash, resolved)
if interaction_binding != canonical_interaction_binding:
raise ValueError("interactionBinding 与当前 task/profile canonical 内容不一致")
else:
canonical_interaction_binding = None
core = {
"schemaVersion": "acceptance-request/3",
"gameId": str(game_id),
@ -1800,8 +1891,8 @@ def build_acceptance_v3_identity(game_id: str, brief: str, *, genre: str, templa
"proofProfileId": resolved["proofProfileId"],
"proofRegistryVersion": resolved["version"],
"taskBindingHash": task_binding_hash,
# Python 编排不走 Node /2 交互绑定路径;v3 schema 的 interactionBinding 必填但允许显式 null。
"interactionBinding": None,
# 普通 puzzle 保持显式 null;仅可信调用方显式选择 shadow profile 时写入 canonical binding。
"interactionBinding": canonical_interaction_binding,
"sourceArtifactHash": source_artifact_hash,
"parentAcceptanceRequestHash": parent_acceptance_request_hash,
"repairOrdinal": repair_ordinal,
@ -1897,6 +1988,13 @@ def write_acceptance_v3_provenance(game_id: str, identity: dict, *, artifact_has
written_request_hash = _write_json_bytes_atomic(request_file, request_payload)
if written_request_hash != expected_request_hash:
raise ValueError("acceptance request 写盘 hash 不一致")
interaction_binding = request_payload.get("interactionBinding")
interaction_binding_file = None
interaction_binding_file_hash = None
if interaction_binding is not None:
interaction_binding_file = evidence_dir / "interaction-binding.json"
interaction_binding_file_hash = _write_json_bytes_atomic(
interaction_binding_file, interaction_binding)
# 消费对账快照形状先做声明子集闸;role/artifactHash 的机械形状由下方 canonical provenance schema 兜底。
consumed = list(consumed_reference_assets or [])
declared_ids = set(request_payload.get("referenceAssetRecordIds") or [])
@ -1980,6 +2078,9 @@ def write_acceptance_v3_provenance(game_id: str, identity: dict, *, artifact_has
"acceptanceRequestHash": expected_request_hash,
"provenanceManifestFile": str(manifest_file),
"provenanceManifestHash": manifest_hash,
"interactionBindingFile": (str(interaction_binding_file)
if interaction_binding_file is not None else None),
"interactionBindingFileHash": interaction_binding_file_hash,
"manifest": manifest,
}
@ -2354,7 +2455,10 @@ def _run_playtest_v3_roll_sync(game_id: str, *, run_id: str, roll: int, requeste
provenance_manifest_file: str, provenance_manifest_hash: str,
acceptance_mode: str, evidence_mode: str, evidence_dir, model_name: str,
port: int, cdp_port: int, timeout: float,
remaining_cost_rmb: float, cfg: dict, legacy_mapping_file=None) -> dict:
remaining_cost_rmb: float, cfg: dict, legacy_mapping_file=None,
interaction_binding_file=None, interaction_binding_file_hash=None,
interaction_profile_id=None, interaction_registry_version=None,
interaction_binding_hash=None) -> dict:
"""调用新 serve 脚本执行一掷;只解析 playtest/3 JSON,不把进程错误伪装成 gameplay。"""
unknown_cost = max(0.0, float(remaining_cost_rmb or 0.0))
script = _playtest_v3_script()
@ -2385,6 +2489,26 @@ def _run_playtest_v3_roll_sync(game_id: str, *, run_id: str, roll: int, requeste
]
if legacy_mapping_file:
argv.append(f"--legacy-mapping-file={legacy_mapping_file}")
interaction_values = (
interaction_binding_file, interaction_binding_file_hash, interaction_profile_id,
interaction_registry_version, interaction_binding_hash,
)
if any(value is not None for value in interaction_values):
if not all(value is not None for value in interaction_values):
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "profile_contract_error",
"reason": "interaction binding runner 参数必须五项全有或全无",
"infraRetryable": False, "costRmb": 0.0,
}
# 新编排使用语义明确的 --interaction-binding-file;Node 同时兼容历史 --interaction-binding。
argv.extend((
f"--interaction-binding-file={interaction_binding_file}",
f"--interaction-binding-file-hash={interaction_binding_file_hash}",
f"--interaction-profile-id={interaction_profile_id}",
f"--interaction-registry-version={interaction_registry_version}",
f"--interaction-binding-hash={interaction_binding_hash}",
))
try:
proc = subprocess.run(argv, cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True,
timeout=timeout, env=_playtest_env(), check=False)
@ -3216,6 +3340,8 @@ async def run_playtest_v3(request: dict, *, run_dir, artifact_hash: str, brief_h
proof_profile_id = str(identity.get("proofProfileId") or "")
proof_registry_version = str(identity.get("proofRegistryVersion") or "")
task_binding_hash = str(identity.get("taskBindingHash") or "")
interaction_binding = (identity.get("interactionBinding")
if isinstance(identity.get("interactionBinding"), dict) else None)
acceptance_request_hash = str(identity.get("acceptanceRequestHash") or "")
acceptance_mode = str(request.get("acceptanceMode") or cfg.get("mode") or "v3_shadow")
evidence_mode = str(request.get("evidenceMode") or "native")
@ -3245,6 +3371,12 @@ async def run_playtest_v3(request: dict, *, run_dir, artifact_hash: str, brief_h
)
if any(not provenance.get(field) for field in required_provenance):
raise ValueError("acceptance provenance 缺 request/manifest 路径或 manifest hash")
binding_provenance_fields = ("interactionBindingFile", "interactionBindingFileHash")
if interaction_binding is None:
if any(provenance.get(field) is not None for field in binding_provenance_fields):
raise ValueError("interactionBinding=null 时不得携带独立 binding 文件或 hash")
elif any(not provenance.get(field) for field in binding_provenance_fields):
raise ValueError("非空 interactionBinding 缺独立 binding 文件或 hash")
except Exception as e: # noqa: BLE001 —— 未知品类/坏注册表是 tester_error,禁止猜测义务后继续
return {"rolls": [], "rollGuards": [],
"merge": {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error",
@ -3294,7 +3426,12 @@ async def run_playtest_v3(request: dict, *, run_dir, artifact_hash: str, brief_h
acceptance_mode=acceptance_mode, evidence_mode=evidence_mode,
evidence_dir=str(run_dir), model_name=cfg["model"], port=port, cdp_port=cdp_port,
timeout=cfg["timeout_s"], remaining_cost_rmb=remaining, cfg=cfg,
legacy_mapping_file=request.get("legacyMappingFile"))
legacy_mapping_file=request.get("legacyMappingFile"),
interaction_binding_file=provenance.get("interactionBindingFile"),
interaction_binding_file_hash=provenance.get("interactionBindingFileHash"),
interaction_profile_id=(interaction_binding or {}).get("interactionProfileId"),
interaction_registry_version=(interaction_binding or {}).get("interactionRegistryVersion"),
interaction_binding_hash=(interaction_binding or {}).get("interactionBindingHash"))
raw["_evidenceDir"] = str(Path(run_dir) / f"roll-{roll_no}")
return raw
@ -3569,12 +3706,19 @@ def _verify_v3_sealed_reference_provenance(
for field in (
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "acceptanceRequestHash",
"proofRegistryVersion", "taskBindingHash", "interactionBinding", "acceptanceRequestHash",
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"designRef", "referenceAssetRecordIds", "consumerRef",
):
if manifest.get(field) != identity.get(field):
raise ValueError(f"sealed provenance {field} 与当前 acceptance identity 不一致")
expected_binding = identity.get("interactionBinding")
binding_bytes = snapshot.files.get("interaction-binding.json")
if expected_binding is None:
if binding_bytes is not None:
raise ValueError("sealed provenance 的 null interactionBinding 不得残留独立 binding 文件")
elif binding_bytes != _stable_json_bytes_v3(expected_binding):
raise ValueError("sealed provenance 的独立 interaction binding 缺失或漂移")
if manifest.get("artifactHash") != artifact_hash:
raise ValueError("sealed provenance artifactHash 与当前产物不一致")
@ -3698,6 +3842,21 @@ def _validate_v3_repair_parent(request: dict, identity: dict,
identity.get("proofRegistryVersion"), parent.get("proofRegistryVersion")),
"taskBindingHash": (identity.get("taskBindingHash"), parent.get("taskBindingHash")),
}
try:
parent_snapshot = artifact_snapshot.capture_artifact_snapshot(Path(evidence_root) / parent_run_id)
parent_request_ref = f"acceptance-request-{parent.get('acceptanceRequestHash')}.json"
parent_request_bytes = parent_snapshot.files.get(parent_request_ref)
if (parent_request_bytes is None
or hashlib.sha256(parent_request_bytes).hexdigest() != parent.get("acceptanceRequestHash")):
raise ValueError("父 acceptance request 缺失或 hash 漂移")
parent_request = json.loads(parent_request_bytes.decode("utf-8"))
if (_stable_json_bytes_v3(parent_request) != parent_request_bytes
or _validate_v3_instance(_V3_ACCEPTANCE_REQUEST_SCHEMA, parent_request)):
raise ValueError("父 acceptance request 不是 canonical 合法请求")
except Exception as exc: # noqa: BLE001 interaction binding 血缘只能信封存父 request
return f"修回父 acceptance request 不可复核:{type(exc).__name__}: {exc}", 0.0
checks["interactionBinding"] = (
identity.get("interactionBinding"), parent_request.get("interactionBinding"))
for field, (actual, expected) in checks.items():
if actual != expected:
return f"修回身份 {field} 未锁定父决策", 0.0
@ -3748,6 +3907,7 @@ async def _run_acceptance_v3_locked(request: dict) -> dict:
proof_profile_id=identity.get("proofProfileId"),
proof_registry_version=identity.get("proofRegistryVersion"),
task_binding_hash=identity.get("taskBindingHash"),
interaction_binding=identity.get("interactionBinding"),
design_ref=identity.get("designRef"),
reference_asset_record_ids=identity.get("referenceAssetRecordIds"),
consumer_ref=identity.get("consumerRef"),
@ -3864,6 +4024,7 @@ async def _run_acceptance_v3_locked(request: dict) -> dict:
or genre not in _V3_GENRES or repair_count != identity["repairOrdinal"]
or (mode in ("v3", "v3_shadow") and evidence_mode != "native")
or (mode == "historical_replay" and evidence_mode != "legacy-adapted")
or (identity.get("interactionBinding") is not None and mode != "v3_shadow")
)
if (invalid_request or registry_error or repair_parent_error or cost_input_error or config_cost_error
or reference_asset_provenance_error):

View File

@ -18,6 +18,7 @@ env:NO_PROXY(网关直连绕代理) + NEWAPI_KEY(_bootstrap 自动从凭据档
import asyncio
import json
import os
import sys
import time
from pathlib import Path
@ -35,8 +36,42 @@ _MAXTOK = genconfig.get("model", "max_tokens", 16000)
# 累计成本硬停线(¥):无人值守跑 25 局,任何一轮累计越线立即停机 PARTIAL(单局另有 ¥10 软/¥15 硬 breaker,此为跨局兜底)。
_HARD_STOP_RMB = 200.0
# ── W-AXIS-V2 波3 批次隔离旋钮(env-gated,默认保持 07-09 hard 基线行为完全不变)──
# WAX_GID_PREFIX:gid 前缀(默认 hard);波3 传 wax2 → gid=wax2-<genre>-r<N>,与 07-09 基线 distinct、不撞归档。
# WAX_JSONL_NAME:落盘文件名(默认 wax-baseline.jsonl);波3 传 wax2-baseline.jsonl,两套基线干净分离可并排对账。
# WAX_ONLY_GENRE:只跑单品类(逐局单进程调用,适配 Bash 600s/局硬约束);默认空=全 5 品类。
_GID_PREFIX = os.environ.get("WAX_GID_PREFIX", "hard").strip() or "hard"
_JSONL_NAME = os.environ.get("WAX_JSONL_NAME", "wax-baseline.jsonl").strip() or "wax-baseline.jsonl"
_ONLY_GENRE = os.environ.get("WAX_ONLY_GENRE", "").strip()
# 结果逐行落盘(断点续跑不丢):每完成一局即 append 一行 JSON。
_JSONL = Path(__file__).resolve().parent / "results" / "wax-baseline.jsonl"
_JSONL = Path(__file__).resolve().parent / "results" / _JSONL_NAME
def _session_result_path(jsonl_path: Path, start_round: int, n_rounds: int) -> Path:
"""由逐局真相文件名派生本次聚合名,保证 wax/wax2 等批次命名空间不互相覆盖。"""
end_round = start_round + n_rounds - 1
return jsonl_path.with_name(f"{jsonl_path.stem}-r{start_round}-{end_round}.json")
def _aggregate_rows(jsonl_path: Path, start_round: int, n_rounds: int) -> list[dict]:
"""从逐行真相账重建指定轮次聚合,断点重入和分品类进程都不会覆盖既有结果。"""
if not jsonl_path.is_file():
return []
end_round = start_round + n_rounds - 1
rows = []
for line in jsonl_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
round_no = row.get("round")
if isinstance(round_no, int) and start_round <= round_no <= end_round:
rows.append(row)
return rows
# 5 个【已覆盖品类】brief——各命中一个 per-genre 黄金模板(route_genre 关键词确定性路由),取自批跑器既有 brief 池:
# narrative/trpg/heritage 用同题 brief(HARD 池);puzzle/sim-business 用跨主题 brief(XTHEME 池,该两类同题无 brief)。
@ -56,6 +91,12 @@ def _row_total_cost(row: dict) -> float:
v2 模式下 judge 段镜像 playtest 裁决、两者 costRmb 同值——取 max 只计一次(防同一笔钱双计);
shadow 模式下 judge 是判定器成本、playtest 是测试员成本,两笔真实分立——相加。据 acceptanceVersion 分辨。
"""
# v3 的 parentChainCostRmb 已含每轮 writer 与验收增量,是跨 repair 不重不漏的权威总成本。
if row.get("acceptanceVersion") in ("v3", "v3_shadow"):
try:
return float(row.get("parentChainCostRmb") or 0.0)
except (TypeError, ValueError):
return 0.0
gen = row.get("costRmb") or 0.0
j = row.get("judge") or {}
pt = row.get("playtest") or {}
@ -71,6 +112,41 @@ def _row_total_cost(row: dict) -> float:
return 0.0
def _v3_batch_metrics(summary: dict) -> dict:
"""从完整封存对象提取 factual 质量账;兼容镜像不参与质量分子。"""
final = summary.get("acceptanceV3") if isinstance(summary.get("acceptanceV3"), dict) else None
if final is None:
return {}
first = (summary.get("acceptanceV3FirstPass")
if isinstance(summary.get("acceptanceV3FirstPass"), dict) else final)
decision = final.get("decision") if isinstance(final.get("decision"), dict) else {}
first_decision = first.get("decision") if isinstance(first.get("decision"), dict) else {}
merge = final.get("merge") if isinstance(final.get("merge"), dict) else {}
obligations = [row for row in final.get("proofObligations") or [] if isinstance(row, dict)]
required = [row for row in obligations if row.get("required") is True]
legacy_compatibility = final.get("compatibility") if isinstance(final.get("compatibility"), dict) else {}
legacy_playtest = (legacy_compatibility.get("playtest")
if isinstance(legacy_compatibility.get("playtest"), dict) else {})
factual_accepted = final.get("outcome") == "accept" and decision.get("accepted") is True
first_accepted = first.get("outcome") == "accept" and first_decision.get("accepted") is True
repair_attempted = isinstance(summary.get("acceptanceV3FirstPass"), dict)
return {
"accepted": factual_accepted,
"firstPassAccepted": first_accepted,
"acceptedAfterRepair": bool(repair_attempted and factual_accepted),
"repairAttempted": repair_attempted,
"outcome": final.get("outcome"),
"rescuedByRoll": decision.get("rescuedByRoll") or merge.get("rescuedByRoll"),
"publishFrozen": decision.get("publishFrozen"),
"proofComplete": (bool(required) and all(row.get("status") == "satisfied" for row in required))
if final.get("schemaVersion") == "playtest/3"
else legacy_playtest.get("proofComplete"),
"acceptanceCostRmb": final.get("costRmb"),
"parentChainCostRmb": decision.get("parentChainCostRmb"),
"acceptanceVersion": final.get("acceptanceMode"),
}
def _prior_cumulative() -> float:
"""读已落盘 JSONL 里所有历史局的累计成本(跨多次调用续跑时,¥200 兜底要含之前轮次已花)。"""
if not _JSONL.is_file():
@ -89,7 +165,7 @@ def _prior_cumulative() -> float:
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}"
gid = f"{_GID_PREFIX}-{genre}-r{round_no}"
t0 = time.time()
# 对齐生产 create 路:brief→route_genre 选 per-genre 黄金模板作起点 + scaffold_desc 描述引导。
scaffold_template, routed_genre = route_genre(brief)
@ -113,14 +189,24 @@ async def _run_one(genre, brief, port, cdp, round_no):
layer = summary.get("failureLayer") or {} # classify_cheap_failure_layer 返回 {layer,reason,failedGates}
j = summary.get("judge") or {} # run_acceptance 写入(仅 run_gates∧finished 时有)
pt = summary.get("playtest") or {} # 测试员段(W-AXIS-V2 波2:v2/shadow 时有,批账观测口径)
v3_metrics = _v3_batch_metrics(summary)
return {
"genre": genre, "gid": gid, "round": round_no,
"template": scaffold_template or "_template(通用)",
# ── 验收信号分开记(W-AXIS-V2 波1 语义,别混):
"accepted": bool(summary.get("accepted")), # ★最终权威 = 四门投影 ∧ 测试员真玩(v2)/ 旧口径(shadow/v1)
# v3 用 factual decision 统计质量;v3_shadow 的 compatibility.accepted=false 仅表示冻结发布,不代表真玩失败。
"accepted": v3_metrics.get("accepted", bool(summary.get("accepted"))),
"firstPassAccepted": v3_metrics.get("firstPassAccepted"),
"acceptedAfterRepair": v3_metrics.get("acceptedAfterRepair"),
"repairAttempted": v3_metrics.get("repairAttempted"),
"outcome": v3_metrics.get("outcome"),
"rescuedByRoll": v3_metrics.get("rescuedByRoll"),
"publishFrozen": v3_metrics.get("publishFrozen"),
"proofComplete": v3_metrics.get("proofComplete"),
"acceptanceCostRmb": v3_metrics.get("acceptanceCostRmb"),
"parentChainCostRmb": v3_metrics.get("parentChainCostRmb"),
"floorPass": floor.get("pass"), # ★预筛权威 = 四门投影(A/B/C/D),取代 verdictPass 作预筛口径
"acceptanceVersion": summary.get("acceptanceVersion"), # v2/shadow/v1(shadow 与 v2 行靠它区分,防波3对账串数)
"shadowV2Accepted": summary.get("shadowV2Accepted"), # shadow 模式下 v2 若生效的影子裁决(新旧口径对照)
"acceptanceVersion": v3_metrics.get("acceptanceVersion", summary.get("acceptanceVersion")),
"verdictPass": v.get("pass"), # harness 原生九门/七门 AND,窗口内仅对照打印、【不作判定输入】
"finished": bool(summary.get("finished")), # 模型自称收敛(check+build 绿),不是验收
"ok": bool(summary.get("ok")), # run_studio 收口 ok(blocking 下 = accepted)
@ -140,7 +226,9 @@ async def _run_one(genre, brief, port, cdp, round_no):
"firstPlay": pt.get("firstPlay"), "model": pt.get("model"),
} if pt else None,
# ── 失败归因三层(driver_contract/mechanical/gameplay/none)+ 归档指针:
"failedGates": v.get("failedGates"),
# harness 原生失败门只作降观测对照,不参与 accepted/failureLayer/failureReason 最终归因。
"failedGates": v.get("failedGates"), # 兼容旧批账消费者,一个版本窗口后停读
"observedFailedGates": v.get("failedGates"), # 明确观测语义,保留 E/G/H 等证据但不误称最终失败
"failureLayer": layer.get("layer"),
"failureReason": layer.get("reason"),
"archivedPriorRunTo": summary.get("archivedPriorRunTo"), # distinct gid 首跑恒 None
@ -173,14 +261,32 @@ async def main():
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})")
# 断点续跑:已落盘 gid 直接跳过——防 Bash 600s 超时重入时同 gid 重跑/双写,守零重跑纪律。
done_gids = set()
if _JSONL.is_file():
for line in _JSONL.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
done_gids.add(json.loads(line).get("gid"))
except json.JSONDecodeError:
continue
# 品类过滤(WAX_ONLY_GENRE 逐局单跑);保留原 index 稳定 port 映射,避免残留 chrome 端口撞。
briefs = [(i, g, b) for i, (g, b) in enumerate(COVERED_BRIEFS) if not _ONLY_GENRE or g == _ONLY_GENRE]
print(f">>> W-AXIS 重测基线:前缀={_GID_PREFIX} 落盘={_JSONL.name} 品类={[g for _, g, _ in briefs]} "
f"本次跑 {n_rounds} 轮(r{start_round}..r{start_round + n_rounds - 1}),串行;"
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):
for cat_i, genre, brief in briefs:
gid = f"{_GID_PREFIX}-{genre}-r{round_no}"
if gid in done_gids:
print(f" ⏭ {gid} 已在 JSONL,跳过(零重跑)")
continue
# 串行:每局固定 port(同 index),前一局 chrome 已收才起下一局。
r = await _run_one(genre, brief, 4340 + cat_i * 2, 9242 + cat_i * 2, round_no)
session_rows.append(r)
@ -218,17 +324,18 @@ async def main():
print(f">>> 预筛(floorPass 四门投影):{fp}/{n}={fp / n:.0%} | 收敛(finished){fin}/{n}={fin / n:.0%} | degraded {deg}/{n}")
print(f">>> 对照(harness 原生 verdictPass,不作判定输入):{vp}/{n}={vp / n:.0%}")
print(f">>> 差额 = 四门过但测试员拒 {fp - acc} 局(机械门放行、测试员逮住)")
# shadow 灰度对照表(§4/波1 验收):shadow 行的旧口径 accepted vs v2 影子裁决(逐局对账,防波3串数)。
shadow_rows = [r for r in session_rows if r.get("acceptanceVersion") == "shadow"
and r.get("shadowV2Accepted") is not None]
if shadow_rows:
old_acc = sum(1 for r in shadow_rows if r.get("accepted"))
v2_acc = sum(1 for r in shadow_rows if r.get("shadowV2Accepted"))
print(f">>> shadow 对照表:{len(shadow_rows)} 局灰度,旧口径 accepted={old_acc} vs v2 影子 accepted={v2_acc}")
outcomes = {name: sum(1 for r in session_rows if r.get("outcome") == name)
for name in ("accept", "reject", "inconclusive", "tester_error")}
repaired = sum(1 for r in session_rows if r.get("acceptedAfterRepair"))
rescued = sum(1 for r in session_rows if r.get("rescuedByRoll") == 2)
frozen = sum(1 for r in session_rows if r.get("publishFrozen") is True)
proof = sum(1 for r in session_rows if r.get("proofComplete") is True)
print(f">>> v3 四态={outcomes} | repair 后接受={repaired} | 二掷救回={rescued} | 冻结={frozen} | 硬证完整={proof}")
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")
# 聚合从权威 JSONL 按轮次重建:断点重入跳过旧 gid 时仍保留历史行,分品类进程也只会增量汇合。
out = _session_result_path(_JSONL, start_round, n_rounds)
out.write_text(json.dumps(_aggregate_rows(_JSONL, start_round, n_rounds), ensure_ascii=False, indent=2),
encoding="utf-8")
print(f">>> 本次结果 → {out};逐局全量 → {_JSONL}")

View File

@ -0,0 +1,203 @@
"""生成 fresh Match-3 金标候选;只跑现行生成主链,不预写人工结论。
每款使用独立 gameId,串行执行 AgentScope Writer → check → build → stage → smoke。
本批不调用通用 puzzle 验收,后续统一交给 playtest/3 Match-3 专项门和真人浏览器定标。
"""
from __future__ import annotations
import asyncio
import hashlib
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_roles import SCAFFOLD_DESC_BY_TEMPLATE # noqa: E402
from worker import genconfig # noqa: E402
RESULTS_DIR = Path(__file__).resolve().parent / "results"
RESULTS_FILE = RESULTS_DIR / "match3-gold-generation-r3.jsonl"
MANIFEST_FILE = RESULTS_DIR / "match3-gold-batch-manifest-r3.json"
GAME_ROOT = Path(__file__).resolve().parents[1] / "game-runtime" / "games"
TEMPLATE = "_template-puzzle"
MODEL = "MiniMax-M3"
INTERACTION_PROFILE_ID = "match3.orthogonal-swap-v1"
MAX_TOKENS = genconfig.get("model", "max_tokens", 32000)
HARD_STOP_RMB = 75.0
COMMON_REQUIREMENTS = """
玩法必须是经典相邻交换三消解谜,不得改成点选找目标、华容道或点击消除。使用竖屏 390×844,init 后首屏必须直接进入 8×8 棋盘,禁止创建或启动 menu。玩家先点一个棋子,再点上下左右相邻棋子;有效交换形成至少三个同类连续棋子,随后消除、下落、补新棋子并允许连锁;无效交换必须换回并显示换回结果,盘面不得保留无效交换。每次交换消耗一步,共 20 步。本关目标是达到 120 分:首次达到目标后进入并驻留 gameover 成功页;20 步耗尽仍未达到目标则进入并驻留 gameover 失败页。两种终态都显示分数、已用步数和重玩入口。
平台集成必须完整:ctx.log 只能写诊断,所有业务事实必须通过 ctx.event 写原生 game 事件。先 read_file contracts/play-loop/game-event.schema.json,再按 schema 原样实现,禁止自创字段。每次有效交换依次写:puzzle.move-applied 的 payload 为 { moveId, boardHash:{ before, after } };puzzle.swap-committed 的 payload 只能是 { moveId, pair:[{row,column},{row,column}], boardRevisionBefore, boardRevisionAfter };紧接着 puzzle.match-formed 的 payload 只能是 { moveId, runs:[{axis,cells}], matchedCells, matchedCount }。swap 与 match 使用同一非空 moveId,pair、runs、matchedCells 均为真实棋格且使用 1-based row/column;matchedCells 是 runs 的去重并集,matchedCount 与其长度一致。达到 120 分时写 puzzle.goal-reached { goalId:'score-120', score },再写 puzzle.level-completed { level:1, result:{ score, movesUsed, movesLeft } }。不得伪造事件或在未形成新三连时记录成功;ctx.event 返回 false 必须作为错误记录,不能用 ctx.log 冒充成功。
_forensicsView().state() 必须只读返回 phase、result、score、movesLeft、8×8 board 快照和全部棋格中心 targets;棋格中心使用逻辑像素坐标。棋盘图标必须清晰可分,格心、行列间距和棋盘几何在点击前后保持稳定。使用 runtime 注入能力实现反馈,不引入外部网络资源。check 与 build 全绿后立即 finish。
本任务绑定 match3.orthogonal-swap-v1。先 read_file game-runtime/src/plugins/match3-producer-profile/api.d.ts 和 game-runtime/src/plugins/match3-visual-timeline/api.d.ts,再从 plugins 解构受保护的 match3ProducerProfile 与 match3VisualTimeline。棋盘必须逐格调用 drawCellShell;逐格调用 themeSafeBounds(cell),格心与命中只用其 centerX/centerY,主题图标只能画在返回的安全内区;选中态只能调用 drawSelectionOverlay。不得自行计算另一套棋盘坐标,不得自行绘制高亮、填充、缩放或环形描边。第一次点选只更新 selected,除 canonical 白环外整帧不得产生其他像素变化,也不得写任何业务事件。
第二次合法相邻点击必须在同一次 handleTap 同步调用栈内完成盘面提交、初始三连、全部确定性连锁、重力补新、计分和目标/通关判定,并按实际发生顺序写 puzzle.move-applied / puzzle.swap-committed / 每轮 puzzle.match-formed / puzzle.goal-reached / puzzle.level-completed;这些业务事实不得延迟。同步结算同时收集标准 0-based 8×8 beforeKinds、swappedKinds、每轮 {runs,matchedCells,afterFallKinds}、finalKinds 与 pendingTerminal,并立即调用 match3VisualTimeline.begin(...) 封存视觉事务。handleTap 最前面必须 `if (match3VisualTimeline.isLocked()) return;`,锁定期间不得改 selected、盘面、分数、步数、revision、事件或终局。update(dt) 必须调用 match3VisualTimeline.update(dt),render(g) 必须读取 match3VisualTimeline.view().tiles 绘制交换、匹配停留、消失、下落补位和逐轮连锁,禁止读取已经完成全部补位的业务终盘伪装动画。重玩必须调用 reset()。业务终态可以同步 latch,但成功/失败场景只能在 timeline settle 后显示。无效交换必须同步换回业务盘面,同时用 valid:false 事务播放 swap_forward→rollback→settle;若耗尽最后一步,失败结果仍同步封存但延迟显示。若不遵守,生成静态门、真实 Chromium profile preflight 或后续视觉事务门会拒绝。
""".strip()
CANDIDATES = (
{
"gameId": "gold-m3-gem-r3",
"label": "高对比宝石基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:深色背景上的六类高对比几何宝石。主题图标全部限制在 capability 返回的安全内区;消除有粒子、音效和分数反馈。",
},
{
"gameId": "gold-m3-candy-r3",
"label": "糖果形状基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类颜色和轮廓都不同的糖果。糖果主体不得接触 capability 的 selection support;背景与棋格保持高对比。",
},
{
"gameId": "gold-m3-porcelain-r3",
"label": "中式器物图标",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类中式瓷器、玉佩、铜钱等程序化图标,不使用纯色方块冒充。每类保持稳定轮廓和配色,同类跨棋格外观一致,图标不得越出 capability 的主题安全区。",
},
{
"gameId": "gold-m3-fruit-r3",
"label": "水果轮廓基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类轮廓明显不同的水果切片,使用稳定程序化形状和高对比配色。同类跨棋格外观一致,不加入障碍、万能块或特殊棋子。",
},
{
"gameId": "gold-m3-rune-r3",
"label": "符文图形基准",
"brief": COMMON_REQUIREMENTS + "\n\n美术主题:六类形状和内部刻纹都不同的发光符文,保持静态稳定,不做呼吸、闪烁或漂移动效。同类跨棋格外观逐像素一致,不加入障碍、万能块或特殊棋子。",
},
)
def _batch_manifest() -> dict:
"""完整封存本批合成题面;不受通用用户 brief 的 1000 字符隐私限长影响。"""
return {
"schemaVersion": "Match3GoldGenerationBatch/3",
"template": TEMPLATE,
"model": MODEL,
"candidates": [
{
"gameId": candidate["gameId"],
"label": candidate["label"],
"brief": candidate["brief"],
"briefSha256": hashlib.sha256(candidate["brief"].encode("utf-8")).hexdigest(),
}
for candidate in CANDIDATES
],
}
def _write_batch_manifest() -> None:
"""生成前先写完整 manifest,硬失败或中断也能还原模型收到的全部题面。"""
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
MANIFEST_FILE.write_text(
json.dumps(_batch_manifest(), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _load_completed_ids() -> set[str]:
"""只认已落 JSONL 的 gameId;失败同样属于金标候选,不用同 ID 洗结果。"""
if not RESULTS_FILE.is_file():
return set()
completed = set()
for line in RESULTS_FILE.read_text(encoding="utf-8").splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row.get("gameId"), str):
completed.add(row["gameId"])
return completed
def _prior_cost() -> float:
"""跨次运行累计成本必须计入硬停线,避免断点续跑绕过批次预算。"""
if not RESULTS_FILE.is_file():
return 0.0
total = 0.0
for line in RESULTS_FILE.read_text(encoding="utf-8").splitlines():
try:
total += float(json.loads(line).get("costRmb") or 0.0)
except (json.JSONDecodeError, TypeError, ValueError):
continue
return total
async def _run_candidate(candidate: dict) -> dict:
"""执行单款 generation-only;异常也原样入账,供后续负金标归因。"""
started = time.time()
game_id = candidate["gameId"]
try:
summary = await cheap_studio.run_studio(
game_id,
candidate["brief"],
run_gates=False,
interaction_profile_id=INTERACTION_PROFILE_ID,
scaffold_template=TEMPLATE,
scaffold_desc=SCAFFOLD_DESC_BY_TEMPLATE[TEMPLATE],
genre="puzzle",
verify_richness_enabled=False,
max_tokens=MAX_TOKENS,
)
except Exception as exc: # noqa: BLE001 - 生成异常本身就是需要保留的样本事实。
return {
"gameId": game_id,
"label": candidate["label"],
"generated": False,
"finished": False,
"staged": False,
"smokeOk": False,
"costRmb": 0.0,
"wallSec": round(time.time() - started, 1),
"error": f"{type(exc).__name__}: {exc}",
}
return {
"gameId": game_id,
"label": candidate["label"],
"generated": True,
"finished": bool(summary.get("finished")),
"checkOk": bool((summary.get("check") or {}).get("ok")),
"buildOk": bool((summary.get("build") or {}).get("ok")),
"staged": bool(summary.get("staged")),
"smokeOk": bool((summary.get("smoke") or {}).get("ok")),
"attempts": summary.get("attempts"),
"costRmb": float(summary.get("costRmb") or 0.0),
"tokens": summary.get("tokens"),
"wallSec": round(time.time() - started, 1),
"sourceDir": f"game-runtime/games/amgen-{game_id}",
"stagedDir": f"game-runtime/games/_wg1-gen/{game_id}",
"runSummary": f"game-runtime/games/amgen-{game_id}/evidence/run-summary.json",
}
async def main() -> int:
"""按声明顺序串行生成前 N 款;默认五款,CLI 可先用 1 做冒烟。"""
requested = int(sys.argv[1]) if len(sys.argv) > 1 else len(CANDIDATES)
if requested < 1 or requested > len(CANDIDATES):
raise ValueError(f"候选数量必须是 1..{len(CANDIDATES)}")
_write_batch_manifest()
completed = _load_completed_ids()
cumulative = _prior_cost()
print(f">>> fresh Match-3 生成批:目标 {requested} 款,已花 ¥{cumulative:.4f},硬停 ¥{HARD_STOP_RMB:.0f}")
for candidate in CANDIDATES[:requested]:
if candidate["gameId"] in completed:
print(f">>> 跳过已落盘 {candidate['gameId']},同 ID 不重跑")
continue
row = await _run_candidate(candidate)
with RESULTS_FILE.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
cumulative += float(row.get("costRmb") or 0.0)
print(f">>> {candidate['gameId']} finished={row.get('finished')} staged={row.get('staged')} "
f"smoke={row.get('smokeOk')} cost=¥{row.get('costRmb')} wall={row.get('wallSec')}s")
if cumulative > HARD_STOP_RMB:
print(f">>> 累计成本 ¥{cumulative:.4f} 超过硬停线,停止剩余候选")
return 2
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -9,4 +9,6 @@ agentscope[service,storage]==2.0.3
openai>=2.41.1 # 便宜档走 OpenAI 兼容端点(new-api 的 MiniMax-M3,base_url 带 /v1,/v1/chat/completions)
pyyaml # generation.yaml 熔断/预算旋钮外置(对 tier2 的 genconfig)
json-repair # 脏 JSON 工具参数兜底(承袭 tier2/裸路)
Pillow==12.3.0 # playtest/3 Actor guide 离屏合成;与 prompt eval 共用同一锁版 renderer
opencv-python-headless==4.13.0.92 # playtest/3 VisualTargetSet 离线轮廓、连通域与重复布局识别
nacos-sdk-python==1.0.0 # 配置控制面热源:cheap 经 sys.path 复用 tier2 的 worker.genconfig_nacos,该包须与 tier2/gen-worker 同版声明;漏声明则新建 venv 起 Service 时 Nacos 热源静默断裂、回落本地 YAML(2026-07-05 dev 起栈真暴露)

View File

@ -9,7 +9,8 @@
(后端 resolveEngineBundleText 取值落 GamePackage.engineBundle 入 feed);
· sourceProject = 源工程 2.0 JSON 字符串 = best-effort 可选源血缘(additive,缺则后端源落库旁路)。
成功门:verdict.pass ∧ engineBundle 含 __GameBundle,否则 status=failed + 七值 failureReason。
成功门:playtest/3 active v3 的可信 accept(未冻结且硬证完整)或存量验收通过,且 engineBundle 含
__GameBundle;否则 status=failed + 契约枚举内 failureReason。
纯函数(吃 job + run-summary + 产物目录,出 dict),无网络无 LLM、同输入恒同输出——可单测。
"""
@ -17,6 +18,7 @@ import hashlib
import json
from pathlib import Path
import artifact_snapshot
import cheap_verify # W-AXIS-V2 波1:四门投影(project_floor)= 便宜档预筛权威的唯一产地
# §6.1 result-out / 契约#6 output.failureReason 枚举(对齐后端 FailureReasonEnum,真验改正:
@ -29,6 +31,169 @@ _FAILURE_REASONS = {
"llm_error", "timeout", "asset_gen_failed", "quota_exhausted",
}
# v3/v3_shadow 是生产 playtest/3 闭环的声明值;historical_replay 与未知值也必须识别为新协议,
# 但永远不可发布。只有明确的 v1/v2 才能进入存量兼容回落。
_V3_MODES = {"v3", "v3_shadow"}
_LEGACY_MODES = {"v1", "v2"}
def _has_v3_projection_marker(value) -> bool:
"""轻量投影是否已经声明 playtest/3;只用于识别协议代际,不参与通过判定。"""
return isinstance(value, dict) and any(key in value for key in (
"schemaVersion", "outcome", "acceptanceMode", "publishFrozen", "proofComplete", "rollMerge",
))
def _declared_v3_mode(summary: dict) -> str | None:
"""识别 run-summary 是否声明了 v3;声明后丢失 acceptanceV3 必须 fail-closed。
这里仅判断“是否进入 v3 协议”,不判断结果真假。完整真假仍只交给
``cheap_verify.validate_acceptance_v3_payload`` / ``is_v3_publishable``。
"""
for key in ("acceptanceVersion", "acceptanceMode"):
if key not in summary:
continue
mode = summary.get(key)
if not isinstance(mode, str) or mode not in _LEGACY_MODES:
return mode if isinstance(mode, str) and mode else "unknown"
if "publishFrozen" in summary:
return "v3"
playtest = summary.get("playtest")
if _has_v3_projection_marker(playtest):
mode = playtest.get("acceptanceMode")
return mode if mode in _V3_MODES else "v3"
trace = summary.get("trace")
if isinstance(trace, dict):
if "acceptanceVersion" in trace:
mode = trace.get("acceptanceVersion")
if not isinstance(mode, str) or mode not in _LEGACY_MODES:
return mode if isinstance(mode, str) and mode else "unknown"
if "publishFrozen" in trace or _has_v3_projection_marker(trace.get("playtest")):
playtest = trace.get("playtest") if isinstance(trace.get("playtest"), dict) else {}
mode = playtest.get("acceptanceMode")
return mode if mode in _V3_MODES else "v3"
return None
def _acceptance_v3_payload(summary: dict) -> dict | None:
"""取 run-summary 内完整封存的 playtest/2 或 playtest/3 结果;坏形态触发 fail-closed。
生产三路统一把完整结果放在 ``summary['acceptanceV3']``。允许 summary 本身就是封存对象,
仅用于纯函数测试和离线回放;不再接受其他别名,避免多入口逐渐漂移成多份事实源。
"""
if "acceptanceV3" in summary:
value = summary.get("acceptanceV3")
return value if isinstance(value, dict) else {}
if (summary.get("schemaVersion") in ("playtest/2", "playtest/3")
and isinstance(summary.get("decision"), dict)):
return summary
# v3 声明已出现却没有完整封存对象,说明入口发生降级或字段丢失;返回空对象触发统一 fail-closed。
if _declared_v3_mode(summary) is not None:
return {}
return None
def _v3_playtest_projection(v3: dict, *, engine_bundle_hash: str | None) -> dict:
"""把封存决策压成后端 trace.playtest;事实直接取顶层,兼容层只做镜像。"""
decision = v3.get("decision") if isinstance(v3.get("decision"), dict) else {}
compatibility = v3.get("compatibility") if isinstance(v3.get("compatibility"), dict) else {}
merge = v3.get("merge") if isinstance(v3.get("merge"), dict) else {}
floor = v3.get("floor") if isinstance(v3.get("floor"), dict) else {}
final_postguard = v3.get("finalPostguard") if isinstance(v3.get("finalPostguard"), dict) else {}
first_play = v3.get("firstPlay") if isinstance(v3.get("firstPlay"), dict) else {}
obligations = [row for row in v3.get("proofObligations") or [] if isinstance(row, dict)]
required_obligations = [row for row in obligations if row.get("required") is True]
evidence_refs = {
"actions": list(dict.fromkeys(ref for row in obligations for ref in row.get("actionRefs") or [])),
"frames": list(dict.fromkeys(ref for row in obligations for ref in row.get("postFrameRefs") or [])),
"events": [str(ref) for ref in dict.fromkeys(
ref for row in obligations for ref in row.get("eventRefs") or [])],
}
outcome = decision.get("outcome")
rescued_by_roll = decision.get("rescuedByRoll")
return {
"schemaVersion": v3.get("schemaVersion"),
"acceptanceMode": v3.get("acceptanceMode"),
# 六字段是 Java 发布闸读取的最小防重放身份。hash 只来自当前已验证暂存产物;
# 暂存产物不可复核时保留键但置空,让可信消费侧明确 fail-closed。
"runId": v3.get("runId"),
"gameId": v3.get("gameId"),
"briefHash": v3.get("briefHash"),
"artifactHash": v3.get("artifactHash"),
"taskBindingHash": v3.get("taskBindingHash"),
"acceptanceRequestHash": v3.get("acceptanceRequestHash"),
"engineBundleHash": engine_bundle_hash,
"outcome": outcome,
# accepted 不复制兼容字段,始终由权威 outcome 单向派生,保证 accepted == (outcome == accept)。
"accepted": outcome == "accept",
"publishFrozen": decision.get("publishFrozen"),
"compatibilityAccepted": compatibility.get("accepted"),
"compatibilityOk": compatibility.get("ok"),
"proofComplete": outcome == "accept" and bool(required_obligations) and all(
row.get("status") == "satisfied" for row in required_obligations),
"rollMerge": merge.get("mergeCandidate"),
"mergeConflicts": merge.get("conflicts"),
"rescued": rescued_by_roll in (1, 2),
"floorPass": floor.get("pass"),
"finalPostguardPass": final_postguard.get("pass"),
"writeAuthority": final_postguard.get("writeAuthority"),
"finalChecks": final_postguard.get("checks"),
"blockingProblems": final_postguard.get("blockingProblems"),
"contradictions": final_postguard.get("contradictions"),
"evidenceRefs": {
"actions": evidence_refs.get("actions"),
"frames": evidence_refs.get("frames"),
"events": evidence_refs.get("events"),
},
"firstPlay": {
"playableAtMs": first_play.get("interactiveAtVirtualMs"),
"firstFeedbackMs": first_play.get("firstFeedbackAtVirtualMs"),
"loopClosed": first_play.get("loopClosed"),
"proofRefs": first_play.get("proofObligationRefs"),
},
}
def _v3_publishable(summary: dict, v3: dict, *, release_binding_ok: bool) -> bool:
"""复用 canonical 发布谓词,并要求本次任务、摘要和产物均绑定到同一封存决策。"""
if not release_binding_ok:
return False
if not cheap_verify.is_v3_publishable(v3):
return False
compatibility = v3.get("compatibility") if isinstance(v3.get("compatibility"), dict) else {}
# 直接离线回放时 summary 就是封存对象,没有平铺层;生产 run-summary 则要求已有字段逐字镜像。
if summary is not v3:
for key in ("accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId"):
if key in summary and summary.get(key) != compatibility.get(key):
return False
return True
def _v3_observation_state(v3: dict, publishable: bool, canonical_valid: bool) -> str:
"""把不可发布原因压成稳定观测态;不参与发布判定。"""
if not v3:
return "downgrade_detected"
mode = v3.get("acceptanceMode")
decision = v3.get("decision") if isinstance(v3.get("decision"), dict) else {}
# historical_replay 与未知模式即使对象本身 contract 合法,也只供离线校准,不能伪装成 active/frozen。
if not isinstance(mode, str) or mode not in _V3_MODES:
return "downgrade_detected"
if mode == "v3_shadow":
return "shadow"
outcome = decision.get("outcome")
if outcome == "accept" and decision.get("publishFrozen") is True:
return "frozen"
if not canonical_valid:
return "downgrade_detected"
if outcome in ("reject", "inconclusive", "tester_error"):
return outcome
if publishable:
return "active"
# 完整对象存在但 canonical 校验未过,仍按协议降级单列,不能伪装成普通玩法失败。
return "downgrade_detected"
def newapi_error_status_text(exc) -> tuple:
"""从一个 new-api 调用异常里尽力抽出 (http_status, 错误体文本),供 classify_newapi_failure_reason 分辨(§3.9)。
@ -82,6 +247,9 @@ def classify_newapi_failure_reason(status=None, text: str = "") -> str:
# 源工程 2.0 profile 必填三枚举(contracts/agent-loop/source-project.schema.json:39)。
_REQUIRED_PROFILE_KEYS = ("tickModel", "inputModel", "progressModel")
_SOURCE_PROJECT_SCAFFOLDS = {
"_template-story", "_template-trpg", "_template-feiyi", "_template-puzzle", "_template-shop",
}
def _valid_profile(profile) -> bool:
@ -95,16 +263,21 @@ def _source_hash(files: dict) -> str:
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
def build_source_project(game_dir, profile, entry: str = "entry.js") -> str | None:
def build_source_project(game_dir, profile, entry: str = "entry.js", *, scaffold_template=None,
origin_brief=None, origin_brief_hash=None) -> str | None:
"""best-effort 组源工程 2.0 JSON 字符串(additive 可选源血缘)。
读 amgen-<id>/src/*.js 组 files、算 sourceHash、填 profile;**profile 三枚举不全 / src 空 → 返 None**
读 amgen-<id>/src/*.js 组 files、算 sourceHash、填 profile;可信 create 路可附 scaffoldTemplate,
供后续 v3 modify 锁定原 proof profile。**profile 三枚举不全 / src 空 / template 非白名单 → 返 None**
(省略 sourceProject,后端源落库旁路、不阻断成功,绝不为凑 schema 伪造 profile)。
Args:
game_dir: amgen-<id>/ 目录(读其下 src/)。
profile: {tickModel,inputModel,progressModel};上游从 play-spec/品类派生,派生不出传 None。
entry: esbuild 入口相对路径(默认 entry.js)。
scaffold_template: 已封存 acceptanceV3.templateRoute;历史记录可不传,禁止传任意模板名。
origin_brief: create 时冻结的原始题面;active v3 必传,历史记录可不传。
origin_brief_hash: origin_brief 的 UTF-8 sha256;与题面不自洽时拒绝组源。
Returns:
合法 2.0 源工程 JSON 字符串,或 None(无法组出合法 2.0 时)。
@ -117,6 +290,13 @@ def build_source_project(game_dir, profile, entry: str = "entry.js") -> str | No
files[f"src/{p.name}"] = p.read_text(encoding="utf-8")
if not files or not _valid_profile(profile):
return None
if scaffold_template is not None and scaffold_template not in _SOURCE_PROJECT_SCAFFOLDS:
return None
if origin_brief is not None or origin_brief_hash is not None:
if (not isinstance(origin_brief, str) or not origin_brief.strip()
or not isinstance(origin_brief_hash, str)
or hashlib.sha256(origin_brief.encode("utf-8")).hexdigest() != origin_brief_hash):
return None
source_project = {
"schemaVersion": "2.0",
"sourceHash": _source_hash(files),
@ -125,6 +305,13 @@ def build_source_project(game_dir, profile, entry: str = "entry.js") -> str | No
"entry": entry,
"globalName": "__GameBundle",
}
# sourceHash 的冻结契约明确只覆盖 files,不暗改既有可寻址语义;scaffoldTemplate 随完整
# sourceProject JSON 由可信回调和数据库 canonical hash 封存,跨请求消费时再与 acceptance identity 对账。
if scaffold_template is not None:
source_project["scaffoldTemplate"] = scaffold_template
if origin_brief is not None:
source_project["originBrief"] = origin_brief
source_project["originBriefHash"] = origin_brief_hash
return json.dumps(source_project, ensure_ascii=False)
@ -137,10 +324,122 @@ def _derive_title(brief: str) -> str:
def _read_bundle(game_dir: Path) -> str | None:
"""读 amgen-<id>/bundle.iife.js 全文(engineBundle 真值);缺失返 None。"""
p = Path(game_dir) / "bundle.iife.js"
return p.read_text(encoding="utf-8") if p.is_file() else None
try:
return p.read_text(encoding="utf-8") if p.is_file() else None
except (OSError, UnicodeError):
return None
def _map_failure_reason(summary: dict, explicit: str | None) -> str:
def _capture_artifact_snapshot(root: Path) -> artifact_snapshot.ArtifactSnapshot:
"""发布入口复用验收入口的唯一可信快照实现。"""
return artifact_snapshot.capture_artifact_snapshot(root)
def _source_files_from_snapshot(snapshot: artifact_snapshot.ArtifactSnapshot) -> dict | None:
"""只从已封存快照解码 SourceProject 的直接 ``src/*.js`` 文件。"""
files = {}
for relative, data in snapshot.files.items():
if not relative.startswith("src/") or not relative.endswith(".js"):
continue
if "/" in relative[len("src/"):]:
continue
files[relative] = data.decode("utf-8")
return files or None
def _v3_source_project_matches_staged(source_project, v3: dict, staged_files) -> bool:
"""复核 v3 SourceProject 确实描述本次验收的 staged 源,而不是发布目录的后验漂移。"""
try:
value = json.loads(source_project) if isinstance(source_project, str) else source_project
except (TypeError, ValueError):
return False
if not isinstance(value, dict) or not isinstance(staged_files, dict) or not staged_files:
return False
if (value.get("schemaVersion") != "2.0" or value.get("globalName") != "__GameBundle"
or value.get("entry") != "entry.js" or not _valid_profile(value.get("profile"))
or value.get("scaffoldTemplate") != v3.get("templateRoute")
or not isinstance(value.get("originBrief"), str) or not value["originBrief"].strip()
or value.get("originBriefHash") != v3.get("briefHash")
or hashlib.sha256(value["originBrief"].encode("utf-8")).hexdigest() != value.get("originBriefHash")
or value.get("files") != staged_files
or value.get("sourceHash") != _source_hash(staged_files)):
return False
return True
def _v3_release_binding(job: dict, summary: dict, v3: dict, game_dir) -> dict:
"""把 active v3 的 accept 绑定到当前 job、当前 run-summary 与当前暂存/发布 bundle。
``decision.json`` 只证明某次验收接受了某个 staged artifact;它本身不能授权另一任务、另一题面或
后续被替换的 bundle。这里在最终回调边界重新计算 staged artifactHash,并要求发布目录 bundle 与
staged bundle 原始字节完全相同。任何字段缺失、路径异常或字节漂移都只返回不可发布,不抛出回调线程。
"""
result = {"ok": False, "bundleText": None, "engineBundleHash": None, "stagedSourceFiles": None}
try:
game_id_value = job.get("gameId")
if game_id_value is None:
return result
game_id = str(game_id_value)
if not cheap_verify._is_safe_game_id_v3(game_id):
return result
if v3.get("gameId") != game_id or str(summary.get("gameId")) != game_id:
return result
trace_id = job.get("traceId") or job.get("job_id")
if v3.get("taskBindingHash") != cheap_verify.task_binding_hash_v3(trace_id):
return result
# create 的 job.brief 就是验收题面;modify/regen 的 brief 是修改指令,原题面由 SourceProject/本地
# brief 文件继承,因此只在 create 路执行此等值,和 Java callback gate 保持同一口径。
is_modify = bool(job.get("modifyMode")) or job.get("baseVersionId") not in (None, "")
current_brief_hash = hashlib.sha256(str(job.get("brief") or "").encode("utf-8")).hexdigest()
if not is_modify and v3.get("briefHash") != current_brief_hash:
return result
# run-summary 必须显式镜像本次刚完成的封存身份。直接拿旧 decision.json 作为 summary,或把旧
# acceptanceV3 塞进新摘要,都无法满足这组逐字段绑定。
summary_identity = {
"acceptanceRunId": v3.get("runId"),
"acceptanceRequestHash": v3.get("acceptanceRequestHash"),
"acceptanceTaskBindingHash": v3.get("taskBindingHash"),
"acceptanceArtifactHash": v3.get("artifactHash"),
"acceptanceBriefHash": v3.get("briefHash"),
}
if any(summary.get(key) != expected for key, expected in summary_identity.items()):
return result
release_dir = Path(game_dir)
if release_dir.is_symlink() or release_dir.name != f"amgen-{game_id}":
return result
staged_dir = release_dir.parent / "_wg1-gen" / game_id
if not staged_dir.is_dir() or staged_dir.is_symlink():
return result
# 一次捕获后立即在内存快照上复算验收 hash;之后 bundle/source 均只读 snapshot,
# 不再用“前后两次目录 hash”推断中间读取没有遭遇改写→恢复。
snapshot = _capture_artifact_snapshot(staged_dir)
if snapshot.artifact_hash != v3.get("artifactHash"):
return result
release_bundle = release_dir / "bundle.iife.js"
staged_bytes = snapshot.files.get("bundle.iife.js")
if staged_bytes is None or not release_bundle.is_file() or release_bundle.is_symlink():
return result
if release_bundle.read_bytes() != staged_bytes:
return result
bundle_text = staged_bytes.decode("utf-8")
if "__GameBundle" not in bundle_text:
return result
engine_bundle_hash = hashlib.sha256(staged_bytes).hexdigest()
# 最终 callback 发送的是该文本;显式复算,防未来 decode/变换改动让 trace hash 与真实文本分叉。
if hashlib.sha256(bundle_text.encode("utf-8")).hexdigest() != engine_bundle_hash:
return result
staged_source_files = _source_files_from_snapshot(snapshot)
return {"ok": True, "bundleText": bundle_text, "engineBundleHash": engine_bundle_hash,
"stagedSourceFiles": staged_source_files}
except (OSError, UnicodeError, ValueError, TypeError):
return result
def _map_failure_reason(summary: dict, explicit: str | None, *, trust_summary: bool = True) -> str:
"""把 run-summary 的失败信号映射到契约#6 failureReason 枚举。
优先级:显式入参(在枚举内)> summary.failureReason(在枚举内)> catch-all `llm_error`。
@ -149,15 +448,17 @@ def _map_failure_reason(summary: dict, explicit: str | None) -> str:
其余图内部真因[九门不过 / 未收敛 / 预算熔断 / engineBundle 缺]不在枚举内 → 归「生成执行链异常」最近桶
(SaaGraphDispatcher 同口径,真因已落 summary/日志);枚举无 budget/generation 专项值,不强造、统一 llm_error。
"""
if explicit and explicit in _FAILURE_REASONS:
if isinstance(explicit, str) and explicit in _FAILURE_REASONS:
return explicit
svc_reason = (summary or {}).get("failureReason")
if svc_reason and svc_reason in _FAILURE_REASONS:
return svc_reason
if trust_summary:
svc_reason = (summary or {}).get("failureReason")
if isinstance(svc_reason, str) and svc_reason in _FAILURE_REASONS:
return svc_reason
return "llm_error"
def _build_trace(summary: dict) -> dict | None:
def _build_trace(summary: dict, *, v3: dict | None, v3_publishable: bool,
v3_canonical_valid: bool, engine_bundle_hash: str | None) -> dict | None:
"""从(U1 富化的)run-summary 组 9d trace(camelCase,镜像 wg1 service._extract_trace;后端 D11
ReadinessScorer 据此算就绪分)。成功/失败路都抽(失败路 repairs/attempts/stage/wallS 仍有排障价值);
summary 空(兜底路)→ None(不塞 trace)。
@ -198,8 +499,12 @@ def _build_trace(summary: dict) -> dict | None:
if driver_type is not None:
trace["gatespec"] = {"driver": driver_type}
# cost.totalRmb(维持现状,additive 成本台账;顶层不可有 costRmb,会被后端 ObjectMapper 拒)。
if "costRmb" in summary:
trace.setdefault("cost", {})["totalRmb"] = summary["costRmb"]
# v3 三路接线若未把成本平铺回 summary,则从封存结果取同一笔验收成本。
cost_rmb = summary.get("costRmb")
if cost_rmb is None and v3 is not None:
cost_rmb = v3.get("costRmb")
if cost_rmb is not None:
trace.setdefault("cost", {})["totalRmb"] = cost_rmb
# similarity(U3 D9 查重产;additive,有则带)。
similarity = summary.get("similarity")
if similarity is not None:
@ -207,20 +512,28 @@ def _build_trace(summary: dict) -> dict | None:
# gameplayJudge(W-AXIS 波2):additive 透传玩法地板判定裁决,给观测线区分「玩法判定失败」类目
# (后端 GenMetrics/OTLP 消费前 Jackson 静默接收、ReadinessScorer 不读 → 无害;消费后据它补 gate 归因)。
# 只带轻量裁决字段(不塞完整 checks/截图):accepted 是最终验收权威,rejectClasses 供 gate 桶归因。
judge = summary.get("judge")
judge = None
if v3:
judge = v3.get("judge")
elif v3 is None:
judge = summary.get("judge")
if isinstance(judge, dict):
trace["gameplayJudge"] = {
"accepted": summary.get("accepted"),
"verdict": judge.get("verdict"),
"rejectClasses": judge.get("rejectClasses"),
# v3 下这是独立 Judge 摘要,不能误用 shadow 的 compatibility.accepted=false 覆盖 Judge 自身裁决。
"accepted": judge.get("decision") == "accept",
"verdict": judge.get("decision"),
"rejectClasses": [judge.get("failureSignature")] if judge.get("failureSignature") else [],
"degraded": judge.get("degraded"),
"blocking": judge.get("blocking"),
"ran": judge.get("ran"),
"blocking": judge.get("conflicts"),
"ran": True,
}
# playtest(W-AXIS-V2 波1):additive 透传测试员真玩裁决 + firstPlay 三字段(§5 首局门语义并入,后端
# ReadinessScorer.scoreFirstPlay 改读 trace.playtest.firstPlay)。trace.gameplayJudge 双写窗口保留(旧消费面)。
playtest = summary.get("playtest")
if isinstance(playtest, dict):
if v3:
# v3 真相层优先于所有平铺兼容字段;后端按 outcome/readiness/冻结位消费,旧 accepted 只保留派生值。
trace["playtest"] = _v3_playtest_projection(v3, engine_bundle_hash=engine_bundle_hash)
elif v3 is None and isinstance(playtest, dict):
fp = playtest.get("firstPlay") if isinstance(playtest.get("firstPlay"), dict) else {}
trace["playtest"] = {
"accepted": playtest.get("accepted"),
@ -233,16 +546,26 @@ def _build_trace(summary: dict) -> dict | None:
}
# floor 四门投影(预筛权威)与 acceptanceVersion:additive,后端可据它区分预筛与最终验收、对账 shadow/v2。
floor = summary.get("floor")
if not isinstance(floor, dict) and v3:
floor = v3.get("floor")
if isinstance(floor, dict):
trace["floor"] = {"pass": floor.get("pass"), "gates": floor.get("gates")}
av = summary.get("acceptanceVersion")
if v3 is not None:
av = (v3.get("acceptanceMode") if v3 else None) or _declared_v3_mode(summary) or av
if av is not None:
trace["acceptanceVersion"] = av
if v3 is not None:
decision = v3.get("decision") if v3 and isinstance(v3.get("decision"), dict) else {}
# 根级状态让 Java/指标在 playtest 投影丢失时仍能识别 v3 降级,不能回落 legacy pass=true。
trace["acceptanceState"] = _v3_observation_state(v3, v3_publishable, v3_canonical_valid)
trace["publishFrozen"] = decision.get("publishFrozen") if v3 else True
return trace
def build_result_out(job: dict, summary: dict, game_dir, *,
failure_reason: str | None = None, source_project: str | None = None) -> dict:
failure_reason: str | None = None, source_project: str | None = None,
expected_acceptance_mode: str | None = None) -> dict:
"""据 §6.1 job-in + run-summary + 产物目录组 result-out(DifyCallbackReqVO 形态)。
Args:
@ -251,6 +574,8 @@ def build_result_out(job: dict, summary: dict, game_dir, *,
game_dir: 本 run 的 amgen-<id>/ 目录(读 bundle.iife.js)。
failure_reason: 失败路显式失败原因(可空,缺则从 summary 推)。
source_project: best-effort 源工程 2.0 JSON 字符串(可空=省略;由 build_source_project 预组)。
expected_acceptance_mode: 可信 worker 在入队前冻结的验收代际;值为 v3/v3_shadow 时,summary 即使
被全量剥掉 marker 也不得回落 legacy。
Returns:
result-out dict(traceId/status/templateId/gameConfig/assets[+engineBundle|+failureReason]
@ -261,6 +586,7 @@ def build_result_out(job: dict, summary: dict, game_dir, *,
brief = job.get("brief") or ""
bundle_text = _read_bundle(game_dir)
callback_bundle_text = bundle_text
verdict = summary.get("verdict") or {}
vf = summary.get("verdictFull") if isinstance(summary.get("verdictFull"), dict) else verdict
# W-AXIS-V2 波1:预筛权威 = 四门投影(floor)。三级取值(注意 summary.verdict 是 brief 版、无 guards):
@ -275,11 +601,34 @@ def build_result_out(job: dict, summary: dict, game_dir, *,
else:
floor = {"pass": bool(verdict.get("pass")), "gates": {}}
prefilter_pass = bool(floor.get("pass"))
# W-AXIS 波2:验收权威 = accepted(= 预筛 ∧ 测试 agent 真玩裁决)。summary 无 accepted 键(旧产物 / 对照路 /
# 验收未跑)→ 回落四门投影(向后兼容,不误拒);测试员拒 / degraded 时 accepted=False → status=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
# v3 以封存的 decision 为唯一权威:active v3 + accept + 未冻结 + 完整硬证才允许 succeeded。
# 旧 v2/存量结果没有 acceptanceV3,继续按 accepted → 四门投影回落,保持兼容窗口不受影响。
v3 = _acceptance_v3_payload(summary)
expected_v3 = expected_acceptance_mode in _V3_MODES
if expected_v3 and v3 is None:
# 显式预期是 v3,但 summary 的 acceptanceV3/marker 全丢失:以空 v3 进入统一降级路径,
# 关闭“剥掉全部新协议字段后被误当真 legacy”的最后一个窗口。
v3 = {}
v3_publishable = False
v3_canonical_valid = False
engine_bundle_hash = None
if v3 is not None:
release_binding = _v3_release_binding(job, summary, v3, game_dir)
callback_bundle_text = release_binding["bundleText"]
engine_bundle_hash = release_binding["engineBundleHash"]
# active accept 主路只跑一次 helper;仅不可发布时再取错误列表,用于区分正常四态与协议降级观测。
expected_mode_matches = not expected_v3 or v3.get("acceptanceMode") == expected_acceptance_mode
source_binding_ok = _v3_source_project_matches_staged(
source_project, v3, release_binding.get("stagedSourceFiles"))
v3_publishable = _v3_publishable(
summary, v3, release_binding_ok=(release_binding["ok"] and expected_mode_matches
and source_binding_ok))
v3_canonical_valid = v3_publishable or not cheap_verify.validate_acceptance_v3_payload(v3)
gates_pass = v3_publishable
else:
accepted = summary.get("accepted")
gates_pass = (accepted is True) if accepted is not None else prefilter_pass
engine_ok = bool(callback_bundle_text) and "__GameBundle" in callback_bundle_text
status = "succeeded" if (gates_pass and engine_ok) else "failed"
payload = {
@ -297,17 +646,26 @@ def build_result_out(job: dict, summary: dict, game_dir, *,
}
if status == "succeeded":
# 成功路携 bundle 全文 → 后端落 GamePackage.engineBundle 入 feed。
payload["engineBundle"] = bundle_text
payload["engineBundle"] = callback_bundle_text
else:
# 失败路:后端据 status=failed 把任务置 FAILED;failureReason 供排障(七值枚举)。
payload["failureReason"] = _map_failure_reason(summary, failure_reason)
# playtest/3 已有权威 outcome/failure 投影;顶层 failureReason 可能是旧 worker 遗留值,
# 不能把玩法拒绝误归为 unsafe_prompt 等旧桶。v3 精确真因只从 trace 读取,旧枚举统一落兼容 catch-all。
payload["failureReason"] = _map_failure_reason(
summary, failure_reason if v3 is None else None, trust_summary=v3 is None)
# ── M3b U2:组 9d trace(必填七项 + D11 首局维 sevenGateVerdict/gatespec + cost + similarity)──
# camelCase 逐字镜像 wg1 _extract_trace + 后端 ReadinessScorer 读取口径;成功/失败路都抽,summary 空则省略。
trace = _build_trace(summary)
trace = _build_trace(summary, v3=v3, v3_publishable=v3_publishable,
v3_canonical_valid=v3_canonical_valid,
engine_bundle_hash=engine_bundle_hash)
if trace:
payload["trace"] = trace
# sourceProject best-effort(additive:有则带、缺则省,后端源落库旁路不阻断)。
# legacy 维持原 best-effort 行为;v3 只允许成功回调携带与 acceptance artifactHash 同源的 staged 源。
# v3 失败、release/src 后验漂移、sourceHash/templateRoute 不一致时均省略,避免落成可被 A11 继承的错误血缘。
if source_project is not None:
payload["sourceProject"] = source_project
if v3 is None or (status == "succeeded"
and _v3_source_project_matches_staged(
source_project, v3, release_binding.get("stagedSourceFiles"))):
payload["sourceProject"] = source_project
return payload

View File

@ -11,6 +11,8 @@
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m3_deterministic.py
"""
import hashlib
import json
import sys
import tempfile
from pathlib import Path
@ -218,6 +220,72 @@ def test_materialize_skips_escape_path():
assert not (gd.parent / "escape.js").exists(), "越界路径必须被跳过"
def test_deterministic_v3_reject_uses_one_restricted_writer(monkeypatch):
"""A11 确定性改首轮 v3 verified reject 后只调用一次受限 writer,并以复验结果收口。"""
import cheap_verify
gid = "det-v3-repair"
gd = cheap_run.game_dir(gid)
gd.mkdir(parents=True, exist_ok=True)
(gd / "entry-bundle.js").write_text("// entry", encoding="utf-8")
origin_brief = "一个数字华容道解谜"
source = {"scaffoldTemplate": "_template-puzzle",
"originBrief": origin_brief,
"originBriefHash": hashlib.sha256(origin_brief.encode("utf-8")).hexdigest(),
"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"ROUND_MS": 20000}}}
monkeypatch.setattr(cheap_run, "build", lambda game_id: {"ok": True, "output": ""})
monkeypatch.setattr(cheap_run, "stage", lambda game_id: {"ok": True, "output": ""})
monkeypatch.setattr(cheap_run, "smoke", lambda *a, **k: {"ok": True})
verdict = {"pass": False, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
monkeypatch.setattr(cheap_run, "play", lambda *a, **k: {"ok": True, "verdict": verdict})
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
async def _reject(req):
return {"runId": "a11-r1", "floor": {"pass": True},
"artifactHash": "a" * 64,
"acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "reject", "accepted": False, "publishFrozen": True,
"repairEligible": True, "repairFeedback": "修闭环", "parentChainCostRmb": 0.2},
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3", "trace": {}}}
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _reject)
monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized",
lambda payload: (payload.get("decision") or {}).get("repairEligible") is True)
monkeypatch.setattr(cheap_verify, "is_v3_publishable",
lambda payload: (payload.get("decision") or {}).get("outcome") == "accept")
repair_calls = []
def _repair(game_id, intent, **kwargs):
repair_calls.append((game_id, intent, kwargs))
return {"accepted": True, "acceptanceVersion": "v3", "verdictFull": verdict,
"acceptanceV3": {"runId": "a11-r2", "decision": {"outcome": "accept", "accepted": True}}}
monkeypatch.setattr(M, "_default_rewrite", _repair)
out = M.execute_deterministic_modify(
gid, source, patch, base_version_id="base-1", task_trace_id="trace-det-v3-repair")
assert out["status"] == "succeeded" and len(repair_calls) == 1
_, intent, kwargs = repair_calls[0]
assert intent == "修闭环" and kwargs["acceptance_repair_count"] == 1
assert kwargs["acceptance_parent_run_id"] == "a11-r1"
assert kwargs["acceptance_brief"] == "一个数字华容道解谜", "复验沿用原游戏 brief,不得换成修复反馈"
assert kwargs["genre"] == "puzzle"
acceptance = out["acceptance"]
assert acceptance["acceptanceV3FirstPass"]["runId"] == "a11-r1"
assert acceptance["repairAttempted"] is True and acceptance["acceptedAfterRepair"] is True
def test_a11_origin_brief_requires_nonempty_self_consistent_source_lineage():
"""fresh worker 只能从 SourceProject 恢复原题面;缺失、空值和伪 hash 均 fail-closed。"""
brief = "原始解谜题面"
valid = {"originBrief": brief,
"originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()}
assert M._resolve_a11_origin_brief(valid) == (brief, valid["originBriefHash"])
assert M._resolve_a11_origin_brief({}) == (None, None)
assert M._resolve_a11_origin_brief({"originBrief": "", "originBriefHash": hashlib.sha256(b"").hexdigest()}) == (None, None)
assert M._resolve_a11_origin_brief({"originBrief": brief, "originBriefHash": "a" * 64}) == (None, None)
# ───────────────────────── ④ worker 接线 ─────────────────────────
def test_worker_deterministic_route_attaches_manifest_on_failed():
@ -258,13 +326,33 @@ def test_worker_deterministic_route_success_carries_bundle_and_source():
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: profile)
job = {"job_id": "m2", "traceId": "m2", "modifyMode": "deterministic", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "点点乐", "callback": {"target": "http://cb"}}
"sourceProject": '{"scaffoldTemplate":"_template-puzzle"}',
"modifyPatch": "{}", "brief": "点点乐", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "succeeded"
assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle"
assert p["trace"]["modifyManifest"][0]["key"] == "ROUND_MS"
assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)"
assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-puzzle"
def test_worker_modify_summary_preserves_v3_sealed_identity():
"""deterministic 包装层不得丢 acceptanceV3,否则 result_out 会错误回落 legacy。"""
sealed = {
"runId": "run-1", "artifactHash": "a" * 64, "briefHash": "b" * 64,
"acceptanceRequestHash": "c" * 64, "floor": {"pass": True},
"decision": {"outcome": "accept", "accepted": True, "publishFrozen": False},
"compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3", "trace": {}},
}
result = {"status": "succeeded", "stage": "play", "verdict": None,
"acceptance": {"acceptanceV3": sealed}}
summary = W._modify_summary(result, "g1")
assert summary["acceptanceV3"] is sealed
assert summary["acceptanceRunId"] == "run-1"
assert summary["acceptanceArtifactHash"] == "a" * 64
assert summary["acceptanceBriefHash"] == "b" * 64
assert summary["acceptanceRequestHash"] == "c" * 64
def test_worker_regenerate_module_routes_to_executor_no_llm():

View File

@ -13,6 +13,8 @@
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m4_regenerate.py
"""
import hashlib
import json
import sys
import tempfile
from pathlib import Path
@ -100,7 +102,8 @@ def test_regenerate_changed_and_nontarget_stable():
gid = "m4ok"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "把命中加分从固定改成连击递增"}}
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_ok)
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4ok")
assert out["status"] == "succeeded", out # 九门桩 pass ∧ 非目标稳 → succeeded
man = out["manifest"]
assert man["file"] == "src/game-logic.js" and man["kind"] == "behavior"
@ -128,7 +131,8 @@ def test_regenerate_empty_intent_failed_no_rewrite():
{"target": {"kind": "behavior"}, "payload": {"intent": ""}},
{"target": {"kind": "behavior"}, "payload": {}},
{"target": {"kind": "behavior"}}):
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_spy)
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_spy, task_trace_id="trace-m4empty")
assert out["status"] == "failed", out
assert out["stage"] == "intent"
assert "intent" in (out["error"] or "")
@ -141,7 +145,8 @@ def test_regenerate_tamper_nontarget_caught_by_assert2():
gid = "m4tamper"
_, sp = _setup_base(gid)
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法但误伤了 core"}}
out = M.execute_regenerate_modify(gid, sp, patch, rewrite_fn=_fake_rewrite_tamper)
out = M.execute_regenerate_modify(
gid, sp, patch, rewrite_fn=_fake_rewrite_tamper, task_trace_id="trace-m4tamper")
assert out["manifest"]["changed"] is True, "game-logic 仍被改"
assert out["manifest"]["untouchedStable"] is False, "断言②应抓到非目标 core.js 被改"
assert out["status"] == "failed", "误伤非目标=边界泄漏,即便九门桩 pass 也判失败"
@ -151,12 +156,70 @@ def test_regenerate_tamper_nontarget_caught_by_assert2():
def test_regenerate_no_source_failed():
"""无 base 源 files → failed(无可改源),不重写。"""
patch = {"target": {"kind": "behavior"}, "payload": {"intent": "随便改"}}
out = M.execute_regenerate_modify("m4nosrc", {"files": {}}, patch, rewrite_fn=_fake_rewrite_ok)
out = M.execute_regenerate_modify(
"m4nosrc", {"files": {}}, patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4nosrc")
assert out["status"] == "failed" and "源" in (out["error"] or "")
out2 = M.execute_regenerate_modify("m4nosrc2", "{bad json", patch, rewrite_fn=_fake_rewrite_ok)
out2 = M.execute_regenerate_modify(
"m4nosrc2", "{bad json", patch, rewrite_fn=_fake_rewrite_ok, task_trace_id="trace-m4nosrc2")
assert out2["status"] == "failed", "坏 source_project 容错 failed,不抛"
def test_a11_route_only_accepts_source_project_scaffold_lineage():
"""v3 modify 只认 SourceProject.scaffoldTemplate;旧 genre/templateId/brief 推断全部 fail-closed。"""
assert M._resolve_a11_template_route({"scaffoldTemplate": "_template-trpg"}) == (
"_template-trpg", "trpg")
assert M._resolve_a11_template_route({"config": {"genre": "trpg"}}) == (None, None)
assert M._resolve_a11_template_route({"config": {"templateId": "_template-puzzle"}}) == (None, None)
assert M._resolve_a11_template_route({"templateId": "_template-shop"}) == (None, None)
def test_regenerate_fresh_worker_inherits_origin_brief_from_source_project(monkeypatch):
"""本机没有 evidence/brief.json 时,active v3 regenerate 仍从 base SourceProject 恢复原题面。"""
import cheap_verify
gid = "m4-origin-fresh"
_, sp = _setup_base(gid)
brief = "原始经营游戏题面"
sp.update({"scaffoldTemplate": "_template-shop", "originBrief": brief,
"originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()})
captured = {}
def _rewrite(game_id, intent, **kwargs):
captured.update(kwargs)
(cheap_run.game_dir(game_id) / "src" / "game-logic.js").write_text("// rewritten from source origin\n", encoding="utf-8")
return {"accepted": True, "verdict": {"pass": True},
"verdictFull": {"pass": True, "guards": {}}, "gameId": game_id}
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
monkeypatch.setattr(M, "_default_rewrite", _rewrite)
out = M.execute_regenerate_modify(
gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "增加连击"}},
base_version_id="base-1", task_trace_id="trace-m4-origin-fresh")
assert out["status"] == "succeeded"
assert captured["acceptance_brief"] == brief
assert captured["acceptance_identity"]["briefHash"] == sp["originBriefHash"]
def test_regenerate_active_v3_missing_origin_fails_before_writer(monkeypatch):
"""历史 SourceProject 缺原题面时,active v3 不得启动 Writer 或猜修改指令。"""
import cheap_verify
gid = "m4-origin-missing"
_, sp = _setup_base(gid)
sp["scaffoldTemplate"] = "_template-shop"
called = {}
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
monkeypatch.setattr(M, "_default_rewrite", lambda *a, **k: called.update(called=True) or {})
out = M.execute_regenerate_modify(
gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "增加连击"}},
base_version_id="base-1", task_trace_id="trace-m4-origin-missing")
assert out["status"] == "failed" and "originBrief" in out["error"]
assert not called
def test_regenerate_rewrite_exception_best_effort():
"""重写器抛异常 → best-effort failed + 诊断,绝不抛崩。"""
gid = "m4boom"
@ -166,7 +229,7 @@ def test_regenerate_rewrite_exception_best_effort():
raise RuntimeError("LLM exploded")
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "x"}},
rewrite_fn=_boom)
rewrite_fn=_boom, task_trace_id="trace-m4boom")
assert out["status"] == "failed" and "异常" in (out["error"] or "")
assert out["stage"] == "code"
@ -184,7 +247,7 @@ def test_regenerate_gates_fail_is_plumbing_pass():
"attempts": 6, "stage": "play", "gameId": game_id}
out = M.execute_regenerate_modify(gid, sp, {"target": {"kind": "behavior"}, "payload": {"intent": "改玩法"}},
rewrite_fn=_rewrite_gatefail)
rewrite_fn=_rewrite_gatefail, task_trace_id="trace-m4gatefail")
assert out["status"] == "failed" and out["error"] == "九门未通过"
assert out["manifest"]["changed"] is True and out["manifest"]["untouchedStable"] is True, "plumbing 仍通(改了+非目标稳)"
@ -211,7 +274,8 @@ def test_worker_regenerate_route_success_carries_manifest_bundle_source():
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: profile)
job = {"job_id": "rmok", "traceId": "rmok", "modifyMode": "regenerate-module", "gameId": gid,
"sourceProject": "{}", "modifyPatch": "{}", "brief": "连击小游戏", "callback": {"target": "http://cb"}}
"sourceProject": '{"scaffoldTemplate":"_template-shop"}',
"modifyPatch": "{}", "brief": "连击小游戏", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "succeeded"
@ -220,6 +284,7 @@ def test_worker_regenerate_route_success_carries_manifest_bundle_source():
assert man["untouchedStable"] is True and man["changed"] is True, "manifest(单 dict)进 trace"
assert man["kind"] == "behavior"
assert "sourceProject" in p, "成功路 best-effort 带新版源(供链式改)"
assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-shop"
assert p["trace"]["attempts"] == 4, "富 run-summary → trace.attempts"
assert p["trace"]["cost"]["totalRmb"] == 0.7, "富 run-summary → trace.cost"

View File

@ -169,6 +169,14 @@ async def _fake_pt_reject(game_id, **kw):
"firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}}
async def _fake_pt_degraded(game_id, **kw):
"""模拟测试员图像通道故障,验证它被归到验收仪器而非游戏缺陷。"""
return {"accepted": False, "verdict": "reject", "pass": False, "degraded": True,
"reason": "图像通道故障", "problems": [], "feedback": "", "summary": "image-blind",
"rollCount": 1, "costRmb": 0.1, "model": "M",
"firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}}
async def _fake_apply_judge(summary, **kw):
summary["accepted"] = True
summary["ok"] = True
@ -176,27 +184,37 @@ async def _fake_apply_judge(summary, **kw):
return summary
def _acc(monkeypatch, verdict, mode, pt=_fake_pt_accept, judge=None):
def _acc(monkeypatch, verdict, mode, pt=_fake_pt_accept, judge=None, summary=None):
monkeypatch.setattr(cheap_verify, "run_playtest", pt)
if judge is not None:
monkeypatch.setattr(cheap_verify, "apply_gameplay_judge", judge)
monkeypatch.setattr(cheap_verify, "_writeback_verdict_json", lambda *a, **k: None)
ev = str(Path(tempfile.mkdtemp()))
return asyncio.run(cheap_verify.run_acceptance({}, game_id="t", brief="b", verdict=verdict,
return asyncio.run(cheap_verify.run_acceptance(summary or {}, game_id="t", brief="b", verdict=verdict,
mode=mode, evidence_dir=ev))
def test_run_acceptance_v2_accept(monkeypatch):
s = _acc(monkeypatch, _guards(), "v2", pt=_fake_pt_accept)
# 模拟 CLI/Service 在验收前已经按旧 E_live 写入 gameplay 失败;最终 accepted 必须覆盖成成功语义。
legacy = {"failureLayer": {"layer": "gameplay", "reason": "旧 E_live 失败", "failedGates": ["E_live"]}}
verdict = _guards(extra={"E_live": {"pass": False}})
s = _acc(monkeypatch, verdict, "v2", pt=_fake_pt_accept, summary=legacy)
assert s["accepted"] is True and s["ok"] is True and s["acceptanceVersion"] == "v2"
assert s["floor"]["pass"] is True and s["playtest"]["accepted"] is True
assert s["failureLayer"] == {"layer": "none", "reason": None, "failedGates": []}
assert "repairFeedback" not in s # accepted 无失败现象,不拼装续修反馈(E2E 曾误拼自相矛盾文案)
def test_run_acceptance_v2_reject_by_playtest(monkeypatch):
s = _acc(monkeypatch, _guards(), "v2", pt=_fake_pt_reject)
verdict = _guards(extra={"E_live": {"pass": False}, "H_progress": {"pass": False}})
s = _acc(monkeypatch, verdict, "v2", pt=_fake_pt_reject)
assert s["accepted"] is False # 四门过但测试员拒
assert s["judge"]["rejectClasses"] == ["playtest_reject"]
assert s["failureLayer"]["layer"] == "gameplay"
assert "第2步点(100,200)后画面未变" in s["failureLayer"]["reason"]
assert "no" not in s["failureLayer"]["reason"] # 有客观现象时不得混入测试员摘要中的判断性文本
assert "推测" not in s["failureLayer"]["reason"]
assert "E_live" not in s["failureLayer"]["reason"] and "H_progress" not in s["failureLayer"]["reason"]
assert "repairFeedback" in s and "第2步点(100,200)后画面未变" in s["repairFeedback"]
@ -210,14 +228,65 @@ def test_run_acceptance_v2_floor_fail_skips_playtest(monkeypatch):
s = _acc(monkeypatch, _guards(c=False), "v2", pt=spy)
assert called["n"] == 0 and s["accepted"] is False # 四门不过不跑测试员(省 ¥)
assert s["playtest"]["reason"].startswith("四门")
assert s["failureLayer"]["layer"] == "mechanical"
assert s["failureLayer"]["failedGates"] == ["C_frame"]
def test_run_acceptance_v2_tester_degraded_attribution(monkeypatch):
"""四门全绿但测试员评不出时,归因必须是 tester_degraded,不得指责玩法。"""
s = _acc(monkeypatch, _guards(extra={"E_live": {"pass": False}}), "v2", pt=_fake_pt_degraded)
assert s["accepted"] is False
assert s["failureLayer"]["layer"] == "tester_degraded"
assert "图像通道故障" in s["failureLayer"]["reason"]
assert s["failureLayer"]["failedGates"] == []
def test_v2_failure_attribution_missing_floor_fails_closed_as_mechanical():
"""floor 证据缺失时按四门全失败归因,不得越过机械地板指责玩法。"""
s = cheap_verify.apply_acceptance_failure_attribution({
"acceptanceVersion": "v2", "accepted": False,
"playtest": {"accepted": False, "degraded": False, "problems": ["点击后无变化"]},
})
assert s["failureLayer"]["layer"] == "mechanical"
assert s["failureLayer"]["failedGates"] == ["A_boot", "B_uncaught", "C_frame", "D_render"]
def test_run_acceptance_v2_exception_after_green_floor_is_tester_degraded(monkeypatch):
"""四门全绿后测试员调用抛错,顶层 fail-closed 必须归验收器降级。"""
async def broken_playtest(game_id, **kw):
raise RuntimeError("测试员崩溃")
s = _acc(monkeypatch, _guards(), "v2", pt=broken_playtest)
assert s["accepted"] is False and s["ok"] is False
assert s["failureLayer"]["layer"] == "tester_degraded"
assert "测试员崩溃" in s["failureLayer"]["reason"]
def test_run_acceptance_v2_writeback_exception_overrides_existing_playtest(monkeypatch):
"""测试员已接受但证据写回失败时,最终失败属于验收器,不得误记为玩法失败。"""
monkeypatch.setattr(cheap_verify, "run_playtest", _fake_pt_accept)
def broken_writeback(*args, **kwargs):
raise OSError("verdict 写回失败")
monkeypatch.setattr(cheap_verify, "_writeback_verdict_json", broken_writeback)
ev = str(Path(tempfile.mkdtemp()))
s = asyncio.run(cheap_verify.run_acceptance(
{}, game_id="t", brief="b", verdict=_guards(), mode="v2", evidence_dir=ev))
assert s["accepted"] is False and s["ok"] is False
assert s["playtest"]["accepted"] is True # 保留测试员原始证据
assert s["failureLayer"]["layer"] == "tester_degraded"
assert "verdict 写回失败" in s["failureLayer"]["reason"]
def test_run_acceptance_shadow_takes_old_verdict(monkeypatch):
# shadow:accepted 取旧口径(apply_gameplay_judge accept),测试员照跑照落 + shadowV2Accepted 对照。
s = _acc(monkeypatch, _guards(), "shadow", pt=_fake_pt_reject, judge=_fake_apply_judge)
legacy = {"failureLayer": {"layer": "driver_contract", "reason": "旧口径归因", "failedGates": ["H_progress"]}}
s = _acc(monkeypatch, _guards(), "shadow", pt=_fake_pt_reject, judge=_fake_apply_judge, summary=legacy)
assert s["accepted"] is True # 旧口径判 accept
assert s["shadowV2Accepted"] is False # v2 影子(测试员拒)——新旧口径对照
assert s["playtest"]["accepted"] is False # 测试员照跑照落证据
assert s["failureLayer"] == legacy["failureLayer"] # shadow 保持旧验收归因,避免灰度口径漂移
def test_run_acceptance_v1_uses_apply_judge(monkeypatch):
@ -227,9 +296,11 @@ def test_run_acceptance_v1_uses_apply_judge(monkeypatch):
called["n"] += 1
return {}
s = _acc(monkeypatch, _guards(), "v1", pt=spy, judge=_fake_apply_judge)
legacy = {"failureLayer": {"layer": "none", "reason": "旧口径未见失败门", "failedGates": []}}
s = _acc(monkeypatch, _guards(), "v1", pt=spy, judge=_fake_apply_judge, summary=legacy)
assert s["accepted"] is True and called["n"] == 0 # v1 不跑测试员,走旧判定器
assert s["acceptanceVersion"] == "v1" and s["floor"]["pass"] is True
assert s["failureLayer"] == legacy["failureLayer"] # v1 回退态继续沿用旧归因
# ══════════════════════════════════════════════════════════════════════════════

View File

@ -25,6 +25,7 @@ HERITAGE_VALID = (
CONTRACTS / "samples" / "playtest-evidence" / "valid" / "06-accepted-heritage-ordered-series.json"
)
BRIEF = "一款分支叙事游戏"
MATCH3_PROFILE_ID = "match3.orthogonal-swap-v1"
def _load(path: Path) -> dict:
@ -56,6 +57,21 @@ def _identity(game_id="v3-test", brief=BRIEF, *, repair_ordinal=0, source=None,
)
def _match3_identity(game_id="match3-v3-test", *, repair_ordinal=0, source=None, parent=None,
task_trace_id="trace-match3-v3-test", interaction_binding=None):
"""构造显式 Match-3 shadow 身份;普通 puzzle 测试仍直接调用 builder 并保持 binding=null。"""
kwargs = ({"interaction_profile_id": MATCH3_PROFILE_ID}
if interaction_binding is None and repair_ordinal == 0
else {"interaction_binding": interaction_binding})
return V.build_acceptance_v3_identity(
game_id, "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
repair_ordinal=repair_ordinal, source_artifact_hash=source,
parent_acceptance_request_hash=parent,
task_binding_hash=V.task_binding_hash_v3(task_trace_id),
**kwargs,
)
def test_direct_cli_local_task_binding_is_unique_per_invocation():
"""直接 CLI 没有后端 traceId 时,也不能让同 gameId 的下一次运行复用旧证据。"""
first = cheap_studio._new_local_acceptance_trace_id("same-game")
@ -496,6 +512,82 @@ def test_identity_v3_schema_and_backward_compat_optional_fields():
V._V3_ACCEPTANCE_REQUEST_SCHEMA, V._canonical_acceptance_request(identity))
def test_explicit_match3_identity_builds_canonical_binding_without_auto_select():
"""显式 profile 才生成 InteractionBinding/1;普通 puzzle 不得按模板或 brief 自动选择。"""
task_hash = V.task_binding_hash_v3("trace-match3-v3-test")
content = {
"interactionProfileId": MATCH3_PROFILE_ID,
"interactionRegistryVersion": "2026-07-15.v2",
"taskBindingHash": task_hash,
}
expected_hash = hashlib.sha256(
b"interaction-binding/1\n" + json.dumps(
content, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
).hexdigest()
explicit = _match3_identity()
ordinary = V.build_acceptance_v3_identity(
"ordinary-puzzle", "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
task_binding_hash=V.task_binding_hash_v3("trace-ordinary-puzzle"),
)
assert explicit["interactionBinding"] == {
"schemaVersion": "InteractionBinding/1",
**content,
"interactionBindingHash": expected_hash,
}
assert ordinary["interactionBinding"] is None
def test_match3_binding_must_match_proof_route_and_repair_parent_binding():
"""profile 与 proof 路由必须同源;修回只能继承首轮 binding,篡改或降为 null 都会被拒绝。"""
first = _match3_identity(game_id="match3-repair")
repair = _match3_identity(
game_id="match3-repair", repair_ordinal=1, source="a" * 64,
parent=first["acceptanceRequestHash"], interaction_binding=first["interactionBinding"],
)
assert repair["interactionBinding"] == first["interactionBinding"]
drifted = copy.deepcopy(first["interactionBinding"])
drifted["taskBindingHash"] = "f" * 64
with pytest.raises(ValueError, match="interactionBinding"):
_match3_identity(
game_id="match3-repair", repair_ordinal=1, source="a" * 64,
parent=first["acceptanceRequestHash"], interaction_binding=drifted,
)
with pytest.raises(ValueError, match="interactionProfileId"):
V.build_acceptance_v3_identity(
"wrong-route", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3("trace-wrong-route"),
interaction_profile_id=MATCH3_PROFILE_ID,
)
def test_run_studio_external_match3_identity_inherits_profile_before_scaffold(monkeypatch):
"""外部 canonical identity 已显式绑定 Match-3 时,create 重验不得把 profile 丢在 scaffold 外。"""
trace_id = "trace-run-studio-match3"
identity = _match3_identity(
game_id="run-studio-match3", task_trace_id=trace_id)
scaffold_profiles = []
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(cheap_studio.cheap_run, "archive_prior_run", lambda _gid: None)
def _stop_scaffold(_gid, _template=None, interaction_profile_id=None):
scaffold_profiles.append(interaction_profile_id)
return {"ok": False, "output": "测试在 scaffold 边界停止"}
monkeypatch.setattr(cheap_studio.cheap_run, "scaffold", _stop_scaffold)
result = asyncio.run(cheap_studio.run_studio(
"run-studio-match3", "标准三消解谜",
scaffold_template="_template-puzzle", genre="puzzle",
acceptance_identity=identity, acceptance_task_trace_id=trace_id,
))
assert scaffold_profiles == [MATCH3_PROFILE_ID]
assert result["ok"] is False
def test_identity_v3_declares_reference_asset_fields():
"""三个新字段声明时入 identity 并参与 canonical hash;非法形状在冻结前拒绝。"""
base = _identity()
@ -554,6 +646,78 @@ def test_provenance_v3_freezes_consumed_reference_asset_snapshot(tmp_path):
"artifactHash": "8" * 64}])
def test_match3_provenance_writes_independent_canonical_binding_file(tmp_path):
"""非空 binding 必须有独立 canonical 文件与字节 hash;null 路径不产生半套证据。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("ok", encoding="utf-8")
identity = _match3_identity()
artifact_hash = V._artifact_hash_v3(identity["gameId"], staged)
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=tmp_path / "match3-results", artifact_path=staged)
binding_file = Path(provenance["interactionBindingFile"])
expected_bytes = json.dumps(
identity["interactionBinding"], ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
assert binding_file.name == "interaction-binding.json"
assert binding_file.read_bytes() == expected_bytes
assert provenance["interactionBindingFileHash"] == hashlib.sha256(expected_bytes).hexdigest()
ordinary = _identity(game_id="ordinary-provenance")
ordinary_provenance = V.write_acceptance_v3_provenance(
ordinary["gameId"], ordinary, artifact_hash=artifact_hash,
evidence_root=tmp_path / "ordinary-results", artifact_path=staged)
assert ordinary_provenance["interactionBindingFile"] is None
assert ordinary_provenance["interactionBindingFileHash"] is None
assert not (tmp_path / "ordinary-results" / "interaction-binding.json").exists()
def test_sealed_match3_provenance_rejects_independent_binding_drift(tmp_path):
"""sealed 重入必须复核独立 binding 字节;只改文件、不改 manifest 也不能返回旧结果。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("ok", encoding="utf-8")
identity = _match3_identity(game_id="sealed-match3")
artifact_hash = V._artifact_hash_v3(identity["gameId"], staged)
run_dir = tmp_path / "run"
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=run_dir, artifact_path=staged)
V._verify_v3_sealed_reference_provenance(run_dir, identity, artifact_hash, None)
Path(provenance["interactionBindingFile"]).write_text("{}", encoding="utf-8")
with pytest.raises(ValueError, match="interaction binding"):
V._verify_v3_sealed_reference_provenance(run_dir, identity, artifact_hash, None)
def test_repair_parent_rejects_binding_downgrade_to_null(tmp_path, monkeypatch):
"""修回不得沿用父 request hash 却把父 Match-3 binding 降为 null。"""
first = _match3_identity(game_id="repair-binding-parent")
evidence_root = tmp_path / "evidence"
parent_run = evidence_root / "parent-run"; parent_run.mkdir(parents=True)
parent_request = V._canonical_acceptance_request(first)
request_file = parent_run / f"acceptance-request-{first['acceptanceRequestHash']}.json"
V._write_json_bytes_atomic(request_file, parent_request)
parent = {
"artifactHash": "a" * 64, "acceptanceRequestHash": first["acceptanceRequestHash"],
"gameId": first["gameId"], "briefHash": first["briefHash"], "genre": first["genre"],
"templateRoute": first["templateRoute"], "proofProfileId": first["proofProfileId"],
"proofRegistryVersion": first["proofRegistryVersion"],
"taskBindingHash": first["taskBindingHash"],
"decision": {"repairCountAcrossParentChain": 0, "parentChainCostRmb": 0.0},
}
monkeypatch.setattr(V, "_load_verified_v3_sealed", lambda *_args, **_kwargs: parent)
monkeypatch.setattr(V, "is_v3_repair_authorized", lambda _payload: True)
downgraded = V.build_acceptance_v3_identity(
first["gameId"], "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
source_artifact_hash=parent["artifactHash"],
parent_acceptance_request_hash=first["acceptanceRequestHash"], repair_ordinal=1,
proof_profile_id=first["proofProfileId"],
proof_registry_version=first["proofRegistryVersion"], task_binding_hash=first["taskBindingHash"],
)
error, _cost = V._validate_v3_repair_parent(
{"parentRunId": "parent-run"}, downgraded, evidence_root)
assert "interactionBinding" in error
# ── W-GOLD-LIVE 消费对账纪律:只消费 active + 版本门 + 缺维度拒绝负例 ──────────────────────
@ -579,7 +743,7 @@ def test_reconcile_consumes_only_active_records():
assert result["ok"] is False and result["consumed"] == []
assert "参照资产未激活不得消费" in result["errors"][0]
artifact_hash = "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b"
artifact_hash = "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
active = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gac-shanhai-xingji", "role": "game_content_gold",
"artifactHash": artifact_hash}],
@ -1385,9 +1549,99 @@ def test_runner_boundary_passes_playtest3_cli_fields(tmp_path, monkeypatch):
"--acceptance-mode=v3", "--evidence-mode=native",
):
assert expected in args
assert not any(arg.startswith("--interaction-") for arg in args)
assert out["costRmb"] == 0.0
def test_runner_boundary_passes_all_match3_binding_fields_only_when_verified(tmp_path, monkeypatch):
"""非空 binding 必须把独立文件、文件 hash 与三项身份一次性传给 Node runner。"""
identity = _match3_identity(); captured = {}
script = tmp_path / "runner.sh"; script.write_text("#!/bin/sh\n", encoding="utf-8")
binding_file = tmp_path / "interaction-binding.json"
binding_bytes = json.dumps(
identity["interactionBinding"], ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
binding_file.write_bytes(binding_bytes)
binding_file_hash = hashlib.sha256(binding_bytes).hexdigest()
monkeypatch.setattr(V, "_playtest_v3_script", lambda: script)
class Proc:
returncode = 2
stderr = ""
stdout = json.dumps({
"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError",
"failureSubtype": "browser_error", "reason": "boot", "infraRetryable": True,
"error": {"message": "boot"}, "costRmb": 0.0,
"cost": {"costRmb": 0.0, "entries": []},
})
def run(argv, **kwargs):
captured["argv"] = argv
return Proc()
monkeypatch.setattr(V.subprocess, "run", run)
kwargs = _sync_kwargs(tmp_path, identity)
kwargs.update({
"interaction_binding_file": str(binding_file),
"interaction_binding_file_hash": binding_file_hash,
"interaction_profile_id": MATCH3_PROFILE_ID,
"interaction_registry_version": "2026-07-15.v2",
"interaction_binding_hash": identity["interactionBinding"]["interactionBindingHash"],
})
V._run_playtest_v3_roll_sync(identity["gameId"], **kwargs)
args = set(captured["argv"])
assert {
f"--interaction-binding-file={binding_file}",
f"--interaction-binding-file-hash={binding_file_hash}",
f"--interaction-profile-id={MATCH3_PROFILE_ID}",
"--interaction-registry-version=2026-07-15.v2",
f"--interaction-binding-hash={identity['interactionBinding']['interactionBindingHash']}",
} <= args
def test_run_playtest_v3_forwards_provenance_binding_to_sync_runner(tmp_path, monkeypatch):
"""异步编排必须从 identity/provenance 取五项绑定,不能只在底层 sync helper 留孤立参数。"""
identity = _match3_identity(); captured = []
binding_file = tmp_path / "interaction-binding.json"
binding_file.write_text("{}", encoding="utf-8")
def _runner(*args, **kwargs):
captured.append(kwargs)
return {
"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError", "roll": 1,
"outcome": "tester_error", "failureSubtype": "browser_error", "reason": "测试停止",
"infraRetryable": False, "costRmb": 0.0,
"cost": {"costRmb": 0.0, "entries": []},
}
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", _runner)
request = {
"gameId": identity["gameId"], "brief": "标准三消解谜", "acceptanceIdentity": identity,
"acceptanceMode": "v3_shadow", "evidenceMode": "native", "runId": "match3-forward",
"gameSeed": 101, "policySeed": 202,
"acceptanceProvenance": {
"acceptanceRequestFile": str(tmp_path / "request.json"),
"provenanceManifestFile": str(tmp_path / "manifest.json"),
"provenanceManifestHash": "a" * 64,
"interactionBindingFile": str(binding_file),
"interactionBindingFileHash": "b" * 64,
},
}
asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path / "run", artifact_hash="c" * 64,
brief_hash=identity["briefHash"],
cfg={**V._acceptance_v3_cfg(), "second_roll": False, "infra_retry": 0},
))
assert len(captured) == 1
assert captured[0]["interaction_binding_file"] == str(binding_file)
assert captured[0]["interaction_binding_file_hash"] == "b" * 64
assert captured[0]["interaction_profile_id"] == MATCH3_PROFILE_ID
assert captured[0]["interaction_registry_version"] == "2026-07-15.v2"
assert captured[0]["interaction_binding_hash"] == identity["interactionBinding"]["interactionBindingHash"]
@pytest.mark.parametrize("failure,expected_cost", [("timeout", 1.5), ("no-json", 1.5),
("proof-corrupt", 0.12)])
def test_cost_paths_use_trusted_ledger_or_remaining_cap(tmp_path, monkeypatch, failure, expected_cost):

View File

@ -83,10 +83,10 @@ 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]
# W-AXIS 波1:真相层 CheapTurnsMiddleware 追加在末位(observe-only,默认开);breaker 仍在 idx=3。
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware", "RepairMiddleware",
# v3 下旧 RepairMiddleware 必须摘除;真相层仍追加在末位,check/build 由工具与 ReAct 保留。
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware",
"CircuitBreakerMiddleware", "CheapTurnsMiddleware"]
breaker = mws[3]
breaker = mws[2]
assert breaker.soft_budget is True, "决策①:cheap 软预算切 soft"
assert breaker.rmb_hard_limit == 10.0, "cheap ¥10(soft 档软目标,不再是硬地板)"
# 决策① + Codex C4:成本上界靠 max_repairs=6;三道次数/轮数闸抬到 150 当纯失控兜底(非成本尺)。
@ -96,6 +96,26 @@ def test_middlewares_factory_soft_budget_and_failsafe_caps(tmp_path, monkeypatch
assert breaker.enable_rmb_gate is True
collector = mws[0]
assert collector._game_id == "70020", "C2:collector 绑后端 gameId(经 sidecar 解析),非 session_id"
assert collector._repair is None, "v3 的 gameplay 修复权只来自 final verified reject"
def test_middlewares_factory_keeps_repair_only_for_legacy_mode(tmp_path, monkeypatch):
"""历史 v1/v2 回放仍可装旧 RepairMiddleware,但 v3 默认路径绝不装。"""
import json
from worker import genconfig
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
p = tmp_path / "_cheap-sessions" / "legacy.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70022"}), encoding="utf-8")
original_get = genconfig.get
monkeypatch.setattr(genconfig, "get", lambda area, key, default=None:
"v2" if (area, key) == ("acceptance", "mode") else original_get(area, key, default))
mws = asyncio.run(A._cheap_middlewares_factory("u1", "a1", "legacy"))
names = [type(m).__name__ for m in mws]
assert names[2] == "RepairMiddleware"
breaker = mws[3]
repair = mws[2]
assert repair._max_repairs == 6 # genconfig iteration.max_resumes(成本上界=优雅终止)
# budget_exhausted 读实测已花 ¥ 超上限(非预估):未超 False、超 True。

View File

@ -3,6 +3,8 @@ worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_driver.py -v
"""
import sys
import json
import subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
@ -13,6 +15,9 @@ import result_out # noqa: E402
import worker_service as W # noqa: E402
MATCH3_PROFILE_ID = "match3.orthogonal-swap-v1"
def test_credential_payload_openai_compatible_with_v1(monkeypatch):
# 便宜档 M3 走 OpenAI 兼容路:type=openai_credential、base_url 带 /v1、api_key 非空。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
@ -322,6 +327,8 @@ def test_drive_v3_verified_reject_posts_exactly_one_repair(tmp_path, monkeypatch
assert acceptance_requests[1]["parentRunId"] == "r1"
assert acceptance_requests[0]["writerCostRmb"] == 0.4
assert acceptance_requests[1]["writerCostRmb"] == 0.2
assert all(request["acceptanceIdentity"]["interactionBinding"] is None
for request in acceptance_requests), "普通 puzzle 不得按 brief/template 自动选 Match-3"
assert all("parentChainCostRmb" not in request for request in acceptance_requests)
assert summary["acceptanceV3"]["runId"] == "r2" and summary["accepted"] is True
assert summary["acceptanceV3FirstPass"]["runId"] == "r1"
@ -329,6 +336,120 @@ def test_drive_v3_verified_reject_posts_exactly_one_repair(tmp_path, monkeypatch
assert summary["attempts"] == 2 and summary["costRmb"] == 0.6
def test_explicit_match3_shadow_job_reaches_scaffold_identity_and_obligation_resolution(tmp_path, monkeypatch):
"""可信 job 显式 profile 才贯通 scaffold/identity,并在 Node 义务解析中提升有效交换义务。"""
import asyncio
import httpx
import _bootstrap
import cheap_studio
import cheap_verify
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
captured = {"scaffold": None, "request": None}
def _scaffold(gid, template=None, interaction_profile_id=None):
captured["scaffold"] = (gid, template, interaction_profile_id)
return {"ok": True, "output": ""}
monkeypatch.setattr(cheap_run, "scaffold", _scaffold)
verdict = {"pass": True, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
async def _floor(_gid):
return verdict
async def _accept(request):
captured["request"] = request
return {
"runId": "match3-shadow-run", "floor": {"pass": True}, "artifactHash": "a" * 64,
"acceptanceRequestHash": request["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "accept", "accepted": False, "shadowAccepted": True,
"publishFrozen": True, "repairEligible": False},
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3_shadow",
"playtest": {"outcome": "accept"}, "judge": {"verdict": "accept"},
"failureLayer": {"layer": "none"}, "failureReason": None, "trace": {}},
}
monkeypatch.setattr(D, "_run_v3_floor_gates", _floor)
monkeypatch.setattr(D, "_read_service_run_summary",
lambda gid, wait_s=5.0: {"costRmb": 0.2, "rmbGate": "active", "repairs": 0})
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _accept)
async def _ended(*args, **kwargs):
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
class _Resp:
def __init__(self, data): self._data = data
def json(self): return self._data
class _Http:
def __init__(self, *args, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): return False
async def post(self, url, **kwargs):
if url.endswith("/credential/"): return _Resp({"credential_id": "c1"})
if url.endswith("/agent/"): return _Resp({"agent_id": "a1"})
if url.endswith("/sessions/"): return _Resp({"session_id": "s1"})
return _Resp({})
async def patch(self, *args, **kwargs): return _Resp({})
monkeypatch.setattr(httpx, "AsyncClient", _Http)
asyncio.run(D.drive_cheap_generation({
"gameId": "match3-job", "traceId": "trace-match3-job", "brief": "标准三消解谜",
"interactionProfileId": MATCH3_PROFILE_ID,
}))
assert captured["scaffold"] == ("match3-job", "_template-puzzle", MATCH3_PROFILE_ID)
identity = captured["request"]["acceptanceIdentity"]
assert identity["interactionBinding"]["interactionProfileId"] == MATCH3_PROFILE_ID
# 调真实 Node 义务解析器,证明 job 冻结的 binding 会提升 Match-3 专属有效交换义务。
core = Path(__file__).resolve().parents[2] / "game-runtime/games/_wg1-gen/_shared/playtest-v3.core.cjs"
registry = Path(__file__).resolve().parents[2] / "contracts/play-loop/proof-obligations.v2.json"
code = (
"const fs=require('fs');const c=require(process.argv[1]);"
"const r=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));"
"const i=JSON.parse(process.argv[3]);"
"const o=c.resolveProofObligations(r,i.proofProfileId,{genre:i.genre,"
"templateRoute:i.templateRoute,registryVersion:i.proofRegistryVersion,"
"taskBindingHash:i.taskBindingHash,interactionBinding:i.interactionBinding});"
"process.stdout.write(JSON.stringify(o));"
)
proc = subprocess.run(
["node", "-e", code, str(core), str(registry), json.dumps(identity)],
capture_output=True, text=True, check=True,
)
obligations = json.loads(proc.stdout)
valid_swap = next(item for item in obligations["obligations"]
if item["id"] == "puzzle.match3-valid-swap")
assert obligations["interactionRuleMatches"] == ["puzzle-match3-standard-swap"]
assert valid_swap["required"] is True
def test_service_rejects_explicit_match3_profile_outside_shadow_before_scaffold(tmp_path, monkeypatch):
"""显式 interactionProfileId 只属于 v3_shadow 校准;v3 live 必须在 scaffold/Writer 前拒绝。"""
import asyncio
import cheap_studio
_install_common_stubs(tmp_path, monkeypatch)
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3")
scaffolded = []
monkeypatch.setattr(cheap_run, "scaffold", lambda *args, **kwargs: scaffolded.append(args) or {"ok": True})
summary, _ = asyncio.run(D.drive_cheap_generation({
"gameId": "match3-live", "traceId": "trace-match3-live", "brief": "标准三消解谜",
"interactionProfileId": MATCH3_PROFILE_ID,
}))
assert scaffolded == []
assert summary["ok"] is False
assert "v3_shadow" in (summary.get("stoppedReason") or summary.get("failureReason") or "")
def _install_fake_http(monkeypatch, cred=None):
"""装 fake httpx.AsyncClient:setup 三 POST 返可控体(cred 缺省给全 id),patch 空体。供回合前 unlink / fail-fast 用例复用。"""
import httpx

View File

@ -0,0 +1,96 @@
"""hard_genre_batch 聚合结果命名测试。"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hard_genre_batch import _aggregate_rows, _row_total_cost, _session_result_path, _v3_batch_metrics # noqa: E402
def test_session_result_path_follows_jsonl_stem():
"""自定义 JSONL 必须拥有独立聚合前缀,不能覆盖默认 wax 基线。"""
base = Path("/tmp/results/wax2-baseline.jsonl")
assert _session_result_path(base, 1, 1) == Path("/tmp/results/wax2-baseline-r1-1.json")
assert _session_result_path(base, 2, 4) == Path("/tmp/results/wax2-baseline-r2-5.json")
def test_session_result_path_keeps_default_name():
"""默认 wax-baseline 命名保持兼容。"""
base = Path("/tmp/results/wax-baseline.jsonl")
assert _session_result_path(base, 1, 1) == Path("/tmp/results/wax-baseline-r1-1.json")
def test_aggregate_rows_keeps_existing_rows_on_resume(tmp_path):
"""断点重入没有新行时,聚合仍从 JSONL 恢复既有结果,不能被空数组覆盖。"""
p = tmp_path / "wax2-baseline.jsonl"
p.write_text(
'{"gid":"wax2-narrative-r1","genre":"narrative","round":1}\n'
'{"gid":"wax2-trpg-r1","genre":"trpg","round":1}\n',
encoding="utf-8",
)
rows = _aggregate_rows(p, 1, 1)
assert [row["gid"] for row in rows] == ["wax2-narrative-r1", "wax2-trpg-r1"]
def test_aggregate_rows_merges_genres_but_filters_rounds(tmp_path):
"""分品类进程共享同一轮次聚合时保留全部品类,同时排除范围外轮次。"""
p = tmp_path / "wax2-baseline.jsonl"
p.write_text(
'{"gid":"wax2-narrative-r1","genre":"narrative","round":1}\n'
'{"gid":"wax2-trpg-r1","genre":"trpg","round":1}\n'
'{"gid":"wax2-puzzle-r2","genre":"puzzle","round":2}\n',
encoding="utf-8",
)
rows = _aggregate_rows(p, 1, 1)
assert {row["genre"] for row in rows} == {"narrative", "trpg"}
def test_v3_metrics_use_factual_shadow_decision_and_keep_repair_history():
"""shadow compatibility.accepted=false 不能污染质量分子;repair 前后事实必须同时可审计。"""
first = {"schemaVersion": "playtest/3", "outcome": "reject", "decision": {"accepted": False}}
final = {
"schemaVersion": "playtest/3", "outcome": "accept",
"acceptanceMode": "v3_shadow", "costRmb": 0.3, "sourceRollId": "roll-2",
"merge": {"mergeCandidate": "accept", "rescuedByRoll": 2},
"decision": {"accepted": True, "publishFrozen": True, "rescuedByRoll": 2,
"parentChainCostRmb": 1.7},
"firstPlay": {"loopClosed": True},
"proofObligations": [
{"id": "narrative.ending-reached", "required": True, "status": "satisfied"},
],
"compatibility": {
"accepted": False, "ok": False, "acceptanceVersion": "v3_shadow",
"publishFrozen": True, "schemaVersion": "playtest/3", "sourceRollId": "roll-2",
},
}
metrics = _v3_batch_metrics({"acceptanceV3": final, "acceptanceV3FirstPass": first})
assert metrics["accepted"] is True and metrics["firstPassAccepted"] is False
assert metrics["repairAttempted"] is True and metrics["acceptedAfterRepair"] is True
assert metrics["rescuedByRoll"] == 2 and metrics["publishFrozen"] is True
assert metrics["proofComplete"] is True
assert set(final["compatibility"]) == {
"accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId",
}
def test_frozen_v2_metrics_are_historical_observation_only():
"""playtest/2 只保留历史 proofComplete 观测,明确冻结且不伪装为 active v3。"""
frozen = {
"schemaVersion": "playtest/2", "outcome": "accept",
"acceptanceMode": "historical_replay", "costRmb": 0.1,
"decision": {"accepted": True, "publishFrozen": True, "parentChainCostRmb": 0.1},
"compatibility": {"playtest": {"proofComplete": True}},
}
metrics = _v3_batch_metrics({"acceptanceV3": frozen})
assert metrics["acceptanceVersion"] == "historical_replay"
assert metrics["publishFrozen"] is True
assert metrics["proofComplete"] is True
def test_v3_total_cost_uses_parent_chain_without_double_counting():
row = {"acceptanceVersion": "v3", "costRmb": 1.0, "acceptanceCostRmb": 0.3,
"parentChainCostRmb": 1.7}
assert _row_total_cost(row) == 1.7

View File

@ -0,0 +1,84 @@
"""fresh Match-3 金标生成批次的静态边界测试。"""
import sys
import hashlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import match3_gold_batch as batch # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_candidates_are_five_distinct_fresh_games():
"""五款必须独立 gameId,不能把同一 fixture 的多轮冒充多款。"""
ids = [candidate["gameId"] for candidate in batch.CANDIDATES]
assert len(ids) == 5
assert len(set(ids)) == 5
assert all(game_id.startswith("gold-m3-") for game_id in ids)
assert ids == [
"gold-m3-gem-r3", "gold-m3-candy-r3", "gold-m3-porcelain-r3",
"gold-m3-fruit-r3", "gold-m3-rune-r3",
]
def test_candidates_keep_canonical_match3_requirements():
"""每份题面都必须明确 8×8、相邻交换、真实三连、终态和原生事件。"""
required = (
"8×8", "上下左右相邻", "无效交换必须换回", "20 步", "gameover",
"ctx.event", "puzzle.move-applied", "puzzle.swap-committed", "puzzle.match-formed",
"boardRevisionBefore", "boardRevisionAfter", "matchedCells", "matchedCount",
"puzzle.goal-reached", "puzzle.level-completed", "1-based row/column",
)
for candidate in batch.CANDIDATES:
assert all(fragment in candidate["brief"] for fragment in required)
assert candidate["brief"].count("_forensicsView") == 1
assert "通过 ctx.log 写原生 game 业务事件" not in candidate["brief"]
assert "禁止创建或启动 menu" in candidate["brief"]
assert "match3ProducerProfile" in candidate["brief"]
assert "drawSelectionOverlay" in candidate["brief"]
assert "第一次点选只更新 selected" in candidate["brief"]
assert "同一次 handleTap 同步调用栈" in candidate["brief"]
assert "全部确定性连锁、重力补新、计分和目标/通关判定" in candidate["brief"]
assert "这些业务事实不得延迟" in candidate["brief"]
assert "立即调用 match3VisualTimeline.begin(...) 封存视觉事务" in candidate["brief"]
assert "update(dt) 必须调用 match3VisualTimeline.update(dt)" in candidate["brief"]
assert "render(g) 必须读取 match3VisualTimeline.view().tiles" in candidate["brief"]
assert "禁止读取已经完成全部补位的业务终盘伪装动画" in candidate["brief"]
assert "业务终态可以同步 latch,但成功/失败场景只能在 timeline settle 后显示" in candidate["brief"]
assert "若耗尽最后一步,失败结果仍同步封存但延迟显示" in candidate["brief"]
def test_producer_api_exposes_machine_perceivable_theme_contract():
"""Writer 被要求先读的 API 必须写清图标感知约束,不能只给安全区坐标。"""
source = (REPO_ROOT / "game-runtime/src/plugins/match3-producer-profile/api.d.ts").read_text(
encoding="utf-8",
)
required = (
"覆盖格心的单个连续实心前景",
"禁止空心中心、分离装饰、文字和多块碎片",
"70..200 千分比",
"同一类别跨棋格逐像素保持同构",
"不叠加渐变、半透明柔光、随机纹理或位置相关高光",
)
assert all(fragment in source for fragment in required)
def test_generation_batch_never_uses_fixture_as_target():
"""生成批只允许从黄金 puzzle 起点生成 fresh ID,不得覆盖历史 match3 control。"""
assert batch.TEMPLATE == "_template-puzzle"
assert all(candidate["gameId"] != "match3" for candidate in batch.CANDIDATES)
assert batch.HARD_STOP_RMB == 75.0
def test_batch_manifest_keeps_full_briefs_and_hashes():
"""通用 brief 取证即使限长,合成金标批仍必须保留完整可复核原文。"""
manifest = batch._batch_manifest()
assert manifest["schemaVersion"] == "Match3GoldGenerationBatch/3"
assert len(manifest["candidates"]) == 5
for source, recorded in zip(batch.CANDIDATES, manifest["candidates"], strict=True):
assert recorded["brief"] == source["brief"]
assert len(recorded["brief"]) > 1000
assert recorded["briefSha256"] == hashlib.sha256(source["brief"].encode()).hexdigest()

View File

@ -30,11 +30,11 @@ POLICY_REF = "contracts/play-loop/reference-asset-consumption-policy.initial.jso
MANIFEST_REF = "game-runtime/games/shanhai-xingji/reference/consumption-manifest.json"
BUNDLE_REF = "game-runtime/games/shanhai-xingji/dist/shanhai-bundle.js"
POLICY_ID = "survivor-gold-v1"
EXPECTED_RELEASE_HASH = "5856d607dd973c64dbafc448ccff87cc825c917ede48b6072a52421569e6ccd0"
EXPECTED_REGISTRY_HASH = "57c82adcef617fdb929d056733a395f7c4133636eb16bd38926637857b331b78"
EXPECTED_RELEASE_HASH = "506e181f688f082ea34bd7515e4b1c9952b30a1caad1a5799972ba2d7a3d27a5"
EXPECTED_REGISTRY_HASH = "614ab70de698dd31c5d4b289325e3922ecb64ae88f7a9e7a637a4fa134410e1f"
EXPECTED_POLICY_HASH = "695b6143fec3c7b30d7be5f5ce088ccb1c1add97899dae9f167bd3a9524d8bbf"
EXPECTED_MANIFEST_HASH = "0f600598cfb842b213f7d54ae1be1d07fe277a3e66b3e4ab3ad21b73dea320d9"
EXPECTED_BUNDLE_HASH = "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b"
EXPECTED_BUNDLE_HASH = "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
def _gate():

View File

@ -3,19 +3,23 @@
守的不变量(KTD2 BLOCKER 修正,镜像 SaaGraphDispatcher.buildCallbackReqVO + service.py build_callback_payload):
· gameConfig = 最小占位 Map(templateId/title/theme/engineDriven),绝不塞源工程;
· engineBundle = bundle.iife.js 全文(含 __GameBundle)= 运行时承重产物;
· 成功门 = verdict.pass ∧ engineBundle 含 __GameBundle,否则 failed + 七值 failureReason;
· 成功门 = active v3 canonical accept,或真正 legacy 验收通过;两路都要求 engineBundle 含 __GameBundle;
· sourceProject = best-effort 可选(profile 派生不出即省略,不伪造、不阻断成功);
· traceId 取 job.traceId 或 job_id;纯函数无网络无 LLM、同输入恒同输出。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_result_out.py
"""
import copy
import hashlib
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import result_out as R # noqa: E402
import worker_service as W # noqa: E402
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
@ -240,6 +244,57 @@ def test_build_source_project_valid_2_0():
assert re.fullmatch(r"[a-f0-9]{64}", sp["sourceHash"])
def test_build_source_project_carries_trusted_scaffold_without_changing_source_hash():
"""scaffoldTemplate 是 additive 血缘;sourceHash 冻结为只覆盖 files,不能暗改既有寻址语义。"""
gd = _make_game_dir(_tmp())
profile = {"tickModel": "event", "inputModel": "discrete-choice", "progressModel": "narrative"}
without_route = json.loads(R.build_source_project(gd, profile))
brief = "点击开始完成一局"
brief_hash = hashlib.sha256(brief.encode("utf-8")).hexdigest()
with_route = json.loads(R.build_source_project(
gd, profile, scaffold_template="_template-story",
origin_brief=brief, origin_brief_hash=brief_hash))
assert with_route["scaffoldTemplate"] == "_template-story"
assert with_route["originBrief"] == brief and with_route["originBriefHash"] == brief_hash
assert with_route["sourceHash"] == without_route["sourceHash"]
assert with_route["files"] == without_route["files"]
def test_build_source_project_rejects_unknown_scaffold_lineage():
"""任意模板名不能进入受信 SourceProject 血缘。"""
gd = _make_game_dir(_tmp())
profile = {"tickModel": "event", "inputModel": "discrete-choice", "progressModel": "narrative"}
assert R.build_source_project(gd, profile, scaffold_template="_template-generic") is None
def test_build_source_project_rejects_empty_or_forged_origin_brief():
"""active v3 原题面必须非空且正文与 sha256 自洽,不能只带一个格式正确的 hash。"""
gd = _make_game_dir(_tmp())
profile = {"tickModel": "event", "inputModel": "discrete-choice", "progressModel": "narrative"}
assert R.build_source_project(gd, profile, scaffold_template="_template-story",
origin_brief="", origin_brief_hash=hashlib.sha256(b"").hexdigest()) is None
assert R.build_source_project(gd, profile, scaffold_template="_template-story",
origin_brief="题面甲", origin_brief_hash="a" * 64) is None
def test_source_project_contract_samples_cover_route_whitelist():
"""新增正负样本必须由 Draft-07 schema 一正一拒,防 enum 只写文档未进机器门。"""
from jsonschema import Draft7Validator
root = Path(__file__).resolve().parents[2]
schema = json.loads((root / "contracts/agent-loop/source-project.schema.json").read_text(encoding="utf-8"))
valid = json.loads((root / "contracts/agent-loop/samples/source-project/valid"
/ "01-v2-with-scaffold-template.json").read_text(encoding="utf-8"))
invalid = json.loads((root / "contracts/agent-loop/samples/source-project/invalid"
/ "01-unknown-scaffold-template.json").read_text(encoding="utf-8"))
missing_origin_hash = json.loads((root / "contracts/agent-loop/samples/source-project/invalid"
/ "02-origin-brief-missing-hash.json").read_text(encoding="utf-8"))
validator = Draft7Validator(schema)
assert list(validator.iter_errors(valid)) == []
assert list(validator.iter_errors(invalid)), "未知 scaffoldTemplate 必须被 schema 拒绝"
assert list(validator.iter_errors(missing_origin_hash)), "originBrief/hash 必须成对出现"
def test_build_source_project_omitted_when_profile_incomplete():
"""profile 派生不出(None / 三枚举不全)→ 返 None(省略、绝不伪造)。"""
gd = _make_game_dir(_tmp())
@ -394,6 +449,693 @@ def test_trace_keys_camelcase_match_readiness_scorer():
assert "H_progress" in tr["sevenGateVerdict"]["guards"]
# ──────────────────────────────────────────────────────────────────────────────
# 生成验收 v3 消费层:decision/outcome 唯一写权、shadow 冻结、trace.playtest 轻量投影
# ──────────────────────────────────────────────────────────────────────────────
_V2_HISTORY_SAMPLE_DIR = (Path(__file__).resolve().parents[2] / "contracts" / "play-loop"
/ "samples" / "playtest-evidence" / "valid")
_V2_HISTORY_SAMPLE_BY_OUTCOME = {
"accept": "01-accepted-narrative.json",
"reject": "02-rejected-mechanical.json",
"tester_error": "03-tester-error-action-protocol.json",
"inconclusive": "04-inconclusive-insufficient-proof.json",
}
def _v3_fixture_hash(label: str) -> str:
"""为 A/B/consensus/目标解析 fixture 生成稳定 SHA-256。"""
return hashlib.sha256(label.encode("utf-8")).hexdigest()
def _upgrade_v3_action(action: dict, index: int) -> dict:
"""把历史坐标动作升级为 playtest/3 的结构化选择和确定性解析事实。"""
upgraded = copy.deepcopy(action)
normalized = upgraded["normalized"]
action_type = normalized["type"]
target_set_hash = _v3_fixture_hash(f"target-set-{index}")
if action_type == "tap":
target = {"id": f"r{index:02d}"}
selection = {
"schemaVersion": "ActorSelection/1", "type": "tap",
"targetSetHash": target_set_hash, "target": target,
}
points = [{"role": "target", "target": target,
"point": {"x": normalized["x"], "y": normalized["y"]}}]
elif action_type == "key":
selection = {"schemaVersion": "ActorSelection/1", "type": "key", "key": normalized["key"]}
points = None
elif action_type == "wait":
selection = {"schemaVersion": "ActorSelection/1", "type": "wait", "ms": normalized["ms"]}
points = None
else:
raise AssertionError(f"v3 result fixture 暂不支持动作:{action_type}")
upgraded["request"] = {
"raw": json.dumps(selection, ensure_ascii=False, sort_keys=True),
"parsed": copy.deepcopy(selection),
}
upgraded["retryCount"] = 0
upgraded["protocolErrors"] = []
if points is None:
upgraded["targetResolution"] = {"status": "not_applicable"}
else:
upgraded["targetResolution"] = {
"status": "resolved", "targetSetRef": f"targets/action-{index}.json",
"targetSetHash": target_set_hash,
"sourceFrameRef": upgraded["preFrameRef"]["path"],
"sourceFrameHash": upgraded["preFrameRef"]["hash"],
"guideManifestRef": f"guides/action-{index}.json",
"guideManifestHash": _v3_fixture_hash(f"guide-{index}"),
"resolverVersion": "1.0.0", "selection": copy.deepcopy(selection),
"resolvedPoints": points, "error": None,
}
return upgraded
def _upgrade_history_result_to_v3(v2: dict) -> dict:
"""把冻结 v2 DECIDED 样本升级成 active/shadow 测试使用的 playtest/3 对象。"""
v3 = copy.deepcopy(v2)
v3.pop("_note", None)
v3["schemaVersion"] = "playtest/3"
v3["environment"]["virtualTime"]["waitMaxMs"] = 600
if isinstance(v3.get("actor"), dict):
v3["actor"]["contextProjectionVersion"] = "ActorView/3"
if v3.get("outcome") == "tester_error":
# 失败 selection 只属于 attempt audit,不得伪装为正式 actionRecord。
v3["actions"] = []
else:
v3["actions"] = [_upgrade_v3_action(action, index)
for index, action in enumerate(v3.get("actions") or [], start=1)]
legacy_judge = v2.get("judge") if isinstance(v2.get("judge"), dict) else None
source_roll_id = None
if legacy_judge is not None and v3.get("rolls"):
source_roll_id = str(v3["rolls"][-1]["rollId"])
package_hash = str(legacy_judge["packageHash"])
result_a = _v3_fixture_hash(f"{source_roll_id}-judge-a-result")
result_b = _v3_fixture_hash(f"{source_roll_id}-judge-b-result")
consensus_hash = _v3_fixture_hash(f"{source_roll_id}-consensus")
for roll in v3["rolls"]:
roll_id = str(roll["rollId"])
roll.update({
"proofPackageHash": _v3_fixture_hash(f"{roll_id}-proof"),
"judgePackageHash": package_hash,
"judgeARef": {
"rawRef": f"{roll_id}/judge-a/raw.json", "rawHash": _v3_fixture_hash(f"{roll_id}-a-raw"),
"parsedRef": f"{roll_id}/judge-a/parsed.json", "parsedHash": _v3_fixture_hash(f"{roll_id}-a-parsed"),
"normalizedRef": f"{roll_id}/judge-a/normalized.json", "resultHash": result_a,
},
"judgeAHash": _v3_fixture_hash(f"{roll_id}-a-manifest"),
"judgeBRef": {
"rawRef": f"{roll_id}/judge-b/raw.json", "rawHash": _v3_fixture_hash(f"{roll_id}-b-raw"),
"parsedRef": f"{roll_id}/judge-b/parsed.json", "parsedHash": _v3_fixture_hash(f"{roll_id}-b-parsed"),
"normalizedRef": f"{roll_id}/judge-b/normalized.json", "resultHash": result_b,
},
"judgeBHash": _v3_fixture_hash(f"{roll_id}-b-manifest"),
"judgeConsensusRef": f"{roll_id}/consensus.json",
"judgeConsensusHash": consensus_hash,
"costReservationRef": f"{roll_id}/cost-reservation.json",
"costReservationHash": _v3_fixture_hash(f"{roll_id}-reservation"),
})
v3["judge"] = {
"schemaVersion": "JudgeConsensus/1", "sourceRollId": source_roll_id,
"consensusRef": f"{source_roll_id}/consensus.json", "consensusHash": consensus_hash,
"packageHash": package_hash, "strategyVersion": "1.0.0",
"decision": legacy_judge["decision"], "degraded": bool(legacy_judge.get("degraded")),
"sameModel": True,
"independenceChecks": {
"differentLogicalClient": True, "differentSession": True, "differentPolicySeed": True,
"differentPromptArtifact": True, "differentEvidenceDir": True,
"differentRawFile": True, "sameJudgePackage": True,
"sameImageManifest": True, "sameModel": True,
},
"judgeAResultHash": result_a, "judgeBResultHash": result_b,
"obligationResults": copy.deepcopy(legacy_judge["obligationResults"]),
"conflicts": list(legacy_judge.get("contradictions") or []),
"failureSignature": None,
}
else:
v3["judge"] = None
# 旧 action-protocol tester_error 只有失败 attempt;正式 actionRecord 不应收录它。
if v3.get("outcome") == "tester_error":
v3["actions"], v3["rolls"], v3["events"] = [], [], []
v3["costRmb"] = 0
v3["decision"]["costRmb"] = 0
v3["sourceRollId"] = source_roll_id
accepted = v3["outcome"] == "accept"
v3["compatibility"] = {
"accepted": accepted and v3["acceptanceMode"] == "v3",
"ok": accepted and v3["acceptanceMode"] == "v3",
"acceptanceVersion": v3["acceptanceMode"],
"publishFrozen": bool(v3["decision"]["publishFrozen"]),
"schemaVersion": "playtest/3", "sourceRollId": source_roll_id,
}
return v3
def _set_v3_mode(v3: dict, mode: str) -> None:
"""在完整 canonical 样本上同步切换 active/shadow,保持所有镜像字段逐字一致。"""
accepted = v3["outcome"] == "accept"
frozen = mode == "v3_shadow" or not accepted
v3["acceptanceMode"] = mode
v3["decision"]["shadowAccepted"] = accepted if mode == "v3_shadow" else None
v3["decision"]["publishFrozen"] = frozen
compatibility = v3["compatibility"]
compatibility["accepted"] = accepted and mode == "v3"
compatibility["ok"] = accepted and mode == "v3"
compatibility["acceptanceVersion"] = mode
compatibility["publishFrozen"] = frozen
def _v3_result(outcome="accept", *, mode="v3", accepted=None, frozen=None, proof_complete=None):
"""把冻结样本升级为 v3;旧文件只作历史素材,active fixture 只返回 playtest/3。"""
path = _V2_HISTORY_SAMPLE_DIR / _V2_HISTORY_SAMPLE_BY_OUTCOME[outcome]
v3 = _upgrade_history_result_to_v3(json.loads(path.read_text(encoding="utf-8")))
_set_v3_mode(v3, mode)
if accepted is not None:
v3["decision"]["accepted"] = accepted
if frozen is not None:
v3["decision"]["publishFrozen"] = frozen
v3["compatibility"]["publishFrozen"] = frozen
if proof_complete is not None:
for obligation in v3["proofObligations"]:
if obligation.get("required") is True:
obligation["status"] = "satisfied" if proof_complete else "missing"
return v3
def _v3_summary(outcome="accept", **kwargs):
"""生产形态:完整结果在 acceptanceV3,顶层只镜像六字段 compatibility。"""
v3 = _v3_result(outcome, **kwargs)
compatibility = v3["compatibility"]
summary = {"verdict": {"pass": True}, "floor": copy.deepcopy(v3["floor"])}
for key in ("accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId"):
summary[key] = copy.deepcopy(compatibility[key])
summary["acceptanceV3"] = v3
summary["publishFrozen"] = v3["decision"]["publishFrozen"]
return summary
def _bind_v3_run(summary: dict, gd: Path, *, game_id: str = "t1", brief: str = "x") -> dict:
"""构造真实发布边界:当前 job、run-summary、staged artifact 与 release bundle 四者同源。"""
v3 = summary.get("acceptanceV3")
assert isinstance(v3, dict), "生产绑定夹具必须使用 acceptanceV3 包装态"
staged = gd.parent / "_wg1-gen" / game_id
staged.mkdir(parents=True, exist_ok=True)
release_bytes = (gd / "bundle.iife.js").read_bytes()
(staged / "bundle.iife.js").write_bytes(release_bytes)
staged_src = staged / "src"
staged_src.mkdir(parents=True, exist_ok=True)
for source_file in sorted((gd / "src").glob("*.js")):
(staged_src / source_file.name).write_bytes(source_file.read_bytes())
v3["gameId"] = game_id
v3["briefHash"] = hashlib.sha256(brief.encode("utf-8")).hexdigest()
trace_id = f"trace-{game_id}"
v3["taskBindingHash"] = hashlib.sha256(trace_id.encode("utf-8")).hexdigest()
v3["artifactHash"] = R.cheap_verify._artifact_hash_v3(game_id, staged)
v3["environment"]["buildRef"] = v3["artifactHash"]
summary["gameId"] = game_id
summary["acceptanceRunId"] = v3["runId"]
summary["acceptanceRequestHash"] = v3["acceptanceRequestHash"]
summary["acceptanceTaskBindingHash"] = v3["taskBindingHash"]
summary["acceptanceArtifactHash"] = v3["artifactHash"]
summary["acceptanceBriefHash"] = v3["briefHash"]
return {"traceId": trace_id, "gameId": game_id, "brief": brief}
def _v3_source_project(summary: dict, gd: Path, origin_brief: str) -> str:
"""从验收同源 staged/src 组 active v3 SourceProject 测试夹具。"""
v3 = summary["acceptanceV3"]
staged = gd.parent / "_wg1-gen" / v3["gameId"]
profile = {"tickModel": "event", "inputModel": "discrete-choice", "progressModel": "narrative"}
source_project = R.build_source_project(
staged, profile, scaffold_template=v3["templateRoute"], origin_brief=origin_brief,
origin_brief_hash=hashlib.sha256(origin_brief.encode("utf-8")).hexdigest())
assert source_project is not None
return source_project
def test_v3_accept_projects_trusted_trace_and_succeeds():
"""active v3 accept + 完整硬证才成功;trace 只保留后端消费所需轻量字段。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
out = R.build_result_out(job, summary, gd,
source_project=_v3_source_project(summary, gd, "x"))
assert out["status"] == "succeeded"
playtest = out["trace"]["playtest"]
assert playtest["schemaVersion"] == "playtest/3"
assert playtest["acceptanceMode"] == "v3" and playtest["outcome"] == "accept"
assert playtest["accepted"] is True and playtest["publishFrozen"] is False
assert playtest["compatibilityAccepted"] is True and playtest["compatibilityOk"] is True
assert playtest["floorPass"] is True and playtest["finalPostguardPass"] is True
assert playtest["writeAuthority"] is True and all(playtest["finalChecks"].values())
assert playtest["blockingProblems"] == [] and playtest["contradictions"] == []
assert playtest["mergeConflicts"] == [] and playtest["proofComplete"] is True
assert all(playtest["evidenceRefs"][key] for key in ("actions", "frames", "events"))
assert playtest["firstPlay"]["loopClosed"] is True and playtest["firstPlay"]["proofRefs"]
assert out["trace"]["acceptanceState"] == "active"
identity = out["trace"]["playtest"]
assert {key: identity[key] for key in (
"runId", "gameId", "briefHash", "artifactHash", "taskBindingHash", "acceptanceRequestHash"
)} == {key: summary["acceptanceV3"][key] for key in (
"runId", "gameId", "briefHash", "artifactHash", "taskBindingHash", "acceptanceRequestHash"
)}
assert identity["engineBundleHash"] == hashlib.sha256(out["engineBundle"].encode("utf-8")).hexdigest()
assert set(summary["acceptanceV3"]["compatibility"]) == {
"accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId",
}
def test_v3_shadow_accept_is_frozen_even_when_other_fields_are_green():
"""shadow decision 可为 accept,但 publishFrozen 是硬闸,不能携包进入 succeeded。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept", mode="v3_shadow")
job = _bind_v3_run(summary, gd)
out = R.build_result_out(job, summary, gd)
assert out["status"] == "failed"
assert out["trace"]["playtest"]["outcome"] == "accept"
assert out["trace"]["playtest"]["accepted"] is True
assert out["trace"]["playtest"]["publishFrozen"] is True
assert out["trace"]["acceptanceState"] == "shadow"
assert "engineBundle" not in out
def test_v3_non_accept_outcomes_always_fail_despite_legacy_accept_true():
"""旧平铺 accepted 被污染为 true 也不能覆盖 v3 四态 decision。"""
gd = _make_game_dir(_tmp())
for outcome in ("reject", "inconclusive", "tester_error"):
summary = _v3_summary(outcome)
summary["accepted"] = True
summary["ok"] = True
job = _bind_v3_run(summary, gd)
job["traceId"] = f"v3-{outcome}"
out = R.build_result_out(job, summary, gd)
assert out["status"] == "failed", outcome
assert out["trace"]["playtest"]["outcome"] == outcome
assert out["trace"]["playtest"]["accepted"] is False
def _assert_v3_failed(summary: dict, trace_id: str) -> None:
"""统一断言 v3 单点变异不能携带产物进入 succeeded。"""
gd = _make_game_dir(_tmp())
job = _bind_v3_run(summary, gd)
job["traceId"] = trace_id
out = R.build_result_out(job, summary, gd)
assert out["status"] == "failed"
assert "engineBundle" not in out
assert out["trace"]["acceptanceState"] in ("downgrade_detected", "shadow", "frozen")
def test_v3_contradiction_1_top_outcome_fails_closed():
"""顶层 outcome 与 decision/compatibility 不一致,canonical 语义校验必须拦截。"""
summary = _v3_summary("accept")
summary["acceptanceV3"]["outcome"] = "reject"
_assert_v3_failed(summary, "v3-bad-outcome")
def test_v3_contradiction_2_floor_fails_closed():
"""accept 却让机械四门 floor 失败,不能靠旧 verdict.pass=true 放行。"""
summary = _v3_summary("accept")
summary["acceptanceV3"]["floor"]["pass"] = False
_assert_v3_failed(summary, "v3-bad-floor")
def test_v3_contradiction_3_final_postguard_fails_closed():
"""finalPostguard 任一检查、写权或矛盾项异常都必须 fail-closed。"""
variants = []
bad_check = _v3_summary("accept")
bad_check["acceptanceV3"]["finalPostguard"]["checks"]["floorPass"] = False
variants.append(bad_check)
bad_writer = _v3_summary("accept")
bad_writer["acceptanceV3"]["finalPostguard"]["writeAuthority"] = False
variants.append(bad_writer)
bad_problem = _v3_summary("accept")
bad_problem["acceptanceV3"]["finalPostguard"]["blockingProblems"] = ["仍有阻塞问题"]
variants.append(bad_problem)
for index, summary in enumerate(variants):
_assert_v3_failed(summary, f"v3-bad-final-{index}")
def test_v3_contradiction_4_merge_fails_closed():
"""mergeCandidate 被改写或仍有两掷冲突时,最终 accept 不成立。"""
bad_candidate = _v3_summary("accept")
bad_candidate["acceptanceV3"]["merge"]["mergeCandidate"] = "reject"
bad_conflict = _v3_summary("accept")
bad_conflict["acceptanceV3"]["merge"]["conflicts"] = ["两掷证据冲突"]
_assert_v3_failed(bad_candidate, "v3-bad-merge-candidate")
_assert_v3_failed(bad_conflict, "v3-bad-merge-conflict")
def test_v3_contradiction_5_judge_fails_closed():
"""独立 Judge 拒绝或降级时,兼容投影再绿也不能发布。"""
bad_decision = _v3_summary("accept")
bad_decision["acceptanceV3"]["judge"]["decision"] = "reject"
bad_degraded = _v3_summary("accept")
bad_degraded["acceptanceV3"]["judge"]["degraded"] = True
_assert_v3_failed(bad_decision, "v3-bad-judge-decision")
_assert_v3_failed(bad_degraded, "v3-bad-judge-degraded")
def test_v3_contradiction_6_compatibility_fails_closed():
"""compatibility 的 mode、冻结位或 accepted/ok 与权威 decision 矛盾时不得放行。"""
for key, value in (("ok", False), ("accepted", False), ("publishFrozen", True),
("acceptanceVersion", "v3_shadow")):
summary = _v3_summary("accept")
summary["acceptanceV3"]["compatibility"][key] = value
_assert_v3_failed(summary, f"v3-bad-compat-{key}")
def test_v3_contradiction_7_proof_refs_loop_fails_closed():
"""required proof、三类引用或首局闭环任一缺失,都不能被模型 accept 文本替代。"""
bad_proof = _v3_summary("accept")
bad_proof["acceptanceV3"]["proofObligations"][0]["status"] = "failed"
bad_refs = _v3_summary("accept")
bad_refs["acceptanceV3"]["proofObligations"][0]["actionRefs"] = []
bad_loop = _v3_summary("accept")
bad_loop["acceptanceV3"]["firstPlay"]["loopClosed"] = False
for index, summary in enumerate((bad_proof, bad_refs, bad_loop)):
_assert_v3_failed(summary, f"v3-bad-proof-{index}")
def test_v3_flat_projection_contradiction_fails_closed():
"""完整封存对象虽有效,run-summary 平铺 accepted 被污染也必须失败。"""
summary = _v3_summary("accept")
summary["accepted"] = False
_assert_v3_failed(summary, "v3-bad-flat")
def test_v3_declared_without_acceptance_payload_is_downgrade_detected():
"""任一 v3 声明已出现却缺 acceptanceV3 时,不得回落 legacy verdict/accepted。"""
claims = [
{"acceptanceVersion": "v3"},
{"acceptanceVersion": "historical_replay"},
{"acceptanceVersion": "v999"},
{"acceptanceVersion": {"corrupt": True}},
{"publishFrozen": False},
{"trace": {"playtest": {"schemaVersion": "playtest/2", "outcome": "accept"}}},
]
gd = _make_game_dir(_tmp())
for index, claim in enumerate(claims):
summary = {"verdict": {"pass": True}, "accepted": True, "ok": True, **claim}
out = R.build_result_out({"traceId": f"v3-downgrade-{index}", "brief": "x"}, summary, gd)
assert out["status"] == "failed"
assert out["trace"]["acceptanceState"] == "downgrade_detected"
assert out["trace"]["publishFrozen"] is True
assert "playtest" not in out["trace"]
def test_expected_v3_blocks_fully_stripped_summary_from_legacy_fallback():
"""生产边界已冻结 v3 时,即使 summary 所有 marker 都被剥掉,legacy pass=true 也不能放行。"""
gd = _make_game_dir(_tmp())
summary = {"gameId": "t1", "verdict": {"pass": True}, "accepted": True, "ok": True}
out = R.build_result_out(
{"traceId": "v3-fully-stripped", "gameId": "t1", "brief": "x"}, summary, gd,
expected_acceptance_mode="v3")
assert out["status"] == "failed"
assert out["trace"]["acceptanceState"] == "downgrade_detected"
assert out["trace"]["publishFrozen"] is True
assert "engineBundle" not in out
def test_legacy_v1_stays_compatible_but_sealed_v2_is_observation_only():
"""无协议的 v1 存量仍兼容;冻结 playtest/2 可观察但不能作为 active 发布授权。"""
gd = _make_game_dir(_tmp())
legacy = {"verdict": {"pass": True}, "accepted": True, "playtest": {"accepted": True}}
legacy_out = R.build_result_out({"traceId": "legacy-v1", "brief": "x"}, legacy, gd)
assert legacy_out["status"] == "succeeded"
assert "acceptanceState" not in legacy_out["trace"]
frozen_v2 = json.loads(
(_V2_HISTORY_SAMPLE_DIR / _V2_HISTORY_SAMPLE_BY_OUTCOME["accept"]).read_text(encoding="utf-8")
)
observed = R.build_result_out({"traceId": "historical-v2", "brief": "x"}, frozen_v2, gd)
assert observed["status"] == "failed"
assert observed["trace"]["playtest"]["schemaVersion"] == "playtest/2"
assert "engineBundle" not in observed
def test_v3_unknown_schema_and_outcome_fail_closed():
"""未知 schema/outcome 不能回落顶层 accepted 或旧四门判定。"""
gd = _make_game_dir(_tmp())
bad_schema = _v3_summary("accept")
bad_schema["acceptanceV3"]["schemaVersion"] = "playtest/999"
bad_outcome = _v3_summary("accept")
bad_outcome["acceptanceV3"]["decision"]["outcome"] = "maybe"
schema_job = _bind_v3_run(bad_schema, gd)
outcome_job = _bind_v3_run(bad_outcome, gd)
assert R.build_result_out(schema_job, bad_schema, gd)["status"] == "failed"
assert R.build_result_out(outcome_job, bad_outcome, gd)["status"] == "failed"
def test_v3_failure_ignores_stale_legacy_failure_reason():
"""v3 outcome 已成为真相后,旧 worker 残留的合法枚举也不能把玩法失败误归为 unsafe_prompt。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("reject", mode="v3")
summary["failureReason"] = "unsafe_prompt"
job = _bind_v3_run(summary, gd)
out = R.build_result_out(job, summary, gd,
failure_reason="unsafe_prompt")
assert out["status"] == "failed"
assert out["failureReason"] == "llm_error"
assert out["trace"]["playtest"]["outcome"] == "reject"
def test_historical_replay_marker_never_falls_back_to_legacy_pass():
"""历史回放即使携带旧 pass/accepted=true 且 bundle 完整,也只能降级阻断,永不发布。"""
gd = _make_game_dir(_tmp())
summary = {
"verdict": {"pass": True}, "accepted": True, "ok": True,
"acceptanceVersion": "historical_replay",
}
out = R.build_result_out({"traceId": "historical-no-publish", "brief": "x"}, summary, gd)
assert out["status"] == "failed"
assert out["trace"]["acceptanceState"] == "downgrade_detected"
assert out["trace"]["publishFrozen"] is True
assert "engineBundle" not in out
def test_v3_direct_sealed_result_is_observable_but_never_publishable():
"""裸 decision.json 可离线观察,但缺当前 run-summary 身份绑定,不能直接授权发布。"""
gd = _make_game_dir(_tmp())
out = R.build_result_out({"traceId": "v3-direct", "gameId": "t1", "brief": "x"},
_v3_result("accept"), gd)
assert out["status"] == "failed"
assert out["trace"]["playtest"]["schemaVersion"] == "playtest/3"
def test_v3_old_accept_cannot_publish_for_different_job():
"""同一封存 accept 被塞给另一 gameId 时必须 fail-closed。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
job["gameId"] = "t2"
assert R.build_result_out(job, summary, gd)["status"] == "failed"
def test_v3_accept_cannot_publish_for_different_create_brief():
"""create 当前 brief 与封存 briefHash 不同,旧 accept 不得跨题面重放。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
job["brief"] = "另一道题"
assert R.build_result_out(job, summary, gd)["status"] == "failed"
def test_v3_modify_keeps_original_brief_hash_binding():
"""modify job.brief 是修改指令;原题面 briefHash 由封存验收继承,不能按 create 规则误拒。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd, brief="原游戏题面")
job.update({"brief": "把速度调快", "modifyMode": "deterministic", "baseVersionId": "base-1"})
source_project = _v3_source_project(summary, gd, "原游戏题面")
assert R.build_result_out(job, summary, gd, source_project=source_project)["status"] == "succeeded"
def test_v3_modify_rejects_missing_empty_or_cross_brief_source_origin():
"""modify 不得用缺失/空/另一题面的 SourceProject 原题面授权 active v3 发布。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd, brief="原游戏题面")
job.update({"brief": "把速度调快", "modifyMode": "deterministic", "baseVersionId": "base-1"})
assert R.build_result_out(job, summary, gd, source_project=None)["status"] == "failed"
valid = json.loads(_v3_source_project(summary, gd, "原游戏题面"))
for brief in ("", "另一款游戏题面"):
forged = dict(valid)
forged["originBrief"] = brief
forged["originBriefHash"] = hashlib.sha256(brief.encode("utf-8")).hexdigest()
out = R.build_result_out(job, summary, gd, source_project=json.dumps(forged, ensure_ascii=False))
assert out["status"] == "failed"
def test_v3_regenerate_outer_assertion_failure_blocks_canonical_accept():
"""玩法验收虽 accept,模块重生成 no-op/越界的外层失败也必须阻断发布。"""
gd = _make_game_dir(_tmp())
accepted_summary = _v3_summary("accept")
job = _bind_v3_run(accepted_summary, gd, brief="原游戏题面")
job.update({"brief": "改玩法", "modifyMode": "regenerate-module", "baseVersionId": "base-1"})
failed_result = {
"status": "failed",
"stage": "play",
"summary": accepted_summary,
"verdict": accepted_summary.get("verdictFull"),
"error": "断言①失败:目标文件未变",
}
wrapped = W._regen_summary(failed_result, "t1")
out = R.build_result_out(job, wrapped, gd, expected_acceptance_mode="v3")
assert wrapped["ok"] is False, "外层三断言失败必须覆盖旧 run-summary.ok=true"
assert out["status"] == "failed"
assert "engineBundle" not in out
def test_v3_run_summary_identity_mismatch_fails_closed():
"""acceptanceRunId/requestHash 等平铺身份必须与本次 sealed decision 逐字段一致。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
summary["acceptanceRunId"] = "old-run"
assert R.build_result_out(job, summary, gd)["status"] == "failed"
def test_v3_accept_cannot_replay_same_content_across_tasks():
"""作品、题面与产物完全相同,只要当前 traceId 改变,旧封存裁决也不得授权新任务。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
replay = {**job, "traceId": "another-task-trace"}
source_project = _v3_source_project(summary, gd, "x")
assert R.build_result_out(replay, summary, gd, source_project=source_project)["status"] == "failed"
def test_v3_accept_cannot_publish_foreign_release_bundle():
"""发布目录 bundle 在验收后被替换,即使仍含 __GameBundle 也不得回调成功。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
(gd / "bundle.iife.js").write_text("var __GameBundle={foreign:true};", encoding="utf-8")
assert R.build_result_out(job, summary, gd)["status"] == "failed"
def test_v3_accept_cannot_publish_drifted_staged_artifact():
"""staged 目录任一普通文件漂移都会改变 artifactHash,不能只比 bundle 文件。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
staged = gd.parent / "_wg1-gen" / "t1"
(staged / "late-change.txt").write_text("drift", encoding="utf-8")
assert R.build_result_out(job, summary, gd)["status"] == "failed"
def test_v3_snapshot_rejects_bundle_rewrite_then_restore(monkeypatch):
"""快照若读到恶意中间态,即使文件随后恢复,内存快照 hash 仍不匹配旧验收,必须拒绝。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
source_project = _v3_source_project(summary, gd, "x")
staged_bundle = gd.parent / "_wg1-gen" / "t1" / "bundle.iife.js"
accepted_bytes = staged_bundle.read_bytes()
original_capture = R._capture_artifact_snapshot
def capture_evil_then_restore(root):
staged_bundle.write_text("var __GameBundle={evil:true};", encoding="utf-8")
try:
return original_capture(root)
finally:
staged_bundle.write_bytes(accepted_bytes)
monkeypatch.setattr(R, "_capture_artifact_snapshot", capture_evil_then_restore)
out = R.build_result_out(job, summary, gd, source_project=source_project)
assert out["status"] == "failed"
assert "engineBundle" not in out
def test_v3_snapshot_rejects_artifact_file_count_over_hard_cap(monkeypatch):
"""发布快照文件数超过与 Node 一致的 4096 硬帽时,必须 fail-closed。"""
assert R.artifact_snapshot.MAX_ARTIFACT_FILES == 4096
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
# 标准 staged 树已有 bundle + 两个 src 文件;缩小测试帽即可覆盖第 N+1 个文件前拒绝的路径。
monkeypatch.setattr(R.artifact_snapshot, "MAX_ARTIFACT_FILES", 2)
out = R.build_result_out(job, summary, gd, source_project=_v3_source_project(summary, gd, "x"))
assert out["status"] == "failed"
assert "engineBundle" not in out
def test_v3_snapshot_rejects_artifact_bytes_over_hard_cap(monkeypatch):
"""发布快照总字节超过与 Node 一致的 128 MiB 硬帽时,必须在发布前拒绝。"""
assert R.artifact_snapshot.MAX_ARTIFACT_BYTES == 128 * 1024 * 1024
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
monkeypatch.setattr(R.artifact_snapshot, "MAX_ARTIFACT_BYTES", 1)
out = R.build_result_out(job, summary, gd, source_project=_v3_source_project(summary, gd, "x"))
assert out["status"] == "failed"
assert "engineBundle" not in out
def test_v3_source_project_rejects_release_src_drift_and_accepts_staged_src():
"""v3 源血缘只认验收同源 staged/src;release/src 的后验改写不得落库。"""
gd = _make_game_dir(_tmp(), src_files={"game-logic.js": "// accepted logic\n",
"core.js": "export const SPEED=1;\n"})
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
staged = gd.parent / "_wg1-gen" / "t1"
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
# 验收后仅漂移 release 源,bundle 与 staged artifact 仍保持不变。
(gd / "src" / "core.js").write_text("export const SPEED=999; // 未验收源\n", encoding="utf-8")
drifted = R.build_source_project(
gd, profile, scaffold_template="_template-story", origin_brief="x",
origin_brief_hash=hashlib.sha256(b"x").hexdigest())
rejected = R.build_result_out(job, summary, gd, source_project=drifted,
expected_acceptance_mode="v3")
assert rejected["status"] == "failed", "active v3 的源血缘属于跨请求可信边界,漂移时必须阻断"
assert "sourceProject" not in rejected, "release/src 后验漂移不得随成功回调落库"
accepted = R.build_source_project(
staged, profile, scaffold_template="_template-story", origin_brief="x",
origin_brief_hash=hashlib.sha256(b"x").hexdigest())
published = R.build_result_out(job, summary, gd, source_project=accepted,
expected_acceptance_mode="v3")
assert published["status"] == "succeeded"
assert json.loads(published["sourceProject"])["files"]["src/core.js"] == "export const SPEED=1;\n"
def test_v3_failed_result_never_carries_source_project():
"""v3 最终发布绑定失败时,即使 SourceProject 来自 staged,也不得落成可继承血缘。"""
gd = _make_game_dir(_tmp())
summary = _v3_summary("accept")
job = _bind_v3_run(summary, gd)
staged = gd.parent / "_wg1-gen" / "t1"
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
source_project = R.build_source_project(
staged, profile, scaffold_template="_template-story", origin_brief="x",
origin_brief_hash=hashlib.sha256(b"x").hexdigest())
(gd / "bundle.iife.js").write_text("var __GameBundle={foreign:true};", encoding="utf-8")
out = R.build_result_out(job, summary, gd, source_project=source_project,
expected_acceptance_mode="v3")
assert out["status"] == "failed"
assert "sourceProject" not in out
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0

View File

@ -10,6 +10,7 @@ verdict 由 serve-and-play.sh 产出且结构正确)。前台串行(非 LLM
import json
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run
@ -30,6 +31,24 @@ def test_scaffold():
assert (gd / "src" / "host-config.js").exists()
def test_scaffold_interaction_profile_cli_passthrough(monkeypatch):
"""可信 profile 必须进入 scaffold 固定入口,不能只私下注入 smoke 浏览器。"""
calls = []
def fake_run(argv, **_kwargs):
calls.append(argv)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cheap_run.subprocess, "run", fake_run)
assert cheap_run.scaffold(
"profile-match3", "_template-puzzle",
interaction_profile_id="match3.orthogonal-swap-v1",
)["ok"]
assert calls[-1][-4:] == [
"scaffold-saa", "profile-match3", "_template-puzzle", "match3.orthogonal-swap-v1",
]
def test_stage():
gid = "cheap-run-st"
assert cheap_run.scaffold(gid)["ok"]
@ -42,6 +61,30 @@ def test_stage():
assert (dst / "bundle.iife.js").exists() and (dst / "index.html").exists()
def test_smoke_interaction_profile_cli_passthrough(monkeypatch):
"""显式 profile 追加 CLI 参数;默认 None 保持历史参数数量与顺序。"""
calls = []
def fake_run(argv, **_kwargs):
calls.append(argv)
return SimpleNamespace(
returncode=0,
stdout='[smoke] PASS {"ok":true,"state":null}\n',
stderr='',
)
monkeypatch.setattr(cheap_run.subprocess, "run", fake_run)
assert cheap_run.smoke("profile-default")["ok"] is True
assert calls[-1][-4:] == ["smoke", "profile-default", "4320", "9222"]
assert cheap_run.smoke(
"profile-match3", interaction_profile_id="match3.orthogonal-swap-v1",
)["ok"] is True
assert calls[-1][-5:] == [
"smoke", "profile-match3", "4320", "9222", "match3.orthogonal-swap-v1",
]
def test_ensure_play_spec_keycycle():
"""state 无 targets → key-cycle driver。"""
gid = "cheap-run-spec-kc"

View File

@ -13,14 +13,24 @@ import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run
GID = "cheap-smoke1"
RUN_SUMMARY = cheap_run.game_dir(GID) / "evidence" / "run-summary.json"
# 默认 pytest 是离线单测入口;真模型 smoke 必须先由上方 CLI 显式产证。
# 直接执行本文件时 pytestmark 不生效,仍会由 _require_run 严格拒绝缺失证据。
pytestmark = pytest.mark.skipif(
not RUN_SUMMARY.exists(),
reason="需先显式运行 cheap_studio 真模型 smoke 生成 cheap-smoke1 证据",
)
def _require_run() -> dict:
p = cheap_run.game_dir(GID) / "evidence" / "run-summary.json"
p = RUN_SUMMARY
if not p.exists():
raise AssertionError(
f"先跑端到端单局:cheap-worker/.venv/bin/python cheap-worker/cheap_studio.py --id {GID} --brief ...(缺 {p})")

View File

@ -72,6 +72,42 @@ def test_furthest_stage_ordering():
assert S._furthest_stage(finished=False, staged=False, smoke_ran=False, played=False) == "scaffold"
def test_closeout_gates_passes_optional_interaction_profile(monkeypatch, tmp_path):
"""完整 closeout 的 smoke 透传 profile;play 顺序和默认结果形状保持不变。"""
received = []
monkeypatch.setattr(S.cheap_run, "stage", lambda _gid: {"ok": True, "output": ""})
monkeypatch.setattr(
S.cheap_run, "smoke",
lambda _gid, **kwargs: received.append(kwargs.get("interaction_profile_id"))
or {"ok": True, "state": None, "raw": ""},
)
monkeypatch.setattr(S.cheap_run, "play", lambda _gid, **_kwargs: {"verdict": {"pass": True}})
monkeypatch.setattr(S.cheap_run, "wg1_game_dir", lambda _gid: tmp_path)
out = S._closeout_gates(
"match3-closeout", port=4320, cdp_port=9222,
interaction_profile_id="match3.orthogonal-swap-v1",
)
assert received == ["match3.orthogonal-swap-v1"]
assert out["staged"] is True and out["smoke_ok"] is True
def test_closeout_gates_default_profile_is_none(monkeypatch, tmp_path):
"""未传 profile 时显式透传 None,兼容既有普通游戏 smoke。"""
received = []
monkeypatch.setattr(S.cheap_run, "stage", lambda _gid: {"ok": True, "output": ""})
monkeypatch.setattr(
S.cheap_run, "smoke",
lambda _gid, **kwargs: received.append(kwargs.get("interaction_profile_id"))
or {"ok": True, "state": None, "raw": ""},
)
monkeypatch.setattr(S.cheap_run, "play", lambda _gid, **_kwargs: {"verdict": {"pass": True}})
monkeypatch.setattr(S.cheap_run, "wg1_game_dir", lambda _gid: tmp_path)
S._closeout_gates("default-closeout", port=4320, cdp_port=9222)
assert received == [None]
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0

View File

@ -55,6 +55,29 @@ def test_parse_job_bad_json_returns_none():
assert W.parse_job(b"{not json") is None
def test_default_run_fn_forwards_explicit_interaction_profile(monkeypatch, tmp_path):
"""进程内兼容入口必须原样透传可信 job 的 profile;字段缺失时仍传 None,不按 brief 猜测。"""
import cheap_run
import cheap_studio
received = []
async def _run_studio(game_id, brief, **kwargs):
received.append((game_id, brief, kwargs))
return {"ok": False}
monkeypatch.setattr(cheap_studio, "run_studio", _run_studio)
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / str(gid))
W._default_run_fn({
"gameId": 42, "traceId": "trace-42", "brief": "标准三消解谜",
"interactionProfileId": "match3.orthogonal-swap-v1",
})
W._default_run_fn({"gameId": 43, "traceId": "trace-43", "brief": "标准三消解谜"})
assert received[0][2]["interaction_profile_id"] == "match3.orthogonal-swap-v1"
assert received[1][2]["interaction_profile_id"] is None
# ---------- HMAC ----------
def test_compute_signature_matches_hmac_sha256():
@ -158,10 +181,27 @@ def test_process_job_with_profile_includes_source_project():
assert sp["schemaVersion"] == "2.0" and sp["profile"] == profile
def test_create_scaffold_lineage_only_comes_from_publishable_v3(monkeypatch):
"""worker 只消费权威发布谓词;冻结 v2 和未验证对象都不得产血缘。"""
sample = (Path(__file__).resolve().parents[2] / "contracts/play-loop/samples/playtest-evidence/valid"
/ "01-accepted-narrative.json")
frozen_v2 = json.loads(sample.read_text(encoding="utf-8"))
assert W._accepted_scaffold_template({"acceptanceV3": frozen_v2}) is None
assert W._accepted_scaffold_template({
"acceptanceV3": {"templateRoute": "_template-story", "decision": {"accepted": True}}
}) is None
# 完整 playtest/3 契约与发布谓词由 test_acceptance_v3 覆盖;这里只验证 worker 的路由消费边界。
publishable_v3 = {"schemaVersion": "playtest/3", "templateRoute": "_template-story"}
monkeypatch.setattr(W.cheap_verify, "is_v3_publishable", lambda value: value is publishable_v3)
assert W._accepted_scaffold_template({"acceptanceV3": publishable_v3}) == "_template-story"
# ---------- HTTP 集成(起真服务器 + worker 线程,注入 stub run_fn/send_fn)----------
def test_http_e2e_accept_and_process():
def test_http_e2e_accept_and_process(monkeypatch):
"""POST /generate → 202 投递握手 → worker 串行处理 → 向 callback 发 succeeded result-out。"""
monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v2"})
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
@ -187,6 +227,34 @@ def test_http_e2e_accept_and_process():
server.shutdown()
def test_http_freezes_expected_v3_and_blocks_fully_stripped_summary(monkeypatch):
"""HTTP 入队冻结 v3;run_fn 即使只返 legacy pass=true,也必须 downgrade failed。"""
monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
run_fn=lambda job: (captured.update(job=job) or {"verdict": {"pass": True}, "accepted": True}, gd),
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
profile_fn=lambda j, g: None,
)
W.start_worker(state)
server, port = W.start_server(state, port=0)
try:
body = json.dumps({"job_id": "h-v3", "traceId": "h-v3", "templateId": "generic",
"brief": "x", "gameId": "w-v3",
"callback": {"target": "http://cb"}}).encode("utf-8")
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body,
method="POST", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 202
state.queue.join()
assert captured["job"]["_expectedAcceptanceMode"] == "v3"
assert captured["payload"]["status"] == "failed"
assert captured["payload"]["trace"]["acceptanceState"] == "downgrade_detected"
finally:
server.shutdown()
def test_http_bad_json_returns_400():
state = W.WorkerState(run_fn=lambda j: (None, None), send_fn=lambda *a: (200, ""))
server, port = W.start_server(state, port=0)

View File

@ -27,6 +27,7 @@ from pathlib import Path
import cheap_classify
import cheap_otlp_sink # 面四三跳传播:worker span 作用域 + W3C carrier 提取(顶层零重依赖,otel 惰性)
import cheap_verify
import dedup
import result_out
@ -54,6 +55,53 @@ def _mask_token(tok) -> str:
return "****" if len(s) <= 8 else f"{s[:4]}…{s[-4:]}"
def _source_project_scaffold_template(source_project) -> str | None:
"""从受信 sourceProject 血缘读取模板路由;历史 2.0 缺字段时返回 None,绝不从 brief/genre 猜。"""
try:
value = json.loads(source_project) if isinstance(source_project, str) else source_project
except (TypeError, ValueError):
return None
if not isinstance(value, dict):
return None
template = value.get("scaffoldTemplate")
return template if template in result_out._SOURCE_PROJECT_SCAFFOLDS else None
def _source_project_origin(source_project) -> tuple[str | None, str | None]:
"""读取并复核 base SourceProject 的不可变原题面;缺失或 hash 不自洽时返回空。"""
try:
value = json.loads(source_project) if isinstance(source_project, str) else source_project
except (TypeError, ValueError):
return None, None
if not isinstance(value, dict):
return None, None
brief = value.get("originBrief")
brief_hash = value.get("originBriefHash")
if (not isinstance(brief, str) or not brief.strip() or not isinstance(brief_hash, str)
or hashlib.sha256(brief.encode("utf-8")).hexdigest() != brief_hash):
return None, None
return brief, brief_hash
def _accepted_scaffold_template(summary) -> str | None:
"""只从可信 v3 封存对象恢复 create 模板;未接受、旧模式或字段缺失均不产血缘。"""
acceptance = summary.get("acceptanceV3") if isinstance(summary, dict) else None
if not isinstance(acceptance, dict) or not cheap_verify.is_v3_publishable(acceptance):
return None
template = acceptance.get("templateRoute")
return template if template in result_out._SOURCE_PROJECT_SCAFFOLDS else None
def _source_project_build_dir(summary: dict, game_dir) -> Path:
"""active v3 从验收同源 staged/src 组源;legacy 继续读取既有 release 工作目录。"""
acceptance = summary.get("acceptanceV3") if isinstance(summary, dict) else None
if isinstance(acceptance, dict) and cheap_verify.is_v3_publishable(acceptance):
game_id = str(acceptance.get("gameId") or "")
if cheap_verify._is_safe_game_id_v3(game_id):
return Path(game_dir).parent / "_wg1-gen" / game_id
return Path(game_dir)
# ---------- §6.1 job-in 解析 ----------
def parse_job(raw: bytes) -> dict | None:
@ -85,6 +133,22 @@ def parse_modify(job: dict) -> dict | None:
}
def _freeze_expected_acceptance_mode(job: dict) -> str | None:
"""在 HTTP 入队边界冻结生产验收代际,防 run-summary 全 marker 丢失后被当成 legacy。
运行时配置是主权威;若配置仍是历史模式但可信请求显式要求 v3/v3_shadow,则按请求提高门槛。
只冻结新协议两态,真 legacy 保持 None,继续走原兼容窗口。
"""
requested = job.get("acceptanceMode")
try:
configured = (cheap_verify._acceptance_v3_cfg() or {}).get("mode")
except Exception: # noqa: BLE001 —— 配置读取失败时仍可尊重可信请求;无请求则不在此猜代际
configured = None
if configured in result_out._V3_MODES:
return configured
return requested if requested in result_out._V3_MODES else None
# ---------- HMAC 回调签名(与 Java CallbackSignatureVerifier 对账)----------
def compute_signature(secret: str, data: bytes) -> str | None:
@ -152,7 +216,10 @@ def _default_run_fn(job: dict):
# 与路径段都要求 str,int 会抛 TypeError(expected str, not int) → 生成静默判 llm_error。此处在 job 消费边界统一归一为 str。
game_id = str(game_id) if game_id is not None else None
brief = job.get("brief") or ""
summary = asyncio.run(cheap_studio.run_studio(game_id, brief))
summary = asyncio.run(cheap_studio.run_studio(
game_id, brief, acceptance_task_trace_id=job.get("traceId") or job.get("job_id"),
# 兼容进程内入口只透传可信 job 显式值;None 保持普通 puzzle 不自动选择 profile。
interaction_profile_id=job.get("interactionProfileId")))
return summary, cheap_run.game_dir(game_id)
@ -222,6 +289,7 @@ def process_job(state: WorkerState, job: dict) -> dict:
modify 路在最前分流,下方生成路对无 modifyMode 的 job 零副作用。
"""
trace_id = job.get("traceId") or job.get("job_id")
expected_acceptance_mode = job.get("_expectedAcceptanceMode")
# ── A11 调整路分流(modify):有 modifyMode → deterministic 确定性执行 / regenerate-module 留 M4。──
modify = parse_modify(job)
@ -254,11 +322,20 @@ def process_job(state: WorkerState, job: dict) -> dict:
try:
profile = state.profile_fn(job, game_dir)
if profile:
source_project = result_out.build_source_project(game_dir, profile)
accepted_template = _accepted_scaffold_template(summary)
origin_brief = str(job.get("brief") or "") if accepted_template else None
origin_brief_hash = (hashlib.sha256(origin_brief.encode("utf-8")).hexdigest()
if origin_brief else None)
source_project = result_out.build_source_project(
_source_project_build_dir(summary, game_dir), profile,
scaffold_template=accepted_template, origin_brief=origin_brief,
origin_brief_hash=origin_brief_hash)
except Exception as e: # noqa: BLE001 — sourceProject 非承重,派生失败只记日志、不影响主链
log(f"sourceProject 派生失败(省略,不阻断)trace_id={trace_id}: {type(e).__name__}: {e}")
payload = result_out.build_result_out(job, summary, game_dir, source_project=source_project)
payload = result_out.build_result_out(
job, summary, game_dir, source_project=source_project,
expected_acceptance_mode=expected_acceptance_mode)
callback_url = (job.get("callback") or {}).get("target")
if callback_url:
status, body = state.send_fn(callback_url, payload, state.callback_secret)
@ -282,11 +359,30 @@ def _modify_summary(result: dict, game_id: str) -> dict:
if verdict_full:
guards = verdict_full.get("guards") or {}
failed = [k for k, g in guards.items() if isinstance(g, dict) and g.get("pass") is False]
return {
summary = {
"ok": pass_, "gameId": game_id, "stage": result.get("stage", "play"), "attempts": 1,
"verdict": {"pass": pass_, "failedGates": failed},
"verdictFull": verdict_full,
}
acceptance = result.get("acceptance") if isinstance(result.get("acceptance"), dict) else {}
sealed = acceptance.get("acceptanceV3")
if isinstance(sealed, dict):
# deterministic 执行器把统一验收收在 result.acceptance;回调摘要必须恢复完整封存对象与
# 四个平铺身份,禁止因包装层丢字段而静默退回 legacy verdict。
import cheap_studio # noqa: PLC0415
first_pass = acceptance.get("acceptanceV3FirstPass")
summary = cheap_studio.apply_acceptance_v3(
summary, sealed, first_pass=first_pass if isinstance(first_pass, dict) else None)
# 三断言/执行状态仍是外层硬门。验收 accept 但改动 no-op 或越界时,故意制造与 compatibility.ok
# 的不一致,让 result_out fail-closed,不能只凭玩法仍可玩就把失败的修改发布出去。
summary["ok"] = pass_
else:
# v3 入口失败虽无 canonical payload,也要保留代际标记,确保 result_out 不回落旧九门。
for key in ("acceptanceVersion", "accepted", "ok", "publishFrozen", "failureReason", "failureLayer"):
if key in acceptance:
summary[key] = acceptance[key]
return summary
def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
@ -297,6 +393,7 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
game-logic.js)。未知 mode:显式 failed + 日志(不静默吞)。
"""
trace_id = job.get("traceId") or job.get("job_id")
expected_acceptance_mode = job.get("_expectedAcceptanceMode")
mode = modify.get("mode")
game_id = job.get("gameId") or job.get("job_id")
# 真后端来的 gameId 是 Java Long → JSON 数字 → Python int;cheap_run 的 subprocess shell-out(scaffold/build/stage/smoke/play)
@ -323,7 +420,8 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
modify_fn = cheap_modify.execute_deterministic_modify
result = modify_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"),
base_version_id=modify.get("baseVersionId")) # M5 三断言③血缘透传
base_version_id=modify.get("baseVersionId"),
task_trace_id=trace_id) # M5 三断言③血缘 + v3 当前任务绑定透传
summary = _modify_summary(result, game_id)
game_dir = cheap_run.game_dir(game_id)
@ -333,11 +431,17 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
try:
profile = state.profile_fn(job, game_dir)
if profile:
source_project = result_out.build_source_project(game_dir, profile)
origin_brief, origin_brief_hash = _source_project_origin(modify.get("sourceProject"))
source_project = result_out.build_source_project(
_source_project_build_dir(summary, game_dir), profile,
scaffold_template=_source_project_scaffold_template(modify.get("sourceProject")),
origin_brief=origin_brief, origin_brief_hash=origin_brief_hash)
except Exception as e: # noqa: BLE001 — sourceProject 非承重,派生失败不阻断回调
log(f"调整路 sourceProject 派生失败(省略,不阻断)trace_id={trace_id}: {type(e).__name__}: {e}")
payload = result_out.build_result_out(job, summary, game_dir, source_project=source_project)
payload = result_out.build_result_out(
job, summary, game_dir, source_project=source_project,
expected_acceptance_mode=expected_acceptance_mode)
# 改动清单 manifest 附进 trace(后端 trace 是开放 Map、忽略未知键;此键供前端展示「改了哪几处」+ 排障)。
manifest = result.get("manifest")
if manifest is not None:
@ -378,6 +482,10 @@ def _regen_summary(result: dict, game_id: str) -> dict:
s["verdict"] = {"pass": pass_, "failedGates": failed}
s["verdictFull"] = verdict_full
s["gameId"] = game_id
# v3 不再读取 verdict.pass 作为发布权威,必须把模块重生成外层三断言终态同步写回 ok。
# 当玩法验收本身 accept、但目标 no-op 或误伤非目标时,ok=false 会与封存 compatibility.ok=true
# 形成显式矛盾,由 result_out fail-closed;否则旧 run_summary.ok=true 会把执行器 failed 翻成 succeeded。
s["ok"] = pass_
return s
@ -390,6 +498,7 @@ def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict
changed/untouchedStable。
"""
trace_id = job.get("traceId") or job.get("job_id")
expected_acceptance_mode = job.get("_expectedAcceptanceMode")
game_id = job.get("gameId") or job.get("job_id")
# 真后端来的 gameId 是 Java Long → JSON 数字 → Python int;cheap_run 的 subprocess shell-out(scaffold/build/stage/smoke/play)
# 与路径段都要求 str,int 会抛 TypeError(expected str, not int) → 生成静默判 llm_error。此处在 job 消费边界统一归一为 str。
@ -403,7 +512,8 @@ def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict
regen_fn = cheap_modify.execute_regenerate_modify
result = regen_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"),
base_version_id=modify.get("baseVersionId")) # M5 三断言③血缘透传
base_version_id=modify.get("baseVersionId"),
task_trace_id=trace_id) # M5 三断言③血缘 + v3 当前任务绑定透传
summary = _regen_summary(result, game_id)
game_dir = cheap_run.game_dir(game_id)
@ -413,11 +523,17 @@ def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict
try:
profile = state.profile_fn(job, game_dir)
if profile:
source_project = result_out.build_source_project(game_dir, profile)
origin_brief, origin_brief_hash = _source_project_origin(modify.get("sourceProject"))
source_project = result_out.build_source_project(
_source_project_build_dir(summary, game_dir), profile,
scaffold_template=_source_project_scaffold_template(modify.get("sourceProject")),
origin_brief=origin_brief, origin_brief_hash=origin_brief_hash)
except Exception as e: # noqa: BLE001 — sourceProject 非承重,派生失败不阻断回调
log(f"模块重生成 sourceProject 派生失败(省略,不阻断)trace_id={trace_id}: {type(e).__name__}: {e}")
payload = result_out.build_result_out(job, summary, game_dir, source_project=source_project)
payload = result_out.build_result_out(
job, summary, game_dir, source_project=source_project,
expected_acceptance_mode=expected_acceptance_mode)
# 改动清单 manifest 附进 trace(后端 trace 是开放 Map、忽略未知键;供前端展示「改了玩法、非目标稳没稳」+ 排障)。
manifest = result.get("manifest")
if manifest is not None:
@ -443,7 +559,9 @@ def _send_failed(state: WorkerState, job: dict, reason: str) -> None:
# failure_reason 传七值枚举 catch-all `llm_error`(worker 异常兜底=生成执行链异常的最近桶):
# M3a 前误传非枚举值 `generation_failed`,虽经 _map_failure_reason 映射成 llm_error、回调行为正确,
# 但传真枚举值语义更清、可追溯(错误路红线);具体异常 reason 另由下方日志落痕。
payload = result_out.build_result_out(job, {}, Path("/nonexistent-amgen"), failure_reason="llm_error")
payload = result_out.build_result_out(
job, {}, Path("/nonexistent-amgen"), failure_reason="llm_error",
expected_acceptance_mode=job.get("_expectedAcceptanceMode"))
url = (job.get("callback") or {}).get("target")
if url:
try:
@ -536,6 +654,8 @@ def make_handler(state: WorkerState):
if job is None:
self._send_json(400, {"accepted": False, "error": "bad job json"})
return
# 生产 HTTP 是三条执行路的共同可信入口;覆盖任何同名入站私有字段,避免调用方自行降级。
job["_expectedAcceptanceMode"] = _freeze_expected_acceptance_mode(job)
# 面四断点①(capture):抓入站 W3C traceparent 存进 job,供 worker 线程建 worker span(见 process_job)。
# do_POST 只入队即 202 投递握手,真生成在 worker 线程发生;self.headers 仅这里可得,故此处只抓 header、
# 不建 span(span 建在真处理该 job 的 worker 线程,才能覆盖「调 cheap-service 生成」这一跳并当下游的父)。

View File

@ -0,0 +1,15 @@
{
"schemaVersion": "2.0",
"sourceHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"scaffoldTemplate": "_template-generic",
"profile": {
"tickModel": "event",
"inputModel": "discrete-choice",
"progressModel": "narrative"
},
"files": {
"src/game-logic.js": "export function createGame() { return {}; }"
},
"entry": "entry.js",
"globalName": "__GameBundle"
}

View File

@ -0,0 +1,16 @@
{
"schemaVersion": "2.0",
"sourceHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"scaffoldTemplate": "_template-story",
"originBrief": "点击开始完成一局",
"profile": {
"tickModel": "event",
"inputModel": "discrete-choice",
"progressModel": "narrative"
},
"files": {
"src/game-logic.js": "export function createGame() { return {}; }"
},
"entry": "entry.js",
"globalName": "__GameBundle"
}

View File

@ -0,0 +1,17 @@
{
"schemaVersion": "2.0",
"sourceHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"scaffoldTemplate": "_template-story",
"originBrief": "点击开始完成一局",
"originBriefHash": "f432852cb7fcc4b2f2df6f21f36022cff4318658c7c0529aa9eeb3d2c6932992",
"profile": {
"tickModel": "event",
"inputModel": "discrete-choice",
"progressModel": "narrative"
},
"files": {
"src/game-logic.js": "export function createGame() { return {}; }"
},
"entry": "entry.js",
"globalName": "__GameBundle"
}

View File

@ -14,9 +14,30 @@
"description": "2.0 A-model:多文件代码工件。固定零表达性 plumbing(entry/main/index) + LLM 写的表达性多文件 src/(core/render/game/host-config/balance/assets)。逃离 gamedef 的 JSON 框死——一切玩法/渲染/数值/wiring 都是真 JS。",
"required": ["schemaVersion", "sourceHash", "profile", "files", "entry", "globalName"],
"additionalProperties": false,
"dependencies": {
"originBrief": ["originBriefHash"],
"originBriefHash": ["originBrief"]
},
"properties": {
"schemaVersion": { "const": "2.0", "description": "A-model 源工件契约版本" },
"sourceHash": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "源(规范化 files)sha256(可寻址/幂等键)" },
"scaffoldTemplate": {
"type": "string",
"enum": ["_template-story", "_template-trpg", "_template-feiyi", "_template-puzzle", "_template-shop"],
"description": "可信 create 编排冻结的模板路由;供跨请求 modify 复用同一 proof profile。历史 2.0 可缺,缺失时 v3 modify 必须 fail-closed。sourceHash 仍只覆盖 files;该元数据随完整 sourceProject JSON 由可信回调/数据库边界封存。"
},
"originBrief": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"pattern": ".*\\S.*",
"description": "create 时冻结的原始题面全文;跨请求 modify/regen 的验收上下文权威。历史 2.0 可缺,active v3 必须存在。"
},
"originBriefHash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "originBrief UTF-8 字节的 sha256;active v3 必须与验收 briefHash 一致。"
},
"buildInputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "sha256(sourceHash + buildProfile);确定性构建缓存命中键" },
"profile": { "$ref": "#/$defs/profile" },
"files": {

View File

@ -38,7 +38,9 @@ paths:
description: >-
校验 prompt 非空 + templateId 存在;落库 game_aigc_task(status=0 queued),生成 traceId,投递 RocketMQ 异步队列(T-AGC-07,仅对接点)。
不同步等待生成结果;客户端据 taskId 轮询 GET /app-api/aigc/task/{id}。
关联的 gameId/versionId 由调用方(studio 草稿)传入。
关联的 gameId/versionId 由调用方(studio 草稿)传入。服务端在任何幂等命中前校验资源归属:非空 gameId
必须属于当前用户;versionId/baseVersionId 必须属于当前用户且与 gameId 是同一作品;非空 mode 必须携带
baseVersionId,反向地 baseVersionId 非空时 mode 也必须非空。校验收敛在 AIGC 服务边界,直连本端点不能绕过 studio 编排层。
写权属(决策5):aigc 成功后只回填 game_version.id 关联 + 生成元数据(gen_task_id);不写 game_version 的 package_url/checksum/bundle_size——这些的权威写者=runtime 编译成功后回写(见 runtime V3.0.0 / runtime.yaml)。
requestBody:
required: true
@ -215,12 +217,37 @@ components:
# ---- 请求 VO ----
AigcGenerateReqVO:
type: object
description: >-
异步生成/调整请求。服务端先校验作品和版本归属,再执行幂等查询:非空 gameId 必须归当前用户;
versionId/baseVersionId 必须归当前用户且所属 gameId 与请求一致;任一版本字段非空时 gameId 必填;
mode 与 baseVersionId 必须同时为空或同时非空;空 mode 表示 create。
required: [prompt]
properties:
prompt: { type: string, minLength: 1, maxLength: 1000, description: 创作者一句话自然语言 Prompt(→ 契约#6 input.prompt) }
templateId: { type: string, description: '玩法模板 ID(→ 契约#6 input.templateId;可选,缺省=generic:W-CLEAN 废玩法模板层后,一句话创作无需选模板,入口将缺省 templateId 归一为 generic 单一通用口径,品类区分留 prompt 层;前端亦可显式从模板列表选)' }
gameId: { type: integer, format: int64, description: '关联游戏 ID(project.game_project.id)。产物 GamePackage 经此落 game_version;studio 草稿场景传入' }
versionId: { type: integer, format: int64, description: '关联版本 ID(project.game_version.id)。指定则产物写入该版本;为空时由生成流程新建版本' }
gameId: { type: integer, format: int64, description: '关联游戏 ID(project.game_project.id)。非空时服务端强制校验 owner=当前用户;versionId/baseVersionId 必须属于同一 gameId' }
versionId: { type: integer, format: int64, description: '关联版本 ID(project.game_version.id)。非空时 gameId 必填,且服务端强制校验版本 owner=当前用户、版本所属游戏=gameId' }
mode:
type: string
nullable: true
enum: [deterministic, regenerate-module, extend]
description: '生命周期模式;空=create。mode 与 baseVersionId 必须同时为空或同时非空'
baseVersionId:
type: integer
format: int64
description: 'modify/extend 的可信基线版本。非空时 mode/gameId 必填,且服务端强制校验版本 owner=当前用户、版本所属游戏=gameId'
modifyPatch:
type: string
nullable: true
description: '调整寻址与载荷 JSON 字符串;create 可空'
assetContext:
type: string
nullable: true
description: '六类素材引用上下文 JSON 字符串;可空'
idempotencyKey:
type: string
nullable: true
description: '请求幂等键;资源归属校验先于幂等命中,不能用旧 key 绕过当前请求授权'
attachments:
type: array
description: 附件驱动创作上下文(图片/参考,可空;→ 契约#6 input.attachments)

View File

@ -1,13 +1,116 @@
# contracts/play-loop/ —— 判分闭环两契约(PlaySpec 考卷 · VerdictFeedback 判卷反馈)
# contracts/play-loop/ —— 游戏真玩与可信证据闭环契约
这一目录登记生成引擎判分闭环上的两份契约,它们是 2026-07-03 一次性裁决(Δ1)从散在代码里的隐形约定钉出来的显式契约。闭环是这样一条线:一局游戏生成出来后,harness 按一份**考卷**(PlaySpec)真玩它、九门判分;没过门就把一份结构化**判卷反馈**(VerdictFeedback)喂回续修 agent 让它接着修;修完再玩再判,直到门绿才放行。两份契约分别钉住这条线的两端——考卷说清「怎么玩我、以便据此判我」,反馈说清「哪道门没过、卡在哪、往哪修」。
这一目录同时保存多代真玩契约。`play-spec` 与 `verdict-feedback` 是旧九门驱动闭环的 C5/C6 契约,仍服务 tier2、历史复跑和在途兼容;`playtest/2` 已冻结只读,`playtest/3` 是便宜档生成验收的新签发真相层,把 Actor 操作、runner 取证、独立 Judge、单掷 guard、二掷合并和最终 postguard 分开。v3 不再让测试模型一句 pass 直接放行,也不恢复 `_forensicsView`、play-spec 或 tap-targets 作为便宜档玩法权威。
| 契约文件 | 设计代号 | 是什么 | 消费方 | 状态 |
|---|---|---|---|---|
| `play-spec.schema.json` | C5 | PlaySpec 考卷:游戏声明起局仪式、驱动器族、输入指令、赢/输可观测量、与源工程的派生绑定 | W-S1 修复三单(接 startRitual / derivedFrom 进 harness 与 ensure_play_spec)· tier2 F-1 对齐 | schema 已立、校验器与负样本齐;**生产接线在途** |
| `verdict-feedback.schema.json` | C6 | VerdictFeedback 判卷反馈:每次未过门回喂续修 agent 的结构化载荷(哪道门 / 卡在哪个 phase / 判卷驱动器 / 证据指针 / 疑似失败面 / 修复方向) | W-S1 修复三单(gate_judge 产结构化反馈、middleware 注入续修)· tier2 F-1 对齐 | schema 已立、校验器与负样本齐;**生产接线在途** |
| `game-event.schema.json` | game-event/1 | runtime 可信边界封装的版本化业务事件;native 与 historical legacy-adapted 来源严格分开;Match-3 swap/match payload 为严格结构 | runtime `ctx.event`、历史适配器、runner | schema、payload hash、revision、run 连续性和 matched union/count 正负样本齐 |
| `playtest-evidence.schema.json` | playtest/2 | 上一代单局证据契约,仅用于历史回放和回归验证 | 历史证据读取器 | **冻结只读,不再签发、不允许适配升级** |
| `playtest-evidence-v3.schema.json` | playtest/3 | 新签发单局权威证据:目标解析、双 Judge 独立槽、成本预留、rollGuard/merge/finalPostguard 与兼容投影 | CLI、Service create、A11 modify 共用的 `run_acceptance_v3` | schema、语义校验与样本齐;生产接线在途 |
| `proof-obligation-registry.schema.json` | proof-obligations/2 | 按五个可信 `proofProfileId` 解析精确事件、payload 谓词、有序 sequence、`sameValue` 与 heritage `orderedSeries` | runner obligation resolver、JudgePackage 构建器、postguard | schema 与正负样本齐 |
| `proof-obligations.v2.json` | 2026-07-15.v3 | 五 profile canonical 义务数据;默认义务与 brief 可选提升的唯一事实源 | 与上一行相同 | canonical 数据已立,`--suite` 强制校验 |
| `legacy-game-log-mapping.schema.json` / `legacy-game-log-mapping.v1.json` | legacy-game-log-mapping/1 | historical_replay 专用精确 allowlist,键为 profile+tag+payload paths,禁止读取 message | 历史适配器 | schema、canonical 与负样本齐 |
| `single-roll-fact.schema.json` | SingleRollFact | Node runner 交给 Python rollGuard 的单掷事实,封存动作时间推进、帧、事件、义务与 Judge 包 hash | Node runner、Python rollGuard | schema、真实形状样本与负样本齐 |
| `acceptance-request.schema.json` | acceptance-request/1 | 一次验收的不可变输入身份;初始运行无父链,唯一修回同时绑定原产物与父请求 | Python 编排、Node runner | 精确 11 字段、canonical profile 与当前任务绑定对账、正负样本齐 |
| `acceptance-provenance.schema.json` | acceptance-provenance/1 | 请求身份与修回链的逐字段镜像,并绑定 request 原始字节 hash 和被测 artifactHash | Python 编排、Node runner | 精确 13 字段,不接受 `requestFile` 等可写替代字段 |
| `acceptance-request-v2.schema.json` / `acceptance-provenance-v2.schema.json` | acceptance-request/2 · acceptance-provenance/2 | 新验收身份链;交互身份只能是完整嵌套 `InteractionBinding/1` 或显式 null,完整 request 进入 canonical hash | Python 编排、Node runner、bundle validator | absent/verified/repair 正链与 flat/task drift 负样本齐;`/1` 继续冻结只读 |
| `proof-obligation-resolution.schema.json` | ProofObligationResolution/1 | 按 proof 默认、原始 brief 匹配和可信 interaction 匹配确定性解析 required 义务 | runner resolver、SingleRollProof/2 | sidecar 语义 hash、注册表顺序和 binding 原始文件 hash 均可复算 |
| `single-roll-proof-v1.schema.json` | SingleRollProof/1 frozen | 冻结 legacy 单掷包;保持完整 v3 actionRecord,不含 Resolution sidecar | 历史 bundle validator | 历史动作形状冻结,不受 ProofAction 收窄影响 |
| `single-roll-proof-v2.schema.json` / `proof-action.schema.json` | SingleRollProof/2 · ProofAction/1 | 新 `/2` 请求的单掷封存包;动作只保留 normalized、帧、事件游标和受控时间推进,不含 Actor request/selection/TargetSet/runner group | Node runner、bundle validator、JudgePackage 构建器 | 窄动作正链与 request 泄漏负例齐;完整审计留在 runner 侧 |
| `judge-package.schema.json` | JudgePackage/1 | Judge 唯一可读事实投影;不含 interaction、Resolution 或 solver 身份 | Judge A/B | runner-owned 结构严格隔离;业务 `gameEvents[].payload` 不做字段名黑名单 |
| `proof-resolution-bundle.schema.json` | proof-resolution-bundle/1 | 测试清单;跨文件核对 request→provenance→binding→Resolution→SingleRollProof→JudgePackage→evidence 闭包 | `validate.py --proof-package-dir` | absent/verified/legacy/repair 四条正链与全链重签负例齐;不是生产证据 |
| `actor-selection.schema.json` | ActorSelection/1 | Actor 的唯一输出结构;只允许目标 ID、棋盘单元、安全按键或有限等待 | new-api Anthropic `output_config`、生产 selection parser | canonical schema、生产接线与正负样本齐 |
| `actor-view-v3.schema.json` / `actor-view-v4.schema.json` | ActorView/3 frozen · ActorView/4 | 冻结真实 View/3 投影;View/4 普通路径字段不变,标准 Match-3 只增加脱敏 pair 候选 | 后续 View/4 actor adapter | contract 与兼容矩阵齐;**未接生产 Actor** |
| `actor-selection-v2.schema.json` / `actor-protocol-bundle.schema.json` | ActorSelection/2 · ActorProtocolBundle/1 | 普通四动作保持原义;Match-3 增加唯一 `swap_pair`,联合锁定 View/Selection/prompt/parser 代际 | 后续 View/4 parser、runner | 普通四动作、swap_pair、stale/unknown/endpoint/version drift 样本齐;**未接生产** |
| `judge-result.schema.json` | JudgeResult/1 | Judge A/B 的唯一输出结构;固定顶层裁决、逐义务状态与候选步骤引用形状 | new-api Anthropic `output_config`、生产 Judge parser | canonical schema、生产接线与正负样本齐 |
| `visual-target-set.schema.json` | VisualTargetSet/1 | `opencv-visual-targets@1.0.1` 从 clean frame 生成的 region/lattice/永久 g01;guide 与 manifest 保持独立制品 | target proposer、resolver、Match-3 shadow 感知器 | schema、canonical targetSetHash、真实生产 fixture 与 grid-only 回归齐 |
| `interaction-profile-registry.schema.json` / `interaction-profiles.v1.json` | InteractionProfileRegistry/1 | Writer 前由可信编排器选择的交互规则;首版登记标准 Match-3 正交交换,并与 proof profile 的品类和模板路由对账 | 编排器、runner、builder provenance | canonical 数据、跨表校验与负样本齐;**尚未接生产入口** |
| `interaction-profile-registry-v2.schema.json` / `interaction-profiles.v2.json` | InteractionProfileRegistry/2 | View/4 专用注册表;prompt 绑定仓内 contract-only 工件,producer/solver/parser/resolver/normalizer/equivalence 都携带 strict config 并复算 configHash | D-F 共用 runner | canonical registry、producer capability 与 drift 负例齐;校准路径已接,`runtimeEligible=false`,不改变 live View/3 prompt |
| `match3-producer-preflight.schema.json` | Match3ProducerPreflight/1 | 在 Actor 与成本预留前封存 producer probe、隔离浏览器、首击帧/事件、lattice/SwapSet 与 normalizer 事实;`executionStage` 让早期环境失败以 null 表达未产生证据 | Match-3 专项 runner、上层编排器 | canonical factHash、三类结果、canonical geometry 与早期退出负例齐;校准入口已接,生产仍冻结 |
| `interaction-binding.schema.json` | InteractionBinding/1 | 把 profile、注册表版本和当前 taskBindingHash 封成不可替换的 interactionBindingHash | 编排器、runner | schema、哈希复算与篡改负样本齐;**尚未接生产入口** |
| `match3-board-projection.schema.json` | Match3BoardProjection/1 | frozen clean frame 上的匿名棋格类别投影;逐格 cropHash 始终绑定真实逻辑像素 crop | Match-3 感知器、规则 solver | schema、Lab+直方图+空间形状确定性感知、保守退出与 shadow 审计齐;**未接 Actor** |
| `match3-swap-set.schema.json` | Match3SwapSet/1 | 从匿名棋盘确定性复算的合法无序交换对、交换前连续段和新连续段证明 | Match-3 规则 solver、后续 ActorView/4 | schema、纯规则 solver、sealed pair oracle 与正负样本齐;**未接 Actor/CDP** |
| `actor-selection-attempt.schema.json` / `match3-pair-resolution.schema.json` / `grouped-action-ref.schema.json` | Attempt/1 · PairResolution/1 · GroupedActionRef/1 | 一个 Actor 选择确定一个 pair;runner 展开为两个原生 tap,不伪造第二次 Actor request | 后续 D-F runner audit | ref/hash、pair 另一端、first/second/reset 角色样本齐 |
| `board-identity-check.schema.json` / `match3-action-group.schema.json` | BoardIdentityCheck/1 · Match3ActionGroup/1 | 首 tap 后从 TargetSet+Projection 重算匿名分区;原始 perception audit 的 ref/hash、两帧、首击格、配置、classifier、双跑结果与 support mask 同时进入闭包;只有 equivalent 才允许第二 tap | D-F 共用 runner audit | audit 漂移、伪造 mask、classifier 漂移、changed/重复第二端/缺动作负例齐 |
| `collection-proof.schema.json` / `match3-action-bundle.schema.json` / `match3-roll-interaction-audit.schema.json` | CollectionProof · Match3ActionBundle/1 · Match3RollInteractionAudit/1 | CollectionProof 保存完整 runner 事实;两个测试清单回读 producer preflight、双 tap、Proof/Judge、真实帧字节、post-first perception audit 及最多两组一次 reset 的全部审计工件 | `validate.py --match3-action-bundle` / `--match3-roll-audit` | preflight→roll 身份闭包、完整正链与重签负例齐;测试清单本身不是生产证据 |
| `match3-audio-audit.schema.json` | Match3AudioAudit/1 | limiter 后窗口账;绑定 host 已验证的 BGM 原始字节、runtime 节点、视觉 transition、cue receipt 和逐 cue onset 终态 | host、专项 runner | 完成事务的每个应发声 transition 必须恰有一张成功 receipt,至少一个 onset detected,终局 cue 必须 detected;runner 在每个 completed group 后立即无输入 drain,并在该 group 目录独立封存 |
| `build-manifest.schema.json` / `match3-visual-transaction.schema.json` / `match3-visual-audit.schema.json` | BuildManifest/1 · Match3VisualTransaction/1 · Match3VisualAudit/1 | staged 制品、视觉事务最小可信投影及稳定终盘审计 | 后续 specialty sidecar 生产者 | contract-first,exact schema 与关键负例齐;**未接生产 runner** |
| `match3-effect-render-receipt.schema.json` / `match3-effect-audit.schema.json` | Match3EffectRenderReceipt/1 · Match3EffectAudit/1 | 可信主画布特效回执和 build/filmstrip/palette 闭包 | 后续 specialty sidecar 生产者 | contract-first;**未接生产 runner** |
| `input-lock-probe.schema.json` / `match3-filmstrip-analyzer.schema.json` | InputLockProbe/1 · Match3FilmstripAnalyzer/1 | 独立输入锁 control/probe 与 swap→clear→fall/refill→settle 胶片顺序 | 后续 specialty sidecar 生产者 | contract-first;**未接生产 runner** |
| `match3-roll-interaction-audit-v2.schema.json` / `single-roll-proof-v3.schema.json` | Match3RollInteractionAudit/2 · SingleRollProof/3 | `/2` 单向引用冻结动作 Audit/1 和全部 specialty sidecar;Proof/3 再单向引用 Audit/2 | specialty gate、`validate.py --match3-specialty-proof` | 跨文件正链和缺字段/hash 漂移/旧版冒充负例齐;**当前 runner 禁止签发** |
## 为什么现在钉这两份
AudioAudit 的 group 输出目录是单次 run 身份边界,不可复用。若目录已有正式侧车,sealer 在写候选前报 `artifact_ref_collision`;旧文件只属于旧 run,消费方只能使用本次调用返回的 `ref/hash`,不得扫描目录捡取历史 formal。
## v3 可信证据边界
`playtest/3` 把流程阶段与终裁结果拆成两个字段:
```text
phase = PRECHECK | COLLECTING | JUDGING | DECIDED
outcome = accept | reject | inconclusive | tester_error
```
Actor 只能输出 tap/key/wait/drag 动作。下一轮 `ActorView` 只回灌 runner 记录的客观动作结果,不回灌 Actor 的 why、summary 或结论。Judge 使用独立 API client、session、system prompt、上下文投影和证据目录,只读取 hash 封存后的 JudgePackage。每掷 `rollGuard` 只产 `rollOutcome`,merge 只产 `mergeCandidate`;只有顶层 `finalPostguard` 能写 `decision` 与 compatibility。
终裁和发布是两个不同问题,固定公式如下:
```text
decision.accepted = (outcome == accept)
publishFrozen = (acceptanceMode == v3_shadow or outcome != accept)
compatibility.accepted = compatibility.ok = (acceptanceMode == v3 and outcome == accept)
```
因此 shadow 样本即使硬证完整,`finalPostguard.pass=true`、`decision.accepted=true` 也只说明验收裁决为接受;`decision.publishFrozen` 与 `compatibility.publishFrozen` 仍为 true,`compatibility.accepted/ok` 必须为 false,不能进入自动交付。只有 active `v3` 的 accept 可以解冻发布。`decision.shadowAccepted` 在 shadow 下镜像终裁 accepted,在 active v3 下为 null。
`compatibility` 是 run-summary 唯一平铺投影,固定包含 `accepted / ok / acceptanceVersion / publishFrozen / playtest / judge / failureLayer / failureReason / trace`。其中 `failureLayer` 是 `{layer, reason, failedGates}` 对象;`trace.playtest` 与 `playtest` 逐字一致,`trace.gameplayJudge` 与 `judge` 逐字一致。契约不再保留 `tracePlaytest`、`traceGameplayJudge` 等第二套别名,避免 CLI、Service create、A11 modify 和 Java 消费层各读一份。
`decision` 同时封存冻结位、失败对象、repair 权限与成本:`repairCountAcrossParentChain / repairEligible / repairFeedback / costRmb / parentChainCostRmb`。顶层 `repair` 镜像 count/eligible/attempted/reason;eligible 时 reason 必须逐字等于 repairFeedback,parent 链已发生过修复时 attempted=true。两处不一致属于协议错误,不得靠任一侧猜测修正。请求只提交本轮有限非负的 writer 成本增量;repair 的历史基数必须从 sealed parent decision 读取,调用方不能自报或重置。单掷成本以明细原值计账,顶层与 ledger 的 `costRmb` 必须严格等于十进制 `ROUND_HALF_UP(sum(entries[].rmb), 5)`,不能拿语言默认 round 或未舍入浮点和五位汇总直接比较。
每个 required proof obligation 以 `sequenceRefs[]` 为权威,每一步同时引用动作、动作后截图和 `game-event/1` 业务事件;flat refs 只能从它确定性投影。runner 先按精确 type、payload 谓词、动作白名单和事件顺序选候选,Judge 再把事件与截图交叉核对。真实输入只在同一个浏览器原生 Event 的宿主同步分发栈内产生 action provenance;Promise、timer、RAF、保存事件二次派发、直接 `_emit` 以及外部或真实输入栈内嵌套的 `host.stepFrames/tap/do/state` 都不能继承 actionId。`wait` 只表示在受控时间窗口内观察到注册表允许的定时业务结果,不声称该事件由 wait 内部触发,也永远不能写入首个输入反馈;多步骤义务中的 wait 必须通过 `sameValue` 与其他步骤关联,单步骤 wait 必须有 payload 谓词。官方延迟里程碑均晚于 600ms 输入量子,由显式 wait 取证,不恢复宽时间窗。
验收入口先校验 `acceptance-request/1` 与 `acceptance-provenance/1` 的原始文件字节 hash,再逐字段核对 gameId、briefHash、genre、templateRoute、proofProfileId、registryVersion、taskBindingHash 与修回链。`taskBindingHash` 是当前生成任务 traceId 的 SHA-256,同一任务的唯一 repair 全 parent 链保持不变;发布边界按当前任务 traceId 复算,不允许把内容相同的旧任务证据重放给新任务。两份文件和 playtest evidence 必须位于 gameDir 外;artifactHash 覆盖 staged gameDir 的全部普通文件,名为 `evidence` 的目录也不例外,任何文件或目录 symlink 直接拒绝。runner 启动前调用共用的 Python dirfd helper 一次性捕获普通文件字节并以该快照计算 artifactHash:`artifact-snapshot/1` 先写域标签,文件按 UTF-8 路径字节序排列,每项用 uint64be 分隔 path 与内容长度;根和每级目录、最终文件都经 `openat + O_NOFOLLOW` 打开并核对 device/inode,读取前核普通文件、大小和 128MiB 总帽,读取后复核 inode、大小及纳秒时间戳。Node 对 helper 的长度清晰输出再次复算 hash,浏览器只从本机内存快照服务读取这些字节。活目录的末尾复算不再参与生产裁决,因为它既挡不住“改写、加载、恢复”,又会在快照未变时制造无关假阴。发布边界另行要求当前待发布目录 hash 与 accepted artifactHash 一致。
浏览器业务事件只从导航前注入的固定 bridge 读取。bridge 在产物脚本运行前捕获原生描述符、冻结、String/RegExp、数组判定和受控 RAF 的 Map 方法;capture listener 以闭包 listenerType 对账,不读取 `event.type`,原生 isTrusted getter 也独立捕获。因此页面后续 monkeypatch `Object.*`、`Event.prototype.type/isTrusted`、`String`、`RegExp.prototype.test`、`Map.prototype`、`Array.isArray` 或 `JSON.*` 不会改变可信读口和虚拟帧账。native 模式要求不可替换且整体冻结的 `__genHost`/`__gameHost`,双 reader 也必须不可替换;公开 `window.__gameLog` 只服务 historical_replay,不能注入 native 业务事件或契约错误。每条事件同时携带 Node `stableStringify(payload)` 的 `payloadCanonical` 与其 SHA-256;Python 校验原文字节 hash,并要求 `json.loads(payloadCanonical)` 与 payload 类型严格、有限数值语义等值,不再用自身浮点格式复算。
heritage 使用受限 `orderedSeries`:同一 `workId` 的完整 3–12 步必须按 1..N 出现,每步 action/post frame 两两独立,summary 与 terminal 再逐项核对总数、stepId 顺序、完成位、成品数量和有限评分。TRPG 的天赋返回闭环使用 `sameValue(/upgradeId)` 绑定 level→talent→floor;翻层、下一关、营业结算分别使用 `sourceFloorVisitId`、`sourceLevelVisitId`、`dayId` 绑定前后业务事件,禁止跨周期或独立 timer 拼证据。
注册表的 `briefOptionalRules` 只做一件事:当 brief 明确出现“升级/天赋”“提示”“自动设备”等关键词时,把已登记的可选义务提升为 required。Actor 和 Judge 都不能临时发明或删除义务。运行结果必须记录 `proofRegistryVersion` 与命中的 `briefRuleMatches`,以便复现当时用的是哪一版验收义务。
新签发链使用 `acceptance-request/2`。请求把 `interactionBinding` 固定为完整 `InteractionBinding/1` 或 null,禁止再用平铺字段拼接身份;`acceptance-provenance/2` 必须逐字段镜像请求,并以 `artifactHash` 指向本次被测产物。初始请求的两个 parent 字段和 `sourceArtifactHash` 都为 null;唯一 repair 必须同时引用父 request 与父 provenance,继承 gameId、briefHash、genre、templateRoute、proof profile、注册表版本、task binding 与 interaction binding,且子请求的 `sourceArtifactHash` 必须等于父 provenance 的 `artifactHash`。
repair 校验不会只信父 provenance 的 `acceptanceRequestHash`。父 provenance 还必须逐字段镜像父 request 的 gameId、briefHash、genre、templateRoute、proof profile、注册表版本、task binding、interaction binding、source、parent 和 repairOrdinal;即使 request hash 正确,任一镜像字段单独漂移也会被拦。
Resolution 不是 Judge 输入。可信 resolver 读取原始 brief UTF-8 字节、冻结 proof 注册表和已验证 interaction binding,按注册表原顺序生成 `briefRuleMatches`、`interactionRuleMatches` 与 `requiredObligationIds`,再封存语义 hash。SingleRollProof/2 只通过 ref/hash 绑定该 sidecar;JudgePackage 只含 Judge 判定所需的事实候选、动作、帧、业务事件和文字引用范围。隔离检查只作用于 runner-owned 投影,不能因为游戏业务 payload 恰好出现 `candidateId`、`newRuns` 或 `solver` 就误拦。
`proof-resolution-bundle/1` 只用于测试跨文件闭包。validator 会复算完整 canonical request hash、brief 原始字节 hash、binding 文件 hash、Resolution hash、JudgePackage hash 和 SingleRollProof 封存 hash,并对账 request/provenance/proof/Judge 的 artifact 与身份字段。负样本带 `expectedErrorPattern`,套件必须命中指定根因,不能靠任意旧 hash 或无关结构错误“假绿”。
legacy `/1` bundle 按 request 版本进入冻结分支:request/provenance 使用旧 schema 与旧 proof registry 快照,单掷包使用 `single-roll-proof-v1.schema.json` 和 v3 事实语义校验。旧协议没有定义可复算的 canonical request hash,因此 validator 不新造算法,而是把历史 `acceptanceRequestHash` 当作 opaque 身份:request 与 provenance 逐字段镜像,proof 的 gameId、brief、品类、路由、proof profile、冻结注册表版本和 task binding 必须镜像 request,proof 的 opaque request hash 与 artifactHash 分别绑定 provenance。冻结 proof schema 把 `proofRegistryVersion` 固定为 `2026-07-14.v2`;它不加载当前 interaction registry,也不能仅凭 `packageType` 和外层 hash 被视为合法。
## Match-3 离线规则与初始帧 shadow 边界
`interaction-profiles.v1.json` 的 `match3.orthogonal-swap-v1` 必须逐字对账 `proof-obligations.v2.json` 中 `puzzle.match-board` 的 `genre=puzzle` 与 `templateRoute=_template-puzzle`。`interactionBindingHash` 固定为:
```text
SHA256("interaction-binding/1\n" + stableJson({
interactionProfileId,
interactionRegistryVersion,
taskBindingHash
}))
```
v1 `Match3BoardProjection/1` 的 classId 按 classified 格的行优先首次出现次序编号为 `vc01...`;v2 D-F 审计不依赖 classId 字面值,而是从完整 classified cells 重算两两同类关系矩阵,因此首 tap 后即使类别重编号,只要 dimensions、lattice geometry 和匿名分区相同,BoardIdentity 仍可判 equivalent。alternatives 的 classId 不得重复,并按置信度降序、classId 升序保存。不保存输入颜色名或图标名。`projectionHash`、单候选 `candidateHash` 和 `candidateSetHash` 都采用“域标签 + 换行 + stableJson”口径,并排除自身 hash 字段。规则 solver 只枚举正交相邻、交换前异类、交换后形成此前不存在且包含交换端点的最大连续段;A↔B 与 B↔A 只能留下一个规范 pair。
`validate.py` 对单独的 `Match3SwapSet/1` 只能证明字段、排序、状态和两层 hash 的封存结构自洽;虽然 SwapSet 保存 source frame、TargetSet 与 Projection 引用,单文件校验仍不能证明这些文件真实存在或候选确实由那份棋盘计算。生产可信入口必须调用 `verifyMatch3SwapSetAgainstProjection(projection, swapSet)`:函数先严格校验 Projection,再从它重新运行 solver,并逐字比较完整 SwapSet。即使调用方伪造 `newRuns/classId` 或删除候选后重新签发 `candidateHash/candidateSetHash`,联合校验仍固定报 `swap_set_projection_mismatch`,不得进入 Actor 或动作派发。
第一切片建立离线规则层,第二切片增加初始 frozen clean frame 的只读感知 shadow;D-F contract slice 进一步定义 View/4、Selection/2、pair 双 tap、BoardIdentity、严格双事件和 roll recovery 的审计闭包。一个 `swap_pair` 只产生一次 `ActorSelectionAttempt/1`,PairResolution 从 sealed candidate 确定另一端,再展开为两个相邻原生 action。`eventsRef` 必须是覆盖双 tap 事件游标区间的完整 slice;validator 从第二 action 区间确定性选出相邻的 native/profile-proof `swap-committed→match-formed`,不再接受 manifest 自报 `proofProjection`。Resolution 必须提升 Match-3 义务,CollectionProof 保存完整动作、帧和事件,SingleRollProof 只保留允许的窄投影,JudgePackage 必须是 CollectionProof 的严格只读投影;三张实际 PNG 的原始字节 hash 同时绑定 action、TargetSet、Projection、SwapSet 和 proof/Judge frame。恢复最多两组、一次 reset;预算由 v2 recovery policy 推导,reset 必须重复首组第一端、预算 600ms、完整事件切片不含 swap/match,并从 reset action post frame 贯通 TargetSet→Projection→SwapSet 的新 candidateSet。以上仍是 contract-first,尚未接 Actor/CDP/runtime;`no_candidate` 也只表示当前可靠投影中没有可执行交换,不能直接形成游戏 reject。
标准生产者能力由 v2 registry 唯一给出:390×844 视口、8×8 棋盘、40px pitch、`(35,120)` 原点、20px 深色圆形 cell shell、40×40 冻结白色选中环像素 mask、472 像素支持集和 12px 主题安全内缩。选中环不能用 canvas `arc/stroke` 近似;真 Chrome 已证明同一参数在不同格位会产生 475/468 个变化像素。`Match3ProducerPreflight/1` 的 expected capability 必须逐字来自 registry,probe 自身 configHash 也必须复算;probe 身份漂移只能形成 `tester_error/profile_contract_error`,不能伪装成游戏不兼容。compatible 还必须从 producer grid 复算格心数组与 canonical `geometryHash`,再同时满足首击无业务事件、normalizer=`normalized`、changedPixelCount=472、supportMaskHash 精确命中冻结 mask、格外变化为 0 且重放残差不超过 1。像素或交互形态不满足该固定能力时签发 `incompatible/profile_contract_incompatible`。
Chromium、CDP、截图或本地视觉链未执行完时仍要签发 `tester_error/environment_error`。事实用 `executionStage` 记录最后完成边界;尚未产生的 probe、frame、event、lattice、SwapSet 必须为 null,normalizer 固定为 `not_run`,其 ref/hash 与像素数也全部为 null,禁止用占位 hash 冒充证据。正式 `Match3RollInteractionAudit/1` 必须引用原始 preflight 字节并复算 hash,只接受 `compatible/none`,同时逐字段核对 game、artifact、request、task binding、interaction binding 与 seed。所有结果都带 canonical `factHash`;校准入口已接线,`runtimeEligible=false` 仍保持生产冻结。
## C5/C6 为什么存在
**考卷(C5)**至今是隐形的。驱动器怎么选,靠的是 `cheap_run.py:241-242` 那句「state 里有没有 `targets` 键」——有就用点目标的 tap-targets 族、没有就用循环按键的 key-cycle 族。这条约定只活在一个函数里,读代码才知道。更要命的是考卷和源工程之间**没有任何绑定**:`cheap_run.py:278` 用「play-spec.json 已存在就不覆盖」来兜底,可源工程一旦改过,旧考卷还在,就会拿上一版的考卷去判新工程——要么假绿、要么错判。C5 把驱动器族、起局仪式、赢/输可观测量都升为显式字段,并加一段 `derivedFrom.sourceHash` 把考卷绑到源工程内容上:hash 不一致即算陈旧、必须重生。陈旧从此可判定,而不是靠「不覆盖」听天由命。
@ -29,7 +132,7 @@
## 校验器与样本
校验器 `validate.py` 零依赖(只用 Python 标准库),自带一个 JSON-Schema Draft-2020-12 子集实现——本仓没装 jsonschema、也没 vendor ajv,而红线要求零新依赖;两份契约的消费方(cheap-worker、tier2)本就是 Python,落一份 stdlib 校验器与消费侧同栈。正负样本套件即校验器自身的回归证明:某关键字实现错了,正样本会被误拦或负样本会漏过,跑套件即暴露。
校验器 `validate.py` 零依赖(只用 Python 标准库),自带一个 JSON-Schema Draft-2020-12 子集实现——本仓没装 jsonschema、也没 vendor ajv,而红线要求零新依赖。JSON Schema 负责字段形状;标准 schema 无法表达的跨引用不变量由同文件的语义校验补齐,包括 Actor/Judge 隔离、seed 握手、动作事件区间、proof 三证引用、注册表义务解析、rollGuard/merge/finalPostguard 写权、shadow 发布冻结、decision/repair/compatibility 镜像、成本合计,以及 accepted 不得与 problems/矛盾共存。
```bash
# 套件模式:正样本应全过、负样本应全拦,打印每契约小计与总计
@ -37,12 +140,48 @@ python3 validate.py --suite
# 单点模式:拿任意样本对某 schema 校验(可多文件)
python3 validate.py play-spec.schema.json samples/play-spec/valid/01-t2048-key-cycle.json
# v3 单点模式;冻结历史契约仍可按对应旧 schema 单独校验
python3 validate.py playtest-evidence-v3.schema.json samples/playtest-evidence-v3/valid/01-judging-single-roll.json
python3 validate.py proof-obligation-registry.schema.json proof-obligations.v2.json
python3 validate.py single-roll-fact-v3.schema.json samples/single-roll-fact-v3/valid/01-targeted-dual-judge.json
python3 validate.py acceptance-request.schema.json samples/acceptance-request/valid/01-initial-narrative.json
python3 validate.py acceptance-provenance.schema.json samples/acceptance-provenance/valid/01-initial-narrative.json
python3 validate.py acceptance-request-v2.schema.json samples/acceptance-request-v2/valid/02-verified-match3.json
python3 validate.py acceptance-provenance-v2.schema.json samples/acceptance-provenance-v2/valid/02-verified-match3.json
python3 validate.py proof-obligation-resolution.schema.json samples/proof-obligation-resolution/valid/02-verified-match3.json
python3 validate.py single-roll-proof-v1.schema.json samples/proof-resolution-bundle/fixtures/legacy-single-roll-proof.json
python3 validate.py single-roll-proof-v2.schema.json samples/single-roll-proof-v2/valid/05-complete-match3-proof.json
# 结构化审计日志:成功和失败都记录阶段、引用、仓内相对路径及声明/复算 hash;不记录 brief、payload 或模型内容
python3 validate.py --trace-jsonl artifacts/validation-logs/match3-action-positive.jsonl \
--match3-action-bundle samples/match3-action-bundle/valid/01-complete-double-event.json
python3 validate.py judge-package.schema.json samples/judge-package/valid/01-business-payload-name-collision.json
python3 validate.py interaction-binding.schema.json samples/interaction-binding/valid/01-match3-task.json
python3 validate.py interaction-profile-registry-v2.schema.json interaction-profiles.v2.json
python3 validate.py actor-protocol-bundle.schema.json samples/actor-protocol-bundle/valid/02-view4-selection2.json
python3 validate.py match3-board-projection.schema.json samples/match3-board-projection/valid/01-resolved-3x3.json
python3 validate.py match3-swap-set.schema.json samples/match3-swap-set/valid/01-single-candidate.json
python3 validate.py match3-producer-preflight.schema.json samples/match3-producer-preflight/valid/04-tester-environment-error.json
# 跨文件闭包:也可传 absent、legacy 或 repair 的 manifest
python3 validate.py --proof-package-dir samples/proof-resolution-bundle/valid/02-verified-v2.json
python3 validate.py --match3-action-bundle samples/match3-action-bundle/valid/01-complete-double-event.json
python3 validate.py --match3-roll-audit samples/match3-roll-interaction-audit/valid/01-reset-then-complete.json
python3 validate.py --match3-specialty-proof samples/match3-specialty-proof/valid/01-complete-visual-audio-closure.json
```
样本目录 `samples/<契约>/{valid,invalid}/`,目录名对应同名 schema。每个负样本文件头部的 `_note` 写明它复现的是哪条真实失败模式、对应生产代码哪一行、预期被哪条规则拦下——负样本都是「真实会发生的错法」,不是假想错法:
样本目录 `samples/<契约>/{valid,invalid}/`,目录名对应同名 schema。较大的 v3 衍生样本使用 `_base + _set/_delete` 声明最小单点变异:校验器会先加载完整正样本,再应用 JSON Pointer 变异,因此样本仍是完整契约实例,不会因为复制多份几百行 JSON 而随协议漂移。一般样本用 `_note` 写预期语义;request/provenance 正样本为了证明 exact keys 不带任何测试字段:
- **C5 负样本**:①无 sourceHash 绑定(复现 `cheap_run.py:278` 缺绑定的陈旧兜底)②无任何驱动方式(driver/inputs 皆缺)③驱动器族缺必填参数(key-cycle 无 keys)④进展断言用了 `checkAssertion` 不认的算子(会无声判否)。
- **C6 负样本**:①报 latch 失败却丢 phaseNow(复现 `gate_judge.py:100-102`)②逐门反馈缺 driverType ③声称未过门却给空 items(复现 `gate_judge.py:125-126` 的盲修缺口)④门全绿却仍产反馈(passed=true 矛盾态)。
- **playtest/2 正样本**:active v3 accept、shadow accept 冻结、mechanical reject、inconclusive、tester_error;四态齐且发布态单独覆盖。
- **playtest/2 负样本**:提前写 outcome、accept+blocking problem、proof 引用断裂、Actor/Judge 共 session、插件日志冒充业务事件、mergeCandidate 被越权升级、rollGuard 越权写 decision、requested/actual seed 不一致、brief 义务漏解析、shadow compatibility 假放行、publishFrozen 矛盾、repair 镜像矛盾、decision.accepted 矛盾。
- **proof registry 负样本**:缺 profile、缺动作白名单、直接输入错误允许 wait、brief 孤儿义务、重复义务/步骤、坏谓词与 profile id 漂移。
- **因果证据负样本**:timeAdvance 缺失或帧数不闭合、sequence 倒序、wait 冒充输入、payload 谓词失败、TRPG upgradeId 不同、heritage 缺步骤/复用动作/summary 顺序矛盾。
- **请求与来源负样本**:额外字段、初始运行伪造父链、修回缺 source/parent、profile 与 genre/route 不一致、registryVersion 漂移和坏 request hash。
- **闭包负样本**:brief/interaction 规则漏解析、Resolution/Proof 身份漂移、Judge runner-owned 数据泄漏、provenance 与 proof artifact 分叉、repair 父 binding/brief/source artifact 与父 provenance 镜像漂移、legacy proof 畸形/gameId 替换/冻结注册表升级;全部按目标错误正则验收。
- **Match-3 负样本**:v2 config/prompt/registry/recovery policy 漂移;producer geometry 漂移、`not_run` 伪造 audit、preflight 字节/seed/result 与正式 roll 分叉;stale/unknown candidate、第一端不在 pair、第二 tap 重复、BoardIdentity changed 后仍继续;legacy/cross-profile proof 事件、完整事件流不相邻、event cursor 断裂;swap revision 不递增、payload 额外字段、run 不连续/乱序、matched union/count 漂移;未授权 reset、预算不足、reset 产生 swap、candidateSet 自报和第三组。
## 取样来源
@ -55,3 +194,5 @@ python3 validate.py play-spec.schema.json samples/play-spec/valid/01-t2048-key-c
- `verdict-feedback/valid/01` ← `game-runtime/games/_wg1-gen/t2048/evidence/verdict.json`(H_progress latch.phaseNow='playing' 真实失败)
- `verdict-feedback/valid/02` ← `game-runtime/games/_wg1-gen/_goldenpath-ref/evidence/verdict.json`(F_wiring callCount=0 真实失败)
- `verdict-feedback/valid/03` ← `run.py:1000-1070` verdict_feedback/_economy_evidence + `gate_judge.py:42-53` 前缀口径 + `tier2-verdict.schema.json` richGameGates/findings 形态(tier2 富证据示例值)
- `playtest-evidence/valid/01..05` ← [`2026-07-13-生成线验收v3-可信证据闭环-设计`](../../docs/agent-specs/2026-07-13-生成线验收v3-可信证据闭环-设计.md) 的 playtest/2、四态 outcome、active 发布条件与 shadow 冻结语义。
- `proof-obligations.v2.json` ← 同设计 §3.5 的五 profile 闭环、精确业务事件、动态 heritage 工序和 TRPG 升级周期约束。

View File

@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/acceptance-provenance-v2.schema.json",
"title": "AcceptanceProvenanceV2",
"description": "acceptance-provenance/2。逐字段镜像 acceptance-request/2,并绑定完整 canonical request hash 与被测 artifact。",
"type": "object",
"required": [
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "interactionBinding", "sourceArtifactHash",
"parentAcceptanceRequestHash", "repairOrdinal", "acceptanceRequestHash", "artifactHash"
],
"properties": {
"schemaVersion": { "const": "acceptance-provenance/2" },
"gameId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"briefHash": { "$ref": "#/$defs/sha256" },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"interactionBinding": {
"anyOf": [
{ "$ref": "interaction-binding.schema.json" },
{ "type": "null" }
]
},
"sourceArtifactHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"parentAcceptanceRequestHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"repairOrdinal": { "enum": [0, 1] },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"artifactHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "repairOrdinal": { "const": 0 } }, "required": ["repairOrdinal"] },
"then": {
"properties": {
"sourceArtifactHash": { "type": "null" },
"parentAcceptanceRequestHash": { "type": "null" }
}
}
},
{
"if": { "properties": { "repairOrdinal": { "const": 1 } }, "required": ["repairOrdinal"] },
"then": {
"properties": {
"sourceArtifactHash": { "$ref": "#/$defs/sha256" },
"parentAcceptanceRequestHash": { "$ref": "#/$defs/sha256" }
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/acceptance-provenance.schema.json",
"title": "AcceptanceProvenance",
"description": "验收入口的来源清单。它逐字段镜像请求身份和修回链,并绑定请求原始字节与本次被测产物。",
"type": "object",
"required": [
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"acceptanceRequestHash", "artifactHash"
],
"properties": {
"schemaVersion": { "const": "acceptance-provenance/1" },
"gameId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"briefHash": { "$ref": "#/$defs/sha256" },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"sourceArtifactHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
},
"parentAcceptanceRequestHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
},
"repairOrdinal": { "enum": [0, 1] },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"artifactHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "repairOrdinal": { "const": 0 } },
"required": ["repairOrdinal"]
},
"then": {
"properties": {
"sourceArtifactHash": { "type": "null" },
"parentAcceptanceRequestHash": { "type": "null" }
}
}
},
{
"if": {
"properties": { "repairOrdinal": { "const": 1 } },
"required": ["repairOrdinal"]
},
"then": {
"properties": {
"sourceArtifactHash": { "$ref": "#/$defs/sha256" },
"parentAcceptanceRequestHash": { "$ref": "#/$defs/sha256" }
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/acceptance-request-v2.schema.json",
"title": "AcceptanceRequestV2",
"description": "acceptance-request/2。交互身份只允许嵌套 InteractionBinding/1 或显式 null;完整对象进入 canonical acceptanceRequestHash。",
"type": "object",
"required": [
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "interactionBinding", "sourceArtifactHash",
"parentAcceptanceRequestHash", "repairOrdinal"
],
"properties": {
"schemaVersion": { "const": "acceptance-request/2" },
"gameId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"briefHash": { "$ref": "#/$defs/sha256" },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"interactionBinding": {
"anyOf": [
{ "$ref": "interaction-binding.schema.json" },
{ "type": "null" }
]
},
"sourceArtifactHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"parentAcceptanceRequestHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"repairOrdinal": { "enum": [0, 1] }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "repairOrdinal": { "const": 0 } }, "required": ["repairOrdinal"] },
"then": {
"properties": {
"sourceArtifactHash": { "type": "null" },
"parentAcceptanceRequestHash": { "type": "null" }
}
}
},
{
"if": { "properties": { "repairOrdinal": { "const": 1 } }, "required": ["repairOrdinal"] },
"then": {
"properties": {
"sourceArtifactHash": { "$ref": "#/$defs/sha256" },
"parentAcceptanceRequestHash": { "$ref": "#/$defs/sha256" }
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/acceptance-request.schema.json",
"title": "AcceptanceRequest",
"description": "一次验收运行的不可变输入身份。初始验收不带父链;唯一一次修回必须同时绑定原产物与父请求。",
"type": "object",
"required": [
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal"
],
"properties": {
"schemaVersion": { "const": "acceptance-request/1" },
"gameId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"briefHash": { "$ref": "#/$defs/sha256" },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"sourceArtifactHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
},
"parentAcceptanceRequestHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
},
"repairOrdinal": { "enum": [0, 1] }
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "repairOrdinal": { "const": 0 } },
"required": ["repairOrdinal"]
},
"then": {
"properties": {
"sourceArtifactHash": { "type": "null" },
"parentAcceptanceRequestHash": { "type": "null" }
}
}
},
{
"if": {
"properties": { "repairOrdinal": { "const": 1 } },
"required": ["repairOrdinal"]
},
"then": {
"properties": {
"sourceArtifactHash": { "$ref": "#/$defs/sha256" },
"parentAcceptanceRequestHash": { "$ref": "#/$defs/sha256" }
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-protocol-bundle.schema.json",
"title": "ActorProtocolBundleV1",
"description": "Actor View、Selection、Prompt 与 validator 的联合版本门。",
"type": "object",
"required": ["schemaVersion", "prompt", "validator", "actorView", "selection"],
"properties": {
"schemaVersion": { "const": "ActorProtocolBundle/1" },
"prompt": {
"oneOf": [
{
"type": "object", "required": ["id", "version", "hash"],
"properties": {
"id": { "const": "playtest.actor" },
"version": { "type": "string", "minLength": 1 },
"hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
{
"type": "object", "required": ["id", "version", "artifactRef", "runtimeEligible", "hash"],
"properties": {
"id": { "const": "playtest.actor.contract-match3-v4" },
"version": { "const": "3.0.1" },
"artifactRef": { "const": "artifacts/playtest-actor-v4.contract.md" },
"runtimeEligible": { "const": false },
"hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
]
},
"validator": {
"type": "object", "required": ["id", "version", "selectionSchemaVersion"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"selectionSchemaVersion": { "enum": ["ActorSelection/1", "ActorSelection/2"] }
},
"additionalProperties": false
},
"actorView": {
"anyOf": [
{ "$ref": "actor-view-v3.schema.json" },
{ "$ref": "actor-view-v4.schema.json" }
]
},
"selection": {
"anyOf": [
{ "$ref": "actor-selection.schema.json" },
{ "$ref": "actor-selection-v2.schema.json" }
]
}
},
"additionalProperties": false,
"$defs": { "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }
}

View File

@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-selection-attempt.schema.json",
"title": "ActorSelectionAttemptV1",
"description": "ActorSelectionAttempt/1。封存一次 ActorView/4 到 ActorSelection/2 的模型选择;pair 计划只生成一个 attempt,不能伪造成两个 Actor request。",
"type": "object",
"required": [
"schemaVersion", "attemptId", "actorViewRef", "actorViewHash", "selectionRef",
"selectionHash", "selection", "accepted", "error", "attemptHash"
],
"properties": {
"schemaVersion": { "const": "ActorSelectionAttempt/1" },
"attemptId": { "type": "string", "pattern": "^attempt-[1-9][0-9]*$" },
"actorViewRef": { "type": "string", "minLength": 1 },
"actorViewHash": { "$ref": "#/$defs/sha256" },
"selectionRef": { "type": "string", "minLength": 1 },
"selectionHash": { "$ref": "#/$defs/sha256" },
"selection": { "$ref": "actor-selection-v2.schema.json" },
"accepted": { "type": "boolean" },
"error": {
"anyOf": [
{ "type": "null" },
{
"type": "object", "required": ["code", "message"],
"properties": {
"code": { "type": "string", "minLength": 1 },
"message": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}
]
},
"attemptHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "accepted": { "const": true } }, "required": ["accepted"] },
"then": { "properties": { "error": { "type": "null" } } },
"else": { "properties": { "error": { "type": "object" } } }
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-selection-context.schema.json",
"title": "ActorSelectionContextV1",
"description": "ActorView/4 与 ActorSelection/2 的联合兼容门;普通路径与标准 Match-3 路径互斥。",
"type": "object",
"required": ["schemaVersion", "actorView", "selection"],
"properties": {
"schemaVersion": { "const": "ActorSelectionContext/1" },
"actorView": { "$ref": "actor-view-v4.schema.json" },
"selection": { "$ref": "actor-selection-v2.schema.json" }
},
"additionalProperties": false
}

View File

@ -0,0 +1,118 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-selection-v2.schema.json",
"title": "ActorSelectionV2",
"description": "ActorSelection/2。继承普通目标动作,并为可信标准 Match-3 增加 pair 级选择;Actor 只选择第一端,第二端由 runner 从候选确定。",
"oneOf": [
{ "$ref": "#/$defs/tapSelection" },
{ "$ref": "#/$defs/dragSelection" },
{ "$ref": "#/$defs/keySelection" },
{ "$ref": "#/$defs/waitSelection" },
{ "$ref": "#/$defs/swapPairSelection" }
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"targetRef": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "pattern": "^(r|l|g)[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" },
"subAnchor": {
"enum": [
"center", "north", "south", "east", "west",
"northwest", "northeast", "southwest", "southeast"
]
}
},
"additionalProperties": false,
"allOf": [
{ "if": { "required": ["subAnchor"] }, "then": { "required": ["cell"] } }
]
},
"swapEndpoint": {
"type": "object",
"required": ["id", "cell", "subAnchor"],
"properties": {
"id": { "type": "string", "pattern": "^l[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" },
"subAnchor": { "const": "center" }
},
"additionalProperties": false
},
"tapSelection": {
"type": "object",
"required": ["schemaVersion", "type", "targetSetHash", "target"],
"properties": {
"schemaVersion": { "const": "ActorSelection/2" },
"type": { "const": "tap" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"target": { "$ref": "#/$defs/targetRef" }
},
"additionalProperties": false
},
"dragSelection": {
"type": "object",
"required": ["schemaVersion", "type", "targetSetHash", "from", "to", "ms"],
"properties": {
"schemaVersion": { "const": "ActorSelection/2" },
"type": { "const": "drag" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"from": { "$ref": "#/$defs/targetRef" },
"to": { "$ref": "#/$defs/targetRef" },
"ms": { "const": 600 }
},
"additionalProperties": false
},
"keySelection": {
"type": "object",
"required": ["schemaVersion", "type", "key"],
"properties": {
"schemaVersion": { "const": "ActorSelection/2" },
"type": { "const": "key" },
"key": {
"enum": [
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"KeyW", "KeyA", "KeyS", "KeyD", "Enter", "Escape", "Space"
]
}
},
"additionalProperties": false
},
"waitSelection": {
"type": "object",
"required": ["schemaVersion", "type", "ms"],
"properties": {
"schemaVersion": { "const": "ActorSelection/2" },
"type": { "const": "wait" },
"ms": { "type": "integer", "minimum": 100, "maximum": 600 }
},
"additionalProperties": false
},
"swapPairSelection": {
"type": "object",
"required": [
"schemaVersion", "type", "targetSetHash", "candidateSetHash",
"candidateId", "firstEndpoint"
],
"properties": {
"schemaVersion": { "const": "ActorSelection/2" },
"type": { "const": "swap_pair" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"firstEndpoint": { "$ref": "#/$defs/swapEndpoint" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,97 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-selection.schema.json",
"title": "ActorSelectionV1",
"description": "ActorSelection/1。Actor 只能选择 runner 提供的目标 ID、棋盘单元、安全按键或有限等待,不能直接写屏幕坐标。",
"oneOf": [
{ "$ref": "#/$defs/tapSelection" },
{ "$ref": "#/$defs/dragSelection" },
{ "$ref": "#/$defs/keySelection" },
{ "$ref": "#/$defs/waitSelection" }
],
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1 },
"column": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"targetRef": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "pattern": "^(r|l|g)[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" },
"subAnchor": {
"enum": [
"center", "north", "south", "east", "west",
"northwest", "northeast", "southwest", "southeast"
]
}
},
"additionalProperties": false,
"allOf": [
{
"if": { "required": ["subAnchor"] },
"then": { "required": ["cell"] }
}
]
},
"tapSelection": {
"type": "object",
"required": ["schemaVersion", "type", "targetSetHash", "target"],
"properties": {
"schemaVersion": { "const": "ActorSelection/1" },
"type": { "const": "tap" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"target": { "$ref": "#/$defs/targetRef" }
},
"additionalProperties": false
},
"dragSelection": {
"type": "object",
"required": ["schemaVersion", "type", "targetSetHash", "from", "to", "ms"],
"properties": {
"schemaVersion": { "const": "ActorSelection/1" },
"type": { "const": "drag" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"from": { "$ref": "#/$defs/targetRef" },
"to": { "$ref": "#/$defs/targetRef" },
"ms": { "const": 600 }
},
"additionalProperties": false
},
"keySelection": {
"type": "object",
"required": ["schemaVersion", "type", "key"],
"properties": {
"schemaVersion": { "const": "ActorSelection/1" },
"type": { "const": "key" },
"key": {
"enum": [
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"KeyW", "KeyA", "KeyS", "KeyD", "Enter", "Escape", "Space"
]
}
},
"additionalProperties": false
},
"waitSelection": {
"type": "object",
"required": ["schemaVersion", "type", "ms"],
"properties": {
"schemaVersion": { "const": "ActorSelection/1" },
"type": { "const": "wait" },
"ms": { "type": "integer", "minimum": 100, "maximum": 600 }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-view-v3.schema.json",
"title": "ActorViewV3Frozen",
"description": "冻结当前生产 ActorView/3 可见投影,供版本兼容矩阵验证;不得加入 interactionCandidates。",
"type": "object",
"required": [
"schemaVersion", "projectionVersion", "brief", "currentFrameRef", "currentFrameHash",
"viewport", "legalSelections", "visualTargetCatalog", "history", "protocolErrors",
"remainingBudget", "virtualTimeMs", "objectiveProgress"
],
"properties": {
"schemaVersion": { "const": "playtest/3" },
"projectionVersion": { "const": "ActorView/3" },
"brief": { "type": "string", "minLength": 1 },
"currentFrameRef": {
"type": "string",
"pattern": "^frames/(frame-initial|action-[1-9][0-9]*-(pre|post))\\.png$"
},
"currentFrameHash": { "$ref": "actor-view-v4.schema.json#/$defs/sha256" },
"viewport": {
"type": "object", "required": ["width", "height", "dpr"],
"properties": { "width": { "const": 390 }, "height": { "const": 844 }, "dpr": { "const": 1 } },
"additionalProperties": false
},
"legalSelections": { "$ref": "actor-view-v4.schema.json#/$defs/legalSelections" },
"visualTargetCatalog": { "$ref": "actor-view-v4.schema.json#/$defs/visualTargetCatalog" },
"history": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/historyEntry" } },
"protocolErrors": { "type": "array", "maxItems": 16, "items": { "$ref": "actor-view-v4.schema.json#/$defs/protocolError" } },
"remainingBudget": { "type": "integer", "minimum": 0 },
"virtualTimeMs": { "type": "integer", "minimum": 0 },
"objectiveProgress": {
"anyOf": [{ "$ref": "actor-view-v4.schema.json#/$defs/objectiveProgress" }, { "type": "null" }]
}
},
"additionalProperties": false,
"$defs": {
"historyEntry": {
"type": "object",
"required": [
"actionId", "selection", "normalized", "valid", "retryCount", "preFrameRef",
"preFrameHash", "preEventSeq", "virtualTimeBudgetMs", "postFrameRef",
"postFrameHash", "postEventSeq", "causalGameResponse", "gameEvents", "protocolErrors"
],
"properties": {
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"selection": { "$ref": "actor-selection.schema.json" },
"normalized": { "$ref": "playtest-evidence.schema.json#/$defs/action" },
"valid": { "type": "boolean" },
"retryCount": { "type": "integer", "minimum": 0, "maximum": 2 },
"preFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"preFrameHash": { "$ref": "actor-view-v4.schema.json#/$defs/sha256" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"virtualTimeBudgetMs": { "type": "integer", "minimum": 0 },
"postFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"postFrameHash": { "$ref": "actor-view-v4.schema.json#/$defs/sha256" },
"postEventSeq": { "type": "integer", "minimum": 0 },
"causalGameResponse": { "type": "boolean" },
"gameEvents": { "type": "array", "items": { "$ref": "game-event.schema.json" } },
"protocolErrors": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,242 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/actor-view-v4.schema.json",
"title": "ActorViewV4",
"description": "ActorView/4。普通场景保持 ActorView/3 投影;标准 Match-3 只增加匿名 pair 候选,不暴露分类、置信度、solver 或裁决。",
"type": "object",
"required": [
"schemaVersion", "projectionVersion", "brief", "currentFrameRef", "currentFrameHash",
"viewport", "legalSelections", "visualTargetCatalog", "history", "protocolErrors",
"remainingBudget", "virtualTimeMs", "objectiveProgress", "interactionCandidates"
],
"properties": {
"schemaVersion": { "const": "playtest/3" },
"projectionVersion": { "const": "ActorView/4" },
"brief": { "type": "string", "minLength": 1 },
"currentFrameRef": {
"type": "string",
"pattern": "^frames/(frame-initial|action-[1-9][0-9]*-(pre|post))\\.png$"
},
"currentFrameHash": { "$ref": "#/$defs/sha256" },
"viewport": {
"type": "object",
"required": ["width", "height", "dpr"],
"properties": { "width": { "const": 390 }, "height": { "const": 844 }, "dpr": { "const": 1 } },
"additionalProperties": false
},
"legalSelections": { "$ref": "#/$defs/legalSelections" },
"visualTargetCatalog": { "$ref": "#/$defs/visualTargetCatalog" },
"history": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/historyEntry" } },
"protocolErrors": { "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/protocolError" } },
"remainingBudget": { "type": "integer", "minimum": 0 },
"virtualTimeMs": { "type": "integer", "minimum": 0 },
"objectiveProgress": {
"anyOf": [{ "$ref": "#/$defs/objectiveProgress" }, { "type": "null" }]
},
"interactionCandidates": {
"anyOf": [
{ "type": "null" },
{ "$ref": "#/$defs/interactionCandidates" }
]
}
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"legalSelections": {
"type": "object",
"required": ["types", "safeKeys", "actionBudgetMs", "waitMs"],
"properties": {
"types": {
"type": "array", "minItems": 1, "maxItems": 5,
"items": { "enum": ["tap", "drag", "key", "wait", "swap_pair"] }, "uniqueItems": true
},
"safeKeys": {
"type": "array", "maxItems": 11, "uniqueItems": true,
"items": {
"enum": [
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"KeyW", "KeyA", "KeyS", "KeyD", "Enter", "Escape", "Space"
]
}
},
"actionBudgetMs": { "const": 600 },
"waitMs": { "const": [100, 600] }
},
"additionalProperties": false
},
"visibleTarget": {
"oneOf": [
{
"type": "object",
"required": ["id", "kind", "allowedGestures"],
"properties": {
"id": { "type": "string", "pattern": "^r[0-9]{2}$" },
"kind": { "const": "region" },
"allowedGestures": {
"type": "array", "minItems": 1, "maxItems": 2,
"items": { "enum": ["tap", "drag"] }, "uniqueItems": true
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["id", "kind", "allowedGestures", "dimensions", "subAnchors"],
"properties": {
"id": { "type": "string", "pattern": "^l[0-9]{2}$" },
"kind": { "const": "lattice" },
"allowedGestures": {
"type": "array", "minItems": 1, "maxItems": 2,
"items": { "enum": ["tap", "drag"] }, "uniqueItems": true
},
"dimensions": { "$ref": "#/$defs/dimensions" },
"subAnchors": { "$ref": "#/$defs/subAnchors" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["id", "kind", "allowedGestures", "dimensions", "subAnchors", "fallbackOnly"],
"properties": {
"id": { "const": "g01" },
"kind": { "const": "spatial_grid" },
"allowedGestures": {
"type": "array", "minItems": 1, "maxItems": 2,
"items": { "enum": ["tap", "drag"] }, "uniqueItems": true
},
"dimensions": {
"type": "object", "required": ["rows", "columns"],
"properties": { "rows": { "const": 17 }, "columns": { "const": 8 } },
"additionalProperties": false
},
"subAnchors": { "$ref": "#/$defs/subAnchors" },
"fallbackOnly": { "const": true }
},
"additionalProperties": false
}
]
},
"dimensions": {
"type": "object", "required": ["rows", "columns"],
"properties": {
"rows": { "type": "integer", "minimum": 3, "maximum": 12 },
"columns": { "type": "integer", "minimum": 3, "maximum": 12 }
},
"additionalProperties": false
},
"subAnchors": {
"const": [
"center", "north", "south", "east", "west",
"northwest", "northeast", "southwest", "southeast"
]
},
"visualTargetCatalog": {
"type": "object",
"required": ["schemaVersion", "sourceFrameRef", "sourceFrameHash", "targetSetHash", "targets"],
"properties": {
"schemaVersion": { "const": "VisualTargetSet/1" },
"sourceFrameRef": { "type": "string", "minLength": 1 },
"sourceFrameHash": { "$ref": "#/$defs/sha256" },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"targets": { "type": "array", "minItems": 1, "maxItems": 29, "items": { "$ref": "#/$defs/visibleTarget" } }
},
"additionalProperties": false
},
"historyEntry": {
"type": "object",
"required": [
"actionId", "selection", "normalized", "valid", "retryCount", "preFrameRef",
"preFrameHash", "preEventSeq", "virtualTimeBudgetMs", "postFrameRef",
"postFrameHash", "postEventSeq", "causalGameResponse", "gameEvents", "protocolErrors"
],
"properties": {
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"selection": { "$ref": "actor-selection-v2.schema.json" },
"normalized": { "$ref": "playtest-evidence.schema.json#/$defs/action" },
"valid": { "type": "boolean" },
"retryCount": { "type": "integer", "minimum": 0, "maximum": 2 },
"preFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"preFrameHash": { "$ref": "#/$defs/sha256" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"virtualTimeBudgetMs": { "type": "integer", "minimum": 0 },
"postFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"postFrameHash": { "$ref": "#/$defs/sha256" },
"postEventSeq": { "type": "integer", "minimum": 0 },
"causalGameResponse": { "type": "boolean" },
"gameEvents": { "type": "array", "items": { "$ref": "game-event.schema.json" } },
"protocolErrors": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"protocolError": {
"type": "object", "required": ["code", "message"],
"properties": { "code": { "type": "string", "minLength": 1 }, "message": { "type": "string", "minLength": 1 } },
"additionalProperties": false
},
"objectiveObligation": {
"type": "object",
"required": ["id", "title", "seenStepTitles", "missingStepTitles", "status"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"title": { "type": "string" },
"seenStepTitles": { "type": "array", "items": { "type": "string" } },
"missingStepTitles": { "type": "array", "items": { "type": "string" } },
"status": { "enum": ["unobserved", "partial", "observed"] }
},
"additionalProperties": false
},
"objectiveProgress": {
"type": "object",
"required": ["candidateOnly", "snapshotSeq", "hash", "obligations"],
"properties": {
"candidateOnly": { "const": true },
"snapshotSeq": { "type": "integer", "minimum": 1 },
"hash": { "$ref": "#/$defs/sha256" },
"obligations": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/objectiveObligation" } }
},
"additionalProperties": false
},
"candidateEndpoint": {
"type": "object", "required": ["cell"],
"properties": { "cell": { "$ref": "#/$defs/cell" } },
"additionalProperties": false
},
"match3Candidate": {
"type": "object",
"required": ["candidateId", "latticeId", "pair"],
"properties": {
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"pair": {
"type": "array", "minItems": 2, "maxItems": 2,
"items": { "$ref": "#/$defs/candidateEndpoint" }
}
},
"additionalProperties": false
},
"interactionCandidates": {
"type": "object", "required": ["match3Swaps"],
"properties": {
"match3Swaps": {
"type": "object", "required": ["candidateSetHash", "candidates"],
"properties": {
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidates": { "type": "array", "minItems": 1, "maxItems": 264, "items": { "$ref": "#/$defs/match3Candidate" } }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,33 @@
# Playtest Actor View/4
你是小游戏验收回路里的操作角色。你只选择下一步操作,不判断游戏通过或失败。
每轮只回复一个 JSON 对象。首字符必须是 `{`,末字符必须是 `}`;不要 Markdown 围栏、解释、推理、多个动作或额外字段。`schemaVersion` 固定为 `ActorSelection/2`,动作类型只能来自 `legalSelections.types`。
当 `interactionCandidates.match3Swaps` 存在时,可信 runner 已完成棋盘识别和合法交换枚举。你不需要重新识别颜色、推导三连或发明坐标;图片只用于确认棋盘可见。此时只能输出 `swap_pair`,从 `candidates` 中选择任意一项,并逐字复制以下字段:
- `targetSetHash`:复制 `visualTargetCatalog.targetSetHash`;
- `candidateSetHash`:复制 `interactionCandidates.match3Swaps.candidateSetHash`;
- `candidateId`:复制所选 candidate 的 `candidateId`;
- `firstEndpoint.id`:复制所选 candidate 的 `latticeId`;
- `firstEndpoint.cell`:复制所选 candidate 的 `pair` 中任意一个完整 `cell`;
- `firstEndpoint.subAnchor`:固定为 `center`。
Match-3 的顶层字段必须且只能是 `schemaVersion/type/targetSetHash/candidateSetHash/candidateId/firstEndpoint`。`firstEndpoint` 必须且只能是 `id/cell/subAnchor`。不得输出 `action` 包装层、`targetId`、`cellId`、裸 `row/column`、`secondEndpoint`、另一个候选或任何 solver 字段。第二端由 runner 从已封存 candidate 自动确定。
正确形状如下,尖括号内容必须替换为当前 ActorView 中逐字复制的真实值:
```json
{"schemaVersion":"ActorSelection/2","type":"swap_pair","targetSetHash":"<visualTargetCatalog.targetSetHash>","candidateSetHash":"<match3Swaps.candidateSetHash>","candidateId":"<candidate.candidateId>","firstEndpoint":{"id":"<candidate.latticeId>","cell":{"row":<candidate.pair[0或1].cell.row>,"column":<candidate.pair[0或1].cell.column>},"subAnchor":"center"}}
```
当 `interactionCandidates.match3Swaps` 不存在时,按 `legalSelections.types` 使用普通 `ActorSelection/2`:
- 点击:`{"schemaVersion":"ActorSelection/2","type":"tap","targetSetHash":"从当前目录逐字复制","target":{"id":"r01"}}`
- 拖拽:`{"schemaVersion":"ActorSelection/2","type":"drag","targetSetHash":"从当前目录逐字复制","from":{"id":"l01","cell":{"row":2,"column":3},"subAnchor":"center"},"to":{"id":"l01","cell":{"row":2,"column":4},"subAnchor":"center"},"ms":600}`
- 按键:`{"schemaVersion":"ActorSelection/2","type":"key","key":"ArrowLeft"}`
- 等待:`{"schemaVersion":"ActorSelection/2","type":"wait","ms":200}`
`protocolErrors` 表示上一份 JSON 未派发,当前画面与虚拟时间仍冻结。修正错误时仍须重新逐字读取当前 hash、candidate 和 endpoint,不得沿用自己猜测的字段。
禁止输出 `pass`、`verdict`、`decision`、`problems`、`summary`、`stop` 或任何裁决字段。候选存在和操作成功都不代表游戏通过。

View File

@ -0,0 +1,199 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/board-identity-check.schema.json",
"title": "BoardIdentityCheckV1",
"description": "BoardIdentityCheck/1。首 tap 后对 clean frame 重做 TargetSet、lattice 与匿名类别等价关系,确认第二端仍属于同一棋盘。",
"type": "object",
"required": [
"schemaVersion", "actionGroupId", "interactionProfileId", "interactionRegistryVersion",
"interactionBindingHash", "firstActionRef", "firstActionHash", "firstEndpoint",
"initial", "postFirst", "overlayNormalizer", "overlayNormalizationEvidence",
"equivalenceAlgorithm",
"result", "reasons", "checkHash"
],
"properties": {
"schemaVersion": { "const": "BoardIdentityCheck/1" },
"actionGroupId": { "type": "string", "pattern": "^action-group-[1-9][0-9]*$" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"firstActionRef": { "type": "string", "minLength": 1 },
"firstActionHash": { "$ref": "#/$defs/sha256" },
"firstEndpoint": { "$ref": "actor-selection-v2.schema.json#/$defs/swapEndpoint" },
"initial": { "$ref": "#/$defs/boardSide" },
"postFirst": { "$ref": "#/$defs/boardSide" },
"overlayNormalizer": { "$ref": "#/$defs/normalizer" },
"overlayNormalizationEvidence": { "$ref": "#/$defs/overlayNormalizationEvidence" },
"equivalenceAlgorithm": { "$ref": "#/$defs/equivalenceAlgorithm" },
"result": { "enum": ["equivalent", "changed", "indeterminate"] },
"reasons": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"checkHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "result": { "const": "equivalent" } }, "required": ["result"] },
"then": { "properties": { "reasons": { "maxItems": 0 } } },
"else": { "properties": { "reasons": { "minItems": 1 } } }
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"dimensions": {
"type": "object", "required": ["rows", "columns"],
"properties": {
"rows": { "type": "integer", "minimum": 3, "maximum": 12 },
"columns": { "type": "integer", "minimum": 3, "maximum": 12 }
},
"additionalProperties": false
},
"latticeGeometry": {
"type": "object",
"required": ["latticeId", "dimensions", "rowCenters", "columnCenters"],
"properties": {
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"dimensions": { "$ref": "#/$defs/dimensions" },
"rowCenters": {
"type": "array", "minItems": 3, "maxItems": 12,
"items": { "type": "integer", "minimum": 0, "maximum": 843 }
},
"columnCenters": {
"type": "array", "minItems": 3, "maxItems": 12,
"items": { "type": "integer", "minimum": 0, "maximum": 389 }
}
},
"additionalProperties": false
},
"boardSide": {
"type": "object",
"required": [
"sourceFrameRef", "sourceFrameHash", "targetSetRef", "targetSetHash",
"projectionRef", "projectionHash", "lattice", "equivalenceMatrixHash"
],
"properties": {
"sourceFrameRef": { "type": "string", "minLength": 1 },
"sourceFrameHash": { "$ref": "#/$defs/sha256" },
"targetSetRef": { "type": "string", "minLength": 1 },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"projectionRef": { "type": "string", "minLength": 1 },
"projectionHash": { "$ref": "#/$defs/sha256" },
"lattice": { "$ref": "#/$defs/latticeGeometry" },
"equivalenceMatrixHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
}
},
"additionalProperties": false
},
"normalizer": {
"type": "object",
"required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "const": "match3-selection-overlay-normalizer" },
"version": { "const": "1.0.0" },
"config": {
"type": "object",
"required": ["removeSelectionOverlay", "recomputeTargetSetAfterFirstTap"],
"properties": {
"removeSelectionOverlay": { "const": true },
"recomputeTargetSetAfterFirstTap": { "const": true }
},
"additionalProperties": false
},
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"overlayNormalizationEvidence": {
"type": "object",
"required": [
"schemaVersion", "auditRef", "auditHash", "auditSchemaVersion",
"overlayReferenceFrameRef", "overlayReferenceFrameHash",
"postFirstFrameRef", "postFirstFrameHash", "selectedCell",
"perceptionConfigHash", "selectionOverlayConfigHash", "invocationCount",
"deterministic", "status", "changedPixelCount", "outsideSelectedCellCount",
"supportMaskHash", "maximumAlphaSpreadPermille", "maximumReplayResidual",
"reasonCodes", "resultHash"
],
"properties": {
"schemaVersion": { "const": "OverlayNormalizationEvidence/1" },
"auditRef": { "type": "string", "minLength": 1 },
"auditHash": { "$ref": "#/$defs/sha256" },
"auditSchemaVersion": { "const": "Match3PerceptionShadowAudit/1" },
"overlayReferenceFrameRef": { "type": "string", "minLength": 1 },
"overlayReferenceFrameHash": { "$ref": "#/$defs/sha256" },
"postFirstFrameRef": { "type": "string", "minLength": 1 },
"postFirstFrameHash": { "$ref": "#/$defs/sha256" },
"selectedCell": { "$ref": "actor-selection-v2.schema.json#/$defs/cell" },
"perceptionConfigHash": { "$ref": "#/$defs/sha256" },
"selectionOverlayConfigHash": { "$ref": "#/$defs/sha256" },
"invocationCount": { "const": 2 },
"deterministic": { "const": true },
"status": { "enum": ["normalized", "not-needed", "rejected"] },
"changedPixelCount": { "type": "integer", "minimum": 0 },
"outsideSelectedCellCount": { "type": "integer", "minimum": 0 },
"supportMaskHash": {
"anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }]
},
"maximumAlphaSpreadPermille": {
"anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }]
},
"maximumReplayResidual": {
"anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }]
},
"reasonCodes": {
"type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true
},
"resultHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "status": { "const": "normalized" } }, "required": ["status"] },
"then": {
"properties": {
"changedPixelCount": { "minimum": 1 },
"outsideSelectedCellCount": { "const": 0 },
"supportMaskHash": { "$ref": "#/$defs/sha256" },
"maximumAlphaSpreadPermille": { "type": "integer", "minimum": 0 },
"maximumReplayResidual": { "type": "integer", "minimum": 0 },
"reasonCodes": { "maxItems": 0 }
}
}
},
{
"if": { "properties": { "status": { "const": "not-needed" } }, "required": ["status"] },
"then": {
"properties": {
"changedPixelCount": { "const": 0 },
"outsideSelectedCellCount": { "const": 0 },
"supportMaskHash": { "type": "null" },
"maximumAlphaSpreadPermille": { "type": "null" },
"maximumReplayResidual": { "type": "null" },
"reasonCodes": { "maxItems": 0 }
}
}
}
]
},
"equivalenceAlgorithm": {
"type": "object",
"required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "const": "anonymous-cell-equivalence" },
"version": { "const": "1.0.0" },
"config": {
"type": "object",
"required": ["representation", "classIdInvariant", "requireCompleteClassification"],
"properties": {
"representation": { "const": "pairwise-equality-matrix" },
"classIdInvariant": { "const": true },
"requireCompleteClassification": { "const": true }
},
"additionalProperties": false
},
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/build-manifest.schema.json",
"title": "BuildManifestV1",
"type": "object",
"required": ["schemaVersion", "gameId", "artifactHash", "files"],
"properties": {
"schemaVersion": { "const": "BuildManifest/1" },
"gameId": { "type": "string", "minLength": 1 },
"artifactHash": { "$ref": "#/$defs/sha256" },
"files": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/file" } }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"file": {
"type": "object",
"required": ["role", "ref", "sha256"],
"properties": {
"role": { "enum": ["bundle", "bgm", "asset"] },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" },
"sha256": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/collection-proof.schema.json",
"title": "CollectionProofV1",
"description": "CollectionProof。runner 完整事实账;JudgePackage 只能从这里做允许投影,SingleRollProof 通过原始字节 hash 单向引用。",
"type": "object",
"required": [
"schemaVersion", "packageType", "runId", "roll", "gameId", "genre", "templateRoute",
"proofProfileId", "proofRegistryVersion", "taskBindingHash", "acceptanceRequestHash",
"evidenceMode", "artifactHash", "briefHash", "requestedSeed", "actualSeed", "policySeed",
"environment", "actor", "actions", "frames", "events", "briefRuleMatches",
"obligationCandidates", "objectiveProgress", "unboundEventObservations", "provenance",
"firstPlay", "errors"
],
"properties": {
"schemaVersion": { "const": "playtest/3" },
"packageType": { "const": "CollectionProof" },
"runId": { "type": "string", "minLength": 1 },
"roll": { "type": "integer", "minimum": 1, "maximum": 2 },
"gameId": { "type": "string", "minLength": 1 },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "minLength": 1 },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"evidenceMode": { "const": "native" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"briefHash": { "$ref": "#/$defs/sha256" },
"requestedSeed": { "type": "integer", "minimum": 0 },
"actualSeed": { "type": "integer", "minimum": 0 },
"policySeed": { "type": "integer", "minimum": 0 },
"environment": { "$ref": "playtest-evidence-v3.schema.json#/$defs/environmentV3" },
"actor": { "$ref": "single-roll-fact-v3.schema.json#/$defs/runnerActor" },
"actions": { "type": "array", "minItems": 1, "items": { "$ref": "playtest-evidence-v3.schema.json#/$defs/actionRecord" } },
"frames": { "type": "array", "minItems": 1, "items": { "$ref": "single-roll-fact-v3.schema.json#/$defs/evidenceFrame" } },
"events": { "type": "array", "minItems": 1, "items": { "$ref": "game-event.schema.json" } },
"briefRuleMatches": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"obligationCandidates": { "type": "array", "minItems": 1, "items": { "$ref": "judge-package.schema.json#/$defs/candidate" } },
"objectiveProgress": { "anyOf": [{ "type": "object" }, { "type": "null" }] },
"unboundEventObservations": { "type": "array", "items": { "type": "object" } },
"provenance": {
"type": "object", "required": ["requestHash", "manifestHash"],
"properties": { "requestHash": { "$ref": "#/$defs/sha256" }, "manifestHash": { "$ref": "#/$defs/sha256" } },
"additionalProperties": false
},
"firstPlay": { "$ref": "playtest-evidence.schema.json#/$defs/firstPlay" },
"errors": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false,
"$defs": { "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }
}

View File

@ -0,0 +1,67 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/cost-reservation.schema.json",
"title": "CostReservationV1",
"description": "CostReservation/1。成本账必须在同一锁内同时预留 Judge A/B,随后才能并行发起模型请求。",
"type": "object",
"required": [
"schemaVersion", "reservationId", "ledgerVersion", "judgePackageHash", "pricingSnapshotHash",
"slots", "reservedRmb", "retryReservedRmb", "status", "settlements"
],
"properties": {
"schemaVersion": { "const": "CostReservation/1" },
"reservationId": { "type": "string", "minLength": 1 },
"ledgerVersion": { "type": "string", "minLength": 1 },
"judgePackageHash": { "$ref": "#/$defs/sha256" },
"pricingSnapshotHash": { "$ref": "#/$defs/sha256" },
"slots": {
"type": "array", "minItems": 2, "maxItems": 2,
"items": { "$ref": "#/$defs/slot" }
},
"reservedRmb": { "type": "number", "minimum": 0 },
"retryReservedRmb": { "type": "number", "minimum": 0 },
"status": { "enum": ["reserved", "partially_settled", "settled", "released", "failed"] },
"settlements": {
"type": "array", "maxItems": 4,
"items": { "$ref": "#/$defs/settlement" }
}
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"slot": {
"type": "object",
"required": [
"slot", "requestBytes", "inputTokenUpperBound", "maxOutputTokens",
"reservedRmb", "retryReservedRmb"
],
"properties": {
"slot": { "enum": ["judge-a", "judge-b"] },
"requestBytes": { "type": "integer", "minimum": 1 },
"inputTokenUpperBound": { "type": "integer", "minimum": 1 },
"maxOutputTokens": { "const": 10000 },
"reservedRmb": { "type": "number", "minimum": 0 },
"retryReservedRmb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"settlement": {
"type": "object",
"required": [
"slot", "attempt", "providerRequestId", "promptTokens", "completionTokens",
"contentReceived", "chargedRmb", "releasedRmb"
],
"properties": {
"slot": { "enum": ["judge-a", "judge-b"] },
"attempt": { "type": "integer", "minimum": 1, "maximum": 2 },
"providerRequestId": { "type": ["string", "null"], "minLength": 1 },
"promptTokens": { "type": "integer", "minimum": 0 },
"completionTokens": { "type": "integer", "minimum": 0 },
"contentReceived": { "type": "boolean" },
"chargedRmb": { "type": "number", "minimum": 0 },
"releasedRmb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,145 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/game-event.schema.json",
"title": "GameEvent",
"description": "playtest/2 与 playtest/3 共用的版本化业务事件。ctx.log 诊断不属于本契约;只有 ctx.event 产生的 native 事件和 historical_replay 明确适配的 legacy 事件可以进入业务证据流。playtest/3 可追加 eventRole 区分当前 profile proof 与跨 profile observation。",
"type": "object",
"required": [
"eventVersion", "evidenceMode", "scope", "namespace", "type", "message", "payload",
"payloadCanonical", "payloadHash", "producer", "seq", "actionId", "virtualTimeMs", "sourceEventVersion",
"adapterVersion", "sourceMessageHash"
],
"properties": {
"eventVersion": { "const": "game-event/1" },
"evidenceMode": { "enum": ["native", "legacy-adapted"] },
"scope": { "const": "game" },
"namespace": { "type": "string", "pattern": "^game/[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"type": { "$ref": "#/$defs/eventType" },
"eventRole": {
"enum": ["profile-proof", "cross-profile-observation"],
"description": "playtest/3 运行时分类。当前 proof profile 事件可参与义务候选;其它已注册 profile 事件只供 Judge 观察,不能满足义务。"
},
"message": { "type": "string" },
"payload": { "type": "object" },
"payloadCanonical": {
"type": "string",
"minLength": 2,
"maxLength": 65536,
"description": "Node runner 的 stableStringify(payload) 原文;payloadHash 只对这段 UTF-8 文本取 SHA-256,跨语言消费者不得重序列化浮点复算。"
},
"payloadHash": { "$ref": "#/$defs/sha256" },
"producer": { "type": "string", "minLength": 1 },
"seq": { "type": "integer", "minimum": 1 },
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"virtualTimeMs": { "type": "integer", "minimum": 0 },
"sourceEventVersion": { "type": ["string", "null"] },
"adapterVersion": { "type": ["string", "null"] },
"sourceMessageHash": {
"anyOf": [
{ "$ref": "#/$defs/sha256" },
{ "type": "null" }
]
}
},
"patternProperties": { "^_": {} },
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "evidenceMode": { "const": "native" } },
"required": ["evidenceMode"]
},
"then": {
"properties": {
"sourceEventVersion": { "type": "null" },
"adapterVersion": { "type": "null" },
"sourceMessageHash": { "type": "null" }
}
},
"else": {
"properties": {
"sourceEventVersion": { "const": "legacy-game-log/1" },
"adapterVersion": { "type": "string", "minLength": 1 },
"sourceMessageHash": { "$ref": "#/$defs/sha256" }
}
}
},
{
"if": {
"properties": { "type": { "const": "puzzle.swap-committed" } },
"required": ["type"]
},
"then": { "properties": { "payload": { "$ref": "#/$defs/swapCommittedPayload" } } }
},
{
"if": {
"properties": { "type": { "const": "puzzle.match-formed" } },
"required": ["type"]
},
"then": { "properties": { "payload": { "$ref": "#/$defs/matchFormedPayload" } } }
}
],
"$defs": {
"eventType": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"pair": {
"type": "array", "minItems": 2, "maxItems": 2,
"items": { "$ref": "#/$defs/cell" }
},
"swapCommittedPayload": {
"type": "object",
"required": ["moveId", "pair", "boardRevisionBefore", "boardRevisionAfter"],
"properties": {
"moveId": { "type": "string", "minLength": 1 },
"pair": { "$ref": "#/$defs/pair" },
"boardRevisionBefore": { "type": "integer", "minimum": 0 },
"boardRevisionAfter": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"matchRun": {
"type": "object",
"required": ["axis", "cells"],
"properties": {
"axis": { "enum": ["horizontal", "vertical"] },
"cells": {
"type": "array", "minItems": 3, "maxItems": 12,
"items": { "$ref": "#/$defs/cell" }
}
},
"additionalProperties": false
},
"matchFormedPayload": {
"type": "object",
"required": ["moveId", "runs", "matchedCells", "matchedCount"],
"properties": {
"moveId": { "type": "string", "minLength": 1 },
"runs": {
"type": "array", "minItems": 1, "maxItems": 8,
"items": { "$ref": "#/$defs/matchRun" }
},
"matchedCells": {
"type": "array", "minItems": 3, "maxItems": 144,
"items": { "$ref": "#/$defs/cell" }
},
"matchedCount": { "type": "integer", "minimum": 3, "maximum": 144 }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/grouped-action-ref.schema.json",
"title": "GroupedActionRefV1",
"description": "GroupedActionRef/1。runner 审计层把原生 tap 标成 first/second/reset;不进入 ProofAction 或 JudgePackage。",
"type": "object",
"required": ["schemaVersion", "actionGroupId", "role", "actionId", "endpoint", "actionRef", "actionHash"],
"properties": {
"schemaVersion": { "const": "GroupedActionRef/1" },
"actionGroupId": { "type": "string", "pattern": "^action-group-[1-9][0-9]*$" },
"role": { "enum": ["first", "second", "reset"] },
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"endpoint": {
"anyOf": [
{ "$ref": "actor-selection-v2.schema.json#/$defs/swapEndpoint" },
{ "type": "null" }
]
},
"actionRef": { "type": "string", "minLength": 1 },
"actionHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "role": { "const": "reset" } }, "required": ["role"] },
"then": { "properties": { "endpoint": { "type": "null" } } },
"else": { "properties": { "endpoint": { "type": "object" } } }
}
],
"$defs": { "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }
}

View File

@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/input-lock-probe.schema.json",
"title": "InputLockProbeV1",
"type": "object",
"required": [
"schemaVersion", "artifactHash", "transactionId", "transactionHash", "probeSeed",
"controlFrameRef", "controlFrameHash", "probeFrameRef", "probeFrameHash",
"lockedStateUnchanged", "settleInputRestored", "status"
],
"properties": {
"schemaVersion": { "const": "InputLockProbe/1" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"probeSeed": { "type": "integer", "minimum": 0 },
"controlFrameRef": { "$ref": "#/$defs/ref" },
"controlFrameHash": { "$ref": "#/$defs/sha256" },
"probeFrameRef": { "$ref": "#/$defs/ref" },
"probeFrameHash": { "$ref": "#/$defs/sha256" },
"lockedStateUnchanged": { "const": true },
"settleInputRestored": { "const": true },
"status": { "const": "pass" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" }
}
}

View File

@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/interaction-binding.schema.json",
"title": "InteractionBindingV1",
"description": "InteractionBinding/1。把当前生成任务绑定到可信交互注册表中的唯一 profile。",
"type": "object",
"required": [
"schemaVersion", "interactionProfileId", "interactionRegistryVersion",
"taskBindingHash", "interactionBindingHash"
],
"properties": {
"schemaVersion": { "const": "InteractionBinding/1" },
"interactionProfileId": { "type": "string", "pattern": "^[a-z0-9.-]+$" },
"interactionRegistryVersion": { "enum": ["2026-07-15.v1", "2026-07-15.v2"] },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -0,0 +1,256 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/interaction-profile-registry-v2.schema.json",
"title": "InteractionProfileRegistryV2",
"description": "View/4 enforce 专用 registry;v1 继续冻结服务 solver shadow 与 A-C。",
"type": "object",
"required": ["schemaVersion", "registryVersion", "profiles", "registryHash"],
"properties": {
"schemaVersion": { "const": "InteractionProfileRegistry/2" },
"registryVersion": { "const": "2026-07-15.v2" },
"profiles": {
"type": "object",
"required": ["match3.orthogonal-swap-v1"],
"properties": { "match3.orthogonal-swap-v1": { "$ref": "#/$defs/profile" } },
"additionalProperties": false
},
"registryHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"component": {
"type": "object", "required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "const": "1.0.0" },
"config": { "type": "object" },
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"profile": {
"type": "object",
"required": [
"id", "genre", "proofProfileId", "templateRoute", "solver", "actorProtocol",
"producerCapability", "overlayNormalizer", "equivalenceAlgorithm", "geometryPolicy", "recoveryPolicy"
],
"properties": {
"id": { "const": "match3.orthogonal-swap-v1" },
"genre": { "const": "puzzle" },
"proofProfileId": { "const": "puzzle.match-board" },
"templateRoute": { "const": "_template-puzzle" },
"solver": {
"type": "object", "required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "const": "match3-orthogonal-swap" },
"version": { "const": "1.0.0" },
"config": {
"type": "object",
"required": ["adjacency", "requireNewRun", "minRunLength", "pairOrder"],
"properties": {
"adjacency": { "const": "orthogonal" },
"requireNewRun": { "const": true },
"minRunLength": { "const": 3 },
"pairOrder": { "const": "row-major" }
},
"additionalProperties": false
},
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"producerCapability": {
"type": "object", "required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "const": "match3-producer-profile" },
"version": { "const": "1.0.0" },
"config": {
"type": "object",
"required": ["viewport", "grid", "cellShell", "selectionOverlay", "themeSafeBounds"],
"properties": {
"viewport": {
"type": "object", "required": ["width", "height"],
"properties": {
"width": { "const": 390 },
"height": { "const": 844 }
},
"additionalProperties": false
},
"grid": {
"type": "object", "required": ["rows", "columns", "pitchPx", "originX", "originY"],
"properties": {
"rows": { "const": 8 },
"columns": { "const": 8 },
"pitchPx": { "const": 40 },
"originX": { "const": 35 },
"originY": { "const": 120 }
},
"additionalProperties": false
},
"cellShell": {
"type": "object", "required": ["shape", "radiusPx", "fillStyle"],
"properties": {
"shape": { "const": "circle" },
"radiusPx": { "const": 20 },
"fillStyle": { "const": "#1a1a2e" }
},
"additionalProperties": false
},
"selectionOverlay": {
"type": "object",
"required": ["shape", "maskSizePx", "strokeStyle", "expectedChangedPixelCount", "supportMaskHash"],
"properties": {
"shape": { "const": "pixel-mask-ring" },
"maskSizePx": { "const": 40 },
"strokeStyle": { "const": "#ffffff" },
"expectedChangedPixelCount": { "const": 472 },
"supportMaskHash": { "const": "ea15306223beee4a5d9a1a7b857011d076714c0339451f793e49655a4ec7c18f" }
},
"additionalProperties": false
},
"themeSafeBounds": {
"type": "object", "required": ["insetPx"],
"properties": {
"insetPx": { "const": 12 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"actorProtocol": {
"type": "object", "required": ["viewVersion", "selectionVersion", "prompt", "parser", "resolver"],
"properties": {
"viewVersion": { "const": "ActorView/4" },
"selectionVersion": { "const": "ActorSelection/2" },
"prompt": {
"type": "object", "required": ["id", "version", "artifactRef", "runtimeEligible", "hash"],
"properties": {
"id": { "const": "playtest.actor.contract-match3-v4" },
"version": { "const": "3.0.1" },
"artifactRef": { "const": "artifacts/playtest-actor-v4.contract.md" },
"runtimeEligible": { "const": false },
"hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"parser": {
"allOf": [
{ "$ref": "#/$defs/component" },
{
"properties": {
"id": { "const": "actor-selection-v2" },
"config": {
"type": "object",
"required": ["allowedTypes", "unknownFields", "waitMinMs", "waitMaxMs"],
"properties": {
"allowedTypes": { "const": ["tap", "drag", "key", "wait", "swap_pair"] },
"unknownFields": { "const": "reject" },
"waitMinMs": { "const": 100 },
"waitMaxMs": { "const": 600 }
},
"additionalProperties": false
}
}
}
]
},
"resolver": {
"allOf": [
{ "$ref": "#/$defs/component" },
{
"properties": {
"id": { "const": "match3-pair-resolver" },
"config": {
"type": "object",
"required": ["dispatch", "actionCount", "secondEndpoint", "repeatActorRequest"],
"properties": {
"dispatch": { "const": "native-tap-pair" },
"actionCount": { "const": 2 },
"secondEndpoint": { "const": "other-canonical-pair-endpoint" },
"repeatActorRequest": { "const": false }
},
"additionalProperties": false
}
}
}
]
}
},
"additionalProperties": false
},
"overlayNormalizer": {
"allOf": [
{ "$ref": "#/$defs/component" },
{
"properties": {
"id": { "const": "match3-selection-overlay-normalizer" },
"config": {
"type": "object",
"required": ["removeSelectionOverlay", "recomputeTargetSetAfterFirstTap"],
"properties": {
"removeSelectionOverlay": { "const": true },
"recomputeTargetSetAfterFirstTap": { "const": true }
},
"additionalProperties": false
}
}
}
]
},
"equivalenceAlgorithm": {
"allOf": [
{ "$ref": "#/$defs/component" },
{
"properties": {
"id": { "const": "anonymous-cell-equivalence" },
"config": {
"type": "object",
"required": ["representation", "classIdInvariant", "requireCompleteClassification"],
"properties": {
"representation": { "const": "pairwise-equality-matrix" },
"classIdInvariant": { "const": true },
"requireCompleteClassification": { "const": true }
},
"additionalProperties": false
}
}
}
]
},
"geometryPolicy": {
"type": "object", "required": ["maxCenterDeltaPx", "requireSameDimensions"],
"properties": {
"maxCenterDeltaPx": { "const": 0 },
"requireSameDimensions": { "const": true }
},
"additionalProperties": false
},
"recoveryPolicy": {
"type": "object",
"required": ["maxActionGroupsPerRoll", "maxResetPerRoll", "selectionReset"],
"properties": {
"maxActionGroupsPerRoll": { "const": 2 },
"maxResetPerRoll": { "const": 1 },
"selectionReset": {
"type": "object", "required": ["type", "strategy", "budgetMs"],
"properties": {
"type": { "const": "tap" },
"strategy": { "const": "repeat-first-endpoint" },
"budgetMs": { "const": 600 }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/interaction-profile-registry.schema.json",
"title": "InteractionProfileRegistry",
"description": "InteractionProfileRegistry/1。可信编排器据此在 Writer 前选择交互规则;游戏、Actor 和 Writer 不得改写。",
"type": "object",
"required": ["schemaVersion", "registryVersion", "profiles", "registryHash"],
"properties": {
"schemaVersion": { "const": "InteractionProfileRegistry/1" },
"registryVersion": { "const": "2026-07-15.v1" },
"profiles": {
"type": "object",
"required": ["match3.orthogonal-swap-v1"],
"properties": {
"match3.orthogonal-swap-v1": { "$ref": "#/$defs/match3Profile" }
},
"additionalProperties": false
},
"registryHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"match3Profile": {
"type": "object",
"required": ["id", "genre", "proofProfileId", "templateRoute", "solver"],
"properties": {
"id": { "const": "match3.orthogonal-swap-v1" },
"genre": { "const": "puzzle" },
"proofProfileId": { "const": "puzzle.match-board" },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"solver": {
"type": "object",
"required": ["id", "version", "configHash", "config"],
"properties": {
"id": { "const": "match3-orthogonal-swap" },
"version": { "const": "1.0.0" },
"configHash": { "$ref": "#/$defs/sha256" },
"config": {
"type": "object",
"required": [
"adjacency", "minimumRunLength", "requireDifferentClasses",
"requireNewRun", "requireSwappedEndpoint"
],
"properties": {
"adjacency": { "const": "orthogonal" },
"minimumRunLength": { "const": 3 },
"requireDifferentClasses": { "const": true },
"requireNewRun": { "const": true },
"requireSwappedEndpoint": { "const": true }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,25 @@
{
"schemaVersion": "InteractionProfileRegistry/1",
"registryVersion": "2026-07-15.v1",
"profiles": {
"match3.orthogonal-swap-v1": {
"id": "match3.orthogonal-swap-v1",
"genre": "puzzle",
"proofProfileId": "puzzle.match-board",
"templateRoute": "_template-puzzle",
"solver": {
"id": "match3-orthogonal-swap",
"version": "1.0.0",
"configHash": "c8afb6ffb433ff86a99951a92f96980903e66b9f3e7ab6819c1c8cc5941acc73",
"config": {
"adjacency": "orthogonal",
"minimumRunLength": 3,
"requireDifferentClasses": true,
"requireNewRun": true,
"requireSwappedEndpoint": true
}
}
}
},
"registryHash": "e0fbf0c43553efd044208d0bc68c46cc0955b3713357109d5e605432c323888f"
}

View File

@ -0,0 +1,122 @@
{
"schemaVersion": "InteractionProfileRegistry/2",
"registryVersion": "2026-07-15.v2",
"profiles": {
"match3.orthogonal-swap-v1": {
"id": "match3.orthogonal-swap-v1",
"genre": "puzzle",
"proofProfileId": "puzzle.match-board",
"templateRoute": "_template-puzzle",
"solver": {
"id": "match3-orthogonal-swap",
"version": "1.0.0",
"config": {
"adjacency": "orthogonal",
"requireNewRun": true,
"minRunLength": 3,
"pairOrder": "row-major"
},
"configHash": "e1a3473ff17deb1de8fdcf1d9e8a922e625fbba82c20aefd6e67d35767b1c7fd"
},
"producerCapability": {
"id": "match3-producer-profile",
"version": "1.0.0",
"config": {
"viewport": {
"width": 390,
"height": 844
},
"grid": {
"rows": 8,
"columns": 8,
"pitchPx": 40,
"originX": 35,
"originY": 120
},
"cellShell": {
"shape": "circle",
"radiusPx": 20,
"fillStyle": "#1a1a2e"
},
"selectionOverlay": {
"shape": "pixel-mask-ring",
"maskSizePx": 40,
"strokeStyle": "#ffffff",
"expectedChangedPixelCount": 472,
"supportMaskHash": "ea15306223beee4a5d9a1a7b857011d076714c0339451f793e49655a4ec7c18f"
},
"themeSafeBounds": {
"insetPx": 12
}
},
"configHash": "835be6588b4a2b00f697ed2c4fccca6bcd620d780dfaa6972c455dd3efca6ea5"
},
"actorProtocol": {
"viewVersion": "ActorView/4",
"selectionVersion": "ActorSelection/2",
"prompt": {
"id": "playtest.actor.contract-match3-v4",
"version": "3.0.1",
"artifactRef": "artifacts/playtest-actor-v4.contract.md",
"runtimeEligible": false,
"hash": "dd8a6358637847717bdda886ce32015fc2e0ff639f8b2aa7f6f9b97a3cf26a62"
},
"parser": {
"id": "actor-selection-v2",
"version": "1.0.0",
"config": {
"allowedTypes": ["tap", "drag", "key", "wait", "swap_pair"],
"unknownFields": "reject",
"waitMinMs": 100,
"waitMaxMs": 600
},
"configHash": "11eb324e40a1726aa87951dfe50e930df04ae1fe7c745739432a6f4f2387f22e"
},
"resolver": {
"id": "match3-pair-resolver",
"version": "1.0.0",
"config": {
"dispatch": "native-tap-pair",
"actionCount": 2,
"secondEndpoint": "other-canonical-pair-endpoint",
"repeatActorRequest": false
},
"configHash": "8d01b87f586e50f08625a8c16cb6b99f769d383cd392af5777e13e7ed7086213"
}
},
"overlayNormalizer": {
"id": "match3-selection-overlay-normalizer",
"version": "1.0.0",
"config": {
"removeSelectionOverlay": true,
"recomputeTargetSetAfterFirstTap": true
},
"configHash": "c3ea532173f72d6f5d6d9cb747aa2e66f0e5efc98e793db371b26cb873f68cd2"
},
"equivalenceAlgorithm": {
"id": "anonymous-cell-equivalence",
"version": "1.0.0",
"config": {
"representation": "pairwise-equality-matrix",
"classIdInvariant": true,
"requireCompleteClassification": true
},
"configHash": "5a30822c78b9ee7084c4c0f9c448ae9669cc4b967c3fafb537a7a43be5ba34a5"
},
"geometryPolicy": {
"maxCenterDeltaPx": 0,
"requireSameDimensions": true
},
"recoveryPolicy": {
"maxActionGroupsPerRoll": 2,
"maxResetPerRoll": 1,
"selectionReset": {
"type": "tap",
"strategy": "repeat-first-endpoint",
"budgetMs": 600
}
}
}
},
"registryHash": "d7a5bb706680937cf3ba7101e622d45bef6a027970b1097bfceb69fd4f71a1d0"
}

View File

@ -0,0 +1,125 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/judge-package.schema.json",
"title": "JudgePackageV1",
"description": "playtest/3 Judge 唯一可读包;只含 proof 候选和事实投影,不含 interaction、Resolution 或 solver 身份。",
"type": "object",
"required": [
"schemaVersion", "projectionVersion", "packageType", "gameId", "genre",
"templateRoute", "proofProfileId", "proofRegistryVersion", "taskBindingHash",
"acceptanceRequestHash", "evidenceMode", "brief", "artifactHash", "briefHash",
"registry", "proofObligations", "actions", "frames", "gameEvents",
"textReferenceScope", "environment", "floor", "proofRef", "proofHash"
],
"properties": {
"schemaVersion": { "const": "playtest/3" },
"projectionVersion": { "const": "JudgePackage/1" },
"packageType": { "const": "JudgePackage" },
"gameId": { "type": "string", "minLength": 1 },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"evidenceMode": { "const": "native" },
"brief": { "type": "string" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"briefHash": { "$ref": "#/$defs/sha256" },
"registry": {
"type": "object",
"required": [
"schemaVersion", "registryVersion", "proofProfileId", "genre",
"templateRoute", "briefRuleMatches"
],
"properties": {
"schemaVersion": { "const": "proof-obligations/2" },
"registryVersion": { "type": "string", "minLength": 1 },
"proofProfileId": { "type": "string", "minLength": 1 },
"genre": { "type": "string", "minLength": 1 },
"templateRoute": { "type": "string", "minLength": 1 },
"briefRuleMatches": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
},
"proofObligations": { "type": "array", "items": { "$ref": "#/$defs/candidate" } },
"actions": { "type": "array", "items": { "$ref": "#/$defs/judgeAction" } },
"frames": { "type": "array", "items": { "$ref": "single-roll-fact-v3.schema.json#/$defs/evidenceFrame" } },
"gameEvents": { "type": "array", "items": { "$ref": "game-event.schema.json" } },
"textReferenceScope": {
"type": "object",
"required": ["actionRefs", "frameRefs", "eventRefs"],
"properties": {
"actionRefs": { "type": "array", "items": { "type": "string", "pattern": "^action-[1-9][0-9]*$" }, "uniqueItems": true },
"frameRefs": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"eventRefs": { "type": "array", "items": { "type": "string", "pattern": "^event:[1-9][0-9]*$" }, "uniqueItems": true }
},
"additionalProperties": false
},
"environment": { "$ref": "playtest-evidence-v3.schema.json#/$defs/environmentV3" },
"floor": { "type": "null" },
"proofRef": { "type": "string", "minLength": 1 },
"proofHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"candidate": {
"type": "object",
"required": [
"id", "title", "description", "required", "evidence", "candidateOnly",
"candidateState", "candidateProblems", "sequenceRefs", "actionRefs",
"postFrameRefs", "eventRefs", "evidenceRefs", "seenStepTitles", "missingStepTitles"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"title": { "type": "string" },
"description": { "type": "string" },
"required": { "type": "boolean" },
"evidence": { "$ref": "proof-obligation-registry.schema.json#/$defs/evidence" },
"candidateOnly": { "const": true },
"candidateState": { "enum": ["complete", "contradicted", "missing"] },
"candidateProblems": { "type": "array", "items": { "type": "string" } },
"sequenceRefs": { "type": "array", "items": { "$ref": "playtest-evidence.schema.json#/$defs/sequenceRef" } },
"actionRefs": { "type": "array", "items": { "type": "string" } },
"postFrameRefs": { "type": "array", "items": { "type": "string" } },
"eventRefs": { "type": "array", "items": { "type": "integer" } },
"evidenceRefs": { "type": "array", "items": { "type": "string" } },
"seenStepTitles": { "type": "array", "items": { "type": "string" } },
"missingStepTitles": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"judgeAction": {
"type": "object",
"required": [
"actionId", "normalized", "valid", "preFrameRef", "preFrameHash", "preEventSeq",
"virtualTimeBudgetMs", "postFrameRef", "postFrameHash", "postEventSeq", "gameEvents"
],
"properties": {
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"normalized": { "$ref": "playtest-evidence.schema.json#/$defs/action" },
"valid": { "type": "boolean" },
"preFrameRef": {
"anyOf": [
{ "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
{ "type": "null" }
]
},
"preFrameHash": { "$ref": "#/$defs/sha256" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"virtualTimeBudgetMs": { "type": "integer", "minimum": 0 },
"postFrameRef": {
"anyOf": [
{ "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
{ "type": "null" }
]
},
"postFrameHash": { "$ref": "#/$defs/sha256" },
"postEventSeq": { "type": "integer", "minimum": 0 },
"gameEvents": { "type": "array", "items": { "$ref": "game-event.schema.json" } }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/judge-result.schema.json",
"title": "JudgeResultV1",
"description": "JudgeResult/1。Judge 只能返回顶层裁决、问题与矛盾列表、逐义务结果、失败分类和摘要;候选引用边界与跨字段裁决语义继续由生产 parser 校验。",
"type": "object",
"required": [
"decision",
"problems",
"contradictions",
"obligations",
"failureClass",
"summary"
],
"properties": {
"decision": {
"enum": ["accept", "reject", "inconclusive", "tester_error"]
},
"problems": {
"type": "array",
"items": { "type": "string" }
},
"contradictions": {
"type": "array",
"items": { "type": "string" }
},
"obligations": {
"type": "array",
"items": { "$ref": "#/$defs/obligationResult" }
},
"failureClass": {
"enum": ["none", "broken", "hollow", "off_brief"]
},
"summary": { "type": "string" }
},
"additionalProperties": false,
"$defs": {
"sequenceRef": {
"type": "object",
"required": ["stepId", "eventRef", "actionRef", "postFrameRef"],
"properties": {
"stepId": { "type": "string", "minLength": 1 },
"eventRef": { "type": "integer", "minimum": 1 },
"actionRef": {
"type": "string",
"pattern": "^action-[1-9][0-9]*$"
},
"postFrameRef": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"obligationResult": {
"type": "object",
"required": ["id", "status", "sequenceRefs", "evidenceRefs"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"status": {
"enum": ["satisfied", "failed", "missing", "contradicted"]
},
"sequenceRefs": {
"type": "array",
"items": { "$ref": "#/$defs/sequenceRef" }
},
"evidenceRefs": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "status": { "const": "missing" } },
"required": ["status"]
},
"then": {
"properties": {
"sequenceRefs": { "maxItems": 0 },
"evidenceRefs": { "maxItems": 0 }
}
}
},
{
"if": {
"properties": {
"status": { "enum": ["satisfied", "failed"] }
},
"required": ["status"]
},
"then": {
"properties": {
"sequenceRefs": { "minItems": 1 }
}
}
}
]
}
}
}

View File

@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/legacy-game-log-mapping.schema.json",
"title": "LegacyGameLogMapping",
"description": "historical_replay 专用的 legacy-game-log/1 精确适配 allowlist。映射只看 profile、精确 tag 与 payload 路径,不读取 message,也不能进入 native 生产验收。",
"type": "object",
"required": ["schemaVersion", "mappingVersion", "proofRegistryVersion", "sourceEventVersion", "targetEventVersion", "mappings"],
"properties": {
"schemaVersion": { "const": "legacy-game-log-mapping/1" },
"mappingVersion": { "type": "string", "minLength": 1 },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"sourceEventVersion": { "const": "legacy-game-log/1" },
"targetEventVersion": { "const": "game-event/1" },
"description": { "type": "string", "minLength": 1 },
"mappings": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/mapping" }
}
},
"patternProperties": { "^_": {} },
"additionalProperties": false,
"$defs": {
"mapping": {
"type": "object",
"required": ["id", "proofProfileId", "legacyTag", "requiredPayloadPaths", "type"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"legacyTag": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"requiredPayloadPaths": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^(?:|(?:/(?:[^~/]|~[01])*)+)$" }
},
"type": { "$ref": "game-event.schema.json#/$defs/eventType" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,25 @@
{
"schemaVersion": "legacy-game-log-mapping/1",
"mappingVersion": "2026-07-14.v1",
"proofRegistryVersion": "2026-07-14.v2",
"sourceEventVersion": "legacy-game-log/1",
"targetEventVersion": "game-event/1",
"description": "历史 11 局校准专用精确映射。只读取旧 tag 与 JSON payload 路径;不能用于 native v3,也不能进入生产成功率。",
"mappings": [
{ "id": "narrative-choice", "proofProfileId": "narrative.branching-story", "legacyTag": "choice", "requiredPayloadPaths": ["/from", "/next"], "type": "narrative.choice-committed" },
{ "id": "narrative-ending", "proofProfileId": "narrative.branching-story", "legacyTag": "scene", "requiredPayloadPaths": ["/ending", "/outcome"], "type": "narrative.ending-reached" },
{ "id": "trpg-battle", "proofProfileId": "trpg.dice-tower", "legacyTag": "scene", "requiredPayloadPaths": ["/floors"], "type": "trpg.battle-entered" },
{ "id": "trpg-combat", "proofProfileId": "trpg.dice-tower", "legacyTag": "resolve", "requiredPayloadPaths": ["/hp", "/outcome"], "type": "trpg.combat-resolved" },
{ "id": "trpg-kill", "proofProfileId": "trpg.dice-tower", "legacyTag": "kill", "requiredPayloadPaths": ["/floor"], "type": "trpg.enemy-defeated" },
{ "id": "trpg-floor", "proofProfileId": "trpg.dice-tower", "legacyTag": "floor", "requiredPayloadPaths": ["/floor"], "type": "trpg.floor-entered" },
{ "id": "trpg-talent", "proofProfileId": "trpg.dice-tower", "legacyTag": "talent", "requiredPayloadPaths": ["/id"], "type": "trpg.talent-selected" },
{ "id": "heritage-step", "proofProfileId": "heritage.ordered-craft", "legacyTag": "step", "requiredPayloadPaths": ["/step", "/amount"], "type": "heritage.step-completed" },
{ "id": "heritage-product", "proofProfileId": "heritage.ordered-craft", "legacyTag": "work", "requiredPayloadPaths": ["/works"], "type": "heritage.product-completed" },
{ "id": "puzzle-move", "proofProfileId": "puzzle.match-board", "legacyTag": "move", "requiredPayloadPaths": ["/moves", "/tile"], "type": "puzzle.move-applied" },
{ "id": "puzzle-goal", "proofProfileId": "puzzle.match-board", "legacyTag": "clear", "requiredPayloadPaths": ["/level", "/moves"], "type": "puzzle.goal-reached" },
{ "id": "puzzle-complete", "proofProfileId": "puzzle.match-board", "legacyTag": "clear", "requiredPayloadPaths": ["/level", "/stars"], "type": "puzzle.level-completed" },
{ "id": "puzzle-hint", "proofProfileId": "puzzle.match-board", "legacyTag": "hint", "requiredPayloadPaths": ["/level", "/tile"], "type": "puzzle.hint-shown" },
{ "id": "sim-serve", "proofProfileId": "sim-business.recipe-fulfillment", "legacyTag": "serve", "requiredPayloadPaths": ["/amount", "/total"], "type": "sim-business.order-served" },
{ "id": "sim-settlement", "proofProfileId": "sim-business.recipe-fulfillment", "legacyTag": "scene", "requiredPayloadPaths": ["/currency", "/served", "/lost"], "type": "sim-business.settlement-recorded" }
]
}

View File

@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-action-bundle.schema.json",
"title": "Match3ActionBundleV1",
"description": "D-F 跨文件契约验证入口。回读 runner 审计、CollectionProof、SingleRollProof、JudgePackage 与真实帧字节。",
"type": "object",
"required": [
"schemaVersion", "briefRef", "requestRef", "provenanceRef", "bindingRef", "registryRef",
"actorProtocolBundleRef", "actorViewRef", "selectionRef", "selectionAttemptRef",
"pairResolutionRef", "swapSetRef", "initialTargetSetRef", "postFirstTargetSetRef",
"initialProjectionRef", "postFirstProjectionRef", "firstGroupedActionRef",
"postFirstPerceptionAuditRef", "postFirstPerceptionAuditHash",
"secondGroupedActionRef", "firstActionRef", "secondActionRef",
"boardIdentityCheckRef", "actionGroupRef", "eventsRef", "resolutionRef",
"collectionProofRef", "singleRollProofRef", "judgePackageRef", "rollAuditRef", "rollAuditHash",
"initialFrameFileRef", "initialFrameHash", "postFirstFrameFileRef", "postFirstFrameHash",
"secondPostFrameRef", "secondPostFrameFileRef", "secondPostFrameHash",
"expectedErrorPattern"
],
"properties": {
"schemaVersion": { "const": "Match3ActionBundle/1" },
"briefRef": { "type": "string", "minLength": 1 },
"requestRef": { "type": "string", "minLength": 1 },
"provenanceRef": { "type": "string", "minLength": 1 },
"bindingRef": { "type": "string", "minLength": 1 },
"registryRef": { "type": "string", "minLength": 1 },
"actorProtocolBundleRef": { "type": "string", "minLength": 1 },
"actorViewRef": { "type": "string", "minLength": 1 },
"selectionRef": { "type": "string", "minLength": 1 },
"selectionAttemptRef": { "type": "string", "minLength": 1 },
"pairResolutionRef": { "type": "string", "minLength": 1 },
"swapSetRef": { "type": "string", "minLength": 1 },
"initialTargetSetRef": { "type": "string", "minLength": 1 },
"postFirstTargetSetRef": { "type": "string", "minLength": 1 },
"initialProjectionRef": { "type": "string", "minLength": 1 },
"postFirstProjectionRef": { "type": "string", "minLength": 1 },
"postFirstPerceptionAuditRef": { "type": "string", "minLength": 1 },
"postFirstPerceptionAuditHash": { "$ref": "#/$defs/sha256" },
"firstGroupedActionRef": { "type": "string", "minLength": 1 },
"secondGroupedActionRef": { "type": "string", "minLength": 1 },
"firstActionRef": { "type": "string", "minLength": 1 },
"secondActionRef": { "type": "string", "minLength": 1 },
"boardIdentityCheckRef": { "type": "string", "minLength": 1 },
"actionGroupRef": { "type": "string", "minLength": 1 },
"eventsRef": { "type": "string", "minLength": 1 },
"resolutionRef": { "type": "string", "minLength": 1 },
"collectionProofRef": { "type": "string", "minLength": 1 },
"singleRollProofRef": { "type": "string", "minLength": 1 },
"judgePackageRef": { "type": "string", "minLength": 1 },
"rollAuditRef": { "type": "string", "minLength": 1 },
"rollAuditHash": { "$ref": "#/$defs/sha256" },
"initialFrameFileRef": { "type": "string", "minLength": 1 },
"initialFrameHash": { "$ref": "#/$defs/sha256" },
"postFirstFrameFileRef": { "type": "string", "minLength": 1 },
"postFirstFrameHash": { "$ref": "#/$defs/sha256" },
"secondPostFrameRef": { "type": "string", "minLength": 1 },
"secondPostFrameFileRef": { "type": "string", "minLength": 1 },
"secondPostFrameHash": { "$ref": "#/$defs/sha256" },
"expectedErrorPattern": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }
},
"additionalProperties": false,
"$defs": { "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }
}

View File

@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-action-group.schema.json",
"title": "Match3ActionGroupV1",
"description": "Match3ActionGroup/1。同一 pair 计划展开为两个原生 tap;完整组只在 runner 审计层封存。",
"type": "object",
"required": [
"schemaVersion", "actionGroupId", "selectionAttemptRef", "selectionAttemptHash",
"pairResolutionRef", "pairResolutionHash",
"interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
"candidateSetHash", "candidateId", "initialSourceFrameHash", "initialTargetSetHash",
"latticeId", "pair", "firstEndpoint", "secondEndpoint", "firstActionId",
"secondActionId", "firstGroupedActionRef", "firstGroupedActionHash",
"secondGroupedActionRef", "secondGroupedActionHash",
"boardIdentityCheckRef", "boardIdentityCheckHash",
"state", "abortReason", "actionGroupHash"
],
"properties": {
"schemaVersion": { "const": "Match3ActionGroup/1" },
"actionGroupId": { "type": "string", "pattern": "^action-group-[1-9][0-9]*$" },
"selectionAttemptRef": { "type": "string", "minLength": 1 },
"selectionAttemptHash": { "$ref": "#/$defs/sha256" },
"pairResolutionRef": { "type": "string", "minLength": 1 },
"pairResolutionHash": { "$ref": "#/$defs/sha256" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"initialSourceFrameHash": { "$ref": "#/$defs/sha256" },
"initialTargetSetHash": { "$ref": "#/$defs/sha256" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"pair": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/cell" } },
"firstEndpoint": { "$ref": "#/$defs/endpoint" },
"secondEndpoint": { "$ref": "#/$defs/endpoint" },
"firstActionId": { "anyOf": [{ "$ref": "#/$defs/actionId" }, { "type": "null" }] },
"secondActionId": { "anyOf": [{ "$ref": "#/$defs/actionId" }, { "type": "null" }] },
"firstGroupedActionRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"firstGroupedActionHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"secondGroupedActionRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"secondGroupedActionHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"boardIdentityCheckRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"boardIdentityCheckHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"state": { "enum": ["awaiting_first", "awaiting_second", "completed", "aborted"] },
"abortReason": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"actionGroupHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"cell": {
"type": "object", "required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"endpoint": {
"type": "object", "required": ["id", "cell", "subAnchor"],
"properties": {
"id": { "type": "string", "pattern": "^l[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" },
"subAnchor": { "const": "center" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,240 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-audio-audit.schema.json",
"title": "Match3AudioAuditV1",
"description": "标准 Match-3 limiter 后实时音频审计。auditId 只绑定主题、配置、BGM 原始字节、插件和 auditTap;staged artifact 由 InteractionAudit/2 绑定。",
"type": "object",
"required": [
"schemaVersion", "auditId", "themeHash", "configHash", "audioArtifactBinding",
"runtimeBinding", "capacity", "droppedWindowCount", "firstWindowSeq",
"lastWindowSeq", "samples", "pendingOnsetCueReceiptRefs"
],
"properties": {
"schemaVersion": { "const": "Match3AudioAudit/1" },
"auditId": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"themeHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"configHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"audioArtifactBinding": {
"anyOf": [{ "$ref": "#/$defs/audioArtifactBinding" }, { "type": "null" }]
},
"runtimeBinding": {
"anyOf": [{ "$ref": "#/$defs/runtimeBinding" }, { "type": "null" }]
},
"capacity": { "type": "integer", "minimum": 1, "maximum": 4096 },
"droppedWindowCount": { "type": "integer", "minimum": 0 },
"firstWindowSeq": {
"anyOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }]
},
"lastWindowSeq": {
"anyOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }]
},
"samples": {
"type": "array", "maxItems": 4096,
"items": { "$ref": "#/$defs/window" }
},
"pendingOnsetCueReceiptRefs": {
"type": "array",
"items": { "$ref": "#/$defs/pendingOnsetCueReceiptRef" }
}
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"audioArtifactBinding": {
"type": "object",
"required": ["kind", "sha256"],
"properties": {
"kind": { "const": "bgm-raw-sha256" },
"sha256": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"runtimeBinding": {
"type": "object",
"required": ["pluginName", "pluginVersion", "graphGeneration", "auditTapId", "nodeIds"],
"properties": {
"pluginName": { "const": "match3-audio-director" },
"pluginVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"graphGeneration": { "type": "integer", "minimum": 1 },
"auditTapId": { "$ref": "#/$defs/sha256" },
"nodeIds": {
"type": "array", "minItems": 1, "uniqueItems": true,
"items": { "$ref": "#/$defs/sha256" }
}
},
"additionalProperties": false
},
"nodeIdArray": {
"type": "array", "minItems": 1, "uniqueItems": true,
"items": { "$ref": "#/$defs/sha256" }
},
"sourceIdArray": {
"type": "array", "uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"transitionRef": {
"type": "object",
"required": [
"schemaVersion", "transitionSeq", "transactionId", "transactionHash", "phase", "cascadeIndex",
"transitionVirtualTimeMs", "terminalKind"
],
"properties": {
"schemaVersion": { "const": "Match3VisualTransition/1" },
"transitionSeq": { "type": "integer", "minimum": 1 },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"phase": {
"enum": [
"swap", "match_hold", "clear", "fall_refill", "cascade_gap",
"swap_forward", "rollback", "settle"
]
},
"cascadeIndex": { "type": "integer", "minimum": -1, "maximum": 63 },
"transitionVirtualTimeMs": { "type": "number", "minimum": 0 },
"terminalKind": {
"anyOf": [{ "enum": ["win", "lose"] }, { "type": "null" }]
}
},
"additionalProperties": false
},
"cueReceiptRef": {
"type": "object",
"required": [
"schemaVersion", "cueSeq", "transitionSeq", "transactionId", "phase",
"cascadeIndex", "cueKind", "epochId", "transitionVirtualTimeMs",
"scheduledAudioTime", "startCallAudioTime", "lateByMs", "layerCount",
"pitchSemitones", "cueSemanticHash"
],
"properties": {
"schemaVersion": { "const": "Match3AudioCueReceipt/1" },
"cueSeq": { "type": "integer", "minimum": 1 },
"transitionSeq": { "type": "integer", "minimum": 1 },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"phase": {
"enum": ["swap", "swap_forward", "rollback", "clear", "fall_refill", "settle"]
},
"cascadeIndex": { "type": "integer", "minimum": -1, "maximum": 63 },
"cueKind": { "enum": ["swap", "rollback", "clear", "fall", "terminal-win", "terminal-lose"] },
"epochId": { "type": "string", "minLength": 1, "maxLength": 128 },
"transitionVirtualTimeMs": { "type": "number", "minimum": 0 },
"scheduledAudioTime": { "type": "number", "minimum": 0 },
"startCallAudioTime": { "type": "number", "minimum": 0 },
"lateByMs": { "type": "number", "minimum": 0 },
"layerCount": { "type": "integer", "minimum": 1, "maximum": 16 },
"pitchSemitones": { "type": "number", "minimum": -48, "maximum": 48 },
"cueSemanticHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"cueReceiptIdentity": {
"type": "object",
"required": ["cueSeq", "transitionSeq", "cueSemanticHash"],
"properties": {
"cueSeq": { "type": "integer", "minimum": 1 },
"transitionSeq": { "type": "integer", "minimum": 1 },
"cueSemanticHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"pendingOnsetCueReceiptRef": {
"type": "object",
"required": ["cueSeq", "transitionSeq", "cueSemanticHash", "deadlineAudioTime"],
"properties": {
"cueSeq": { "type": "integer", "minimum": 1 },
"transitionSeq": { "type": "integer", "minimum": 1 },
"cueSemanticHash": { "$ref": "#/$defs/sha256" },
"deadlineAudioTime": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"onsetCheck": {
"type": "object",
"required": [
"cueReceiptRef", "status", "deadlineAudioTime", "firstAboveWindowSeq",
"confirmationWindowSeq", "analyserOnsetAudioTime"
],
"properties": {
"cueReceiptRef": { "$ref": "#/$defs/cueReceiptIdentity" },
"status": { "enum": ["detected", "missed"] },
"deadlineAudioTime": { "type": "number", "minimum": 0 },
"firstAboveWindowSeq": {
"anyOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }]
},
"confirmationWindowSeq": {
"anyOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }]
},
"analyserOnsetAudioTime": {
"anyOf": [{ "type": "number", "minimum": 0 }, { "type": "null" }]
}
},
"additionalProperties": false
},
"window": {
"type": "object",
"required": [
"windowSeq", "frame", "virtualNowMs", "audioTime", "windowStartAudioTime",
"windowEndAudioTime", "fftSize", "sampleRate", "rms", "peak", "muted",
"noiseFloorDbfs", "onsetThresholdDbfs", "onsetDeltaDb", "onsetChecks",
"sourceIds", "nodeIds", "auditTapId", "lastTransitionSeq", "lastCueSeq",
"transitionRefs", "cueReceiptRefs"
],
"properties": {
"windowSeq": { "type": "integer", "minimum": 1 },
"frame": { "type": "integer", "minimum": 0 },
"virtualNowMs": { "type": "number", "minimum": 0 },
"audioTime": { "type": "number", "minimum": 0 },
"windowStartAudioTime": { "type": "number", "minimum": 0 },
"windowEndAudioTime": { "type": "number", "exclusiveMinimum": 0 },
"fftSize": { "const": 2048 },
"sampleRate": { "type": "number", "exclusiveMinimum": 0 },
"rms": { "type": "number", "minimum": 0, "maximum": 1 },
"peak": { "type": "number", "minimum": 0, "maximum": 1 },
"muted": { "type": "boolean" },
"noiseFloorDbfs": { "type": "number", "minimum": -200, "maximum": 0 },
"onsetThresholdDbfs": { "type": "number", "minimum": -200, "maximum": 0 },
"onsetDeltaDb": { "type": "number", "minimum": -200, "maximum": 200 },
"onsetChecks": { "type": "array", "items": { "$ref": "#/$defs/onsetCheck" } },
"sourceIds": { "$ref": "#/$defs/sourceIdArray" },
"nodeIds": { "$ref": "#/$defs/nodeIdArray" },
"auditTapId": { "$ref": "#/$defs/sha256" },
"lastTransitionSeq": { "type": "integer", "minimum": 0 },
"lastCueSeq": { "type": "integer", "minimum": 0 },
"transitionRefs": {
"type": "array", "items": { "$ref": "#/$defs/transitionRef" }
},
"cueReceiptRefs": {
"type": "array", "items": { "$ref": "#/$defs/cueReceiptRef" }
}
},
"additionalProperties": false
}
},
"allOf": [
{
"if": { "properties": { "samples": { "maxItems": 0 } } },
"then": {
"properties": {
"auditId": { "type": "null" },
"themeHash": { "type": "null" },
"configHash": { "type": "null" },
"audioArtifactBinding": { "type": "null" },
"runtimeBinding": { "type": "null" },
"firstWindowSeq": { "type": "null" },
"lastWindowSeq": { "type": "null" },
"droppedWindowCount": { "const": 0 }
}
},
"else": {
"properties": {
"auditId": { "$ref": "#/$defs/sha256" },
"themeHash": { "$ref": "#/$defs/sha256" },
"configHash": { "$ref": "#/$defs/sha256" },
"audioArtifactBinding": { "$ref": "#/$defs/audioArtifactBinding" },
"runtimeBinding": { "$ref": "#/$defs/runtimeBinding" },
"firstWindowSeq": { "type": "integer", "minimum": 1 },
"lastWindowSeq": { "type": "integer", "minimum": 1 }
}
}
}
]
}

View File

@ -0,0 +1,150 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-board-projection.schema.json",
"title": "Match3BoardProjectionV1",
"description": "Match3BoardProjection/1。只读匿名棋格投影;不包含颜色名、DOM、源码或游戏私有状态。",
"type": "object",
"required": [
"schemaVersion", "sourceFrameRef", "sourceFrameHash", "targetSetRef", "targetSetHash", "latticeId", "dimensions",
"interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
"classifier", "cells", "projectionStatus", "projectionHash"
],
"properties": {
"schemaVersion": { "const": "Match3BoardProjection/1" },
"sourceFrameRef": { "type": "string", "minLength": 1 },
"sourceFrameHash": { "$ref": "#/$defs/sha256" },
"targetSetRef": { "type": "string", "minLength": 1 },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"dimensions": { "$ref": "#/$defs/dimensions" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "enum": ["2026-07-15.v1", "2026-07-15.v2"] },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"classifier": { "$ref": "#/$defs/versionedComponent" },
"cells": {
"type": "array",
"minItems": 9,
"maxItems": 144,
"items": { "$ref": "#/$defs/visualCellClass" }
},
"projectionStatus": { "enum": ["resolved", "indeterminate"] },
"projectionHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "interactionRegistryVersion": { "const": "2026-07-15.v2" } },
"required": ["interactionRegistryVersion"]
},
"then": {
"properties": {
"classifier": {
"type": "object",
"required": ["id", "version", "configHash"],
"properties": {
"id": { "const": "deterministic-lab-cell-cluster" },
"version": { "const": "1.1.0" },
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"dimensions": {
"type": "object",
"required": ["rows", "columns"],
"properties": {
"rows": { "type": "integer", "minimum": 3, "maximum": 12 },
"columns": { "type": "integer", "minimum": 3, "maximum": 12 }
},
"additionalProperties": false
},
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"versionedComponent": {
"type": "object",
"required": ["id", "version", "configHash"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"alternative": {
"type": "object",
"required": ["classId", "confidencePermille"],
"properties": {
"classId": { "type": "string", "pattern": "^vc[0-9]{2,3}$" },
"confidencePermille": { "type": "integer", "minimum": 0, "maximum": 1000 }
},
"additionalProperties": false
},
"visualCellClass": {
"type": "object",
"required": [
"cell", "cropHash", "state", "classId", "confidencePermille", "alternatives"
],
"properties": {
"cell": { "$ref": "#/$defs/cell" },
"cropHash": { "$ref": "#/$defs/sha256" },
"state": { "enum": ["classified", "ambiguous", "unreadable"] },
"classId": {
"anyOf": [
{ "type": "string", "pattern": "^vc[0-9]{2,3}$" },
{ "type": "null" }
]
},
"confidencePermille": { "type": "integer", "minimum": 0, "maximum": 1000 },
"alternatives": {
"type": "array",
"maxItems": 12,
"items": { "$ref": "#/$defs/alternative" }
}
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "state": { "const": "classified" } }, "required": ["state"] },
"then": {
"properties": {
"classId": { "type": "string", "pattern": "^vc[0-9]{2,3}$" },
"alternatives": { "maxItems": 0 }
}
}
},
{
"if": { "properties": { "state": { "const": "ambiguous" } }, "required": ["state"] },
"then": {
"properties": {
"classId": { "type": "null" },
"alternatives": { "minItems": 1 }
}
}
},
{
"if": { "properties": { "state": { "const": "unreadable" } }, "required": ["state"] },
"then": {
"properties": {
"classId": { "type": "null" },
"confidencePermille": { "const": 0 },
"alternatives": { "maxItems": 0 }
}
}
}
]
}
}
}

View File

@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-effect-audit.schema.json",
"title": "Match3EffectAuditV1",
"type": "object",
"required": [
"schemaVersion", "artifactHash", "transactionId", "transactionHash",
"buildManifestRef", "buildManifestHash", "filmstripManifestRef", "filmstripManifestHash",
"paletteHash", "effectIds", "cascadeIndexes", "renderReceiptRefs", "status"
],
"properties": {
"schemaVersion": { "const": "Match3EffectAudit/1" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"transactionId": { "$ref": "#/$defs/id" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"buildManifestRef": { "$ref": "#/$defs/ref" },
"buildManifestHash": { "$ref": "#/$defs/sha256" },
"filmstripManifestRef": { "$ref": "#/$defs/ref" },
"filmstripManifestHash": { "$ref": "#/$defs/sha256" },
"paletteHash": { "$ref": "#/$defs/sha256" },
"effectIds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
"cascadeIndexes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "integer", "minimum": 0, "maximum": 6 } },
"renderReceiptRefs": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/sealedRef" } },
"status": { "const": "pass" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" },
"sealedRef": {
"type": "object", "required": ["ref", "hash"],
"properties": { "ref": { "$ref": "#/$defs/ref" }, "hash": { "$ref": "#/$defs/sha256" } },
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-effect-render-receipt.schema.json",
"title": "Match3EffectRenderReceiptV1",
"type": "object",
"required": ["schemaVersion", "transactionId", "cascadeIndex", "effectIds", "mainContextIdentity", "paletteHash"],
"properties": {
"schemaVersion": { "const": "Match3EffectRenderReceipt/1" },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"cascadeIndex": { "type": "integer", "minimum": 0, "maximum": 6 },
"effectIds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
"mainContextIdentity": { "const": "plugin-init-main-context" },
"paletteHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
},
"additionalProperties": false
}

View File

@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-filmstrip-analyzer.schema.json",
"title": "Match3FilmstripAnalyzerV1",
"type": "object",
"required": [
"schemaVersion", "artifactHash", "transactionId", "transactionHash", "configHash",
"frames", "settlementFrameRef", "settlementFrameHash", "status"
],
"properties": {
"schemaVersion": { "const": "Match3FilmstripAnalyzer/1" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"configHash": { "$ref": "#/$defs/sha256" },
"frames": { "type": "array", "minItems": 4, "items": { "$ref": "#/$defs/frame" } },
"settlementFrameRef": { "$ref": "#/$defs/ref" },
"settlementFrameHash": { "$ref": "#/$defs/sha256" },
"status": { "const": "pass" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" },
"frame": {
"type": "object",
"required": ["ref", "hash", "relativeMs", "phase", "cascadeIndex"],
"properties": {
"ref": { "$ref": "#/$defs/ref" },
"hash": { "$ref": "#/$defs/sha256" },
"relativeMs": { "type": "number", "minimum": 0, "maximum": 5000 },
"phase": { "enum": ["swap", "match_hold", "clear", "fall_refill", "cascade_gap", "settle"] },
"cascadeIndex": { "type": "integer", "minimum": -1, "maximum": 6 }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,72 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-pair-resolution.schema.json",
"title": "Match3PairResolutionV1",
"description": "Match3PairResolution/1。把一个 swap_pair 选择解析为 sealed candidate 的规范 pair 与 runner 决定的第二端。",
"type": "object",
"required": [
"schemaVersion", "interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
"selectionRef", "selectionHash", "swapSetRef", "swapSetHash",
"candidateSetHash", "candidateId", "latticeId", "pair", "firstEndpoint",
"secondEndpoint", "status", "error", "resolutionHash"
],
"properties": {
"schemaVersion": { "const": "Match3PairResolution/1" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"selectionRef": { "type": "string", "minLength": 1 },
"selectionHash": { "$ref": "#/$defs/sha256" },
"swapSetRef": { "type": "string", "minLength": 1 },
"swapSetHash": { "$ref": "#/$defs/sha256" },
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"pair": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/cell" } },
"firstEndpoint": { "$ref": "#/$defs/endpoint" },
"secondEndpoint": { "$ref": "#/$defs/endpoint" },
"status": { "enum": ["resolved", "failed"] },
"error": {
"anyOf": [
{ "type": "null" },
{
"type": "object", "required": ["code", "message"],
"properties": {
"code": { "type": "string", "minLength": 1 },
"message": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}
]
},
"resolutionHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "status": { "const": "resolved" } }, "required": ["status"] },
"then": { "properties": { "error": { "type": "null" } } },
"else": { "properties": { "error": { "type": "object" } } }
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"cell": {
"type": "object", "required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"endpoint": {
"type": "object", "required": ["id", "cell", "subAnchor"],
"properties": {
"id": { "type": "string", "pattern": "^l[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" },
"subAnchor": { "const": "center" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,248 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-producer-preflight.schema.json",
"title": "Match3ProducerPreflightV1",
"description": "标准 Match-3 生产者在创建 Actor 与成本预留前签发的一次性隔离兼容性事实。",
"type": "object",
"required": [
"schemaVersion", "preflightId", "gameId", "artifactHash", "taskBindingHash",
"acceptanceRequestHash", "interactionProfileId", "interactionRegistryVersion",
"interactionBindingHash", "seed", "executionStage", "expectedCapability", "probedCapability",
"environment", "initialFrame", "postFrame", "eventInterval", "selectedEndpoint",
"lattice", "swapSet", "normalizerAudit", "result", "reasonCode", "factHash"
],
"properties": {
"schemaVersion": { "const": "Match3ProducerPreflight/1" },
"preflightId": { "type": "string", "pattern": "^match3-preflight-[a-z0-9][a-z0-9-]{0,63}$" },
"gameId": { "type": "string", "minLength": 1 },
"artifactHash": { "$ref": "#/$defs/sha256" },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"seed": { "type": "integer", "minimum": 0 },
"executionStage": {
"enum": [
"environment_setup", "browser_connected", "capability_probed",
"initial_frame_captured", "first_action_dispatched",
"post_frame_captured", "normalizer_completed"
]
},
"expectedCapability": { "$ref": "#/$defs/capability" },
"probedCapability": { "anyOf": [{ "$ref": "#/$defs/capability" }, { "type": "null" }] },
"environment": { "$ref": "#/$defs/environment" },
"initialFrame": { "anyOf": [{ "$ref": "#/$defs/frame" }, { "type": "null" }] },
"postFrame": { "anyOf": [{ "$ref": "#/$defs/frame" }, { "type": "null" }] },
"eventInterval": { "anyOf": [{ "$ref": "#/$defs/eventInterval" }, { "type": "null" }] },
"selectedEndpoint": { "anyOf": [{ "$ref": "#/$defs/endpoint" }, { "type": "null" }] },
"lattice": { "anyOf": [{ "$ref": "#/$defs/latticeEvidence" }, { "type": "null" }] },
"swapSet": { "anyOf": [{ "$ref": "#/$defs/swapSetEvidence" }, { "type": "null" }] },
"normalizerAudit": { "$ref": "#/$defs/normalizerAudit" },
"result": { "enum": ["compatible", "incompatible", "tester_error"] },
"reasonCode": {
"enum": [
"none", "profile_contract_incompatible", "profile_contract_error",
"board_perception_unresolved", "environment_error"
]
},
"factHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"capability": {
"type": "object", "required": ["id", "version", "config", "configHash"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"config": {
"type": "object",
"required": ["viewport", "grid", "cellShell", "selectionOverlay", "themeSafeBounds"],
"properties": {
"viewport": {
"type": "object", "required": ["width", "height"],
"properties": {
"width": { "type": "integer", "minimum": 1 },
"height": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"grid": {
"type": "object", "required": ["rows", "columns", "pitchPx", "originX", "originY"],
"properties": {
"rows": { "type": "integer", "minimum": 1 },
"columns": { "type": "integer", "minimum": 1 },
"pitchPx": { "type": "integer", "minimum": 1 },
"originX": { "type": "integer", "minimum": 0 },
"originY": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"cellShell": {
"type": "object", "required": ["shape", "radiusPx", "fillStyle"],
"properties": {
"shape": { "type": "string", "minLength": 1 },
"radiusPx": { "type": "integer", "minimum": 1 },
"fillStyle": { "type": "string", "pattern": "^#[0-9a-f]{6}$" }
},
"additionalProperties": false
},
"selectionOverlay": {
"type": "object",
"required": ["shape", "maskSizePx", "strokeStyle", "expectedChangedPixelCount", "supportMaskHash"],
"properties": {
"shape": { "type": "string", "minLength": 1 },
"maskSizePx": { "type": "integer", "minimum": 1 },
"strokeStyle": { "type": "string", "pattern": "^#[0-9a-f]{6}$" },
"expectedChangedPixelCount": { "type": "integer", "minimum": 1 },
"supportMaskHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"themeSafeBounds": {
"type": "object", "required": ["insetPx"],
"properties": {
"insetPx": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"environment": {
"type": "object",
"required": [
"host", "browserVersion", "viewport", "runtimeIdentityHash", "contextId",
"contextIsolation", "freshContext", "contextDestroyed", "modelClientCreated",
"costReservationCreated"
],
"properties": {
"host": { "type": "string", "minLength": 1 },
"browserVersion": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"viewport": {
"type": "object", "required": ["width", "height", "dpr"],
"properties": {
"width": { "const": 390 },
"height": { "const": 844 },
"dpr": { "const": 1 }
},
"additionalProperties": false
},
"canvasBacking": {
"anyOf": [
{
"type": "object",
"required": [
"cssWidth", "cssHeight", "backingWidth", "backingHeight",
"scaleX", "scaleY", "devicePixelRatio"
],
"properties": {
"cssWidth": { "const": 390 },
"cssHeight": { "const": 844 },
"backingWidth": { "type": "integer", "minimum": 390 },
"backingHeight": { "type": "integer", "minimum": 844 },
"scaleX": { "type": "integer", "minimum": 1 },
"scaleY": { "type": "integer", "minimum": 1 },
"devicePixelRatio": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
},
{ "type": "null" }
]
},
"runtimeIdentityHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"contextId": { "type": "string", "minLength": 1 },
"contextIsolation": { "enum": ["one-time-dedicated", "not-created"] },
"freshContext": { "type": "boolean" },
"contextDestroyed": { "type": "boolean" },
"modelClientCreated": { "const": false },
"costReservationCreated": { "const": false }
},
"additionalProperties": false
},
"frame": {
"type": "object", "required": ["ref", "hash"],
"properties": {
"ref": { "type": "string", "minLength": 1 },
"hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"eventInterval": {
"type": "object", "required": ["preEventSeq", "postEventSeq", "businessEventCount"],
"properties": {
"preEventSeq": { "type": "integer", "minimum": 0 },
"postEventSeq": { "type": "integer", "minimum": 0 },
"businessEventCount": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"cell": {
"type": "object", "required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 8 },
"column": { "type": "integer", "minimum": 1, "maximum": 8 }
},
"additionalProperties": false
},
"endpoint": {
"type": "object", "required": ["latticeId", "cell"],
"properties": {
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"cell": { "$ref": "#/$defs/cell" }
},
"additionalProperties": false
},
"latticeEvidence": {
"type": "object",
"required": ["targetSetRef", "targetSetHash", "latticeId", "dimensions", "geometryHash"],
"properties": {
"targetSetRef": { "type": "string", "minLength": 1 },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"dimensions": {
"type": "object", "required": ["rows", "columns"],
"properties": {
"rows": { "const": 8 },
"columns": { "const": 8 }
},
"additionalProperties": false
},
"geometryHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"swapSetEvidence": {
"type": "object", "required": ["ref", "hash", "candidateSetHash", "candidateId"],
"properties": {
"ref": { "type": "string", "minLength": 1 },
"hash": { "$ref": "#/$defs/sha256" },
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" }
},
"additionalProperties": false
},
"normalizerAudit": {
"type": "object",
"required": [
"ref", "hash", "status", "changedPixelCount", "outsideSelectedCellCount",
"maximumReplayResidual", "supportMaskHash"
],
"properties": {
"ref": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"hash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"status": { "enum": ["normalized", "rejected", "not_run"] },
"changedPixelCount": { "anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }] },
"outsideSelectedCellCount": { "anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }] },
"maximumReplayResidual": { "anyOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }] },
"supportMaskHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-roll-interaction-audit-v2.schema.json",
"title": "Match3RollInteractionAuditV2",
"description": "标准 Match-3 单掷视觉、特效、音频、输入锁与稳定终盘的闭包入口。旧 /1 通过 actionAuditRef 单向进入本版本。",
"type": "object",
"required": [
"schemaVersion", "rollId", "gameId", "artifactHash", "taskBindingHash",
"acceptanceRequestHash", "seed", "interactionProfileId", "interactionRegistryVersion",
"interactionBindingHash", "actionAuditRef", "actionAuditHash", "buildManifestRef",
"buildManifestHash", "visualTransactionRef", "visualTransactionHash", "visualAuditRef",
"visualAuditHash", "effectAuditRef", "effectAuditHash", "audioAuditRef",
"audioAuditHash", "inputLockProbeRef", "inputLockProbeHash", "filmstripManifestRef",
"filmstripManifestHash", "visualSettlementFrameRef", "visualSettlementFrameHash",
"transactionId", "transactionHash", "moveId", "closureStatus", "auditHash"
],
"properties": {
"schemaVersion": { "const": "Match3RollInteractionAudit/2" },
"rollId": { "type": "string", "pattern": "^roll-[12]$" },
"gameId": { "type": "string", "minLength": 1 },
"artifactHash": { "$ref": "#/$defs/sha256" },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"seed": { "type": "integer", "minimum": 0 },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"actionAuditRef": { "$ref": "#/$defs/ref" },
"actionAuditHash": { "$ref": "#/$defs/sha256" },
"buildManifestRef": { "$ref": "#/$defs/ref" },
"buildManifestHash": { "$ref": "#/$defs/sha256" },
"visualTransactionRef": { "$ref": "#/$defs/ref" },
"visualTransactionHash": { "$ref": "#/$defs/sha256" },
"visualAuditRef": { "$ref": "#/$defs/ref" },
"visualAuditHash": { "$ref": "#/$defs/sha256" },
"effectAuditRef": { "$ref": "#/$defs/ref" },
"effectAuditHash": { "$ref": "#/$defs/sha256" },
"audioAuditRef": { "$ref": "#/$defs/ref" },
"audioAuditHash": { "$ref": "#/$defs/sha256" },
"inputLockProbeRef": { "$ref": "#/$defs/ref" },
"inputLockProbeHash": { "$ref": "#/$defs/sha256" },
"filmstripManifestRef": { "$ref": "#/$defs/ref" },
"filmstripManifestHash": { "$ref": "#/$defs/sha256" },
"visualSettlementFrameRef": { "$ref": "#/$defs/ref" },
"visualSettlementFrameHash": { "$ref": "#/$defs/sha256" },
"transactionId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"moveId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"closureStatus": { "const": "complete" },
"auditHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" }
}
}

View File

@ -0,0 +1,104 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-roll-interaction-audit.schema.json",
"title": "Match3RollInteractionAuditV1",
"description": "单掷 Match-3 actionGroup/一次 reset 的完整审计入口;最多两组、一次恢复。",
"type": "object",
"required": [
"schemaVersion", "rollId", "gameId", "artifactHash", "taskBindingHash",
"acceptanceRequestHash", "seed", "interactionProfileId", "interactionRegistryVersion",
"interactionBindingHash", "interactionBindingRef", "interactionBindingFileHash",
"producerPreflightRef", "producerPreflightHash",
"initialRemainingBudget", "groups", "reset", "finalState", "auditHash"
],
"properties": {
"schemaVersion": { "const": "Match3RollInteractionAudit/1" },
"rollId": { "type": "string", "pattern": "^roll-[12]$" },
"gameId": { "type": "string", "minLength": 1 },
"artifactHash": { "$ref": "#/$defs/sha256" },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"seed": { "type": "integer", "minimum": 0 },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "const": "2026-07-15.v2" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"interactionBindingRef": { "type": "string", "minLength": 1 },
"interactionBindingFileHash": { "$ref": "#/$defs/sha256" },
"producerPreflightRef": { "type": "string", "minLength": 1 },
"producerPreflightHash": { "$ref": "#/$defs/sha256" },
"initialRemainingBudget": { "type": "integer", "minimum": 2 },
"groups": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "$ref": "#/$defs/groupRef" } },
"reset": { "anyOf": [{ "type": "null" }, { "$ref": "#/$defs/resetAudit" }] },
"finalState": { "enum": ["completed", "inconclusive_board_identity_unresolved"] },
"auditHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"groupRef": {
"type": "object",
"required": [
"ref", "hash", "selectionAttemptRef", "selectionAttemptHash", "pairResolutionRef", "pairResolutionHash",
"firstGroupedActionRef", "firstGroupedActionHash", "secondGroupedActionRef",
"secondGroupedActionHash", "boardIdentityCheckRef", "boardIdentityCheckHash",
"actionGroupId", "candidateSetHash", "candidateId", "state"
],
"properties": {
"ref": { "type": "string", "minLength": 1 },
"hash": { "$ref": "#/$defs/sha256" },
"selectionAttemptRef": { "type": "string", "minLength": 1 },
"selectionAttemptHash": { "$ref": "#/$defs/sha256" },
"pairResolutionRef": { "type": "string", "minLength": 1 },
"pairResolutionHash": { "$ref": "#/$defs/sha256" },
"firstGroupedActionRef": { "type": "string", "minLength": 1 },
"firstGroupedActionHash": { "$ref": "#/$defs/sha256" },
"secondGroupedActionRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"secondGroupedActionHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"boardIdentityCheckRef": { "type": "string", "minLength": 1 },
"boardIdentityCheckHash": { "$ref": "#/$defs/sha256" },
"actionGroupId": { "type": "string", "pattern": "^action-group-[1-9][0-9]*$" },
"candidateSetHash": { "$ref": "#/$defs/sha256" },
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"state": { "enum": ["completed", "aborted"] }
},
"additionalProperties": false
},
"resetAudit": {
"type": "object",
"required": [
"betweenActionGroupIds", "groupedActionRef", "groupedActionHash",
"nativeActionRef", "nativeActionHash", "eventSliceRef", "eventSliceHash",
"resetTargetSetRef", "resetTargetSetHash",
"postResetFrameFileRef", "postResetFrameHash",
"postResetTargetSetRef", "postResetTargetSetHash", "postResetProjectionRef",
"postResetProjectionHash", "postResetSwapSetRef", "postResetSwapSetHash",
"budgetMs", "resultingCandidateSetHash"
],
"properties": {
"betweenActionGroupIds": {
"type": "array", "minItems": 2, "maxItems": 2,
"items": { "type": "string", "pattern": "^action-group-[1-9][0-9]*$" }
},
"groupedActionRef": { "type": "string", "minLength": 1 },
"groupedActionHash": { "$ref": "#/$defs/sha256" },
"nativeActionRef": { "type": "string", "minLength": 1 },
"nativeActionHash": { "$ref": "#/$defs/sha256" },
"eventSliceRef": { "type": "string", "minLength": 1 },
"eventSliceHash": { "$ref": "#/$defs/sha256" },
"resetTargetSetRef": { "type": "string", "minLength": 1 },
"resetTargetSetHash": { "$ref": "#/$defs/sha256" },
"postResetFrameFileRef": { "type": "string", "minLength": 1 },
"postResetFrameHash": { "$ref": "#/$defs/sha256" },
"postResetTargetSetRef": { "type": "string", "minLength": 1 },
"postResetTargetSetHash": { "$ref": "#/$defs/sha256" },
"postResetProjectionRef": { "type": "string", "minLength": 1 },
"postResetProjectionHash": { "$ref": "#/$defs/sha256" },
"postResetSwapSetRef": { "type": "string", "minLength": 1 },
"postResetSwapSetHash": { "$ref": "#/$defs/sha256" },
"budgetMs": { "const": 600 },
"resultingCandidateSetHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,79 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-swap-set.schema.json",
"title": "Match3SwapSetV1",
"description": "Match3SwapSet/1。封存确定性规则层声称从匿名棋盘投影枚举出的正交交换集合;单文件 schema/hash 只校验结构自洽,生产入口必须与 BoardProjection 联合复算。",
"type": "object",
"required": [
"schemaVersion", "sourceFrameRef", "sourceFrameHash", "targetSetRef", "targetSetHash", "latticeId",
"interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
"projectionRef", "projectionHash", "solver", "beforeRuns", "candidates", "status", "candidateSetHash"
],
"properties": {
"schemaVersion": { "const": "Match3SwapSet/1" },
"sourceFrameRef": { "type": "string", "minLength": 1 },
"sourceFrameHash": { "$ref": "#/$defs/sha256" },
"targetSetRef": { "type": "string", "minLength": 1 },
"targetSetHash": { "$ref": "#/$defs/sha256" },
"latticeId": { "type": "string", "pattern": "^l[0-9]{2}$" },
"interactionProfileId": { "const": "match3.orthogonal-swap-v1" },
"interactionRegistryVersion": { "enum": ["2026-07-15.v1", "2026-07-15.v2"] },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"projectionRef": { "type": "string", "minLength": 1 },
"projectionHash": { "$ref": "#/$defs/sha256" },
"solver": { "$ref": "#/$defs/versionedComponent" },
"beforeRuns": { "type": "array", "maxItems": 288, "items": { "$ref": "#/$defs/run" } },
"candidates": { "type": "array", "maxItems": 264, "items": { "$ref": "#/$defs/candidate" } },
"status": { "enum": ["resolved", "no_candidate", "indeterminate"] },
"candidateSetHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"classId": { "type": "string", "pattern": "^vc[0-9]{2,3}$" },
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 12 },
"column": { "type": "integer", "minimum": 1, "maximum": 12 }
},
"additionalProperties": false
},
"versionedComponent": {
"type": "object",
"required": ["id", "version", "configHash"],
"properties": {
"id": { "const": "match3-orthogonal-swap" },
"version": { "const": "1.0.0" },
"configHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"run": {
"type": "object",
"required": ["axis", "classId", "cells"],
"properties": {
"axis": { "enum": ["horizontal", "vertical"] },
"classId": { "$ref": "#/$defs/classId" },
"cells": { "type": "array", "minItems": 3, "maxItems": 12, "items": { "$ref": "#/$defs/cell" } }
},
"additionalProperties": false
},
"candidate": {
"type": "object",
"required": [
"candidateId", "pair", "pairKey", "beforeClassIds", "newRuns", "candidateHash"
],
"properties": {
"candidateId": { "type": "string", "pattern": "^ms[0-9]{2,3}$" },
"pair": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/cell" } },
"pairKey": { "type": "string", "pattern": "^r[1-9][0-9]?c[1-9][0-9]?-r[1-9][0-9]?c[1-9][0-9]?$" },
"beforeClassIds": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/classId" } },
"newRuns": { "type": "array", "minItems": 1, "maxItems": 4, "items": { "$ref": "#/$defs/run" } },
"candidateHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-visual-audit.schema.json",
"title": "Match3VisualAuditV1",
"type": "object",
"required": [
"schemaVersion", "artifactHash", "transactionId", "transactionHash", "moveId",
"visualTransactionRef", "visualTransactionHash", "matchedCells", "cascadeCount",
"visualSettlementFrameRef", "visualSettlementFrameHash", "status"
],
"properties": {
"schemaVersion": { "const": "Match3VisualAudit/1" },
"artifactHash": { "$ref": "#/$defs/sha256" },
"transactionId": { "$ref": "#/$defs/id" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"moveId": { "$ref": "#/$defs/id" },
"visualTransactionRef": { "$ref": "#/$defs/ref" },
"visualTransactionHash": { "$ref": "#/$defs/sha256" },
"matchedCells": { "type": "array", "minItems": 3, "items": { "$ref": "#/$defs/cell" } },
"cascadeCount": { "type": "integer", "minimum": 1, "maximum": 7 },
"visualSettlementFrameRef": { "$ref": "#/$defs/ref" },
"visualSettlementFrameHash": { "$ref": "#/$defs/sha256" },
"status": { "const": "pass" }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"ref": { "type": "string", "minLength": 1, "pattern": "^(?!/).+$" },
"cell": {
"type": "object", "required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 8 },
"column": { "type": "integer", "minimum": 1, "maximum": 8 }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/match3-visual-transaction.schema.json",
"title": "Match3VisualTransactionV1",
"description": "视觉事务 sidecar 的最小可信投影;保留 move、轮次命中和 runtime transactionHash。",
"type": "object",
"required": [
"schemaVersion", "transactionId", "transactionHash", "moveId", "valid",
"visualComplete", "degradedReason", "rounds"
],
"properties": {
"schemaVersion": { "const": "Match3VisualTransaction/1" },
"transactionId": { "$ref": "#/$defs/id" },
"transactionHash": { "$ref": "#/$defs/sha256" },
"moveId": { "$ref": "#/$defs/id" },
"valid": { "type": "boolean" },
"visualComplete": { "type": "boolean" },
"degradedReason": { "anyOf": [{ "enum": ["round_limit", "duration_limit"] }, { "type": "null" }] },
"rounds": { "type": "array", "maxItems": 7, "items": { "$ref": "#/$defs/round" } }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
"cell": {
"type": "object",
"required": ["row", "column"],
"properties": {
"row": { "type": "integer", "minimum": 1, "maximum": 8 },
"column": { "type": "integer", "minimum": 1, "maximum": 8 }
},
"additionalProperties": false
},
"round": {
"type": "object",
"required": ["cascadeIndex", "matchedCells"],
"properties": {
"cascadeIndex": { "type": "integer", "minimum": 0, "maximum": 6 },
"matchedCells": { "type": "array", "minItems": 3, "items": { "$ref": "#/$defs/cell" } }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,322 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/playtest-evidence-v3.schema.json",
"title": "PlaytestEvidenceV3",
"description": "playtest/3 权威证据。Actor 只选择结构化视觉目标;每掷封存互相致盲的 Judge A/B 与确定性 consensus;只有 finalPostguard 能写最终结果。",
"type": "object",
"required": [
"schemaVersion", "runId", "parentRunId", "gameId", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "acceptanceRequestHash", "artifactHash", "briefHash",
"acceptanceMode", "evidenceMode", "phase", "environment", "floor", "actor", "actions", "events",
"briefRuleMatches", "proofObligations", "firstPlay", "rolls", "merge", "sourceRollId", "judge",
"repair", "costRmb"
],
"properties": {
"schemaVersion": { "const": "playtest/3" },
"runId": { "type": "string", "minLength": 1 },
"parentRunId": { "type": ["string", "null"] },
"gameId": { "type": "string", "minLength": 1 },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_template-[a-z0-9-]+$" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"artifactHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"briefHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"acceptanceMode": { "enum": ["v3_shadow", "v3", "historical_replay"] },
"evidenceMode": { "enum": ["native", "legacy-adapted"] },
"phase": { "enum": ["PRECHECK", "COLLECTING", "JUDGING", "DECIDED"] },
"outcome": { "$ref": "playtest-evidence.schema.json#/$defs/outcome" },
"environment": { "$ref": "#/$defs/environmentV3" },
"floor": { "$ref": "playtest-evidence.schema.json#/$defs/floor" },
"actor": {
"anyOf": [
{ "$ref": "playtest-evidence.schema.json#/$defs/actor" },
{ "type": "null" }
]
},
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/actionRecord" }
},
"events": {
"type": "array",
"items": { "$ref": "game-event.schema.json" }
},
"briefRuleMatches": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"proofObligations": {
"type": "array",
"items": { "$ref": "playtest-evidence.schema.json#/$defs/proofObligation" }
},
"firstPlay": { "$ref": "playtest-evidence.schema.json#/$defs/firstPlay" },
"rolls": {
"type": "array", "maxItems": 2,
"items": { "$ref": "#/$defs/roll" }
},
"merge": {
"anyOf": [
{ "$ref": "playtest-evidence.schema.json#/$defs/merge" },
{ "type": "null" }
]
},
"sourceRollId": { "type": ["string", "null"], "pattern": "^roll-[12]$" },
"judge": {
"anyOf": [
{ "$ref": "#/$defs/judgeConsensusProjection" },
{ "type": "null" }
]
},
"repair": { "$ref": "playtest-evidence.schema.json#/$defs/repair" },
"costRmb": { "type": "number", "minimum": 0 },
"finalPostguard": { "$ref": "playtest-evidence.schema.json#/$defs/finalPostguard" },
"decision": { "$ref": "playtest-evidence.schema.json#/$defs/decision" },
"compatibility": { "$ref": "#/$defs/compatibility" }
},
"patternProperties": { "^_": {} },
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "phase": { "const": "DECIDED" } }, "required": ["phase"] },
"then": { "required": ["outcome", "finalPostguard", "decision", "compatibility"] },
"else": {
"not": {
"anyOf": [
{ "required": ["outcome"] },
{ "required": ["finalPostguard"] },
{ "required": ["decision"] },
{ "required": ["compatibility"] }
]
}
}
}
],
"$defs": {
"environmentV3": {
"type": "object",
"required": ["host", "chromeVersion", "viewport", "buildRef", "virtualTime"],
"properties": {
"host": { "type": "string", "minLength": 1 },
"chromeVersion": { "type": "string", "minLength": 1 },
"viewport": {
"type": "object",
"required": ["width", "height", "dpr"],
"properties": {
"width": { "const": 390 },
"height": { "const": 844 },
"dpr": { "const": 1 }
},
"additionalProperties": false
},
"buildRef": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"virtualTime": {
"type": "object",
"required": ["mode", "actionQuantumMs", "waitMinMs", "waitMaxMs"],
"properties": {
"mode": { "const": "cdp" },
"actionQuantumMs": { "const": 600 },
"waitMinMs": { "const": 100 },
"waitMaxMs": { "const": 600 },
"rafFrameMs": { "type": "number", "minimum": 1 },
"bootBoundary": { "type": "object" }
},
"additionalProperties": false
},
"timings": {
"type": "object",
"required": [
"chromeProcessReadyWallMs", "cdpConnectedWallMs", "browserContextCreatedWallMs",
"navigationSubmittedWallMs", "runtimeBootedWallMs", "gameInteractiveWallMs"
],
"properties": {
"chromeProcessReadyWallMs": { "type": ["integer", "null"], "minimum": 0 },
"cdpConnectedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"browserContextCreatedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"navigationSubmittedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"runtimeBootedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"gameInteractiveWallMs": { "type": ["integer", "null"], "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"hashRef": {
"type": "object",
"required": ["ref", "hash"],
"properties": {
"ref": { "type": "string", "minLength": 1 },
"hash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" }
},
"additionalProperties": false
},
"actionRecord": {
"type": "object",
"required": [
"actionId", "request", "normalized", "targetResolution", "valid", "retryCount",
"preFrameRef", "preEventSeq", "dispatchReceipt", "virtualTimeBudgetMs", "timeAdvance",
"postFrameRef", "postEventSeq", "protocolErrors"
],
"properties": {
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"request": {
"type": "object",
"required": ["raw", "parsed"],
"properties": {
"raw": { "type": "string", "minLength": 1 },
"parsed": { "$ref": "actor-selection.schema.json" }
},
"additionalProperties": false
},
"normalized": { "$ref": "playtest-evidence.schema.json#/$defs/action" },
"targetResolution": { "$ref": "target-resolution.schema.json" },
"valid": { "const": true },
"retryCount": { "type": "integer", "minimum": 0, "maximum": 2 },
"preFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"dispatchReceipt": { "$ref": "playtest-evidence.schema.json#/$defs/dispatchReceipt" },
"virtualTimeBudgetMs": { "type": "integer", "minimum": 100, "maximum": 600 },
"timeAdvance": { "$ref": "playtest-evidence.schema.json#/$defs/timeAdvance" },
"postFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"postEventSeq": { "type": "integer", "minimum": 0 },
"protocolErrors": {
"type": "array",
"maxItems": 2,
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false
},
"roll": {
"type": "object",
"required": [
"rollId", "roll", "requestedGameSeed", "actualGameSeed", "policySeed", "actorSessionId",
"proofPackageRef", "proofPackageHash", "judgePackageRef", "judgePackageHash",
"judgeARef", "judgeAHash", "judgeBRef", "judgeBHash", "judgeConsensusRef",
"judgeConsensusHash", "costReservationRef", "costReservationHash",
"rollGuard", "rollOutcome", "errors", "costRmb"
],
"properties": {
"rollId": { "type": "string", "pattern": "^roll-[12]$" },
"roll": { "type": "integer", "minimum": 1, "maximum": 2 },
"requestedGameSeed": { "type": "integer", "minimum": 0 },
"actualGameSeed": { "type": "integer", "minimum": 0 },
"policySeed": { "type": "integer", "minimum": 0 },
"actorSessionId": { "type": "string", "minLength": 1 },
"proofPackageRef": { "type": "string", "minLength": 1 },
"proofPackageHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"judgePackageRef": { "type": "string", "minLength": 1 },
"judgePackageHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"judgeARef": { "$ref": "#/$defs/judgeSlotRef" },
"judgeAHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"judgeBRef": { "$ref": "#/$defs/judgeSlotRef" },
"judgeBHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"judgeConsensusRef": { "type": "string", "minLength": 1 },
"judgeConsensusHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"costReservationRef": { "type": "string", "minLength": 1 },
"costReservationHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"rollGuard": { "$ref": "playtest-evidence.schema.json#/$defs/guard" },
"rollOutcome": { "$ref": "playtest-evidence.schema.json#/$defs/outcome" },
"errors": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"costRmb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"judgeSlotRef": {
"type": "object",
"required": ["rawRef", "rawHash", "parsedRef", "parsedHash", "normalizedRef", "resultHash"],
"properties": {
"rawRef": { "type": "string", "minLength": 1 },
"rawHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"parsedRef": { "type": "string", "minLength": 1 },
"parsedHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"normalizedRef": { "type": "string", "minLength": 1 },
"resultHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" }
},
"additionalProperties": false
},
"independenceChecks": {
"type": "object",
"required": [
"differentLogicalClient", "differentSession", "differentPolicySeed", "differentPromptArtifact",
"differentEvidenceDir", "differentRawFile", "sameJudgePackage", "sameImageManifest", "sameModel"
],
"properties": {
"differentLogicalClient": { "const": true },
"differentSession": { "const": true },
"differentPolicySeed": { "const": true },
"differentPromptArtifact": { "const": true },
"differentEvidenceDir": { "const": true },
"differentRawFile": { "const": true },
"sameJudgePackage": { "const": true },
"sameImageManifest": { "const": true },
"sameModel": { "type": "boolean" }
},
"additionalProperties": false
},
"consensusObligation": {
"type": "object",
"required": ["id", "status", "sequenceRefs", "evidenceRefs"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"status": { "enum": ["satisfied", "failed", "missing", "contradicted", "disputed"] },
"sequenceRefs": {
"type": "array",
"items": { "$ref": "playtest-evidence.schema.json#/$defs/sequenceRef" }
},
"evidenceRefs": { "type": "array", "items": { "type": "string", "minLength": 1 } }
},
"additionalProperties": false
},
"judgeConsensusProjection": {
"type": "object",
"required": [
"schemaVersion", "sourceRollId", "consensusRef", "consensusHash", "packageHash",
"strategyVersion", "decision", "degraded", "sameModel", "independenceChecks",
"judgeAResultHash", "judgeBResultHash", "obligationResults", "conflicts", "failureSignature"
],
"properties": {
"schemaVersion": { "const": "JudgeConsensus/1" },
"sourceRollId": { "type": ["string", "null"], "pattern": "^roll-[12]$" },
"consensusRef": { "type": ["string", "null"], "minLength": 1 },
"consensusHash": {
"anyOf": [
{ "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
{ "type": "null" }
]
},
"packageHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"strategyVersion": { "type": "string", "minLength": 1 },
"decision": { "$ref": "playtest-evidence.schema.json#/$defs/outcome" },
"degraded": { "type": "boolean" },
"sameModel": { "type": "boolean" },
"independenceChecks": { "$ref": "#/$defs/independenceChecks" },
"judgeAResultHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"judgeBResultHash": { "$ref": "playtest-evidence.schema.json#/$defs/sha256" },
"obligationResults": {
"type": "array",
"items": { "$ref": "#/$defs/consensusObligation" }
},
"conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"failureSignature": { "type": ["string", "null"] }
},
"additionalProperties": false
},
"compatibility": {
"type": "object",
"required": ["accepted", "ok", "acceptanceVersion", "publishFrozen", "schemaVersion", "sourceRollId"],
"properties": {
"accepted": { "type": "boolean" },
"ok": { "type": "boolean" },
"acceptanceVersion": { "enum": ["v3_shadow", "v3", "historical_replay"] },
"publishFrozen": { "type": "boolean" },
"schemaVersion": { "const": "playtest/3" },
"sourceRollId": { "type": ["string", "null"], "pattern": "^roll-[12]$" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,798 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/playtest-evidence.schema.json",
"title": "PlaytestEvidence",
"description": "便宜档生成验收 v3 的 playtest/2 权威证据契约。phase 与 outcome 正交;decision.accepted 永远等价 outcome=accept,而发布仅允许 active v3 accept。v3_shadow 即使终裁接受也必须 publishFrozen=true 且 compatibility.accepted/ok=false。Actor 只有动作权限,Judge 只读封存后的 JudgePackage,rollGuard 只产 rollOutcome,merge 只产 mergeCandidate,只有 finalPostguard 能写 decision 与唯一 compatibility 投影。",
"type": "object",
"required": [
"schemaVersion", "runId", "parentRunId", "gameId", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "acceptanceRequestHash", "artifactHash", "briefHash",
"acceptanceMode", "evidenceMode", "phase", "environment", "floor", "actor", "actions", "events",
"briefRuleMatches", "proofObligations", "firstPlay", "rolls",
"merge", "judge", "repair", "costRmb"
],
"properties": {
"schemaVersion": {
"type": "string",
"const": "playtest/2"
},
"runId": {
"type": "string",
"minLength": 1
},
"parentRunId": {
"type": ["string", "null"]
},
"gameId": {
"type": "string",
"minLength": 1
},
"genre": {
"type": "string",
"enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"]
},
"templateRoute": {
"type": "string",
"pattern": "^_template-[a-z0-9-]+$"
},
"proofProfileId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"proofRegistryVersion": {
"type": "string",
"minLength": 1
},
"taskBindingHash": {
"$ref": "#/$defs/sha256"
},
"acceptanceRequestHash": {
"$ref": "#/$defs/sha256"
},
"artifactHash": {
"$ref": "#/$defs/sha256"
},
"briefHash": {
"$ref": "#/$defs/sha256"
},
"acceptanceMode": {
"type": "string",
"enum": ["v3_shadow", "v3", "historical_replay"]
},
"evidenceMode": {
"type": "string",
"enum": ["native", "legacy-adapted"]
},
"phase": {
"type": "string",
"enum": ["PRECHECK", "COLLECTING", "JUDGING", "DECIDED"]
},
"outcome": {
"$ref": "#/$defs/outcome"
},
"environment": {
"$ref": "#/$defs/environment"
},
"floor": {
"$ref": "#/$defs/floor"
},
"actor": {
"anyOf": [
{ "$ref": "#/$defs/actor" },
{ "type": "null" }
]
},
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/actionRecord" }
},
"events": {
"type": "array",
"items": { "$ref": "game-event.schema.json" }
},
"briefRuleMatches": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"proofObligations": {
"type": "array",
"items": { "$ref": "#/$defs/proofObligation" }
},
"firstPlay": {
"$ref": "#/$defs/firstPlay"
},
"rolls": {
"type": "array",
"maxItems": 2,
"items": { "$ref": "#/$defs/roll" }
},
"merge": {
"anyOf": [
{ "$ref": "#/$defs/merge" },
{ "type": "null" }
]
},
"judge": {
"anyOf": [
{ "$ref": "#/$defs/judge" },
{ "type": "null" }
]
},
"repair": {
"$ref": "#/$defs/repair"
},
"costRmb": {
"type": "number",
"minimum": 0
},
"finalPostguard": {
"$ref": "#/$defs/finalPostguard"
},
"decision": {
"$ref": "#/$defs/decision"
},
"compatibility": {
"$ref": "#/$defs/compatibility"
}
},
"patternProperties": {
"^_": {}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "phase": { "const": "DECIDED" } },
"required": ["phase"]
},
"then": {
"required": ["outcome", "finalPostguard", "decision", "compatibility"]
},
"else": {
"not": {
"anyOf": [
{ "required": ["outcome"] },
{ "required": ["finalPostguard"] },
{ "required": ["decision"] },
{ "required": ["compatibility"] }
]
}
}
}
],
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"outcome": {
"type": "string",
"enum": ["accept", "reject", "inconclusive", "tester_error"]
},
"environment": {
"type": "object",
"required": ["host", "chromeVersion", "viewport", "buildRef", "virtualTime"],
"properties": {
"host": { "type": "string", "minLength": 1 },
"chromeVersion": { "type": "string", "minLength": 1 },
"viewport": {
"type": "object",
"required": ["width", "height", "dpr"],
"properties": {
"width": { "const": 390 },
"height": { "const": 844 },
"dpr": { "const": 1 }
},
"additionalProperties": false
},
"buildRef": { "$ref": "#/$defs/sha256" },
"virtualTime": {
"type": "object",
"required": ["mode", "actionQuantumMs", "waitMinMs", "waitMaxMs"],
"properties": {
"mode": { "const": "cdp" },
"actionQuantumMs": { "const": 600 },
"waitMinMs": { "const": 100 },
"waitMaxMs": { "const": 3000 },
"rafFrameMs": { "type": "number", "minimum": 1 },
"bootBoundary": { "type": "object" }
},
"additionalProperties": false
},
"timings": {
"type": "object",
"required": [
"chromeProcessReadyWallMs", "cdpConnectedWallMs", "browserContextCreatedWallMs",
"navigationSubmittedWallMs", "runtimeBootedWallMs", "gameInteractiveWallMs"
],
"properties": {
"chromeProcessReadyWallMs": { "type": ["integer", "null"], "minimum": 0 },
"cdpConnectedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"browserContextCreatedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"navigationSubmittedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"runtimeBootedWallMs": { "type": ["integer", "null"], "minimum": 0 },
"gameInteractiveWallMs": { "type": ["integer", "null"], "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"floor": {
"type": "object",
"required": ["pass", "gates"],
"properties": {
"pass": { "type": "boolean" },
"gates": {
"type": "object",
"required": ["A_boot", "B_uncaught", "C_frame", "D_render"],
"properties": {
"A_boot": { "$ref": "#/$defs/gateResult" },
"B_uncaught": { "$ref": "#/$defs/gateResult" },
"C_frame": { "$ref": "#/$defs/gateResult" },
"D_render": { "$ref": "#/$defs/gateResult" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"gateResult": {
"type": "object",
"required": ["pass", "evidenceRef"],
"properties": {
"pass": { "type": "boolean" },
"evidenceRef": { "type": "string", "minLength": 1 },
"measurement": {}
},
"additionalProperties": false
},
"actor": {
"type": "object",
"required": [
"model", "apiClientId", "sessionId", "systemPromptVersion", "contextProjectionVersion",
"evidenceDir", "requestedGameSeed", "actualGameSeed", "policySeed"
],
"properties": {
"model": { "type": "string", "minLength": 1 },
"apiClientId": { "type": "string", "minLength": 1 },
"sessionId": { "type": "string", "minLength": 1 },
"systemPromptVersion": { "type": "string", "minLength": 1 },
"contextProjectionVersion": { "type": "string", "minLength": 1 },
"evidenceDir": { "type": "string", "minLength": 1 },
"requestedGameSeed": { "type": "integer", "minimum": 0 },
"actualGameSeed": { "type": "integer", "minimum": 0 },
"policySeed": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"actionRecord": {
"type": "object",
"required": [
"actionId", "request", "normalized", "valid", "retryCount", "preFrameRef", "preEventSeq",
"dispatchReceipt", "virtualTimeBudgetMs", "timeAdvance", "postFrameRef", "postEventSeq", "protocolErrors"
],
"properties": {
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"request": {
"type": "object",
"required": ["raw", "parsed"],
"properties": {
"raw": { "type": "string" },
"parsed": {
"anyOf": [
{ "$ref": "#/$defs/action" },
{ "type": "null" }
]
}
},
"additionalProperties": false
},
"normalized": {
"anyOf": [
{ "$ref": "#/$defs/action" },
{ "type": "null" }
]
},
"valid": { "type": "boolean" },
"retryCount": { "type": "integer", "minimum": 0, "maximum": 2 },
"preFrameRef": { "$ref": "#/$defs/frameRef" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"dispatchReceipt": {
"anyOf": [
{ "$ref": "#/$defs/dispatchReceipt" },
{ "type": "null" }
]
},
"virtualTimeBudgetMs": { "type": "integer", "minimum": 0, "maximum": 3000 },
"timeAdvance": {
"anyOf": [
{ "$ref": "#/$defs/timeAdvance" },
{ "type": "null" }
]
},
"postFrameRef": { "$ref": "#/$defs/frameRef" },
"postEventSeq": { "type": "integer", "minimum": 0 },
"protocolErrors": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "valid": { "const": true } }, "required": ["valid"] },
"then": { "properties": { "timeAdvance": { "$ref": "#/$defs/timeAdvance" } } },
"else": { "properties": { "timeAdvance": { "type": "null" } } }
}
]
},
"action": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["tap", "key", "wait", "drag"] },
"x": { "type": "number", "minimum": 0 },
"y": { "type": "number", "minimum": 0 },
"key": { "type": "string", "minLength": 1 },
"ms": { "type": "integer", "minimum": 100, "maximum": 3000 },
"from": { "$ref": "#/$defs/point" },
"to": { "$ref": "#/$defs/point" }
},
"additionalProperties": false,
"allOf": [
{
"if": { "properties": { "type": { "const": "tap" } }, "required": ["type"] },
"then": { "required": ["x", "y"] }
},
{
"if": { "properties": { "type": { "const": "key" } }, "required": ["type"] },
"then": { "required": ["key"] }
},
{
"if": { "properties": { "type": { "const": "wait" } }, "required": ["type"] },
"then": { "required": ["ms"] }
},
{
"if": { "properties": { "type": { "const": "drag" } }, "required": ["type"] },
"then": { "required": ["from", "to", "ms"] }
}
]
},
"point": {
"type": "object",
"required": ["x", "y"],
"properties": {
"x": { "type": "number", "minimum": 0 },
"y": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"frameRef": {
"type": "object",
"required": ["path", "hash"],
"properties": {
"path": { "type": "string", "minLength": 1 },
"hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"dispatchReceipt": {
"type": "object",
"required": ["accepted", "wallTimeMs"],
"properties": {
"accepted": { "const": true },
"wallTimeMs": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"timeAdvance": {
"type": "object",
"required": [
"requestedBudgetMs", "plannedFrames", "executedFrames", "callbacksRun", "carryMs", "ok"
],
"properties": {
"requestedBudgetMs": { "type": "integer", "minimum": 100, "maximum": 3000 },
"plannedFrames": { "type": "integer", "minimum": 0 },
"executedFrames": { "type": "integer", "minimum": 0 },
"callbacksRun": { "type": "integer", "minimum": 0 },
"carryMs": { "type": "number", "minimum": 0 },
"ok": { "const": true }
},
"additionalProperties": false
},
"proofObligation": {
"type": "object",
"required": [
"id", "required", "status", "sequenceRefs", "actionRefs", "postFrameRefs", "eventRefs",
"contradictions", "blockingProblems"
],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"required": { "type": "boolean" },
"status": {
"type": "string",
"enum": ["satisfied", "failed", "missing", "contradicted", "not-required"]
},
"sequenceRefs": {
"type": "array",
"items": { "$ref": "#/$defs/sequenceRef" }
},
"actionRefs": {
"type": "array",
"items": { "type": "string", "pattern": "^action-[1-9][0-9]*$" }
},
"postFrameRefs": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"eventRefs": {
"type": "array",
"items": { "type": "integer", "minimum": 1 }
},
"contradictions": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"blockingProblems": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "status": { "const": "satisfied" } },
"required": ["status"]
},
"then": {
"properties": {
"sequenceRefs": { "minItems": 1 },
"actionRefs": { "minItems": 1 },
"postFrameRefs": { "minItems": 1 },
"eventRefs": { "minItems": 1 },
"contradictions": { "maxItems": 0 },
"blockingProblems": { "maxItems": 0 }
}
}
}
]
},
"sequenceRef": {
"type": "object",
"required": ["stepId", "eventRef", "actionRef", "postFrameRef"],
"properties": {
"stepId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?::[1-9][0-9]*)?$" },
"eventRef": { "type": "integer", "minimum": 1 },
"actionRef": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"postFrameRef": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"firstPlay": {
"type": "object",
"required": [
"interactiveAtVirtualMs", "firstFeedbackAtVirtualMs", "loopClosedAtVirtualMs",
"loopClosed", "proofObligationRefs"
],
"properties": {
"interactiveAtVirtualMs": { "type": ["integer", "null"], "minimum": 0 },
"firstFeedbackAtVirtualMs": { "type": ["integer", "null"], "minimum": 0 },
"loopClosedAtVirtualMs": { "type": ["integer", "null"], "minimum": 0 },
"loopClosed": { "type": "boolean" },
"proofObligationRefs": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" }
}
},
"additionalProperties": false
},
"roll": {
"type": "object",
"required": [
"rollId", "roll", "requestedGameSeed", "actualGameSeed", "policySeed", "actorSessionId",
"proofPackageRef", "judgePackageRef", "rollGuard", "rollOutcome", "errors", "costRmb"
],
"properties": {
"rollId": { "type": "string", "pattern": "^roll-[12]$" },
"roll": { "type": "integer", "minimum": 1, "maximum": 2 },
"requestedGameSeed": { "type": "integer", "minimum": 0 },
"actualGameSeed": { "type": "integer", "minimum": 0 },
"policySeed": { "type": "integer", "minimum": 0 },
"actorSessionId": { "type": "string", "minLength": 1 },
"proofPackageRef": { "type": "string", "minLength": 1 },
"judgePackageRef": { "type": ["string", "null"] },
"rollGuard": { "$ref": "#/$defs/guard" },
"rollOutcome": { "$ref": "#/$defs/outcome" },
"errors": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"costRmb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"guard": {
"type": "object",
"required": ["pass", "checks", "blockingProblems", "contradictions", "reason"],
"properties": {
"pass": { "type": "boolean" },
"checks": {
"$ref": "#/$defs/guardChecks"
},
"blockingProblems": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"contradictions": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"reason": { "type": "string" }
},
"additionalProperties": false
},
"guardChecks": {
"type": "object",
"required": [
"floorPass", "requiredObligationsSatisfied", "referencesValid", "sameActionEvidence",
"noContradictions", "noBlockingProblems", "actorHealthy", "runnerHealthy", "judgeHealthy",
"judgeAccept"
],
"properties": {
"floorPass": { "type": "boolean" },
"requiredObligationsSatisfied": { "type": "boolean" },
"referencesValid": { "type": "boolean" },
"sameActionEvidence": { "type": "boolean" },
"noContradictions": { "type": "boolean" },
"noBlockingProblems": { "type": "boolean" },
"actorHealthy": { "type": "boolean" },
"runnerHealthy": { "type": "boolean" },
"judgeHealthy": { "type": "boolean" },
"judgeAccept": { "type": "boolean" }
},
"additionalProperties": false
},
"merge": {
"type": "object",
"required": ["triggered", "rollRefs", "mergeCandidate", "conflicts", "rescuedByRoll", "reason"],
"properties": {
"triggered": { "type": "boolean" },
"rollRefs": {
"type": "array",
"maxItems": 2,
"items": { "type": "string", "pattern": "^roll-[12]$" }
},
"mergeCandidate": { "$ref": "#/$defs/outcome" },
"conflicts": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"rescuedByRoll": { "type": ["integer", "null"], "minimum": 1, "maximum": 2 },
"reason": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"judge": {
"type": "object",
"required": [
"model", "apiClientId", "sessionId", "systemPromptVersion", "contextProjectionVersion",
"evidenceDir", "packageHash", "decision", "degraded", "obligationResults",
"contradictions", "blockingProblems"
],
"properties": {
"model": { "type": "string", "minLength": 1 },
"apiClientId": { "type": "string", "minLength": 1 },
"sessionId": { "type": "string", "minLength": 1 },
"systemPromptVersion": { "type": "string", "minLength": 1 },
"contextProjectionVersion": { "type": "string", "minLength": 1 },
"evidenceDir": { "type": "string", "minLength": 1 },
"packageHash": { "$ref": "#/$defs/sha256" },
"decision": { "$ref": "#/$defs/outcome" },
"degraded": { "type": "boolean" },
"obligationResults": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "status", "sequenceRefs", "evidenceRefs"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"status": { "type": "string", "enum": ["satisfied", "failed", "missing", "contradicted"] },
"sequenceRefs": {
"type": "array",
"items": { "$ref": "#/$defs/sequenceRef" }
},
"evidenceRefs": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false
}
},
"contradictions": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"blockingProblems": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false
},
"finalPostguard": {
"type": "object",
"required": [
"pass", "mergeCandidate", "checks", "blockingProblems", "contradictions",
"writeAuthority", "reason"
],
"properties": {
"pass": { "type": "boolean" },
"mergeCandidate": { "$ref": "#/$defs/outcome" },
"checks": { "$ref": "#/$defs/guardChecks" },
"blockingProblems": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"contradictions": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"writeAuthority": { "const": true },
"reason": { "type": "string" }
},
"additionalProperties": false
},
"repair": {
"type": "object",
"required": ["countAcrossParentChain", "eligible", "attempted", "reason"],
"properties": {
"countAcrossParentChain": { "type": "integer", "minimum": 0, "maximum": 1 },
"eligible": { "type": "boolean" },
"attempted": { "type": "boolean" },
"reason": { "type": "string" }
},
"additionalProperties": false
},
"decision": {
"type": "object",
"required": [
"outcome", "accepted", "shadowAccepted", "publishFrozen", "failure",
"repairCountAcrossParentChain", "repairEligible", "repairFeedback",
"rescuedByRoll", "costRmb", "parentChainCostRmb"
],
"properties": {
"outcome": { "$ref": "#/$defs/outcome" },
"accepted": { "type": "boolean" },
"shadowAccepted": { "type": ["boolean", "null"] },
"publishFrozen": { "type": "boolean" },
"failure": { "$ref": "#/$defs/decisionFailure" },
"repairCountAcrossParentChain": { "type": "integer", "minimum": 0, "maximum": 1 },
"repairEligible": { "type": "boolean" },
"repairFeedback": { "type": ["string", "null"] },
"rescuedByRoll": { "type": ["integer", "null"], "minimum": 1, "maximum": 2 },
"costRmb": { "type": "number", "minimum": 0 },
"parentChainCostRmb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"decisionFailure": {
"type": "object",
"required": ["layer", "subtype", "reason"],
"properties": {
"layer": {
"type": "string",
"enum": ["none", "mechanical", "gameplay", "tester_degraded"]
},
"subtype": { "type": ["string", "null"] },
"reason": { "type": ["string", "null"] }
},
"additionalProperties": false
},
"compatibility": {
"type": "object",
"required": [
"accepted", "ok", "acceptanceVersion", "publishFrozen", "playtest", "judge",
"failureLayer", "failureReason", "trace"
],
"properties": {
"accepted": { "type": "boolean" },
"ok": { "type": "boolean" },
"acceptanceVersion": { "type": "string", "enum": ["v3_shadow", "v3", "historical_replay"] },
"publishFrozen": { "type": "boolean" },
"playtest": { "$ref": "#/$defs/compatibilityPlaytest" },
"judge": { "$ref": "#/$defs/compatibilityJudge" },
"failureLayer": { "$ref": "#/$defs/failureProjection" },
"failureReason": { "type": ["string", "null"] },
"trace": { "$ref": "#/$defs/compatibilityTrace" }
},
"additionalProperties": false
},
"compatibilityPlaytest": {
"type": "object",
"required": [
"schemaVersion", "outcome", "accepted", "rollCount", "rescuedByRoll", "costRmb",
"proofComplete", "firstPlay", "evidenceRefs"
],
"properties": {
"schemaVersion": { "const": "playtest/2" },
"outcome": { "$ref": "#/$defs/outcome" },
"accepted": { "type": "boolean" },
"rollCount": { "type": "integer", "minimum": 0, "maximum": 2 },
"rescuedByRoll": { "type": ["integer", "null"], "minimum": 1, "maximum": 2 },
"costRmb": { "type": "number", "minimum": 0 },
"proofComplete": { "type": "boolean" },
"firstPlay": { "$ref": "#/$defs/compatibilityFirstPlay" },
"evidenceRefs": { "$ref": "#/$defs/compatibilityEvidenceRefs" }
},
"additionalProperties": false
},
"compatibilityFirstPlay": {
"type": "object",
"required": ["playableAtMs", "firstFeedbackMs", "loopClosed", "proofRefs"],
"properties": {
"playableAtMs": { "type": ["integer", "null"], "minimum": 0 },
"firstFeedbackMs": { "type": ["integer", "null"], "minimum": 0 },
"loopClosed": { "type": "boolean" },
"proofRefs": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" }
}
},
"additionalProperties": false
},
"compatibilityEvidenceRefs": {
"type": "object",
"required": ["actions", "frames", "events"],
"properties": {
"actions": { "type": "array", "items": { "type": "string", "pattern": "^action-[1-9][0-9]*$" } },
"frames": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"events": { "type": "array", "items": { "type": "string", "pattern": "^[1-9][0-9]*$" } }
},
"additionalProperties": false
},
"compatibilityJudge": {
"type": "object",
"required": [
"ran", "verdict", "accepted", "degraded", "reason", "acceptanceVersion", "schemaVersion"
],
"properties": {
"ran": { "type": "boolean" },
"verdict": { "$ref": "#/$defs/outcome" },
"accepted": { "type": "boolean" },
"degraded": { "type": "boolean" },
"reason": { "type": ["string", "null"] },
"acceptanceVersion": { "type": "string", "enum": ["v3_shadow", "v3", "historical_replay"] },
"schemaVersion": { "const": "playtest/2" }
},
"additionalProperties": false
},
"failureProjection": {
"type": "object",
"required": ["layer", "reason", "failedGates"],
"properties": {
"layer": {
"type": "string",
"enum": ["none", "mechanical", "gameplay", "tester_degraded"]
},
"reason": { "type": ["string", "null"] },
"failedGates": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false
},
"compatibilityTrace": {
"type": "object",
"required": ["playtest", "gameplayJudge"],
"properties": {
"playtest": { "$ref": "#/$defs/compatibilityPlaytest" },
"gameplayJudge": { "$ref": "#/$defs/compatibilityJudge" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/proof-action.schema.json",
"title": "ProofActionV1",
"description": "ProofAction/1。Proof/Judge 可见的窄动作事实;不含 Actor request、selection、TargetSet、guide、pair plan、BoardIdentity 或 actionGroup。",
"type": "object",
"required": [
"schemaVersion", "actionId", "normalized", "preFrameRef", "preEventSeq",
"virtualTimeBudgetMs", "timeAdvance", "postFrameRef", "postEventSeq"
],
"properties": {
"schemaVersion": { "const": "ProofAction/1" },
"actionId": { "type": "string", "pattern": "^action-[1-9][0-9]*$" },
"normalized": { "$ref": "playtest-evidence.schema.json#/$defs/action" },
"preFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"preEventSeq": { "type": "integer", "minimum": 0 },
"virtualTimeBudgetMs": { "type": "integer", "minimum": 0 },
"timeAdvance": { "$ref": "playtest-evidence.schema.json#/$defs/timeAdvance" },
"postFrameRef": { "$ref": "playtest-evidence.schema.json#/$defs/frameRef" },
"postEventSeq": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}

View File

@ -0,0 +1,566 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/proof-obligation-registry.2026-07-14.v2.schema.json",
"title": "ProofObligationRegistryLegacyV2",
"description": "冻结的 proof-obligations/2 2026-07-14.v2 schema,只服务 acceptance request/provenance /1 与历史证据只读验证。",
"type": "object",
"required": [
"schemaVersion",
"registryVersion",
"eventContractVersion",
"profiles"
],
"properties": {
"schemaVersion": {
"const": "proof-obligations/2"
},
"registryVersion": {
"type": "string",
"minLength": 1
},
"eventContractVersion": {
"const": "game-event/1"
},
"description": {
"type": "string",
"minLength": 1
},
"profiles": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/profile"
}
}
},
"patternProperties": {
"^_": {}
},
"additionalProperties": false,
"$defs": {
"profile": {
"type": "object",
"required": [
"id",
"genre",
"templateRoute",
"description",
"obligations",
"briefOptionalRules"
],
"properties": {
"id": {
"$ref": "#/$defs/profileId"
},
"genre": {
"enum": [
"narrative",
"trpg",
"heritage",
"puzzle",
"sim-business"
]
},
"templateRoute": {
"type": "string",
"pattern": "^_[a-z0-9-]+$"
},
"description": {
"type": "string",
"minLength": 1
},
"obligations": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/obligation"
}
},
"briefOptionalRules": {
"type": "array",
"items": {
"$ref": "#/$defs/briefRule"
}
}
},
"additionalProperties": false
},
"obligation": {
"type": "object",
"required": [
"id",
"title",
"description",
"defaultRequired",
"evidence",
"visualCheck"
],
"properties": {
"id": {
"$ref": "#/$defs/obligationId"
},
"title": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"defaultRequired": {
"type": "boolean"
},
"evidence": {
"$ref": "#/$defs/evidence"
},
"visualCheck": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
},
"evidence": {
"oneOf": [
{
"type": "object",
"required": [
"sequence"
],
"properties": {
"sequence": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/evidenceStep"
}
},
"sameValue": {
"$ref": "#/$defs/sameValue"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"orderedSeries"
],
"properties": {
"orderedSeries": {
"$ref": "#/$defs/orderedSeries"
}
},
"additionalProperties": false
}
]
},
"evidenceStep": {
"type": "object",
"required": [
"stepId",
"title",
"event",
"allowedActionTypes",
"postScreenshotRequired"
],
"properties": {
"stepId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"title": {
"type": "string",
"minLength": 1
},
"event": {
"$ref": "#/$defs/eventSelector"
},
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"tap",
"key",
"drag",
"wait"
]
}
},
"postScreenshotRequired": {
"const": true
}
},
"additionalProperties": false
},
"eventSelector": {
"type": "object",
"required": [
"type",
"predicates"
],
"properties": {
"type": {
"$ref": "game-event.schema.json#/$defs/eventType"
},
"predicates": {
"type": "array",
"items": {
"$ref": "#/$defs/predicate"
}
}
},
"additionalProperties": false
},
"sameValue": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"path",
"stepIds"
],
"properties": {
"path": {
"$ref": "#/$defs/jsonPointer"
},
"stepIds": {
"type": "array",
"minItems": 2,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
}
}
},
"additionalProperties": false
}
},
"orderedSeries": {
"type": "object",
"required": [
"seriesId",
"title",
"event",
"allowedActionTypes",
"postScreenshotRequired",
"groupByPath",
"indexPath",
"itemIdPath",
"totalPath",
"judgementPath",
"minItems",
"maxItems",
"summary",
"terminal"
],
"properties": {
"seriesId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"title": {
"type": "string",
"minLength": 1
},
"event": {
"$ref": "#/$defs/eventSelector"
},
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"tap",
"key",
"drag",
"wait"
]
}
},
"postScreenshotRequired": {
"const": true
},
"groupByPath": {
"$ref": "#/$defs/jsonPointer"
},
"indexPath": {
"$ref": "#/$defs/jsonPointer"
},
"itemIdPath": {
"$ref": "#/$defs/jsonPointer"
},
"totalPath": {
"$ref": "#/$defs/jsonPointer"
},
"judgementPath": {
"$ref": "#/$defs/jsonPointer"
},
"minItems": {
"const": 3
},
"maxItems": {
"const": 12
},
"summary": {
"$ref": "#/$defs/orderedSeriesSummary"
},
"terminal": {
"$ref": "#/$defs/orderedSeriesTerminal"
}
},
"additionalProperties": false
},
"orderedSeriesSummary": {
"type": "object",
"required": [
"stepId",
"title",
"event",
"allowedActionTypes",
"postScreenshotRequired",
"groupByPath",
"totalPath",
"orderedItemIdsPath",
"completedPath"
],
"properties": {
"stepId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"title": {
"type": "string",
"minLength": 1
},
"event": {
"$ref": "#/$defs/eventSelector"
},
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"tap",
"key",
"drag",
"wait"
]
}
},
"postScreenshotRequired": {
"const": true
},
"groupByPath": {
"$ref": "#/$defs/jsonPointer"
},
"totalPath": {
"$ref": "#/$defs/jsonPointer"
},
"orderedItemIdsPath": {
"$ref": "#/$defs/jsonPointer"
},
"completedPath": {
"$ref": "#/$defs/jsonPointer"
}
},
"additionalProperties": false
},
"orderedSeriesTerminal": {
"type": "object",
"required": [
"stepId",
"title",
"event",
"allowedActionTypes",
"postScreenshotRequired",
"groupByPath",
"scorePath",
"worksPath"
],
"properties": {
"stepId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"title": {
"type": "string",
"minLength": 1
},
"event": {
"$ref": "#/$defs/eventSelector"
},
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"tap",
"key",
"drag",
"wait"
]
}
},
"postScreenshotRequired": {
"const": true
},
"groupByPath": {
"$ref": "#/$defs/jsonPointer"
},
"scorePath": {
"$ref": "#/$defs/jsonPointer"
},
"worksPath": {
"$ref": "#/$defs/jsonPointer"
}
},
"additionalProperties": false
},
"predicate": {
"oneOf": [
{
"type": "object",
"required": [
"path",
"op"
],
"properties": {
"path": {
"$ref": "#/$defs/jsonPointer"
},
"op": {
"const": "exists"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"path",
"op",
"value"
],
"properties": {
"path": {
"$ref": "#/$defs/jsonPointer"
},
"op": {
"enum": [
"equals",
"oneOf",
"gt",
"gte",
"lt",
"lte"
]
},
"value": {}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"op",
"beforePath",
"afterPath"
],
"properties": {
"op": {
"enum": [
"changed",
"increased"
]
},
"beforePath": {
"$ref": "#/$defs/jsonPointer"
},
"afterPath": {
"$ref": "#/$defs/jsonPointer"
}
},
"additionalProperties": false
}
]
},
"briefRule": {
"type": "object",
"required": [
"id",
"description",
"when",
"requireObligationIds"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"description": {
"type": "string",
"minLength": 1
},
"when": {
"$ref": "#/$defs/briefCondition"
},
"requireObligationIds": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/obligationId"
}
}
},
"additionalProperties": false
},
"briefCondition": {
"type": "object",
"properties": {
"anyKeywords": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"allKeywords": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
}
},
"additionalProperties": false,
"anyOf": [
{
"required": [
"anyKeywords"
]
},
{
"required": [
"allKeywords"
]
}
]
},
"profileId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"obligationId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"jsonPointer": {
"type": "string",
"pattern": "^(?:|(?:/(?:[^~/]|~[01])*)+)$"
}
}
}

View File

@ -0,0 +1,299 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/proof-obligation-registry.schema.json",
"title": "ProofObligationRegistry",
"description": "proof-obligations/2 注册表。可信编排器按 proofProfileId 选择 profile;机械层只按精确事件类型、payload 谓词和有序步骤选候选,不能据此签发玩法通过。",
"type": "object",
"required": ["schemaVersion", "registryVersion", "eventContractVersion", "profiles"],
"properties": {
"schemaVersion": { "const": "proof-obligations/2" },
"registryVersion": { "type": "string", "minLength": 1 },
"eventContractVersion": { "const": "game-event/1" },
"description": { "type": "string", "minLength": 1 },
"profiles": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/profile" }
}
},
"patternProperties": { "^_": {} },
"additionalProperties": false,
"$defs": {
"profile": {
"type": "object",
"required": [
"id", "genre", "templateRoute", "description", "obligations",
"interactionOptionalRules", "briefOptionalRules"
],
"properties": {
"id": { "$ref": "#/$defs/profileId" },
"genre": { "enum": ["narrative", "trpg", "heritage", "puzzle", "sim-business"] },
"templateRoute": { "type": "string", "pattern": "^_[a-z0-9-]+$" },
"description": { "type": "string", "minLength": 1 },
"obligations": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/obligation" }
},
"interactionOptionalRules": {
"type": "array",
"items": { "$ref": "#/$defs/interactionRule" }
},
"briefOptionalRules": {
"type": "array",
"items": { "$ref": "#/$defs/briefRule" }
}
},
"additionalProperties": false
},
"obligation": {
"type": "object",
"required": ["id", "title", "description", "defaultRequired", "evidence", "visualCheck"],
"properties": {
"id": { "$ref": "#/$defs/obligationId" },
"title": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"defaultRequired": { "type": "boolean" },
"evidence": { "$ref": "#/$defs/evidence" },
"visualCheck": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"evidence": {
"oneOf": [
{
"type": "object",
"required": ["sequence"],
"properties": {
"sequence": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/evidenceStep" }
},
"sameValue": { "$ref": "#/$defs/sameValue" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["orderedSeries"],
"properties": {
"orderedSeries": { "$ref": "#/$defs/orderedSeries" }
},
"additionalProperties": false
}
]
},
"evidenceStep": {
"type": "object",
"required": ["stepId", "title", "event", "allowedActionTypes", "postScreenshotRequired"],
"properties": {
"stepId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1 },
"event": { "$ref": "#/$defs/eventSelector" },
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": { "enum": ["tap", "key", "drag", "wait"] }
},
"postScreenshotRequired": { "const": true }
},
"additionalProperties": false
},
"eventSelector": {
"type": "object",
"required": ["type", "predicates"],
"properties": {
"type": { "$ref": "game-event.schema.json#/$defs/eventType" },
"predicates": {
"type": "array",
"items": { "$ref": "#/$defs/predicate" }
}
},
"additionalProperties": false
},
"sameValue": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["path", "stepIds"],
"properties": {
"path": { "$ref": "#/$defs/jsonPointer" },
"stepIds": {
"type": "array",
"minItems": 2,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
}
},
"additionalProperties": false
}
},
"orderedSeries": {
"type": "object",
"required": [
"seriesId", "title", "event", "allowedActionTypes", "postScreenshotRequired",
"groupByPath", "indexPath", "itemIdPath", "totalPath", "judgementPath",
"minItems", "maxItems", "summary", "terminal"
],
"properties": {
"seriesId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1 },
"event": { "$ref": "#/$defs/eventSelector" },
"allowedActionTypes": {
"type": "array",
"minItems": 1,
"items": { "enum": ["tap", "key", "drag", "wait"] }
},
"postScreenshotRequired": { "const": true },
"groupByPath": { "$ref": "#/$defs/jsonPointer" },
"indexPath": { "$ref": "#/$defs/jsonPointer" },
"itemIdPath": { "$ref": "#/$defs/jsonPointer" },
"totalPath": { "$ref": "#/$defs/jsonPointer" },
"judgementPath": { "$ref": "#/$defs/jsonPointer" },
"minItems": { "const": 3 },
"maxItems": { "const": 12 },
"summary": { "$ref": "#/$defs/orderedSeriesSummary" },
"terminal": { "$ref": "#/$defs/orderedSeriesTerminal" }
},
"additionalProperties": false
},
"orderedSeriesSummary": {
"type": "object",
"required": [
"stepId", "title", "event", "allowedActionTypes", "postScreenshotRequired",
"groupByPath", "totalPath", "orderedItemIdsPath", "completedPath"
],
"properties": {
"stepId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1 },
"event": { "$ref": "#/$defs/eventSelector" },
"allowedActionTypes": {
"type": "array", "minItems": 1,
"items": { "enum": ["tap", "key", "drag", "wait"] }
},
"postScreenshotRequired": { "const": true },
"groupByPath": { "$ref": "#/$defs/jsonPointer" },
"totalPath": { "$ref": "#/$defs/jsonPointer" },
"orderedItemIdsPath": { "$ref": "#/$defs/jsonPointer" },
"completedPath": { "$ref": "#/$defs/jsonPointer" }
},
"additionalProperties": false
},
"orderedSeriesTerminal": {
"type": "object",
"required": [
"stepId", "title", "event", "allowedActionTypes", "postScreenshotRequired",
"groupByPath", "scorePath", "worksPath"
],
"properties": {
"stepId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"title": { "type": "string", "minLength": 1 },
"event": { "$ref": "#/$defs/eventSelector" },
"allowedActionTypes": {
"type": "array", "minItems": 1,
"items": { "enum": ["tap", "key", "drag", "wait"] }
},
"postScreenshotRequired": { "const": true },
"groupByPath": { "$ref": "#/$defs/jsonPointer" },
"scorePath": { "$ref": "#/$defs/jsonPointer" },
"worksPath": { "$ref": "#/$defs/jsonPointer" }
},
"additionalProperties": false
},
"predicate": {
"oneOf": [
{
"type": "object",
"required": ["path", "op"],
"properties": {
"path": { "$ref": "#/$defs/jsonPointer" },
"op": { "const": "exists" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["path", "op", "value"],
"properties": {
"path": { "$ref": "#/$defs/jsonPointer" },
"op": { "enum": ["equals", "oneOf", "gt", "gte", "lt", "lte"] },
"value": {}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["op", "beforePath", "afterPath"],
"properties": {
"op": { "enum": ["changed", "increased"] },
"beforePath": { "$ref": "#/$defs/jsonPointer" },
"afterPath": { "$ref": "#/$defs/jsonPointer" }
},
"additionalProperties": false
}
]
},
"briefRule": {
"type": "object",
"required": ["id", "description", "when", "requireObligationIds"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"description": { "type": "string", "minLength": 1 },
"when": { "$ref": "#/$defs/briefCondition" },
"requireObligationIds": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/obligationId" }
}
},
"additionalProperties": false
},
"interactionRule": {
"type": "object",
"required": ["id", "description", "interactionProfileId", "requireObligationIds"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
"description": { "type": "string", "minLength": 1 },
"interactionProfileId": { "type": "string", "pattern": "^[a-z0-9.-]+$" },
"requireObligationIds": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/obligationId" }
}
},
"additionalProperties": false
},
"briefCondition": {
"type": "object",
"properties": {
"anyKeywords": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"allKeywords": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
}
},
"additionalProperties": false,
"anyOf": [
{ "required": ["anyKeywords"] },
{ "required": ["allKeywords"] }
]
},
"profileId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"obligationId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"
},
"jsonPointer": {
"type": "string",
"pattern": "^(?:|(?:/(?:[^~/]|~[01])*)+)$"
}
}
}

View File

@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/proof-obligation-resolution.schema.json",
"title": "ProofObligationResolutionV1",
"description": "ProofObligationResolution/1。封存 proof 默认义务、brief 提升和可信 interaction 提升的确定性并集。",
"type": "object",
"required": [
"schemaVersion", "proofProfileId", "proofRegistryVersion", "taskBindingHash",
"acceptanceRequestHash", "interactionBindingStatus", "interactionBindingRef",
"interactionBindingFileHash", "interactionBindingHash", "briefRuleMatches",
"interactionRuleMatches", "requiredObligationIds", "resolutionHash"
],
"properties": {
"schemaVersion": { "const": "ProofObligationResolution/1" },
"proofProfileId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"proofRegistryVersion": { "type": "string", "minLength": 1 },
"taskBindingHash": { "$ref": "#/$defs/sha256" },
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"interactionBindingStatus": { "enum": ["absent", "verified"] },
"interactionBindingRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"interactionBindingFileHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"interactionBindingHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"briefRuleMatches": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"interactionRuleMatches": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"requiredObligationIds": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"uniqueItems": true
},
"resolutionHash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "interactionBindingStatus": { "const": "absent" } },
"required": ["interactionBindingStatus"]
},
"then": {
"properties": {
"interactionBindingRef": { "type": "null" },
"interactionBindingFileHash": { "type": "null" },
"interactionBindingHash": { "type": "null" },
"interactionRuleMatches": { "maxItems": 0 }
}
}
},
{
"if": {
"properties": { "interactionBindingStatus": { "const": "verified" } },
"required": ["interactionBindingStatus"]
},
"then": {
"properties": {
"interactionBindingRef": { "type": "string", "minLength": 1 },
"interactionBindingFileHash": { "$ref": "#/$defs/sha256" },
"interactionBindingHash": { "$ref": "#/$defs/sha256" },
"interactionRuleMatches": { "type": "array" }
}
}
}
],
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/play-loop/proof-resolution-bundle.schema.json",
"title": "ProofResolutionBundle",
"description": "跨文件 validator 的输入清单,不是生产证据。引用 request/provenance/binding/Resolution/SingleRollProof 与 evidence 投影。",
"type": "object",
"required": [
"schemaVersion", "requestRef", "provenanceRef", "parentRequestRef", "parentProvenanceRef",
"briefRef", "interactionBindingRef",
"runnerBinding", "resolutionRef", "singleRollProofRef", "proofPackageHash",
"evidenceProjection", "publishFrozen"
],
"properties": {
"schemaVersion": { "const": "proof-resolution-bundle/1" },
"requestRef": { "type": "string", "minLength": 1 },
"provenanceRef": { "type": "string", "minLength": 1 },
"parentRequestRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"parentProvenanceRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"briefRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"interactionBindingRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"runnerBinding": {
"type": "object",
"required": [
"bindingPath", "interactionProfileId", "interactionRegistryVersion",
"interactionBindingHash", "taskBindingHash"
],
"properties": {
"bindingPath": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"interactionProfileId": { "type": ["string", "null"] },
"interactionRegistryVersion": { "type": ["string", "null"] },
"interactionBindingHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] },
"taskBindingHash": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] }
},
"additionalProperties": false
},
"resolutionRef": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },
"singleRollProofRef": { "type": "string", "minLength": 1 },
"proofPackageHash": { "$ref": "#/$defs/sha256" },
"evidenceProjection": {
"type": "object",
"required": ["acceptanceRequestHash", "briefRuleMatches", "proofObligations"],
"properties": {
"acceptanceRequestHash": { "$ref": "#/$defs/sha256" },
"briefRuleMatches": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"proofObligations": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "required"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" },
"required": { "type": "boolean" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"publishFrozen": { "type": "boolean" },
"expectedErrorPattern": { "type": "string", "minLength": 1 }
},
"additionalProperties": false,
"$defs": {
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
}
}

View File

@ -177,8 +177,8 @@
"role": "game_content_gold",
"lifecycleStatus": "active",
"assetRef": "game-runtime/games/shanhai-xingji",
"assetVersion": "map1-vertical-slice-r1",
"artifactHash": "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b",
"assetVersion": "map1-vertical-slice-r1.1",
"artifactHash": "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd",
"consumerRef": "generation-runtime@reference-assets/1",
"designRef": [
"docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md",
@ -189,7 +189,10 @@
"game-runtime/games/shanhai-xingji/evidence/round-14/00-win-result.json",
"game-runtime/games/shanhai-xingji/evidence/round-15/00-round15-summary.json",
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/qa-report.md",
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/browser-evidence.json"
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/browser-evidence.json",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/audio-listening-evidence.md",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/qa-report.md",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/browser-evidence.json"
],
"signedBy": "创始人",
"signedAt": "2026-07-27T13:30:50Z"
@ -237,7 +240,7 @@
"_template-shop": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"_template-story": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"_template-trpg": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"gac-shanhai-xingji": "active:创始人于 2026-07-27 正式签认地图1《裂谷原》完整20分钟纵切版,音频批准范围为 SFX-only。artifactHash 是同日从当前源重新构建 dist/shanhai-bundle.js 后计算的真实 SHA-256;地图2–5、29兽正式素材与6首BGM属于后续完整版,不在本记录承诺范围。consumerRef 对应 cheap_studio.py → cheap_verify.build_v3_reference_asset_generation_constraints 的生成运行时参照资产注入门。",
"gac-shanhai-xingji": "active:创始人于 2026-07-27 正式签认地图1《裂谷原》完整20分钟纵切版,音频批准范围为 SFX-only。2026-07-28 审查修复只改变共享宿主业务事件归档字节帽,游戏源码与资产 blob 不变;资产版本推进为 r1.1,并以重新构建、真浏览器复验后的 bundle SHA-256 作为当前 artifactHash。地图2–5、29兽正式素材与6首BGM属于后续完整版,不在本记录承诺范围。consumerRef 对应 cheap_studio.py → cheap_verify.build_v3_reference_asset_generation_constraints 的生成运行时参照资产注入门。",
"gac-shanhai-xunyi-lu": "candidate:designIntent 尚未追认(designRef 为 null);assetRef/artifactHash 为 candidate 占位(资产树待定位绑定);签认前没有正式 game_content_gold 记录。",
"gac-yeshi-yitiaojie": "candidate:designIntent 尚未追认(designRef 为 null);assetRef/artifactHash 为 candidate 占位(资产树待定位绑定);签认前没有正式 game_content_gold 记录。"
}

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "ReferenceAssetRegistry/2",
"registryVersion": "2026-07-27.trusted-release-v1",
"registryVersion": "2026-07-28.trusted-release-r1.1",
"sourceOfTruth": "docs/architecture/产品/游戏内容金标.md §7",
"records": [
{
@ -213,8 +213,8 @@
"role": "game_content_gold",
"lifecycleStatus": "active",
"assetRef": "game-runtime/games/shanhai-xingji",
"assetVersion": "map1-vertical-slice-r1",
"artifactHash": "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b",
"assetVersion": "map1-vertical-slice-r1.1",
"artifactHash": "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd",
"consumerRef": "generation-runtime@reference-assets/2",
"designRef": [
"docs/agent-specs/2026-07-06-北极星顶级线-肉鸽割草-开发设计书.md",
@ -225,7 +225,10 @@
"game-runtime/games/shanhai-xingji/evidence/round-14/00-win-result.json",
"game-runtime/games/shanhai-xingji/evidence/round-15/00-round15-summary.json",
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/qa-report.md",
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/browser-evidence.json"
"game-runtime/games/shanhai-xingji/evidence/round-17-gold-lock/browser-evidence.json",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/audio-listening-evidence.md",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/qa-report.md",
"game-runtime/games/shanhai-xingji/evidence/round-18-host-refresh/browser-evidence.json"
],
"signedBy": "创始人",
"signedAt": "2026-07-27T13:30:50Z",
@ -282,7 +285,7 @@
"_template-shop": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"_template-story": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"_template-trpg": "artifactHash 为 migration_pending 占位,assetVersion 未冻结;迁移时冻结版本/hash、来源、适用品类与消费它的 prompt 版本;完成前不得新增 live 消费。",
"gac-shanhai-xingji": "active:创始人于 2026-07-27 正式签认地图1《裂谷原》完整20分钟纵切版,音频批准范围为 SFX-only。artifactHash 是同日从当前源重新构建 dist/shanhai-bundle.js 后计算的真实 SHA-256;地图2–5、29兽正式素材与6首BGM属于后续完整版,不在本记录承诺范围。consumerRef 对应 cheap_studio.py → cheap_verify.build_v3_reference_asset_generation_constraints 的生成运行时参照资产注入门。",
"gac-shanhai-xingji": "active:创始人于 2026-07-27 正式签认地图1《裂谷原》完整20分钟纵切版,音频批准范围为 SFX-only。2026-07-28 共享宿主修复后,制品身份推进为 r1.1;artifactHash 是确定性重建 dist/shanhai-bundle.js 后计算的真实 SHA-256,round-17 的旧 r1 hash 只读保留且不得用于当前消费校验。地图2–5、29兽正式素材与6首BGM属于后续完整版,不在本记录承诺范围。consumerRef 对应 cheap_studio.py、cheap_verify.py 与 cheap_service_app.py 共同调用 reference_asset_gate 的 frozen_preflight 可信消费门。",
"gac-shanhai-xunyi-lu": "candidate:designIntent 尚未追认(designRef 为 null);assetRef/artifactHash 为 candidate 占位(资产树待定位绑定);签认前没有正式 game_content_gold 记录。",
"gac-yeshi-yitiaojie": "candidate:designIntent 尚未追认(designRef 为 null);assetRef/artifactHash 为 candidate 占位(资产树待定位绑定);签认前没有正式 game_content_gold 记录。"
}

View File

@ -1,8 +1,8 @@
{
"schemaVersion": "ReferenceAssetRelease/1",
"releaseId": "survivor-gold-release-2026-07-27",
"releaseId": "survivor-gold-release-2026-07-28-r1.1",
"registryRef": "contracts/play-loop/reference-asset-registry.v2.initial.json",
"registryHash": "57c82adcef617fdb929d056733a395f7c4133636eb16bd38926637857b331b78",
"registryHash": "614ab70de698dd31c5d4b289325e3922ecb64ae88f7a9e7a637a4fa134410e1f",
"policyRef": "contracts/play-loop/reference-asset-consumption-policy.initial.json",
"policyHash": "695b6143fec3c7b30d7be5f5ce088ccb1c1add97899dae9f167bd3a9524d8bbf",
"verifierVersion": "reference-asset-verifier/1.0.0",

View File

@ -0,0 +1,6 @@
{
"_base": "../valid/01-absent-puzzle.json",
"_set": {
"/interactionBindingHash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
}
}

View File

@ -0,0 +1,6 @@
{
"_base": "../valid/02-verified-match3.json",
"_set": {
"/interactionBinding/taskBindingHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}
}

View File

@ -0,0 +1,16 @@
{
"schemaVersion": "acceptance-provenance/2",
"gameId": "sample-puzzle-absent-001",
"briefHash": "8728bf7475809ad48aaedb2a26601f030d94981e186f4e2818628d9962f59696",
"genre": "puzzle",
"templateRoute": "_template-puzzle",
"proofProfileId": "puzzle.match-board",
"proofRegistryVersion": "2026-07-15.v3",
"taskBindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"interactionBinding": null,
"sourceArtifactHash": null,
"parentAcceptanceRequestHash": null,
"repairOrdinal": 0,
"acceptanceRequestHash": "fc1769d63f64dba013af63268c8b6053d2205a5513422e95d9456bff802d564c",
"artifactHash": "3333333333333333333333333333333333333333333333333333333333333333"
}

View File

@ -0,0 +1,22 @@
{
"schemaVersion": "acceptance-provenance/2",
"gameId": "sample-match3-verified-001",
"briefHash": "8728bf7475809ad48aaedb2a26601f030d94981e186f4e2818628d9962f59696",
"genre": "puzzle",
"templateRoute": "_template-puzzle",
"proofProfileId": "puzzle.match-board",
"proofRegistryVersion": "2026-07-15.v3",
"taskBindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"interactionBinding": {
"schemaVersion": "InteractionBinding/1",
"interactionProfileId": "match3.orthogonal-swap-v1",
"interactionRegistryVersion": "2026-07-15.v1",
"taskBindingHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"interactionBindingHash": "1c9efaf79571d5e2d3c9da033cf4d79678b4735ddb4c9ab29d8384d17ebcd3ce"
},
"sourceArtifactHash": null,
"parentAcceptanceRequestHash": null,
"repairOrdinal": 0,
"acceptanceRequestHash": "76dc9d09cc16fa98488ecd11a097a52a5c3919997817b37f22d78d6285478c73",
"artifactHash": "3333333333333333333333333333333333333333333333333333333333333333"
}

View File

@ -0,0 +1,9 @@
{
"_base": "01-absent-puzzle.json",
"_set": {
"/sourceArtifactHash": "3333333333333333333333333333333333333333333333333333333333333333",
"/parentAcceptanceRequestHash": "fc1769d63f64dba013af63268c8b6053d2205a5513422e95d9456bff802d564c",
"/repairOrdinal": 1,
"/acceptanceRequestHash": "d9303678229d8e1d116a77418f10f4b977026176a4d1cfcd5cb35e2677305010"
}
}

View File

@ -20,13 +20,13 @@
{
"recordId": "gac-shanhai-xingji",
"role": "game_content_gold",
"artifactHash": "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b"
"artifactHash": "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
}
],
"referenceAssetVerificationReceipts": [
{
"ref": "contracts/play-loop/samples/reference-asset-verification-receipt/valid/01-matching-hashes.json",
"hash": "9e127640db31aeae4ecb2262e35632a31efb773ef0c3c204ee0ffc52d9e5ee8c"
"hash": "0456e8941c0570e1f6bf41a2bd244255217192a663d32a9ae842b485a3708971"
}
]
}

View File

@ -19,13 +19,13 @@
{
"recordId": "gac-shanhai-xingji",
"role": "game_content_gold",
"artifactHash": "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b"
"artifactHash": "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
}
],
"referenceAssetVerificationReceipts": [
{
"ref": "contracts/play-loop/samples/reference-asset-verification-receipt/valid/01-matching-hashes.json",
"hash": "9e127640db31aeae4ecb2262e35632a31efb773ef0c3c204ee0ffc52d9e5ee8c"
"hash": "0456e8941c0570e1f6bf41a2bd244255217192a663d32a9ae842b485a3708971"
}
]
}

View File

@ -0,0 +1,4 @@
{
"_base": "../valid/01-initial-narrative.json",
"_set": { "/requestFile": "acceptance-request.json" }
}

Some files were not shown because too many files have changed in this diff Show More