RFC 0002:可编程工具调用(在游戏里运行智能体写的代码)¶
- 状态:已推迟——这里没有任何东西被实现,而现在这是决定,不是缺口:第一版草案被判定为"为还没被证明的需求做太多设计"。见 整体结构 里的"故意不做的(至少现在)"。当同一个手写 helper 在不止一个实验里反复出现时重开;在那之前,从进程外做(看一眼约 35 毫秒)就够用
- 范围:让智能体把代码注入到游戏进程内部运行
- 相关:RFC 0001、智能体在游戏里是谁
问题,用实测数字说话¶
目前智能体做的每一件事都要跨过一次 socket 和一次 tick 边界。在活世界上实测(64 格内 521 个实体、256 KB JSON):
| 从游戏外部做一次操作 | 代价 |
|---|---|
一个微不足道的请求(state)往返 |
中位数 35 ms(请求要排队到下个客户端 tick) |
| 实体快照,半径 64 | 521 个实体、256 KB、59 ms |
| 一条命令加上读它的回答 | 34 ms,这还没算人为等待 |
一个 Minecraft tick 是 50 ms,而客户端线程同时还在渲染。所以外部探针大致被限制在每秒 10–20 次采样,每次都付几十 KB,并且不先把原始数据搬出去就算不出任何派生量。命令(/player、/data get、Scarpet)形状相同:最多每 tick 一条命令,没有数值库,没有"游戏里还没写好的触发逻辑"。
这个天花板就是本篇 RFC 的理由。智能体应该能说:"盯着这些实体 600 tick,记录每次超过 X 的冲量周围的 Δv 与碰撞箱尺寸,把汇总给我"——并且让这段以 tick 率运行,只有汇总穿过 socket。
智能体需要什么(需求)¶
- tick 率观测,派生量就地计算(Δv、冲量窗口、AABB 分组、计数、分位数)。
- 触发器与捕获窗口:"记录这个事件前后 ±N tick"。
- 带 tick 时序的脚本化动作:T 时刻生成、T+3 放置、T+5 点燃、采样到 T+40——确定性的,而不是每次往返一条命令。
- 紧凑的结果:回到智能体的是 KB,不是几百 KB。
- 可复现:一个脚本文件就是一个实验的定义。
- 一份契约、多个观察点(见下),于是同一个脚本今天能跑在客户端、将来能跑在服务端。
非目标:给人用的通用 modding API、防恶意代码的安全沙箱、取代数据包/命令方块。
观察点¶
| 代码跑在哪 | 能看到 | tick 精确 | 备注 |
|---|---|---|---|
| 客户端(今天的 mod) | 插值后的实体视图、界面、客户端命令、受渲染距离限制 | 是(客户端 tick) | 单机和任何服务器都能用,不需要服务端安装 |
| 服务端(一个服务端 mod) | 权威实体、所有已加载区块、不受渲染距离限制、真实的冲量 | 是(服务端 tick) | 单机:集成服务端跑在同一进程里,所以服务端 mod 在那里同样可用 |
| 外部进程(Minescript 那一类) | API 暴露什么就有什么 | 否 | 写起来最快,循环最慢;见"先例" |
实测出来的差异是真实的:同一个起跳的假人,客户端插值视图读到 vy=0.333,服务端读到 -0.078。做物理,服务端观察点才是诚实的那一个。
运行时调研(查过,不是猜的)¶
游戏跑的是 Mojang 的 Java 25 运行时,对它执行 java --list-modules 能看到 jdk.compiler、jdk.jshell、java.scripting 都在。这意味着游戏可以在运行时编译 Java,且不需要任何额外依赖。
| 运行时 | 体积 | 许可 | 备注 |
|---|---|---|---|
用 jdk.compiler 编译 Java + 子类加载器 |
0(本来就有) | 不适用 | 全速、可直接访问游戏类、编译错误以文本回传;没有沙箱 |
| Rhino(JS,ES5+) | ~1.4 MB | MPL-2.0 | 可嵌入、有 Java 互操作、解释执行速度 |
| LuaJ(Lua 5.2) | ~250 KB | MIT | 极小、速度尚可、Java 互操作偏笨 |
| Nashorn-core + ASM(ES5.1) | ~2 MB | GPLv2+CE | 旧 JDK 引擎的独立维护版 |
| GraalJS | ~40 MB | UPL | 最快的脚本方案,依赖最重 |
| Kotlin scripting | ~50 MB | Apache-2.0 | 语言最舒服,也最重 |
先例¶
| 项目 | 是什么 | 对我们的适配度 |
|---|---|---|
| Minescript(Fabric/Forge/NeoForge 26.2,GPL-3.0) | 外部进程里的 Python 脚本驱动客户端 | 好的人机工程学参考;外部进程 = 上面那个延迟问题 |
| Neo Scripts Lua(Fabric/NeoForge 26.2,GPL-3.0) | /lua(客户端)与 /slua(服务端)的进程内 Lua |
现存最接近的东西;很适合做可选运行时 |
| Allium(Fabric,MIT) | Lua 加载器 | 只有 26.3+,对这台机器的 26.2 太新 |
| Carpet Scarpet | 服务端解释器、tick 精确、/script |
证明了概念;它的语言与能力边界正是我们想抬高的 |
| KubeJS | 给整合包作者的 JS | Fabric 26.2 上没有 |
许可说明:我们的仓库是 MIT。依赖一个 GPL 程序没问题,捆绑或链接 GPL 代码则不行,所以我们发布的东西必须是 MIT/Apache/MPL/UPL 兼容的。
设计提案(供讨论)¶
生命周期原语¶
script_start { id, source, mode: once|tick|on_event, budget_ms, caps, vantage }
script_stop { id }
script_list {}
script_status { id } -> 状态、已跑 tick 数、错误、结果
script_output { id, since } -> 脚本写下的记录,以游标形式
脚本按 id 索引、可热替换;出错的脚本会被停用(不重试)并说明原因。
host API 就是契约¶
脚本默认拿不到裸内部。它拿到的是一个很小的 host 对象,同样的名字必须同时存在于客户端适配器和将来的服务端适配器上:
interface ScriptHost {
long tick(); // 游戏 tick 计数
Self self(); // 坐标、速度、血量、游戏模式
List<Entity> entities(double radius, Filter filter); // 有类型、开销低
void record(String key, Object value); // -> script-<id>.jsonl
void result(Object value); // 最终结果,有上限
void log(String message); // -> 事件流
void schedule(long tick, Runnable action); // 定时到某个 tick 的动作
void command(String command); // 服务端命令,不走往返
void stop(); // 干净地结束
}
逃生舱:一个能力开关(allowGameInternals),额外暴露裸的游戏对象,用于 API 还没覆盖的实验——显式开启,也正是它在总体上无法被沙箱化的原因。
结果¶
record(...) 追加到 <gameDir>/mc-agent/script-<id>.jsonl(有上限、会轮转),并且每秒最多往普通事件流里推几行紧凑记录,这样智能体能看进度又不至于被数据淹死。result(...) 由 script_status 返回并做截断(64 KB)——聚合结论和判断应当放这里。
安全姿态(明确写清)¶
脚本是智能体为用户自己的游戏写的可信本地代码。目标是保护帧率和世界,不是防一个有恶意的作者:
- 每 tick 的墙钟预算(默认约 2 ms),反复超限后自动停止并报告原因;
- 异常按 tick 捕获,只停用那一个脚本;
- 每个脚本都可列出、可记录、可 kill;
- 可选的受限 import(拒绝
java.io、java.net、反射),由编译器的 file manager 执行——是护栏,不是沙箱; - 不做隐式的事:想写文件或开 socket 的脚本必须在能力列表里声明,并且用户得允许。
为什么先做 Java¶
零依赖、紧密的逐 tick 循环能拿到 JVM 的全速、访问游戏类没有互操作边界,而且编译错误对模型来说是很好的反馈。代价是每次改动约 100 ms 的编译时间,以及没有沙箱。JS 或 Lua 运行时以后可以作为额外的 language 字段加入——脚本是对着 host API 写的,所以运行时是可以替换的部件。
供讨论的开放问题¶
- v1 的语言:只做 Java,还是 Java + 一种脚本语言(Rhino/LuaJ)?
- 观察点:先客户端,还是客户端与服务端适配器一起?(物理测量要诚实,靠的是后者。)
- host API 的形状:在我们知道前三个实验之前,值得设计多少类型化 API;还是先用薄 API +
gameInternals逃生舱? - 安全:接受"可信代码 + 预算 + 可 kill"吗,还是第一天就要受限 import?
- 外部 runner:无视 Minescript / Neo Scripts,还是把它们作为同一批工具名后面的可选运行时?
- 结果落在哪:只写文件、只走流,还是两者都做(提案)?