Plugins
A plugin is the bridge between another mod and Numen. It registers new Lua APIs with the agent, letting the agent do things vanilla interactions cannot, such as operating a screen that only exists on the client, or calling a command that has no auto-completion and whose arguments it cannot guess.
A plugin can be written by a mod's author directly into their own mod, or by anyone as a standalone compatibility mod that depends on both the target mod and Numen. Numen's five built-in plugins connect through the same entry point introduced in this chapter; they just have no mod entry point of their own. Instead, they are bundled inside Numen, which calls each one's install(...) (with different parameters) when the target mod is present, so when using them as references, only look at their NumenPlugins.register part. If you are not sure yet whether a mod needs a plugin or a skill, read the Architecture Overview first.
This chapter uses a fictional magic mod, mymod, as an example and writes a plugin from scratch that lets the agent check its own mana and cast spells. The plugins directory of the Numen repository contains real plugins you can refer to at any time.
A plugin can do the following, and this chapter covers each of them.
- Register a set of Lua APIs, which is a plugin's main job
- Ship skills with the mod, teaching the agent how to use these APIs well
- Ship Lua modules with the mod, combining several APIs into one action
- Tell the agent when something happens to it
Getting Ready
Adding the Dependency
If you don't yet know which parts Numen is made of or which artifact to depend on, read Dependencies first. Below, the most common case is used as an example: depending only on the slim API. If you depend on 0.1.4, first read "Known issue in 0.1.4" on the Dependencies page, as that version needs a different setup.
Numen's API is published on numen-maven. A plugin only needs a compile-time dependency on the slim API package (classifier api). The engine at runtime is provided by the Numen that players install, so your mod does not need to carry any of Numen's code.
repositories {
maven { url = 'https://raw.githubusercontent.com/Dwinovo/numen-maven/main' }
}
dependencies {
// NeoForge and Forge
compileOnly "com.dwinovo.numen:numen-api-neoforge-1.21.1:<version>:api"
// On Fabric, use modCompileOnly instead so that Loom remaps it to your mappings
// modCompileOnly "com.dwinovo.numen:numen-api-fabric-1.21.1:<version>:api"
}Replace the loader (neoforge, forge, fabric) and the Minecraft version with your targets, and fill <version> with Numen's version number. Numen's mod version and API version always match: mod 0.1.4 goes with API 0.1.4.
Declaring the Dependency
If the plugin lives in your own mod, Numen should be an optional dependency, so players without Numen can still use your mod normally. Taking NeoForge as an example, declare an optional dependency in neoforge.mods.toml.
[[dependencies.mymod]]
modId = "numen_api"
type = "optional"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"If you are writing a standalone compatibility mod, both the target mod and Numen are required dependencies.
[[dependencies.mymod_numen]]
modId = "numen_api"
type = "required"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"
[[dependencies.mymod_numen]]
modId = "mymod" # The target mod
type = "required"
versionRange = "*"
ordering = "AFTER"
side = "BOTH"ordering = "AFTER" makes your mod construct after Numen. Even if the order were reversed, NumenPlugins.register would wait until Numen is ready before running; this just matches how Numen itself is set up.
A few types such as ApiError and ErrorKind are not in the slim API but in the agent module, which is brought onto the compile classpath automatically through the slim API's dependency metadata. If they cannot be found at compile time, use ./gradlew dependencies --configuration compileClasspath to check whether they were brought in.
Step 1: Register the Plugin
Every integration goes through the same entry point, NumenPlugins.register.
If the plugin lives in your own mod, put the integration code in a class of its own and only touch it when Numen is loaded. That way, without Numen, the class is never loaded at all, so nothing crashes because Numen's classes cannot be found. A standalone compatibility mod has required dependencies on both sides and does not need this check; just call MyModNumen.install() directly in the mod entry point.
// Your mod entry point
@Mod("mymod")
public class MyMod {
public MyMod(IEventBus bus) {
// Only load the integration code if Numen is installed
if (ModList.get().isLoaded("numen_api")) {
MyModNumen.install();
}
}
}// The integration code, in a class of its own
public final class MyModNumen {
static void install() {
// "mymod" is your namespace; the functions you register are written as mymod.<group>.<function> in Lua
NumenPlugins.register("mymod", numen -> {
numen.api("mana", "Your mana and the spells you can cast.", ManaApi.class);
});
}
}The namespace is usually your mod ID or a short form of it. It must start with a lowercase letter, contain only lowercase letters, digits and underscores, be at most 48 characters long, and must not clash with Lua keywords, names Numen already uses (such as numen) or existing module names. Registering during mod construction is enough; Numen waits for the engine's internals to be ready on your behalf.
Step 2: Write a Set of Lua APIs
numen.api(group name, one-line description, class) registers the functions in a class as a set of Lua APIs. Use a domain noun for the group name, such as mana, so this set of functions becomes mymod.mana.xxx in Lua. The one-line description appears in the API index the model sees in every turn, so make clear what the group covers.
Below is the full ManaApi. The types used in this chapter live in these packages.
| Package | Types |
|---|---|
com.dwinovo.numen.api | NumenPlugins, NumenApi, CompanionEvent |
com.dwinovo.numen.sdk | Annotations such as @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 (the agent's body), BodyDelta |
import com.dwinovo.numen.sdk.*; // @Fn, @Doc, ServerCall, EntityRef, Call, etc.
import com.dwinovo.numen.agent.script.ApiError; // From the automatically included agent module
import com.dwinovo.numen.agent.script.ErrorKind;
import net.minecraft.world.entity.Entity;
import java.util.Optional;
public final class ManaApi {
// ---- mymod.mana.get() ----
/** The return value is a record, which becomes a table {current = 80, max = 100} in Lua */
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() is the agent's body, a server-side player entity
MagicData data = MagicData.of(call.her());
return new Mana(data.mana(), data.maxMana());
}
// ---- mymod.mana.cast(spell, {target = ...}) ----
/** Parameters are a record too, with each component being one parameter */
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) {
// Always throw ApiError on failure: say what went wrong, then give one line of next step that can be copied
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);
}
// An EntityRef names an entity; call.entity resolves it to that entity in the world and reports NOT_FOUND automatically if it is gone
Entity target = args.target().map(call::entity).orElse(null);
spell.cast(call.her(), target);
}
}Lua programs written by the agent can then call them like this.
-- First, check how much mana is left
local mana = mymod.mana.get()
-- If there is enough mana, cast a fireball at the nearest monster
if mana.current >= 20 then
local foe = numen.scan.entities("hostile")[1]
mymod.mana.cast("fireball", {target = foe})
endThe Method Signature Is the Contract
Numen reads everything about a function from its method signature, with no separate configuration.
| Part of the signature | What it determines |
|---|---|
| Method name | The function's name in Lua. camelCase becomes snake_case, so castSpell becomes cast_spell. If a name clashes with a Java keyword, specify it with @Fn(value = "...", name = "switch"), and the method itself can be called switch_. |
| First parameter | Where the function runs. See "Running on the Server or the Client" below. |
| Second parameter | A record whose components are the parameters. Non-Optional components become positional parameters in order, and Optional ones go into the trailing options table, such as {target = 184}. If the function takes no parameters, omit the second parameter. |
| Return type | What is handed back, and when. See "Returning Results" below. |
Parameters have two more annotations for friendlier call styles.
| Annotation | Effect | Example |
|---|---|---|
@Positional | On an Optional parameter, makes it an omittable positional parameter instead of an entry in the options table. It must be the last positional parameter. | The 8 in numen.gui.put("minecraft:coal", 8) |
@Rest | On the last List parameter, collects all remaining positional parameters. | numen.scan.blocks("iron_ore", "deepslate_iron_ore") |
Methods must be public static, and their class must be public too.
Documentation for the Model
The model knows nothing about your functions; all it can read is the documentation you write in annotations. This documentation automatically generates the API index and help, and the model can use numen.api.help("mymod.mana.cast") at any time to see a function's full usage.
| Annotation | What to write |
|---|---|
@Fn | One sentence on what the function does. Required. |
@Doc | On a record component, describes what this parameter or returned field is and what values it takes. It can also go on the record type itself, to describe what the whole table is. |
@Omitted | On an Optional parameter, describes what it means when left out. |
@Example | A one-line call example that runs as-is; you can write several. Models are best at adapting from examples, so this is the most useful one. |
@Note | Extra notes, such as "read-only", "what it consumes" or "don't keep retrying on failure". |
@SeeAlso | Related functions, helping the model find what to call next. |
Please write the documentation in annotations in English. Skill bodies can be in any language, and the built-in skills come in both. Models understand English most reliably, and all of Numen's own API documentation is in English. Er, this is also just a suggestion: go with whatever scores higher~
Running on the Server or the Client
The agent's Lua programs run on the server, right next to its body. The first parameter decides where the function runs.
ServerCallruns on the server's main thread, with access to the agent's body (call.her(), of typeNumenPlayer) and the whole world, and can call your mod's game logic directly. The vast majority of functions should be written this way.ClientCallruns on the owner's client and is only for reading data that only the client knows, such as appearance rendered on the client. The server sends a request to the owner's client and waits for its answer. Note that it always reads the data on the owner's client, not the agent's (the agent has no client); if the owner is offline or does not answer within 30 seconds, the call fails withtimeout.
Client functions can only return their result immediately and cannot use Pending or Job. Put code that uses client classes in a class of its own and only reference it inside the function body, so that these classes are not loaded on a dedicated server. Please also follow a few conventions: only do read-only queries with no side effects, so that retrying after a timeout is safe; a client's answer can be tampered with by the player, so no decision may rely on it; and query in batches where possible, filtering on the client and returning only a small result, rather than asking one item at a time in a loop.
Returning Results
The return type decides how the function hands back its result.
| Return type | When to use it |
|---|---|
R (any type) | The result can be computed on the spot, such as checking mana. Use void if nothing is returned. |
Pending<R> | The result takes a while, such as waiting for the owner's approval, waiting for the next game tick, or waiting for a short action that is sure to finish within a few seconds. The agent's body is not occupied while waiting. |
Job<R> | Work that occupies the body for a while, such as walking all the way somewhere or fighting until the target falls. It takes the agent's only task slot, and the program gets the result once it wraps up. |
Both Job and "a short action that is sure to finish within a few seconds" require you to write a task of your own (a TaskRecord and its matching Task). This chapter does not go into it; see PotActRecord and PotActTask in plugins/kaleidoscope. Most APIs do not need it: returning on the spot or waiting a little with Pending is enough.
Parameters and return values support these types, which are converted to and from Lua values automatically.
- Java primitive types and strings, and enums (written as lowercase names in Lua, such as
"wheat") - Records (a table in Lua),
List,Optional,Map<String, T> - Minecraft values, such as the coordinate
BlockPos(written as{x = 1, y = 64, z = 2}in Lua), the itemItem(written as"minecraft:coal"), andEntityReffor naming an entity
For other types, use numen.codec(type, conversion) to register how they convert to and from Lua values. Finding nothing is not a failure: just return an empty list or Optional.empty(). Only when a specific named thing does not exist is it a NOT_FOUND failure.
Reporting Failures
There is only one way to fail: throw an ApiError. It takes three arguments: the kind of failure, what went wrong, and one line of next step that can be copied (pass null if there is none). Don't write the next step as a hand-made string; generate it with Call.of("function name", arguments...), which assembles the arguments in Lua syntax. The kind decides how the model understands the failure. The common ones are below; see ErrorKind for the full list.
| Kind | Meaning |
|---|---|
BAD_ARGUMENT | An argument is wrong |
NOT_FOUND | The named thing does not exist |
OUT_OF_REACH | Cannot reach it from here; walk over first |
NO_MATERIAL | Missing something that needs to be consumed |
NO_PATH | No path to get there |
DENIED | The owner refused, or the rules do not allow it |
FAILED | It was attempted but did not succeed; use this when no other kind fits |
The more specific the error message, the less likely the model is to keep making the same mistake. "Needs 20 mana, you have 12" is far more useful than "not enough mana".
Changing the World Goes Through the Permission Layer
Whether the agent may do things to the world that affect others, such as right-clicking someone else's entity or running a command, is decided centrally by Numen's permission layer. The owner can set rules, and the owner may be asked through a pop-up when needed. So don't write your own "is this allowed" checks in your functions. Instead, use the primitives ServerCall provides, and the permission layer will decide for you at the moment of execution.
call.use(entity, reach, action)is for doing something to an entity. The second argument is aServerCall.Reach, usuallyServerCall.Reach.hand(), meaning the same distance a player can interact at by reaching out; if you need further, writeReach.hand(extra blocks). It checks whether the target is within reach, goes through the permission layer, and runs your action once allowed. The function that sets a maid's work mode inplugins/tlmis written this way.call.authorize(action)is for other things that need a decision. It returns aPendingand continues only once allowed.call.onHer()is for running a command with the server's permission that only affects the agent itself. For example,call.onHer().run("ysm model set", "misc/1_alex")actually runsysm model set <the agent's name> misc/1_alex: the target is always the agent itself, placed right after the prefix you give. It is not suitable for commands that need to target someone else, or that need to run with the agent as the source.
The permission layer only deals with things that affect the world and others. Things that only affect the agent itself, such as teleporting it or spending its own experience, do not need to go through the permission layer and can be done directly, but you must tell it truthfully what changed in its body. See "Telling the Agent What Happened" below.
The "action" that call.authorize takes is a com.dwinovo.numen.permission.Action; use its factory methods to describe what is to be done. It is a fixed set, made up only of what is in the table below. If what you want to do does not fit any of them, don't force it: you can run the corresponding command instead (commands themselves go through the permission layer), or open an issue on GitHub to ask for a new one.
| Factory method | The action it describes |
|---|---|
Action.useBlock(pos, blockState) | Right-clicking a block |
Action.breakBlock(pos, blockState) | Breaking a block |
Action.place(pos, currentBlockState, item) | Placing a block somewhere |
Action.take(containerPos, blockState, item) | Taking something from a container |
Action.useEntity(entity), Action.attack(entity) | Right-clicking or attacking an entity |
Action.drop(item) | Dropping an item |
Action.command(fullCommandLine, commandTreeRoot) | Running a game command. Get the root with server.getCommands().getDispatcher().getRoot() |
When doing something to a block, first check for yourself whether it is within reach, then go through the permission layer. For example, starting a machine:
public static Pending<Started> start(ServerCall call, Start args) {
BlockPos at = args.at();
BlockState state = call.her().serverLevel().getBlockState(at);
// If it can't be reached from here, have the model walk over first
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)));
}
// Go through the permission layer, and only change the machine once allowed
return call.authorize(Action.useBlock(at, state)).then(v -> {
Machine machine = Machine.at(call.her().serverLevel(), at);
machine.start();
return new Started(machine.state());
});
}Step 3: Teach the Agent to Use These APIs Well
APIs only give the agent abilities; when to use them and how to use them well still needs to be taught with skills. Put the skills in the skills/ directory of your mod's resources and hand them to Numen when registering the plugin.
NumenPlugins.register("mymod", numen -> {
numen.api("mana", "Your mana and the spells you can cast.", ManaApi.class);
// Hand the skills directory in the jar to Numen; each subfolder under it is one skill
// findResource returns a Path even for paths that don't exist, so check that it exists first
Path skills = ModList.get().getModFileById("mymod").getFile().findResource("skills");
if (Files.isDirectory(skills)) {
numen.bundleSkills(skills);
}
});These skills are read directly from your jar and are not copied to the player's disk, so they disappear when the mod is uninstalled. Players can also put a skill with the same name in their own skills directory to override your version. On a dedicated server, bundleSkills does nothing and skills only take effect on players' clients; you don't need to check this yourself.
Going Further
Combining APIs with Lua Modules
Some things are a fixed combination of several APIs, such as "keep casting heal until at full health". Rather than having the model write the loop itself every time, write it as a Lua module and ship it with your mod. Put the .lua files in a directory and hand it to bundleModules.
Path modules = ModList.get().getModFileById("mymod").getFile().findResource("modules");
if (Files.isDirectory(modules)) {
numen.bundleModules(modules);
}A module's name is your namespace plus the file name, so mana.lua becomes mymod.mana. A module with the same name as an API group adds its functions to that group.
-- Combined actions related to mana
local M = {}
-- Keep casting heal until at full health or out of mana; returns how many times it was cast
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 MThe comment at the top of the file and the comment above each function are the documentation the model sees in the API index and help, so write them carefully.
Telling the Agent What Happened
Numen's principle is that the agent must know about every change to its body. Depending on when the change happens, there are two ways to tell it.
If the change happens inside your API call, such as casting a spell spending experience or a teleport moving it, explain it in the result of that call. Make the return value clear about the result; for side changes to the body, take a snapshot with BodyDelta before the action, get the changes after it, and write them into the receipt of this call with Pending's report.
BodyDelta before = BodyDelta.open(call.her());
teleport(call.her(), target); // Your action
Pending<Arrived> result = Pending.create();
result.report(String.join("; ", before.changes(call.her()))); // What changed in the inventory, experience, health and riding
result.complete(new Arrived(target.name()));
return result;report must be called before complete, because once there is a result, the receipt of this call is settled.
If the change happens outside your API, such as her using a recall scroll or being cursed by something else, emit an event. Register the event type first, then emit it when it happens.
public final class MyModNumen {
/** The numen received at registration, kept for when events happen */
private static NumenApi numen;
static void install() {
NumenPlugins.register("mymod", api -> {
numen = api;
// Register the event type. The second argument says whether it is always urgent; urgent events make the agent handle them right away
api.registerEventType("cursed", true);
// Subscribe to the agent entering the world, for some initialization
api.on(CompanionEvent.SPAWN, body -> { /* ... */ });
});
// The moment it happens. Listening to your own mod's events uses the event bus you normally use
NeoForge.EVENT_BUS.addListener((CurseAppliedEvent e) -> {
// Check whether this player is the agent
if (e.getPlayer() instanceof NumenPlayer her) {
numen.emit(her, "cursed", Map.of("curse", "weakness"), "You were cursed with weakness.", true);
}
});
}
}numen.on is only for subscribing to Numen's own CompanionEvents, of which there are five: SPAWN (entering the world), REMOVE (leaving the world), DEATH (dying), HURT (getting hurt) and ABORT (the owner interrupted it, and any unfinished work should be dropped). Listen to your own mod's events on its own event bus as usual. Events are handed to the model just like any other message the agent receives, and as with API documentation, writing their text in English is recommended.
Numen already tells the agent about these automatically, so you don't need to emit them again: death (death), changing dimension (dimension_change), getting hungry (hungry), a full inventory (inventory_full), dropping things (dropped), the owner getting hurt (owner_hurt), receiving an advancement reward (advancement_reward), a background task finishing (task_finished), a timer going off (timer), and so on.
State Attached to Every Turn
If your mod adds some lasting state to the agent, such as its current mana or curses on it, you can use contributeBodyState to attach it to the request in every turn, so the model always knows.
numen.contributeBodyState(body -> {
MagicData data = MagicData.of(body);
return "<mana>" + data.mana() + "/" + data.maxMana() + "</mana>";
});Don't include values that change every game tick, such as exact remaining seconds or coordinates, as that would trigger a sync every tick. Values that change only occasionally (such as mana or balance) are fine, as they only sync when they change; for anything that changes too often, let the model call an API to look it up.
Other Entry Points
numen has a few less commonly used methods; check their comments when you need them.
| Method | Purpose |
|---|---|
numen.onClient(...) | Wraps code that only makes sense on the client, such as registering avatars or rendering-related things. The whole block does not run on a dedicated server, so you don't need to check which side you are on. |
numen.contributeState(...) | Like contributeBodyState, but for things only the owner's client knows, such as appearance rendered on the client. |
numen.registerGear(...) | Registers a place where things can be worn, such as an accessory slot, so the agent's equipment system recognizes it. See plugins/curios. |
numen.configDir() | The configuration directory shared by Numen and plugins. Put your configuration files here, with your mod ID in the file name. |
numen.codec(...) | Registers how a type converts to and from Lua values. See "Returning Results". |
Registrations such as api, bundleModules, registerEventType and registerGear must run on both the server and the client, so write them directly in the register block, not inside onClient.
Checking Your Plugin
ApiTester.lint() checks every registered function for writing problems, such as missing documentation, parameters without @Doc, no examples, examples that do not parse, or @SeeAlso pointing to a function that does not exist. ApiTester.lint(list of documents) can also check whether the Lua calls written in skills parse. At registration, Numen only blocks errors that would break the system (such as a function name that cannot be written in Lua, or a parameter type that cannot be converted); writing problems all show up in this report.
The Numen API does not yet provide a unit test fixture for third parties. The most reliable approach is to install Numen in your development environment, launch the game, and print the report once the server has started.
// The development run configuration needs the full Numen installed
NeoForge.EVENT_BUS.addListener((ServerStartedEvent e) ->
ApiTester.lint().stream()
.filter(l -> l.where().startsWith("mymod."))
.forEach(l -> LOGGER.info("[lint] {}", l)));The report has to parse your examples, and if an example calls Numen's own functions such as numen.scan, the full Numen needs to be present, so run it in a development environment with Numen installed. This code is only for development: remove it before release, or register it only in the development environment.
As for whether it is well written and whether the model uses it smoothly, that ultimately comes down to the evaluation score.
Reference Implementations
Numen's five built-in plugins connect only through the public entry points introduced in this chapter, and can serve as templates for writing plugins.
| Plugin | Mod it connects to | Worth looking at |
|---|---|---|
ysm | Yes Steve Model | Does not reference any class of the target mod; connects only through commands and completion |
tlm | Touhou Little Maid | Wraps the features of a screen as APIs, and goes through the permission layer with call.use |
curios | Curios | Connects the accessory slots to the agent's equipment system with registerGear |
ftbquests | FTB Quests | Emits events so the agent knows about quest progress |
kaleidoscope | Kaleidoscope Cookery | How query APIs are written |
License
Numen's code is licensed under LGPL-3.0, but plugins and compatibility mods that are released separately and use Numen only through its API can use any license, including closed source.
