English
Action Pipeline Extension SDK
A third-party plugin registers its own sources, gates, and actions into the Emaki action pipeline so server owners can invoke those stages from any pipeline line. The registration specification covers registration entry points, domain declarations, contract declarations, Folia notes, and verification
The same specification ships with the API jar as javadoc (the package-info of emaki.jiuwu.craft.corelib.api.action and the CoreActionExecutionDomain enum) — hover it in your IDE; the page and that javadoc are kept in sync
Registration entry points
All six entry points live on the EmakiCoreLibApi facade, scoped by owner (the calling plugin), and each returns a closeable handle:
| Entry point | Stage role | How it appears in a pipeline |
|---|---|---|
registerActionSource(Plugin, CoreActionSource) | Produces the target flow | At line start, e.g. <your stage> radius=8 |
registerActionGate(Plugin, CoreActionGate) | Filters / reorders / controls flow | ... | <your stage> ... |
registerActionStage(Plugin, CoreActionStage) | Produces an effect | ... | <your stage> amount=3 |
registerActionTrigger(Plugin, CoreActionTrigger) | Trigger entry | Invoked by the trigger mechanism |
registerTargetCondition(Plugin, CoreTargetCondition) | Target condition type | filter '<your condition>=value' |
registerTargetIdentityProvider(Plugin, CoreTargetIdentityProvider) | External-system identity | Identity recognition and <system id>_level conditions |
No source parameter, no unregister-by-id
A stage is revoked only through the returned handle's close(), or automatically by CoreLib when the owner plugin is disabled. A plugin cannot revoke a stage registered by another plugin. Re-registering the same id (same owner) replaces the old stage
Domain declaration checklist (mandatory)
Every source/action must implement executionTarget(CoreStagePlanningContext) and return a CoreActionExecutionTarget with a declared scheduling domain. A stage declared UNDECLARED is refused at registration — on Folia such a stage crashes the server rather than raising a handleable error
CoreActionExecutionDomain | Meaning | Typical use |
|---|---|---|
SERVER_GLOBAL | Server global thread | Server-wide player lookups, game rules, config writes |
CONTEXT_ENTITY | The current target entity's owner thread | Inventory edits, teleport, glowing, player messages |
LOCATION_REGION | Location/region domain | Nearby entity search, particles/blocks at the origin |
ASYNC_COMPUTE | Pure async computation | Must not require targets (targetRequirement must be NONE, otherwise registration is refused) |
UNDECLARED | Undeclared | Forbidden |
Gates are dispatched through CoreGateThread: PURE (touches no Bukkit state, always safe on Folia, and the default) or NEEDS_ENTITY_READ (reads entity state)
Contract declaration checklist
Run this list before registering; each item is checked at registration, and a mistake returns a reasonKey:
- id: non-empty, lowercase, no
=; registering an id that already exists returnsduplicate_id(with the existing owner name) - Domain: see above; async domain + required targets =
async_needs_target - Parameter table:
CoreStageParameter.required(name, type, desc)/optional(name, type, default, desc)/positional(name, type, desc). At compile time an undeclared argument reportsunknown_argumentand a missing required one reportsmissing_required_argument - Target requirement:
targetRequirement()isNONE(no target needed, e.g. broadcast) /OPTIONAL/REQUIRED_ENTITY/REQUIRED_LOCATION/REQUIRED_ANY. DeclaringREQUIRED_*means calls without a target are skipped (action.run.no_target) — no manual null checks needed - Context keys: declare the
CoreActionKeys your stage reads inrequiredContext(); the compiler checks them against the trigger'sPhaseContract, so a missing key is a load-time error, not a runtime NPE - Argument types:
CoreStageParameterType(STRING/INTEGER/DOUBLE/BOOLEAN/DURATION/ENTITY_TYPE/MATERIAL/PERCENTAGE/EXPRESSION/SOUND). Literal (non-placeholder) values are type-checked at compile time (invalid_argument_type)
Folia notes
- All 15 Emaki runtime modules declare
folia-supported: true. A stage executes in whichever domain it declared — never assume the main thread: aCONTEXT_ENTITYstage runs on the entity owner thread, aLOCATION_REGIONstage on the region-domain thread - A domain declaration tightens rather than loosens: the narrower the declaration, the more restricted the Bukkit API you may call; declaring too wide (e.g. an inventory-editing stage as
SERVER_GLOBAL) throws on Folia - For cross-domain hops use
EmakiCoreLibApi.scheduling()— owner-aware global/entity/location/async scheduling; accessing Bukkit from an async body is forbidden - Async work a stage starts is not managed by CoreLib; the registering owner plugin must cancel it on disable
Re-register after reload
The action stage table is rebuilt when CoreLib reloads, and previously registered stages become invalid. Always attach a rebuild callback after registering:
java
EmakiCoreLibApi.onStageRegistryRebuilt(this, () -> {
registerMyStages();
});Use addStageRegistryRebuildListener(owner, callback) when one plugin needs several independent callbacks (each appended independently, returns a closeable handle). The animation definition registry is not rebuilt on reload and needs no callback
Registration timing in onEnable: check EmakiCoreLibApi.status().usable() first; when CoreLib is unavailable the entry points return inactive handles and never throw
How to verify
- List registered stages:
<any plugin root> debug action registry(e.g./emakicorelib debug action registry) — lists every stage grouped by source/gate/action with the owner plugin name; third-party stages are directly identifiable - Static check:
debug action check "<your stage> args"— parse + validate, outputs every diagnostic (with column and candidate suggestions) - Dry run:
debug action run shadow "<pipeline line>"— sources/gates evaluate for real, actions only print what would run, stepping through the stage's behavior in the pipeline
Full example
A minimal third-party extension skeleton (1 source + 1 gate + 1 action):
java
public final class ExampleExtensionPlugin extends JavaPlugin {
private CoreStageRegistration sourceHandle;
private CoreStageRegistration gateHandle;
private CoreStageRegistration actionHandle;
@Override
public void onEnable() {
if (!EmakiCoreLibApi.status().usable()) {
getLogger().warning("EmakiCoreLib unavailable, skipping stage registration");
return;
}
registerStages();
EmakiCoreLibApi.onStageRegistryRebuilt(this, this::registerStages);
}
private void registerStages() {
sourceHandle = EmakiCoreLibApi.registerActionSource(this, new HighLevelPlayersSource());
gateHandle = EmakiCoreLibApi.registerActionGate(this, new FreshSpawnGate());
actionHandle = EmakiCoreLibApi.registerActionStage(this, new BroadcastConsoleAction());
}
@Override
public void onDisable() {
close(sourceHandle);
close(gateHandle);
close(actionHandle);
}
private static void close(@Nullable CoreStageRegistration handle) {
if (handle != null) {
handle.close();
}
}
}Key points for the three stages:
java
final class HighLevelPlayersSource implements CoreActionSource {
@Override public String id() { return "example_high_level_players"; }
@Override public String description() { return "Online players at or above a level"; }
@Override public List<CoreStageParameter> parameters() {
return List.of(CoreStageParameter.optional("level", CoreStageParameterType.INTEGER, "10", "Minimum level"));
}
@Override public CoreActionExecutionTarget executionTarget(CoreStagePlanningContext context) {
return CoreActionExecutionTarget.global();
}
@Override public CoreSourceResult select(CoreStageContext context, CoreResolvedArguments arguments) {
int min = arguments.getInt("level", 10);
List<CoreActionSubject> matched = new ArrayList<>();
for (Player player : Bukkit.getOnlinePlayers()) {
if (player.getLevel() >= min) matched.add(CoreActionSubject.of(player));
}
return matched.isEmpty()
? CoreSourceResult.empty("example.source.no_match")
: CoreSourceResult.selected(matched);
}
}- Gate: implement
apply(context, inbound, arguments)and returnCoreGateResult.passed(kept flow)/halted(reasonKey)/invalid(reasonKey); pickPURE/NEEDS_ENTITY_READforthreadNeed()depending on whether you read entity state - Action: implement
execute(context, arguments), act oncontext.currentTarget(), and returnCoreActionOutcome.success()/skipped(reasonKey)/failure(kind, reasonKey); declare the domain matching the Bukkit state you actually change - Custom reasonKeys (e.g.
example.source.no_match) resolve their text from the registering plugin's ownlang/; when unconfigured,DiagnosticRendererfalls back to the raw key instead of throwing
Argument types quick reference
| Type | Compile-time check (no placeholders) |
|---|---|
STRING / SOUND | always passes |
INTEGER / DOUBLE | number parsing |
BOOLEAN | true / false |
DURATION | tick/time format (1s, 20t...) |
ENTITY_TYPE / MATERIAL | vanilla enum/material exists |
PERCENTAGE | 0~1 decimal or x/y fraction |
EXPRESSION | expression engine can evaluate |
Read arguments through CoreResolvedArguments: getString / getInt / getDouble / getBoolean / getDurationTicks / getChance / getEntityType / getMaterial / getExpression; every accessor has a fallback, and an optional parameter with a declared defaultValue falls back to that default when omitted