A configurable action system for Paper plugins. Server owners describe what should happen in YAML — messages, titles, sounds, commands, delays, conditions, branching — and the plugin runs it.
Built on okaeri-configs, so action lists are ordinary typed config fields.
Requires Paper 1.18.2 or newer.
- Install
- Quick start
- Config format
- Actions
- Targets
- Conditions
- Placeholders
- Named sequences
- Declaring defaults in Java
- Custom actions
- Scheduling
- Upgrading from the inline format
repositories {
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.okaeri.cloud/releases")
maven("https://repo.tcoded.com/releases")
maven("https://repo.vulpine.top/repository/maven-open/")
}
dependencies {
implementation("top.vulpine:actions:0.1.0")
implementation("eu.okaeri:okaeri-configs-yaml-bukkit:5.0.13")
implementation("eu.okaeri:okaeri-configs-serdes-bukkit:5.0.13")
implementation("com.tcoded:FoliaLib:0.5.1")
compileOnly("io.papermc.paper:paper-api:1.18.2-R0.1-SNAPSHOT")
}Shade and relocate the libraries into your plugin:
tasks.shadowJar {
val base = "com.example.myplugin.libs"
relocate("top.vulpine.actions", "$base.actions")
relocate("top.vulpine.commons", "$base.commons")
relocate("eu.okaeri", "$base.okaeri")
relocate("com.tcoded.folialib", "$base.foliaLib")
}Do not relocate
net.kyori.adventure. Paper provides it unrelocated, and a relocatedComponentwill not satisfy Paper's own method signatures.
1. Declare an action list in your config class.
public class Config extends OkaeriConfig {
public List<Action> join = new ArrayList<>(List.of(
MessageAction.builder()
.target(Target.SELF)
.text("<green>Welcome, %player%")
.build()
));
}2. Register the serdes pack when loading the config.
ActionRegistry registry = BuiltinActions.registry();
Config config = ConfigManager.create(Config.class, it -> {
it.withConfigurer(new YamlBukkitConfigurer(), new SerdesBukkit(),
new ActionSerdes(registry));
it.withBindFile(new File(getDataFolder(), "config.yml"));
it.saveDefaults();
it.load(true);
});3. Run a list.
FoliaLib foliaLib = new FoliaLib(this);
ActionScheduler scheduler = new FoliaLibScheduler(foliaLib);
ActionContext context = ActionContext.builder(scheduler)
.player(player)
.value("player", player.getName())
.build();
ActionExecutor executor = ActionExecutor.run(config.join, context);Keep the returned executor if the list can contain a delay, and call
executor.cancel() when the run should be abandoned — a player quitting, for example.
An action is a YAML block with a type:
join:
- type: message
target: all
text: "<aqua>%player% <gray>joined"
- type: delay
time: 3s
- type: title
target: self
title: "<green>Welcome"
subtitle: "<gray>Enjoy your stay"Every key except type is optional and has a default.
An action that cannot be understood — an unknown type, a malformed value — logs a
warning at startup, does nothing at runtime, and is preserved unchanged when the file
is saved. The rest of the config still loads.
Sends chat lines.
- type: message
target: self
text: "<green>Hello"
- type: message
target: all
text:
- "<gray>First line"
- "<gray>Second line"| Key | Default | |
|---|---|---|
target |
self |
see Targets |
text |
— | a string or a list of strings |
Shows a title, a subtitle, or both.
- type: title
target: self
title: "<gradient:#00ff87:#60efff>Welcome"
subtitle: "<gray>Enjoy your stay"
fade_in: 20t
stay: 3s
fade_out: 20t| Key | Default | |
|---|---|---|
target |
self |
|
title |
empty | leave blank for a subtitle-only effect |
subtitle |
empty | |
fade_in |
10t |
see durations |
stay |
40t |
|
fade_out |
10t |
Sends text above the hotbar.
- type: actionbar
target: self
text: "<yellow>Teleporting in %time%s"| Key | Default | |
|---|---|---|
target |
self |
|
text |
empty |
Plays a sound.
- type: sound
target: self
key: "entity.player.levelup"
volume: 1.0
pitch: 1.4
source: master| Key | Default | |
|---|---|---|
target |
self |
|
key |
empty | a namespaced key; blank disables the sound |
volume |
1.0 |
|
pitch |
1.0 |
|
source |
master |
master, music, record, weather, block, hostile, neutral, player, ambient, voice — decides which volume slider applies |
Keys are lowercase and dotted, as the client knows them: entity.player.levelup,
block.note_block.pling, ui.button.click. Resource pack sounds work too.
Uppercase names such as ENTITY_PLAYER_LEVELUP are rejected with a warning at
startup. They are not convertible by lowercasing — many sounds keep an underscore
inside a segment, so BLOCK_NOTE_BLOCK_PLING is block.note_block.pling and not
block.note.block.pling. The full list is in the
vanilla sounds.json.
Runs a command.
- type: command
as: console
command: "give %player% diamond 1"
- type: command
as: player
target: all
command: "spawn"| Key | Default | |
|---|---|---|
as |
console |
console runs it once; player runs it once per targeted player |
target |
self |
only used when as: player |
command |
empty | a leading / is optional |
- type: gamemode
target: self
mode: adventure| Key | Default | |
|---|---|---|
target |
self |
|
mode |
— | survival, creative, adventure, spectator |
Teleports to fixed coordinates.
- type: teleport
target: self
world: lobby
x: 0.5
y: 100
z: 0.5
yaw: 90
pitch: 0| Key | Default | |
|---|---|---|
target |
self |
|
world |
— | the world name |
x y z |
0 |
|
yaw pitch |
0 |
The world is looked up when the action runs, so a world loaded later by a world manager still works. If it is missing, the action logs a warning and is skipped.
Pauses the list. Everything after it continues once the wait is over, including inside nested branches.
- type: delay
time: 3s| Key | Default | |
|---|---|---|
time |
none | see durations |
Runs one of two lists depending on a condition.
- type: if
condition: "%player_level% >= 10"
then:
- type: message
target: self
text: "<green>Unlocked"
else:
- type: message
target: self
text: "<gray>You need level 10"| Key | Default | |
|---|---|---|
condition |
always true | see Conditions |
then |
empty | |
else |
empty |
Runs exactly one of several alternatives.
- type: random
options:
- weight: 3
actions:
- type: message
target: self
text: "<gray>Tip: try /spawn"
- weight: 1
actions:
- type: message
target: self
text: "<gray>Tip: try /kit"Weights are relative, not percentages — 3 and 1 mean three times out of four.
Omitting weight means 1, so equally likely alternatives need no weights.
Runs a list several times.
- type: repeat
times: 3
actions:
- type: sound
target: self
key: "block.note_block.pling"
- type: delay
time: 10tTo space the passes out, put a delay inside actions. Maximum 1000 passes.
Abandons the rest of the run, including any enclosing lists. Useful as an early exit:
- type: if
condition: "permission: lobby.bypass"
then:
- type: stop
- type: gamemode
target: self
mode: adventureStores a value that later actions in the same run can read as %name%.
- type: set
key: balance_before
value: "%vault_eco_balance%"
- type: command
as: console
command: "eco take %player% 100"
- type: message
target: self
text: "<gray>You had %balance_before% coins"| Key | Default | |
|---|---|---|
key |
— | the variable name, without percent signs |
value |
empty | may contain placeholders |
The value lives for the length of the run. Two players triggering the same list each have their own.
Runs a named sequence.
- type: run
sequence: vip_welcomeAnywhere a duration is accepted:
20t |
ticks |
500ms |
milliseconds, rounded up to at least one tick |
3s |
seconds |
2m |
minutes |
Who an action applies to.
self |
the player who triggered the run |
all |
everyone online |
others |
everyone except the triggering player |
world:<name> |
everyone in that world |
radius:<blocks> |
everyone within that distance of the triggering player |
permission:<node> |
everyone holding that permission |
An unrecognised target logs a warning and falls back to self.
A condition is a single expression, a list, or a block of all / any / not.
condition: "%player_level% >= 10" # one expression
condition: [ A, B ] # a list means all of them
condition:
all: [ A, B ] # every one true
any: [ A, B ] # at least one true
not: A # A falseKeys next to each other combine with AND. So this reads as "(vip or rich) and not in the arena":
condition:
any:
- "permission: lobby.vip"
- "%vault_eco_balance% >= 10000"
not: "world: arena"Any element of all or any can itself be a block, so nesting is unlimited:
condition:
any:
- "permission: lobby.vip"
- not: "world: arena"
- all:
- "%player_level% >= 10"
- any: [ "world: hub", "world: lobby" ]not takes a single condition. To negate a group, nest it:
not:
any: [ "permission: a", "permission: b" ] # holds neither permission| Example | ||
|---|---|---|
== |
"%player_world% == lobby" |
case-insensitive |
!= |
"%player_gamemode% != CREATIVE" |
case-insensitive |
> >= < <= |
"%player_level% >= 10" |
numeric |
contains |
"%player_name% contains admin" |
case-insensitive |
starts_with |
"%player_world% starts_with arena_" |
case-insensitive |
ends_with |
"%player_name% ends_with _alt" |
case-insensitive |
matches |
"%player_name% matches ^[A-Z][a-z]+$" |
regex, case-sensitive |
If both sides are numbers, the comparison is numeric — 10 > 9 is true. If either
side is not a number, > >= < <= log a warning and evaluate to false, since
ordering text alphabetically is rarely what was meant.
| Example | |
|---|---|
permission: |
"permission: lobby.vip" |
world: |
"world: lobby" |
gamemode: |
"gamemode: adventure" |
sneaking: |
"sneaking: true" |
chance: |
"chance: 25%" or "chance: 0.25" |
%name% tokens are expanded from two sources: values the plugin puts in the context,
and anything the plugin has hooked up externally.
For PlaceholderAPI:
Actions.placeholders(PlaceholderAPI::setPlaceholders);If a placeholder does not expand — usually a missing expansion — conditions that use it evaluate to false and log a warning naming the placeholder. The warning appears once, not on every event.
Values supplied through the context are escaped before being rendered as text, so a player whose name contains MiniMessage syntax cannot alter the formatting of a message or inject a clickable tag. Values are not escaped inside commands or condition operands, where the raw text is what is wanted.
A sequence is an action list with a name, callable from anywhere. Define them in a config of your own and register them:
# actions.yml
sequences:
vip_welcome:
- type: message
target: self
text: "<gold>Welcome back"
- type: sound
target: self
key: "entity.player.levelup"SequenceRegistry sequences = new SequenceRegistry();
sequences.putAll(actionsConfig.sequences);
ActionContext context = ActionContext.builder(scheduler)
.player(player)
.sequences(sequences)
.build();Then anywhere:
- type: run
sequence: vip_welcomeSequences are resolved when the action runs, so one may be defined after another that calls it. A sequence that eventually calls itself is abandoned after 32 levels of nesting, with a warning.
Every action has a builder. target defaults to self and can be omitted.
public List<Action> join = new ArrayList<>(List.of(
TitleAction.builder()
.title("<green>Welcome, %player%")
.subtitle("<gray>Enjoy your stay")
.stay("3s")
.build(),
MessageAction.builder()
.target(Target.ALL)
.text("<aqua>%player% <gray>joined")
.build(),
SoundAction.builder()
.key("entity.player.levelup")
.pitch(1.4F)
.build(),
DelayAction.builder()
.time("1s")
.build(),
IfAction.builder()
.condition("permission: lobby.vip")
.then(CommandAction.builder()
.asConsole()
.command("give %player% diamond 1")
.build())
.otherwise(MessageAction.builder()
.text("<gray>No perks")
.build())
.build(),
RandomAction.builder()
.choice(3, MessageAction.builder().text("<gray>Try /spawn").build())
.choice(1, MessageAction.builder().text("<gray>Try /kit").build())
.build()
));Conditions accept the same shapes as YAML — a string, a List, or a Map:
IfAction.builder()
.condition(Map.of(
"any", List.of("permission: lobby.vip", "%vault_eco_balance% >= 10000"),
"not", "world: arena"))
.then(/* … */)
.build();Implement Action and register it. The registry maps a type id to two functions:
one that reads a config block, and optionally one that reads an inline string.
public final class FlagAction implements Action {
public static final String TYPE = "flag";
private final String flag;
private FlagAction(final String flag) {
this.flag = flag;
}
public static FlagAction read(final DeserializationData data) {
return new FlagAction(data.get("flag", String.class));
}
@Override
public String type() {
return TYPE;
}
@Override
public Flow execute(final ActionContext context) {
// … do the work …
return Flow.CONTINUE;
}
@Override
public void write(final SerializationData data) {
data.add("flag", flag);
}
}ActionRegistry registry = BuiltinActions.registry();
registry.register(FlagAction.TYPE, FlagAction::read);execute returns what the executor should do next:
Flow.CONTINUE |
move to the next action |
Flow.STOP |
abandon the whole run |
Flow.enter(list) |
run a nested list, then carry on |
Flow.delay(ticks) |
pause, resuming at this position later |
Scheduling and resumption are the executor's job, so an action never needs a reference
to it. Flow.enter on an empty list and Flow.delay on zero both mean CONTINUE.
To start from an empty vocabulary instead of the built-ins, use
new ActionRegistry() directly.
ActionScheduler has two methods. A FoliaLib-backed implementation ships with the
library and covers Folia, Paper, Spigot and legacy Bukkit:
ActionScheduler scheduler = new FoliaLibScheduler(new FoliaLib(this));FoliaLib is a compileOnly dependency here, so your plugin declares and relocates it
(see Install). If your plugin already uses FoliaLib, pass in the instance
you already have.
To use something else, implement the interface:
public interface ActionScheduler {
void run(Entity entity, Runnable task);
Cancellable runLater(Entity entity, Runnable task, long ticks);
}A null entity means the work is not tied to one, and belongs on the global region.
By default warnings go through the logger from top.vulpine:commons. To route them
elsewhere:
Actions.logger(message -> getLogger().warning(message));An action can also be written as a single line:
- "[message] self; <green>Welcome"
- "[delay] 3s"This form is deprecated. It cannot express nesting, and its ; separator has no
escape, so a value containing a semicolon silently shifts the remaining fields.
Both forms load, and can be mixed in the same list. To convert a config in place, enable migration:
new ActionSerdes(registry).migrateShorthand(true)Inline entries are then written back as blocks the next time the config is saved.
Back up the config file first. okaeri regenerates the file from the schema, so comments added by hand are lost and key order may change. Tell the server owner where the backup went.
For reference while migrating:
| Inline | Fields |
|---|---|
[message] <target>; <text> |
|
[title] <target>; <title>; <subtitle>; <fade_in>; <stay>; <fade_out> |
bare numbers are ticks |
[actionbar] <target>; <text> |
|
[sound] <target>; <key>; <volume>; <pitch> |
|
[command] <console|player>; <command> |
|
[gamemode] <target>; <mode> |
|
[delay] <duration> |
a bare number is milliseconds |
[stop] |
|
[set] <key>; <value> |
|
[run] <sequence> |
player and global are accepted as older names for self and all.
if, random, repeat and teleport have no inline form.
MIT