Skip to content

插件 ​

插件是连接其他模组与言出法随的桥梁。它向智能体注册新的 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),运行时的引擎由玩家安装的言出法随提供,你的模组不需要携带任何言出法随的代码。

gradle
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 里声明一条可选依赖。

toml
[[dependencies.mymod]]
    modId = "numen_api"
    type = "optional"
    versionRange = "*"
    ordering = "AFTER"
    side = "BOTH"

如果你写的是一个独立的兼容模组,那么目标模组和言出法随都是它的必需依赖。

toml
[[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() 即可。

java
// 你的模组入口
@Mod("mymod")
public class MyMod {
    public MyMod(IEventBus bus) {
        // 只有装了言出法随,才加载对接代码
        if (ModList.get().isLoaded("numen_api")) {
            MyModNumen.install();
        }
    }
}
java
// 对接代码单独一个类
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.apiNumenPlugins、NumenApi、CompanionEvent
com.dwinovo.numen.sdk@Fn 等注解、ServerCall、ClientCall、Pending、Job、EntityRef、Call、ApiTester
com.dwinovo.numen.agent.scriptApiError、ErrorKind
com.dwinovo.numen.permissionAction
com.dwinovo.numen.entityNumenPlayer(智能体的身体)、BodyDelta
java
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 程序就可以这样调用它们。

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() 取得

对方块做事时,先自己确认够不够得着,再过权限层。比如启动一台机器:

java
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/ 目录下,在登记插件时交给言出法随。

java
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。

java
Path modules = ModList.get().getModFileById("mymod").getFile().findResource("modules");
if (Files.isDirectory(modules)) {
    numen.bundleModules(modules);
}

模块名是你的名字空间加文件名,mana.lua 就是 mymod.mana。和某个 API 组同名的模块,会把它的函数加进那一组。

lua
-- 法力相关的组合动作
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 写进这次调用的回执。

java
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 之外,比如她用了回城卷轴、被别的东西施加了诅咒,就发一个事件。先登记这种事件,再在发生时发出它。

java
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 在每一轮把它附在请求里,让模型随时知道。

java
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 还没有提供给第三方的单元测试夹具,最可靠的做法是在开发环境里装上言出法随启动游戏,服务器启动后打印报告。

java
// 开发环境的运行配置里需要装上完整的言出法随
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 过权限层
curiosCurios用 registerGear 把饰品栏接进智能体的穿戴系统
ftbquestsFTB Quests发出事件,让智能体知道任务进度
kaleidoscope森罗厨房查询类 API 的写法

授权 ​

言出法随的代码以 LGPL-3.0 授权,但单独发布、只通过 API 使用言出法随的插件和兼容模组可以采用任何协议,包括闭源。