293 lines
19 KiB
JSON
293 lines
19 KiB
JSON
{
|
||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||
"$id": "https://wanxiang.ai/contracts/play-loop/play-spec.schema.json",
|
||
"title": "PlaySpec",
|
||
"description": "PlaySpec 考卷契约(生成引擎 agentic 架构设计代号 C5,2026-07-03 一次性裁决 Δ1)。一局游戏声明「该怎么玩我、以便据此判我」的那份考卷:判分门(play.cdp.cjs 九门)不认游戏本身,只认这份 spec——spec 说清用哪种驱动器去玩、玩前要不要先起局、玩后看哪些可观测量算过。它落盘为每局工程目录下的 play-spec.json(game-runtime/games/_wg1-gen/<id>/play-spec.json,或 tier2 工程的 play-spec.json),生产侧现由 cheap_run.ensure_play_spec 据 forensics state 自动产、由 tier2 run_gates 落盘。本契约把这些至今散在代码里的隐形约定钉成显式字段,让两档(便宜档 / tier2 富游戏档)产出的 spec 同形同义。为什么现在钉:现行 cheap_run.py:241-244 用「state 里有没有 targets 键」这一隐形约定选驱动器、现行 play-spec.json 与源工程之间没有任何版本绑定(cheap_run.py:278『已存在不覆盖』是缺绑定时的粗糙兜底:源工程改了、旧 spec 还在,就永远吃陈考卷),这些都需要一份契约来固定语义并让陈旧可判定。消费方=W-S1 修复三单(把 startRitual / derivedFrom 接进 harness 与 ensure_play_spec)与 tier2 F-1 对齐。红线:字段不编码 tier 枚举(Δ9③),档位差异一律用能力字段的『在不在』(economy / controlCheck / firstPlay 出现 = 该局具备该能力)和 ext 扩展段表达,绝不出现 tier:cheap|tier2。",
|
||
"type": "object",
|
||
"required": ["derivedFrom"],
|
||
"anyOf": [
|
||
{ "required": ["driver"] },
|
||
{ "required": ["inputs"] }
|
||
],
|
||
"properties": {
|
||
"schemaVersion": {
|
||
"description": "契约版本标签(可选,建议填 'play-spec/1')。新增字段不升版本;删改字段或改语义才升版本,便于 harness 与 ensure_play_spec 判定自己在读哪一代考卷。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"derivedFrom": {
|
||
"description": "派生绑定:这份考卷是从哪份源工程派生出来的。spec 由源工程派生,源工程一改(sourceHash 变),旧 spec 即算陈旧、必须重新生成——否则会拿旧考卷判新工程(假绿或错判)。这正是现行 cheap_run.py:278『play-spec.json 已存在就不覆盖』留下的坑:它没有任何绑定,无从知道源工程是否已经变过,于是永远沿用第一次产出的 spec。本段令陈旧可判定:harness / ensure_play_spec 只要比对当前源工程 hash 与此处 sourceHash 不一致,就该重生 spec。现网 play-spec.json 尚无此段,接线在 W-S1 三单在途。",
|
||
"type": "object",
|
||
"required": ["sourceHash"],
|
||
"properties": {
|
||
"sourceHash": {
|
||
"description": "源工程内容哈希(如 sha256 十六进制串,或工程 store 的 versionId 派生 hash)。它是 spec↔源工程的绑定锚:值变=源工程变=spec 须重生。非空。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"versionId": {
|
||
"description": "源工程版本 ID(可选)。tier2 长生命周期项目同一 gameId 改源重建会产多个版本,落库寻址用(对接 tier2-source-project 版本寻址);传输形态兼容数字与字符串。",
|
||
"type": ["string", "integer"]
|
||
},
|
||
"generatedAt": {
|
||
"description": "本 spec 生成时刻(可选,ISO-8601 或毫秒时间戳字符串),供排障与陈旧度观测。",
|
||
"type": ["string", "integer"]
|
||
},
|
||
"generator": {
|
||
"description": "产出方标识(可选),如 'cheap_run.ensure_play_spec' / 'tier2.run_gates',供追溯是哪条线、哪个版本的产出逻辑写的这份考卷。",
|
||
"type": "string"
|
||
},
|
||
"regenerateOnSourceChange": {
|
||
"description": "语义声明(可选,默认真):源工程 hash 变化时是否必须重生 spec。留字段是为了把『源变即重生』这条规则写进考卷本身,而不是只活在某段代码注释里。",
|
||
"type": "boolean"
|
||
}
|
||
},
|
||
"additionalProperties": false
|
||
},
|
||
"startRitual": {
|
||
"description": "起局仪式(可选):从『装载完成』到『可被驱动器真玩』之间,需要先做的一次性动作。多数游戏装载即进游玩态,省略本段即可;但有的游戏停在菜单/开始页,驱动器直接开跑会一直点在死菜单上(played=false、G_input/H_progress 必挂)。这时要声明先点开始按钮或先走一小段固定输入。现网 play-spec.json 目前把这类前置动作混在 inputs 数组开头(如先 wait 再 tap),本段把它显式拆出来,让 harness 先完成起局、再交给 driver 判分。接线在 W-S1 三单在途。",
|
||
"type": "object",
|
||
"required": ["kind"],
|
||
"properties": {
|
||
"kind": {
|
||
"description": "起局方式:auto=装载即可玩,无需仪式(等价省略本段);tap-selector=点一个具名元素(如开始按钮)进入游玩;input-sequence=走一小段固定输入进入游玩。",
|
||
"type": "string",
|
||
"enum": ["auto", "tap-selector", "input-sequence"]
|
||
},
|
||
"selector": {
|
||
"description": "kind=tap-selector 时必填:要点击进入游玩的元素标识(如 'menuStartBtn' 这类游戏侧约定的可点起局元素)。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"inputs": {
|
||
"description": "kind=input-sequence 时必填:进入游玩前要走的固定输入序列,元素同顶层 inputs 的输入指令。",
|
||
"type": "array",
|
||
"items": { "$ref": "#/$defs/inputEvent" }
|
||
},
|
||
"why": {
|
||
"description": "为什么需要这道起局仪式(人读一句,供排障与续修 agent 理解)。",
|
||
"type": "string"
|
||
}
|
||
},
|
||
"additionalProperties": false,
|
||
"allOf": [
|
||
{
|
||
"if": { "properties": { "kind": { "const": "tap-selector" } }, "required": ["kind"] },
|
||
"then": { "required": ["selector"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "kind": { "const": "input-sequence" } }, "required": ["kind"] },
|
||
"then": { "required": ["inputs"] }
|
||
}
|
||
]
|
||
},
|
||
"driver": {
|
||
"description": "驱动器(driver 与 inputs 至少择一):适配性真玩器,读游戏可观测状态自动接招(如接球、点亮的目标、循环按键),比固定坐标序列更能真玩到技巧类游戏、少假阴。type 是驱动器族,不是 tier:族的选择依据是游戏暴露了什么可观测状态——现行 cheap_run.py:241-242 的隐形约定是『state 里有 targets/target 键 → tap-targets occupied 族,否则 → key-cycle 族』。本契约把驱动器族显式化,并允许在 selectionBasis 里记下这条选择依据,让考卷自解释、不必回头读 _build_play_spec 的代码。",
|
||
"$ref": "#/$defs/driver"
|
||
},
|
||
"inputs": {
|
||
"description": "固定输入序列(driver 与 inputs 至少择一):没有 driver 时按此序列逐步派发(play.cdp.cjs:986-993)。适合动作固定、无需读态接招的游戏,或作为起局/兜底。元素见 $defs/inputEvent。",
|
||
"type": "array",
|
||
"items": { "$ref": "#/$defs/inputEvent" }
|
||
},
|
||
"expectedEngineCallPrefixes": {
|
||
"description": "F_wiring 门的语义期望:真玩期间应发生的引擎能力调用前缀集(如 'particles.' / 'audio.synth' / 'sessionScore.')。这是外生的预置期望(held 期望),绝不能从本局实际 __engineCalls 反推——一旦反推,期望恒等于实际、F_wiring 退化成自证必过的假绿(cheap_run.py:230-231 的防自证铁律)。空数组=只要有任意引擎调用即算接线。",
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"assertAfterPlay": {
|
||
"description": "玩后进展断言(赢/输可观测量的『进展』一半):真玩前后各读一次可观测状态,逐条判是否按预期变化,证明这局不是七门全过的空心壳(play.cdp.cjs:1072 checkAssertion)。每条见 $defs/assertion。空/省略=不判进展(老游戏向后兼容走 H 门 SKIP)。",
|
||
"type": "array",
|
||
"items": { "$ref": "#/$defs/assertion" }
|
||
},
|
||
"expectLatch": {
|
||
"description": "是否要求真玩到终局态并驻留(赢/输可观测量的『终局』一半):true=须玩到 phase 落在终局态(gameover/win/lose/over 等)且不回弹;未驱动到终态但有真进展时降 advisory 不致命(限时/无快速失败态类),无进展才致命(play.cdp.cjs:1077-1094)。省略=不判终局(H 门 SKIP)。",
|
||
"type": "boolean"
|
||
},
|
||
"latchTerminals": {
|
||
"description": "终局态同义词集(可选):判『玩到终局』时认哪些 phase 值为终态(默认 gameover/over/win/lose)。让终态叫 win/lose 的游戏不被只认 gameover 的判据误伤。",
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"exportState": {
|
||
"description": "本局承诺经 _forensicsView().state() 暴露的可观测键(可选,如 phase/score/targets/remaining)。它是 assertAfterPlay 的 path 与终局判定的取数来源;声明了机制门却读不到 state=可测性红线违约、判否(play.cdp.cjs:1070)。",
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"controlCheck": {
|
||
"description": "控制跟手门配置(能力字段,非 tier):声明它就启用 I_control 门,验玩家控制体(如挡板 paddle.x)平滑逼近触点、逮『一格一跳/卡死/不跟手』(play.cdp.cjs:972、595 runControlCheck)。省略=I_control SKIP。它出现与否表达的是该局有没有可跟手的控制体这一能力,不是档位。",
|
||
"type": "object",
|
||
"required": ["paddlePath"],
|
||
"properties": {
|
||
"paddlePath": {
|
||
"description": "控制体位置在可观测状态里的路径(如 'paddle.x')。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"paddleY": { "description": "控制体所在的 y(点击目标行),可选。", "type": "number" },
|
||
"tapXs": { "description": "验跟手时连点的一组目标 x,可选。", "type": "array", "items": { "type": "number" } },
|
||
"tapsPerTarget": { "description": "每个目标连点几拍,可选。", "type": "integer" },
|
||
"moveMin": { "description": "判『真移动了』的最小位移阈值,可选。", "type": "number" },
|
||
"tolerance": { "description": "判『逼近到位』的容差,可选。", "type": "number" },
|
||
"why": { "description": "这道控制门在验什么(人读一句)。", "type": "string" }
|
||
},
|
||
"additionalProperties": true
|
||
},
|
||
"firstPlay": {
|
||
"description": "首玩核心闭环门配置(能力字段,非 tier):声明它就启用『首次真玩能否在时限内达成品类核心反馈闭环』的观测(play.cdp.cjs:1100,coreLoopReached 从 H 门派生 + 时限维度)。省略=不做首玩时限判定。",
|
||
"type": "object",
|
||
"properties": {
|
||
"coreLoopThresholdMs": {
|
||
"description": "核心闭环达成的墙钟时限(毫秒),如 60000。",
|
||
"type": "integer",
|
||
"minimum": 1
|
||
}
|
||
},
|
||
"additionalProperties": true
|
||
},
|
||
"economy": {
|
||
"description": "经济能力段(能力字段,非 tier):声明它=该局有经营/经济胜负语义,经济门读它的验收外生赢线 winThreshold 判『盈利路能否在有界真玩内到赢态』(tier2 run.py:1000 _economy_evidence 读 spec.economy.winThreshold)。便宜档轻游戏不含此段。它的『在不在』表达能力差异,故无需 tier 枚举。",
|
||
"type": "object",
|
||
"properties": {
|
||
"winThreshold": {
|
||
"description": "验收外生赢线(品类金标锚定、agent 不可调):真玩要在此金额/指标线之上才算盈利路通过。",
|
||
"type": "number"
|
||
}
|
||
},
|
||
"additionalProperties": true
|
||
},
|
||
"ext": {
|
||
"description": "扩展段(Δ9③):某一档独有、不进对称核心的考卷字段塞这里,各写各的、没有的字段绝不编造。便宜档一般不写;tier2 富游戏档在此放它独有的判分旋钮。留此段是为了在不加 tier 枚举的前提下容纳档位差异——接口对称(上面的核心字段两档同名同义),内容不对称(ext 各写各的)。",
|
||
"type": "object",
|
||
"additionalProperties": true
|
||
}
|
||
},
|
||
"patternProperties": {
|
||
"^_": {
|
||
"description": "下划线开头的作者元数据(如 _genre / _baseSample / _note),供人读与金标标注,harness 不消费。放行任意值。"
|
||
}
|
||
},
|
||
"additionalProperties": false,
|
||
"$defs": {
|
||
"driver": {
|
||
"type": "object",
|
||
"required": ["type"],
|
||
"properties": {
|
||
"type": {
|
||
"description": "驱动器族(非 tier):现有族=key-cycle(循环按方向键,如 2048/俄罗斯方块)、tap-targets(点可点目标,配 targetMode/targetsPath,如打地鼠/点击得分/经营点客)、paddle-intercept(读球位自动移挡板接球,如打砖块/弹球)。新增族可扩,harness 的 runDriver 须认得该 type。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"selectionBasis": {
|
||
"description": "驱动器族的选择依据(可选,人读一句):为什么这局选这个族。把 cheap_run.py:241-242『state 有 targets/target 键 → tap-targets,否则 → key-cycle』这条隐形约定写进考卷本身,让 spec 自解释。",
|
||
"type": "string"
|
||
},
|
||
"keys": {
|
||
"description": "key-cycle 族必填:循环派发的按键序列(如 ['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'])。",
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"targetMode": {
|
||
"description": "tap-targets 族的目标选取模式(如 'occupied'=点被占用/亮起的格位)。",
|
||
"type": "string"
|
||
},
|
||
"targetsPath": {
|
||
"description": "tap-targets 族必填:可点目标数组在可观测状态里的路径(如 'targets')。",
|
||
"type": "string"
|
||
},
|
||
"ballPath": {
|
||
"description": "paddle-intercept 族必填:被接物(球)位置在可观测状态里的路径(如 'ball.x')。",
|
||
"type": "string"
|
||
},
|
||
"paddleY": { "description": "paddle-intercept 族:挡板所在 y。", "type": "number" },
|
||
"steps": { "description": "驱动步数(真玩多少拍)。", "type": "integer", "minimum": 1 },
|
||
"stepMs": { "description": "每拍间隔毫秒。", "type": "integer", "minimum": 1 },
|
||
"downMs": { "description": "key 类按下时长毫秒(可选)。", "type": "integer", "minimum": 0 },
|
||
"winThreshold": { "description": "驱动器内联的验收赢线(可选,与顶层 economy.winThreshold 同义、tier2 早期形态)。", "type": "number" }
|
||
},
|
||
"additionalProperties": true,
|
||
"allOf": [
|
||
{
|
||
"if": { "properties": { "type": { "const": "key-cycle" } }, "required": ["type"] },
|
||
"then": { "required": ["keys"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "type": { "const": "tap-targets" } }, "required": ["type"] },
|
||
"then": { "required": ["targetsPath"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "type": { "const": "paddle-intercept" } }, "required": ["type"] },
|
||
"then": { "required": ["ballPath"] }
|
||
}
|
||
]
|
||
},
|
||
"inputEvent": {
|
||
"type": "object",
|
||
"required": ["t"],
|
||
"properties": {
|
||
"t": {
|
||
"description": "输入指令类型:tap=点击(配 x,y)、key=按键(配 code,可选 downMs)、drag=拖拽(配 from,to,可选 ms)、wait=等待(配 ms)。",
|
||
"type": "string",
|
||
"enum": ["tap", "key", "drag", "wait"]
|
||
},
|
||
"x": { "description": "tap 的 x 坐标。", "type": "number" },
|
||
"y": { "description": "tap 的 y 坐标。", "type": "number" },
|
||
"code": { "description": "key 的键码(如 'ArrowUp' / 'Space')。", "type": "string" },
|
||
"downMs": { "description": "key 按下时长毫秒(可选)。", "type": "integer", "minimum": 0 },
|
||
"from": { "description": "drag 起点 [x,y]。", "type": "array", "items": { "type": "number" } },
|
||
"to": { "description": "drag 终点 [x,y]。", "type": "array", "items": { "type": "number" } },
|
||
"ms": { "description": "wait/drag 的毫秒时长。", "type": "integer", "minimum": 0 }
|
||
},
|
||
"additionalProperties": true,
|
||
"allOf": [
|
||
{
|
||
"if": { "properties": { "t": { "const": "tap" } }, "required": ["t"] },
|
||
"then": { "required": ["x", "y"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "t": { "const": "key" } }, "required": ["t"] },
|
||
"then": { "required": ["code"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "t": { "const": "wait" } }, "required": ["t"] },
|
||
"then": { "required": ["ms"] }
|
||
},
|
||
{
|
||
"if": { "properties": { "t": { "const": "drag" } }, "required": ["t"] },
|
||
"then": { "required": ["from", "to"] }
|
||
}
|
||
]
|
||
},
|
||
"assertion": {
|
||
"type": "object",
|
||
"required": ["path", "op"],
|
||
"properties": {
|
||
"path": {
|
||
"description": "被断言的可观测状态路径(如 'score' / 'remaining')。非空。",
|
||
"type": "string",
|
||
"minLength": 1
|
||
},
|
||
"op": {
|
||
"description": "比较算子(play.cdp.cjs:188 checkAssertion 支持集):increased/decreased/changed 不带 value;>、>=、<、<=、== 带 value 阈值;in、reached 带 value 允许集数组。用集合外的算子 harness 会静默判否(pass 恒 false),故锁为闭集。",
|
||
"type": "string",
|
||
"enum": ["increased", "decreased", "changed", ">", ">=", "<", "<=", "==", "in", "reached"]
|
||
},
|
||
"value": {
|
||
"description": "阈值或允许集:op 属于 {>,>=,<,<=,==} 时为阈值标量、op 属于 {in,reached} 时为允许集数组;increased/decreased/changed 不需要本字段。"
|
||
},
|
||
"why": {
|
||
"description": "这条断言在证明什么(人读一句,未过时回喂续修 agent)。",
|
||
"type": "string"
|
||
}
|
||
},
|
||
"additionalProperties": false,
|
||
"allOf": [
|
||
{
|
||
"if": { "properties": { "op": { "enum": [">", ">=", "<", "<=", "==", "in", "reached"] } }, "required": ["op"] },
|
||
"then": { "required": ["value"] }
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|