插件
插件是连接其他模组与言出法随的桥梁。它向智能体注册新的 Lua API,让智能体能做到原版交互做不到的事,比如操作只在客户端存在的界面,或者调用一条没有补全、不知道该填什么的指令。
插件既可以由模组作者直接写进自己的模组,也可以由任何人写成一个独立的兼容模组,同时依赖目标模组和言出法随。言出法随内置的五个插件对接时用的也是本章介绍的同一个入口,只是它们没有自己的模组入口,而是打包在言出法随里,由言出法随在目标模组存在时调用它们各自的 install(...)(参数各不相同),所以参考它们时只看其中 NumenPlugins.register 那一段即可。如果你还不确定某个模组需要插件还是技能,请先阅读架构概览。
本章以一个虚构的魔法模组 mymod 为例,从零写出一个插件,让智能体能查询自己的法力、施放法术。言出法随仓库的 plugins 目录里有真实的插件,写的时候可以随时参考。
一个插件可以做这些事,本章会逐一讲到。
- 注册一组 Lua API,这是插件最主要的工作
- 随模组附带技能,教智能体怎样用好这些 API
- 随模组附带 Lua 模块,把几个 API 组合成一件事
- 在智能体身上发生某件事时告诉它
准备工作
添加依赖
如果你还不了解言出法随由哪些部分组成、该依赖哪个坐标,请先阅读依赖关系。下面以最常见的情况为例,只依赖精简 API。如果你依赖的是 0.1.4,请先看依赖关系页里"0.1.4 的已知问题",那个版本需要换一种写法。
言出法随的 API 发布在 numen-maven。插件只需要在编译时依赖精简版的 API 包(classifier 为 api),运行时的引擎由玩家安装的言出法随提供,你的模组不需要携带任何言出法随的代码。
repositories {
maven { url = 'https://raw.githubusercontent.com/Dwinovo/numen-maven/main' }
}
dependencies {
// NeoForge 与 Forge
compileOnly "com.dwinovo.numen:numen-api-neoforge-1.21.1:<version>:api"
// Fabric 改用 modCompileOnly,让 Loom 把它映射成你的命名
// modCompileOnly "com.dwinovo.numen:numen-api-fabric-1.21.1:<version>:api"
}把加载器(neoforge、forge、fabric)和 Minecraft 版本换成你的目标,<version> 填言出法随的版本号。言出法随的模组版本和 API 版本始终一致,模组 0.1.4 就对应 API 0.1.4。
声明依赖
如果插件写在你自己的模组里,言出法随应当是可选依赖,没装言出法随的玩家照样能正常使用你的模组。以 NeoForge 为例,在 neoforge.mods.toml 里声明一条可选依赖。
[[dependencies.mymod]]
modId = "numen_api"
type = "optional"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"如果你写的是一个独立的兼容模组,那么目标模组和言出法随都是它的必需依赖。
[[dependencies.mymod_numen]]
modId = "numen_api"
type = "required"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"
[[dependencies.mymod_numen]]
modId = "mymod" # 目标模组
type = "required"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"ordering = "AFTER" 让你的模组在言出法随之后构造。即使顺序反了,NumenPlugins.register 也会等言出法随就绪再执行,这里只是和言出法随自己的写法保持一致。
ApiError、ErrorKind 等少数类型不在精简 API 里,而在 agent 模块中,它会随精简 API 的依赖信息自动带进编译类路径。如果编译时找不到它们,可以用 ./gradlew dependencies --configuration compileClasspath 查看是否带进来了。
第一步 登记插件
所有对接都经过同一个入口 NumenPlugins.register。
如果插件写在你自己的模组里,把对接代码单独放在一个类里,只在言出法随已加载时才去碰它,这样没装言出法随时,这个类一次都不会被加载,也就不会因为找不到言出法随的类而崩溃。独立的兼容模组两边都是必需依赖,不需要这层判断,直接在模组入口里调用 MyModNumen.install() 即可。
// 你的模组入口
@Mod("mymod")
public class MyMod {
public MyMod(IEventBus bus) {
// 只有装了言出法随,才加载对接代码
if (ModList.get().isLoaded("numen_api")) {
MyModNumen.install();
}
}
}// 对接代码单独一个类
public final class MyModNumen {
static void install() {
// "mymod" 是你的名字空间,你注册的函数在 Lua 里都写成 mymod.<组>.<函数>
NumenPlugins.register("mymod", numen -> {
numen.api("mana", "Your mana and the spells you can cast.", ManaApi.class);
});
}
}名字空间一般就用你的模组 id 或它的简写。它必须以小写字母开头,只含小写字母、数字和下划线,最长 48 个字符,并且不能和 Lua 的关键字、言出法随已占用的名字(比如 numen)或已有的模块名重复。在模组构造时登记即可,引擎内部的各个部分什么时候就绪,由言出法随替你等。
第二步 编写一组 Lua API
numen.api(组名, 一句话说明, 类) 把一个类里的函数登记成一组 Lua API。组名用一个领域名词,比如 mana,于是这组函数在 Lua 里就是 mymod.mana.xxx。一句话说明会出现在模型每一轮都能看到的 API 索引里,所以要写清楚这一组管什么。
下面是 ManaApi 的完整写法。本章用到的类型分别在这些包里。
| 包 | 类型 |
|---|---|
com.dwinovo.numen.api | NumenPlugins、NumenApi、CompanionEvent |
com.dwinovo.numen.sdk | @Fn 等注解、ServerCall、ClientCall、Pending、Job、EntityRef、Call、ApiTester |
com.dwinovo.numen.agent.script | ApiError、ErrorKind |
com.dwinovo.numen.permission | Action |
com.dwinovo.numen.entity | NumenPlayer(智能体的身体)、BodyDelta |
import com.dwinovo.numen.sdk.*; // @Fn、@Doc、ServerCall、EntityRef、Call 等
import com.dwinovo.numen.agent.script.ApiError; // 来自自动带入的 agent 模块
import com.dwinovo.numen.agent.script.ErrorKind;
import net.minecraft.world.entity.Entity;
import java.util.Optional;
public final class ManaApi {
// ---- mymod.mana.get() ----
/** 返回值是一个 record,在 Lua 里会变成一张表 {current = 80, max = 100} */
public record Mana(@Doc("Mana you have now.") int current,
@Doc("Most mana you can hold.") int max) {}
@Fn("Your mana now and the most you can hold.")
@Example("mymod.mana.get()")
@Note("Read-only.")
@SeeAlso("mymod.mana.cast")
public static Mana get(ServerCall call) {
// call.her() 是智能体的身体,一个服务端的玩家实体
MagicData data = MagicData.of(call.her());
return new Mana(data.mana(), data.maxMana());
}
// ---- mymod.mana.cast(spell, {target = ...}) ----
/** 参数也是一个 record,每个组件是一个参数 */
public record Cast(@Doc("The spell's id, e.g. fireball.") String spell,
@Doc("The entity to cast it on: an id or an entity from numen.scan.entities.")
@Omitted("cast it in front of you")
Optional<EntityRef> target) {}
@Fn("Cast one of your spells.")
@Example("mymod.mana.cast(\"heal\")")
@Example("mymod.mana.cast(\"fireball\", {target = 184})")
@SeeAlso("mymod.mana.get")
public static void cast(ServerCall call, Cast args) {
MagicData data = MagicData.of(call.her());
Spell spell = Spells.byId(args.spell());
if (spell == null) {
// 失败一律抛 ApiError,说清楚错在哪,再给一行能照抄的下一步
throw new ApiError(ErrorKind.NOT_FOUND,
"there is no spell '" + args.spell() + "'",
Call.of("mymod.spell.list"));
}
if (data.mana() < spell.cost()) {
throw new ApiError(ErrorKind.NO_MATERIAL,
spell.id() + " needs " + spell.cost() + " mana, you have " + data.mana(),
null);
}
// EntityRef 是对一只实体的点名,call.entity 把它认成世界里的那只实体,不在了会自动报 NOT_FOUND
Entity target = args.target().map(call::entity).orElse(null);
spell.cast(call.her(), target);
}
}智能体写出的 Lua 程序就可以这样调用它们。
-- 先看看还剩多少法力
local mana = mymod.mana.get()
-- 法力够的话,对最近的一只怪物放火球
if mana.current >= 20 then
local foe = numen.scan.entities("hostile")[1]
mymod.mana.cast("fireball", {target = foe})
end方法的签名就是契约
言出法随从方法的签名里读出这个函数的一切,不需要另写配置。
| 签名的部分 | 决定了什么 |
|---|---|
| 方法名 | Lua 里的函数名。驼峰会换成下划线,castSpell 就是 cast_spell。名字和 Java 关键字冲突时,用 @Fn(value = "...", name = "switch") 这样指定,方法本身可以叫 switch_。 |
| 第一个参数 | 函数在哪里执行,见下文"在服务端还是客户端执行"。 |
| 第二个参数 | 一个 record,它的每个组件是一个参数。不是 Optional 的按顺序作为位置参数,Optional 的放进最后的选项表,比如 {target = 184}。函数不收参数时,省略第二个参数。 |
| 返回类型 | 交回什么,以及什么时候交回,见下文"交回结果"。 |
参数还有两个注解,用来写出更顺手的调用方式。
| 注解 | 作用 | 例子 |
|---|---|---|
@Positional | 写在一个 Optional 参数上,让它作为可以省略的位置参数,而不是放进选项表。它必须是最后一个位置参数。 | numen.gui.put("minecraft:coal", 8) 里的 8 |
@Rest | 写在最后一个 List 参数上,收下其余全部位置参数。 | numen.scan.blocks("iron_ore", "deepslate_iron_ore") |
方法必须是 public static,所在的类也必须是 public。
给模型看的说明
模型对你的函数一无所知,它能读到的只有你写在注解里的说明。这些说明会自动生成 API 索引和帮助,模型可以随时用 numen.api.help("mymod.mana.cast") 查看一个函数的完整用法。
| 注解 | 写什么 |
|---|---|
@Fn | 一句话说明这个函数做什么。必写。 |
@Doc | 写在 record 的组件上,说明这个参数或返回值的字段是什么、收什么样的值。也可以写在 record 类型本身上,说明整张表是什么。 |
@Omitted | 写在 Optional 参数上,说明不传时是什么意思。 |
@Example | 一行能直接运行的调用示例,可以写多个。模型最擅长照着例子改,这是最有用的一项。 |
@Note | 补充说明,比如"只读""会消耗什么""失败了不要反复重试"。 |
@SeeAlso | 相关的函数,帮模型找到下一步该调什么。 |
注解里的说明请用英文写。技能正文用什么语言都可以,内置技能两种都有。模型对英文的理解最稳定,言出法随自带的 API 说明也全部是英文。呃这也是建议,怎么跑分高怎么来~
在服务端还是客户端执行
智能体的 Lua 程序在服务端运行,紧挨着它的身体。第一个参数决定这个函数在哪里执行。
ServerCall在服务端的主线程上执行,能拿到智能体的身体(call.her(),类型是NumenPlayer)和整个世界,可以直接调用你模组的游戏逻辑。绝大多数函数都应该这样写。ClientCall在主人的客户端执行,只用于读取那些只有客户端才知道的数据,比如客户端渲染的外观。服务端会向主人的客户端发一个请求,等它答复。注意它读到的永远是主人客户端上的数据,而不是智能体的(智能体没有客户端);主人不在线或 30 秒内没有答复时,这次调用以timeout失败。
客户端函数只能当场返回结果,不能用 Pending 或 Job。用到客户端类的代码请单独放进一个类,只在函数体里引用它,这样专用服务器上不会加载这些类。此外请遵守几条约定。只做只读的查询,不产生副作用,这样超时后重试是安全的;客户端的答复可以被玩家篡改,任何裁决都不能依赖它;尽量一次查一批,在客户端过滤好只回小结果,而不是在循环里一条一条地问。
交回结果
返回类型决定了函数怎样交回结果。
| 返回类型 | 适用场景 |
|---|---|
R(任意类型) | 当场就能算出结果,比如查询法力。不返回值就写 void。 |
Pending<R> | 要等一会儿才有结果,比如等主人同意、等下一个游戏刻,或者等一件几秒内必然做完的短动作。等待期间不占用智能体的身体。 |
Job<R> | 一件要占用身体、持续一段时间的活,比如一路走过去、一直战斗到目标倒下。它会占用智能体唯一的任务槽,程序等它收尾才拿到结果。 |
Job 和"几秒内必然做完的短动作"都需要你自己写一个任务(TaskRecord 和它对应的 Task),本章不展开,可以参考 plugins/kaleidoscope 里的 PotActRecord 与 PotActTask。大多数 API 用不到它,当场返回或用 Pending 等一下就够了。
参数和返回值支持这些类型,它们会自动和 Lua 的值互相转换。
- Java 的基本类型和字符串、枚举(在 Lua 里写成小写的名字,比如
"wheat") - record(在 Lua 里是一张表)、
List、Optional、Map<String, T> - Minecraft 的值,比如坐标
BlockPos(在 Lua 里写成{x = 1, y = 64, z = 2})、物品Item(写成"minecraft:coal")、点名一只实体的EntityRef
其他类型需要你用 numen.codec(类型, 转换方式) 登记它和 Lua 值之间怎样互转。查不到东西不算失败,返回空列表或 Optional.empty() 即可。只有点名的某个具体东西不存在时,才是 NOT_FOUND 失败。
报告失败
失败只有一种写法,抛出 ApiError。它有三个参数,失败的种类、错在哪里、一行能照抄的下一步(没有就传 null)。下一步不要手写字符串,用 Call.of("函数名", 参数...) 生成,它会按 Lua 的写法拼好参数。种类决定了模型怎样理解这次失败,常用的有这些,完整的种类见 ErrorKind。
| 种类 | 含义 |
|---|---|
BAD_ARGUMENT | 参数写错了 |
NOT_FOUND | 点名的东西不存在 |
OUT_OF_REACH | 站在原地够不着,要先走过去 |
NO_MATERIAL | 缺少要消耗的东西 |
NO_PATH | 找不到去那儿的路 |
DENIED | 主人拒绝,或规则不允许 |
FAILED | 做了但没做成,其他种类都说不上时用它 |
错误信息写得越具体,模型越不容易在同一个地方反复出错。"需要 20 点法力,你只有 12 点"远比"法力不足"有用。
改变世界要经过权限层
智能体对世界做的、会影响到别人的事,比如右键别人的实体、执行一条指令,是否允许由言出法随的权限层统一裁决,主人可以设置规则,必要时还会弹窗询问主人。所以你的函数里不要自己写"能不能做"的判断,而是使用 ServerCall 提供的原语,权限层会在执行的那一刻替你裁决。
call.use(实体, 够不够得着, 动作)用于对一只实体做某件事。第二个参数是ServerCall.Reach,一般写ServerCall.Reach.hand(),表示和玩家伸手能交互的距离一样,需要更远就写Reach.hand(多出的格数)。它会检查够不够得着、过权限层,放行之后再执行你的动作。plugins/tlm里设置女仆工作模式的函数就是这样写的。call.authorize(动作)用于其他需要裁决的事,返回一个Pending,放行后才继续。call.onHer()用于以服务器的权限执行一条只作用于智能体自己的指令。比如call.onHer().run("ysm model set", "misc/1_alex")实际执行的是ysm model set <智能体的名字> misc/1_alex,目标总是智能体自己,紧跟在你给的前缀后面。它不适合需要把别人当目标、或者以智能体为来源执行的指令。
权限层只管会影响到世界和别人的事。只作用于智能体自己的事,比如把它自己传送走、消耗它自己的经验,不需要过权限层,直接做即可,但要把它的身体变了什么如实告诉它,见下文"告诉智能体发生了什么"。
call.authorize 收的"动作"是 com.dwinovo.numen.permission.Action,用它的工厂方法描述要做的事。它是一个固定的集合,只有下表这些。如果你要做的事找不到贴合的一种,不要硬套,可以改为执行对应的指令(指令本身会过权限层),或者到 GitHub 提 issue 请求新增。
| 工厂方法 | 描述的动作 |
|---|---|
Action.useBlock(坐标, 方块状态) | 右键一个方块 |
Action.breakBlock(坐标, 方块状态) | 挖掉一个方块 |
Action.place(坐标, 当前方块状态, 物品) | 在某处放置方块 |
Action.take(容器坐标, 方块状态, 物品) | 从容器里取东西 |
Action.useEntity(实体)、Action.attack(实体) | 右键、攻击一只实体 |
Action.drop(物品) | 丢弃物品 |
Action.command(整行指令, 指令树的根) | 执行一条游戏指令。根用 server.getCommands().getDispatcher().getRoot() 取得 |
对方块做事时,先自己确认够不够得着,再过权限层。比如启动一台机器:
public static Pending<Started> start(ServerCall call, Start args) {
BlockPos at = args.at();
BlockState state = call.her().serverLevel().getBlockState(at);
// 站在原地够不着,就让模型先走过去
if (!call.her().canInteractWithBlock(at, 0.0)) {
throw new ApiError(ErrorKind.OUT_OF_REACH, "the machine is too far away",
Call.of("numen.move.to", at, Map.of("arrive", "near", "range", 2)));
}
// 过权限层,放行之后才真正改动机器
return call.authorize(Action.useBlock(at, state)).then(v -> {
Machine machine = Machine.at(call.her().serverLevel(), at);
machine.start();
return new Started(machine.state());
});
}第三步 教智能体用好这些 API
API 只给了智能体能力,什么时候用、怎样用得好,还需要技能来教。把技能放在模组资源的 skills/ 目录下,在登记插件时交给言出法随。
NumenPlugins.register("mymod", numen -> {
numen.api("mana", "Your mana and the spells you can cast.", ManaApi.class);
// 把 jar 里的 skills 目录交给言出法随,其下每个子文件夹是一个技能
// findResource 对不存在的路径也会返回一个 Path,所以先确认它存在
Path skills = ModList.get().getModFileById("mymod").getFile().findResource("skills");
if (Files.isDirectory(skills)) {
numen.bundleSkills(skills);
}
});这些技能直接从你的 jar 里读取,不会复制到玩家的磁盘上,卸载模组后随之消失。玩家也可以在自己的技能目录里放一个同名技能来覆盖你的版本。在专用服务器上 bundleSkills 什么都不做,技能只在玩家的客户端上生效,你不需要自己判断。
进阶
用 Lua 模块组合 API
有些事是几个 API 的固定组合,比如"一直放治疗术,直到满血"。与其每次都让模型自己写循环,不如把它写成一个 Lua 模块随模组发布。把 .lua 文件放在一个目录里,交给 bundleModules。
Path modules = ModList.get().getModFileById("mymod").getFile().findResource("modules");
if (Files.isDirectory(modules)) {
numen.bundleModules(modules);
}模块名是你的名字空间加文件名,mana.lua 就是 mymod.mana。和某个 API 组同名的模块,会把它的函数加进那一组。
-- 法力相关的组合动作
local M = {}
-- 一直放治疗术,直到满血或法力耗尽,返回放了几次
function M.heal_up()
local count = 0
while numen.status.self().hp < numen.status.self().max_hp do
if mymod.mana.get().current < 10 then break end
mymod.mana.cast("heal")
count = count + 1
end
return count
end
return M文件开头那行注释和每个函数上面的注释,就是模型在 API 索引和帮助里看到的说明,请认真写。
告诉智能体发生了什么
言出法随的原则是,智能体的身体发生的任何变化都要让它知道。按变化发生的时机,有两种告诉它的方式。
变化发生在你的 API 调用里,比如施法消耗了经验、传送移动了位置,就在这次调用的结果里说明。返回值写清楚结果;身体上顺带发生的变化,可以用 BodyDelta 在动作前拍一张快照,动作后取出变化,再用 Pending 的 report 写进这次调用的回执。
BodyDelta before = BodyDelta.open(call.her());
teleport(call.her(), target); // 你的动作
Pending<Arrived> result = Pending.create();
result.report(String.join("; ", before.changes(call.her()))); // 背包、经验、血量、骑乘变了什么
result.complete(new Arrived(target.name()));
return result;report 要在 complete 之前调用,因为一旦有了结果,这次调用的回执就定下来了。
变化发生在你的 API 之外,比如她用了回城卷轴、被别的东西施加了诅咒,就发一个事件。先登记这种事件,再在发生时发出它。
public final class MyModNumen {
/** 登记时拿到的 numen,存下来供事件发生时使用 */
private static NumenApi numen;
static void install() {
NumenPlugins.register("mymod", api -> {
numen = api;
// 登记事件种类。第二个参数表示它是否恒为急件,急件会让智能体立刻处理
api.registerEventType("cursed", true);
// 订阅智能体进入世界,用来做一些初始化
api.on(CompanionEvent.SPAWN, body -> { /* ... */ });
});
// 事情发生的那一刻。监听你自己模组的事件,用的是你平常用的事件总线
NeoForge.EVENT_BUS.addListener((CurseAppliedEvent e) -> {
// 判断这个玩家是不是智能体
if (e.getPlayer() instanceof NumenPlayer her) {
numen.emit(her, "cursed", Map.of("curse", "weakness"), "You were cursed with weakness.", true);
}
});
}
}numen.on 只用来订阅言出法随自己的 CompanionEvent,共有五种,SPAWN(进入世界)、REMOVE(离开世界)、DEATH(死亡)、HURT(受伤)和 ABORT(主人打断了它,手上没做完的活该放弃了)。你模组的事件照常在自己的事件总线上监听。事件会像智能体收到的其他消息一样交给模型,文本和 API 说明一样建议用英文写。
言出法随已经会自动告诉智能体这些事,不需要你再发:死亡(death)、换维度(dimension_change)、饿了(hungry)、背包满了(inventory_full)、东西掉了(dropped)、主人受伤(owner_hurt)、获得进度奖励(advancement_reward)、后台任务结束(task_finished)、定时器到点(timer)等。
每一轮都附上的状态
如果你的模组给智能体身上加了某种持续的状态,比如当前法力、身上的诅咒,可以用 contributeBodyState 在每一轮把它附在请求里,让模型随时知道。
numen.contributeBodyState(body -> {
MagicData data = MagicData.of(body);
return "<mana>" + data.mana() + "/" + data.maxMana() + "</mana>";
});不要放每个游戏刻都在变的值,比如精确的剩余秒数或坐标,那样每一刻都会触发一次同步。偶尔才变的值(比如法力、余额)没有问题,它只在变化时同步;变得太频繁的,就让模型调用 API 去查。
其他入口
numen 上还有几个用得较少的方法,需要时再查它们的注释。
| 方法 | 用途 |
|---|---|
numen.onClient(...) | 包一段只在客户端才有意义的代码,比如注册头像、渲染相关的东西。专用服务器上整段不执行,你不需要自己判断当前是哪一端。 |
numen.contributeState(...) | 和 contributeBodyState 类似,但用于只有主人客户端才知道的事,比如客户端渲染的外观。 |
numen.registerGear(...) | 登记一处能穿戴东西的位置,比如饰品栏,让智能体的穿戴系统认得它。参考 plugins/curios。 |
numen.configDir() | 言出法随与插件共用的配置目录,你的配置文件放在这里,文件名带上自己的模组 id。 |
numen.codec(...) | 登记一种类型和 Lua 值之间怎样互转,见"交回结果"。 |
api、bundleModules、registerEventType、registerGear 这些登记在服务端和客户端都要执行,请直接写在 register 的块里,不要放进 onClient。
检查你的插件
ApiTester.lint() 会检查所有已登记函数的写法问题,比如没写说明、参数没写 @Doc、没有例子、例子读不通、@SeeAlso 指向的函数不存在。ApiTester.lint(文档列表) 还能检查技能里写的 Lua 调用读不读得通。登记时言出法随只会拦下会破坏系统的错误(比如函数名在 Lua 里写不出来、参数类型无法转换),写法上的问题都在这份报告里。
目前 Numen API 还没有提供给第三方的单元测试夹具,最可靠的做法是在开发环境里装上言出法随启动游戏,服务器启动后打印报告。
// 开发环境的运行配置里需要装上完整的言出法随
NeoForge.EVENT_BUS.addListener((ServerStartedEvent e) ->
ApiTester.lint().stream()
.filter(l -> l.where().startsWith("mymod."))
.forEach(l -> LOGGER.info("[lint] {}", l)));报告要读通你写的例子,例子里如果调用了 numen.scan 这类言出法随自带的函数,就需要完整的言出法随在场,所以请在装了言出法随的开发环境里运行。这段代码只用于开发,发布前删掉,或者只在开发环境里注册它。
至于写法好不好,模型用得顺不顺,最终要看评测的分数。
参考实现
言出法随内置的五个插件都只通过本章介绍的公共入口对接,可以作为编写插件的模板。
| 插件 | 对接的模组 | 值得参考的地方 |
|---|---|---|
ysm | 是,史蒂夫模型 | 不引用目标模组的任何类,只通过指令和补全对接 |
tlm | 车万女仆 | 把界面里的功能封装成 API,用 call.use 过权限层 |
curios | Curios | 用 registerGear 把饰品栏接进智能体的穿戴系统 |
ftbquests | FTB Quests | 发出事件,让智能体知道任务进度 |
kaleidoscope | 森罗厨房 | 查询类 API 的写法 |
授权
言出法随的代码以 LGPL-3.0 授权,但单独发布、只通过 API 使用言出法随的插件和兼容模组可以采用任何协议,包括闭源。
