添加游戏
游戏卡带定义了参赛者行动于其中的世界:世界生成、规则、观测、动作、计分与回放重建。平台运行它的 WebAssembly 组件,参赛者的模型则由 Orion 的 models 实体运行。游戏代码不得自行安排对局、获取模型或写入评分。
Ants 是参考实现,并且遵守本页的每一条规则:它的仓库从 engine/ 构建组件、从 viz/ 构建查看器,并把两者作为一个 GitHub 发布版本压缩包一起发布。本地运行器已经可以接入第二个游戏,而不需要写一行 Rust。tinybrains 只认识五个函数名、cartridge.json 和回放封包,每张地图、席位数和限制都从清单里读取,所以只要在项目的 games.toml 里加一个条目,就能把一个游戏加进去。更难的那一半是天梯:Soma 的时钟、Kalam 的包定义和注册脚本仍然带着 Ants 专有的配置,所以在天梯上添加游戏意味着逐一检查这些集成点。天梯目前还没有游戏的自助上传流程。
添加游戏不需要部署任何东西。一个完成的卡带就是一个签过名的组件加两份清单,没有任何服务需要构建、扩容或看护。如果发布你的游戏似乎还需要别的什么,先提出来讨论,不要绕过去。
五个函数
在游戏自己的 tb.<game> 命名空间里导出这些操作。每一个都是纯粹的 JSON 到 JSON 函数;一个组件导出全部五个,并按名称分发。
| 函数后缀 | 输入与结果 |
|---|---|
worldgen | 种子与地图对象(整个波次一个,或每个种子一个)→ 初始的打包波次状态 |
observe | 波次状态与不透明的席位引用 → 每个席位的观测 |
step | 波次状态与动作 → 更新后的状态、完成标志和回放增量 |
finish | 波次状态 → 每场对局的名次、整数得分、结束原因,以及每场已结束对局所用的地图 |
replay-decode | 回放封包与一个回合(或 from/to 范围)→ 重建出的帧 |
在插件清单里准确声明输入字段,并把 Ants 的 engine/plugin.toml 当作完整的实例契约;上表只是它的缩写。接口形状上有三个细节,是在真实的 Orion 上跑过之后才定下来的:
replay-decode,用连字符。Orion 拒绝不符合[a-z][a-z0-9-]*的函数标签,所以下划线的写法无法加载。observe接收一个扁平的引用列表,每个引用各自带着对局索引和席位,卡带据此匹配并原样回显到观测视图上,不做检查。列表之所以扁平,是因为调用方每回合都从仍在进行的席位重建它,而嵌套的索引会在某场对局一结束时就发生错位。- 动作可以按位置给出,与上一次
observe对齐,因为工作流无法把两个数组 zip 起来。同时也要接受显式的{m, seat, action}形式,供省略了某个席位的调用方使用。
要以一个波次的对局为单位运作,包括各场对局在不同回合结束的波次。已结束的对局不再返回观测:没有终止消息,也没有任何东西告诉模型它输了。对局结果必须给出从 1 开始的名次(允许并列)和整数得分。finish 是平台对你的游戏唯一的语义依赖,而名次意味着什么由天梯决定。
天梯发送的波次只含一场对局。Kalam 认领一行对局并把它打完,所以只有本地运行器,以及想用它的卡带,才会真正用到波次这一维度。即便如此也要保持 ABI 的波次形状:按
(m, seat)而不是按位置来匹配席位,回显的引用才是安全的,而在m恒为 0 时这不花任何代价。
状态与确定性
所有状态都必须经由打包的波次值往返。沙箱在两次调用之间什么都不保留,所以凡是你没有编码进去的东西,下一回合就没了。
- 让它不透明且紧凑:用打包的二进制编码再经 base64,而不是可读的 JSON。卡带之外的任何东西都不能解码它,于是“平台从不解析游戏状态”在构造上就成立了。
- 不要给它版本字段。一个波次从不跨越两个卡带版本,所以生成这个编码的摘要就是它的版本。
- 按下文的插件上限控制它的大小。状态每次调用都要穿越沙箱边界两次,所以状态越紧凑,吞吐量越高。
游戏状态只用整数:游戏逻辑里没有浮点数。整数让平台的构建和浏览器的构建逐位一致,回放因此可以是一条动作流,而不是多上百倍的帧。得分是整数也是同样的原因。其余的由沙箱强制:组件的世界没有任何导入(没有时钟、没有随机数、没有文件系统、没有套接字),所以每一次随机抽取都源自调用方交给 worldgen 的种子。Ants 在源码层面检查整数规则(tools/deny.sh),因为 Rust 里没有任何东西强制它。
你的测试应当覆盖种子可重复性、状态往返、玩家对称性、观测隐私、动作顺序、结束条件、并列以及回放重建。在宣称回放可以跨平台忠实还原之前,你还需要在平台与浏览器之间做一次跨宿主的一致性运行;Ants 参考实现目前还没有提供这个浏览器端的运行。
地图(如果你的游戏有的话)
Ants 在对局时不生成任何东西。它的地图是文件,每张地图一个 JSON 文件,装着它的网格、水域、蚁丘和第 0 回合的食物,而组件不带任何一张地图。worldgen 为它开启的每一场对局接收完整的地图对象。它没有可以按名字查找的目录,也没有可供选择的东西,所以没有地图的调用会被拒绝。设计时要考虑三个后果:
- 地图永远不会改变引擎摘要。管理员把赛季的地图上传到赛季里;Soma 随赛季保存它们,并在运行器认领每一场对局时把地图一并发过去。没有任何发布版本或仓库保存它们,而赛季可以在进行当中、在开赛时的引擎上,把一张地图投入使用或撤下。
- 对称性是一条断言。生成器可以在构造上保证公平;经人手编辑过的文件做不到。每张地图在任何对局用它开打之前都要通过校验器:地形、蚁丘和第 0 回合的食物在地图自身的平移下封闭;该平移走过与席位数相同的步数后回到原点;每一格陆地都能从其他任何一格到达;每个席位的蚁丘数量相同;没有蚁丘落在水域上或被围死。拒绝归为
caller_input,因为同一张地图永远不可能成功。平台在上传时运行同一个校验器:Soma 加载组件,对管理员上传的每张地图调用worldgen,并在管理员把地图投入使用时再调用一次,所以无法开打的地图会在那里失败,而不会让配对到它上面的对局失败。 - 卡带声明地图可以是什么样。清单里的
limits.boards限定席位数、每条边长和格子数,超出它的上传会被 Soma 拒绝。这个界限同时也是审核的承诺:审核用来验证每个适配器的参考观测,必须取自覆盖整个界限的地图,因为赛季可以在 Soma 审核通过某个模型之后再添加地图。Ants 的发布版本里带有五张基础地图,每种尺寸一张,从两个席位到八个,从最短的边到最长的。它从这些地图推导出limits.boards,并在它们上面生成参考观测集,所以两者不可能不一致。
参赛者仍然无法选择。在天梯上,配对时钟从赛季正在使用的地图里选出地图,并随之分配种子。平台对地图的读取止于头部(id、players、rows、cols);文件里的其他一切都属于卡带。生成器仍然保留,作为写出这些文件的工具。Ants 把它放在引擎旁边的一个 crate 里,在组件源码之外,而它的构建门禁会重新生成每一张基础地图并逐字节比较,所以手工编辑过的地图会让构建失败。
即使一个游戏的配置不是地图,worldgen 也同样会收到一张:无论它玩的是什么样的设定,都写成一个带有平台可读头部的文件,再配上一个说明它可以是什么样的 limits.boards。
回放
回放就是动作流,所以重新模拟它需要知道起始局面。让封包自带地图:finish 返回每场已结束对局所用的地图,封包把它和种子、回合上限存在一起。这样无论赛季之后发生什么,无论这张地图在赛季中的去留如何,回放都始终可以查看。
- 用平台真正写出的封包来测试。Ants 保留了一个真实的封包作为测试夹具,因为自己构造封包的往返测试,在写入方和读取方渐行渐远时仍会一直通过。
- 给查看器一个范围。解码是从第 0 回合开始重新模拟的,所以逐帧请求的进度条是平方级的开销;
from/to只把对局走一遍。 - 把查看器不该自己推算的东西放进帧里。Ants 的帧按席位列出该回合首次揭示的格子,所以查看器不必重新实现视野就能画出每个席位探索过的范围。帧里还带着每只蚂蚁的 id、该回合死亡的蚂蚁及击杀它们的敌人,以及被攻陷的蚁丘及攻下它的席位,所以查看器直接按记录画出击杀和蚁丘的摧毁,不必重读任何规则去推断。
两份清单
插件清单描述 Orion ABI、组件路径、带命名空间的函数和输入字段。name 是小写的反向域名标识符,至少两段,每个函数都写作 <name>.<label>。字段表是 Orion 会检查的契约:如果某个插件版本的结构定义已不再被某个在用的工作流满足(字段改名,或字段变成了必填),Orion 会拒绝激活它,指出那个工作流,并让上一版本继续服务。Ants 手写 engine/plugin.toml,再由它生成 plugin.json。
Soma 在注册游戏时读取一次卡带注册清单:
{
"game": "ants", "version": "1.0.0", "abi": 1,
"limits": { "max_turns": 1000, "turn_ms": 1000,
"boards": { "players": [2, 8], "sides": [24, 124], "cells_max": 14880 } },
"budgets": { "adapter_ops_max": 1000000 },
"maps": [ { "id": "basic-large-6p", "rows": 80, "cols": 96, "players": 6, "food_target": 108, "sha256": "sha256:…" },
… ],
"about": { "tagline": "…", "provenance": "…", "story": ["…"], "links": [ { "label": "…", "href": "https://…" } ] }
}
地图自带席位数,因为席位数属于地图,游戏本身没有自己的席位数。清单也不声明任何地图池:它不指定赛季要玩哪张地图。limits.boards 规定任何地图可以是什么样,平台只在一个地方读它,就是赛季的上传。maps 保存每张基础地图的元数据和摘要,这样参赛者的工具不用加载组件就能列出发布版本自带的地图,并核对导出的副本。about 只能是纯文本:它来自仓库,而由浏览器渲染。
注册信息要从引擎所用的定义生成,这样地图尺寸和席位数就不会漂移;组件和清单要一起发布。Ants 从基础地图生成 cartridge.json,并由它们推导出 limits.boards,所以界限和证明它的地图不可能不一致。它自己的 GitHub Actions 工作流构建组件、两份清单、基础地图、参考观测和查看器,并把它们作为一个发布版本压缩包发布,每个使用方按标签或取最新版获取。
构建组件
基于 orion-plugin-sdk 构建一个面向 wasm32-unknown-unknown 的 Rust cdylib,再用 wasm-tools component new 把它转成组件。不要以 WASI 为目标:它的标准库会导入 WASI,Orion 会拒绝这样的组件。一次拒绝是一个 PluginError,带有一个符合 ^[A-Z][A-Z0-9_]{0,63}$ 的稳定错误码和一个类别:同样的输入永远不可能成功时是 caller_input,卡带自身出错时是 internal。选择调用方能据以行动的错误码:WAVE_STATE_CORRUPT 胜过 ERROR。
锁定工具链并重映射构建路径。rustc 会把 panic 可能提到的每个源文件的绝对路径烤进二进制文件,所以同一个提交在两台机器上构建,会得到两个引擎摘要。Ants 在它的 GitHub build 工作流里、在 ubuntu-24.04-arm 上构建组件,rustc 在 rust-toolchain.toml 里锁定到补丁版本,wasm-tools 在 build.sh 里锁定,并使用 --remap-path-prefix。构建主机同样是摘要的一部分,所以笔记本上的构建永远不是天梯的:每个使用方都从 Ants 的发布版本获取组件,自己不做任何构建。
上限
每次调用都在一个全新的实例里运行,受运维方设定的限制约束。清单不能请求任何资源,而按插件的覆盖设置只能调低上限。
| 上限 | 默认值 | 限制的是什么 |
|---|---|---|
max_memory_bytes | 64 MiB | 每次调用的线性内存 |
max_timeout_ms | 5,000 | 实际耗时;任务自身的时限同样生效,以较短者为准 |
max_request_bytes | 1 MiB | 求值的输入,也就限制了波次的大小 |
max_response_bytes | 1 MiB | 返回的 JSON |
max_concurrency_per_function | 64 | 同一函数的并发调用数 |
fuel_backstop | 10¹¹ 条指令 | 一道兜底,远高于时限内所能执行的量,不构成契约 |
Orion 只重试超时,不重试其他任何失败,因为纯函数重试没有代价。它不重试 caller_input 或 internal 拒绝、trap、panic、超出大小限制或非 JSON 的结果。不要跨调用缓存任何东西:没有什么能从一次调用留到下一次。
签名
组件带有签名,而且 Orion 会强制校验。每个插件都带有一个对其摘要字符串(sha256:<64 hex>)的分离式 Ed25519 签名。签名由持有该部署信任密钥的人签发,而不是由包签发。Orion 在你上传组件时检查它,在每个加载它的节点上再检查一次;签名缺失或不匹配会把调用该组件的频道隔离,节点也会在启动应用包时停下。新游戏的组件要以同样方式签名,并且每次重新构建后都要重新签名,因为重新构建会改变摘要。
注册与集成
在兼容的 Kalam 工作节点上加载组件,注册它的清单和引擎摘要,并为审核提供参考观测。凡是写着 Ants 的地方都要更新:执行函数的引用、Soma 用来评判上传地图的组件,以及部署接线。让游戏当前赛季的标识与将要认领其对局的引擎保持一致。
提供覆盖最小与最大状态以及关键边界条件的参考用例,并且从引擎生成它们。审核只用这些观测来验证适配器,别无其他,所以手工维护的集合测试的可能是游戏早已不再产生的形状。用真实的模型和适配器对它们做验证:空的或没有代表性的参考集,会让审核没有任何依据去约束适配器。
查看器
查看器是卡带中唯一位于组件之外的部分:一个 ES 模块,平台按约定从 /cartridges/{slug}/viz.js 加载它。它导出 mount(target, replay, opts),返回一个带 destroy() 的对象。Ants 的查看器接受的选项包括 tier(整个舞台、播放器、卡片里的小块或缩略图)、turn、from/to 范围、autoplay、speed、theme、labels(宿主给每个席位的称呼)和 onTurn 回调等,并在 mount 之外还导出 drawFrame、mountGraph 和 mountMap。
查看器通过你自己的 replay-decode 重新模拟,它用 jco 从记录这场对局的同一个组件摘要转译而来,所以查看器和裁判不可能有分歧。用 JavaScript 重新实现一条规则就等于第二个引擎。记下你构建打包文件所用的摘要:用不同引擎重新模拟的查看器不会报任何错误,只会画出一场看似合理却从未发生过的对局。查看器从不运行模型。它只回放记录下来的动作,所以宿主可以把它嵌到任何地方,而不必继承求值器的安全暴露面。
Ants 把整个播放器(播放控件、时间轴、标题栏)一并发布,因为它的三个宿主是各自独立的应用:网站、本书和 tinybrains view。代价是:第二个卡带得自己写进度条,直到有了两个真实的查看器,才看得出什么值得抽出来共用。查看器对它的宿主页面要遵守这些规则:
- 把每条样式规则都限定在你的根类名之下。查看器会向宿主文档注入一份样式表;没有限定范围的规则会落到宿主自己的元素上。
- 界面外框的颜色取自宿主的设计令牌,地图的颜色保持自己的。一场对局在两种主题下都应该看起来是它自己。
- 把画面留给地图。宿主只会用它能匀出的高度来嵌入查看器,所以地图旁边任何常驻的东西都得配得上它占的高度。
- 布局问题由你自己承担。如果宿主需要另一种布局,就在
mount()上给它一个选项;宿主的样式表永远不会伸进你的类名里。 - 接受宿主给你的宽度。任何不换行的东西都会变成查看器的最小宽度;在根元素上设置
contain: inline-size,内容就不会反过来决定宿主那一栏的宽度。
游戏不得需要的东西
如果添加你的游戏需要下面任何一项,请提出来,不要绕过去。这条分界线只有守得住才有价值。
| 改动五个函数 | 这组函数就是 ABI;第六个函数是一场设计讨论 |
| 浮点数的游戏状态 | 它最先弄坏的就是回放 |
| 在两次调用之间保留状态 | 没有地方可放;把它穿在波次状态里传递 |
| 任何形式的 I/O | 卡带里的任何东西都不得读取文件、时钟、套接字或密钥 |
| 了解评分、重量级、用户或赛季 | 你给对局计分并给玩家排名次;其余交给平台 |
| 按单场对局的 step 函数 | 它会让波次失去批处理 |
| 你自己的张量布局 | 参赛者的清单负责把观测数据变成张量 |
参赛者需要的文档
发布游戏概览、世界描述、精确的回合顺序、结束与计分规则、地图的限制范围、观测结构、动作结构以及可运行的示例。对模型看不到的东西,要和看得到的东西一样用心。写明精确的限制,以及无效动作的精确行为。Ants 把它的协议发布在本书里:模型看到什么和模型回答什么就是契约,引擎发送内容的改动必须连同它们的改动一起落地。
如果配图解释有帮助,先预留一个回放可视化占位,写明它要说明的概念、需要的玩家视角和要选取的回合。之后再附上真实录制的对局,带上相匹配的引擎标识和文字说明。在任何人实现查看器之前,参赛者就应该能从文字读懂规则。