Custom Functions
You can register new function types that become available to every menu, dialog, item name/lore, action argument, and requirement value via the inline $name(...) syntax. See Functions for the YAML-side reference.
Step 1 — Implement PaperFunction
Functions are records implementing PaperFunction. The record components are the call's arguments, wrapped in PlaceholderWrapper<T> so each is resolved lazily inside the call's placeholder context.
Argument resolution
Component type | Behaviour |
|---|---|
| Argument resolved and parsed as int. |
| Resolved as a generic |
| Resolved as a string. |
|
|
| Optional — missing/blank arguments become |
Always resolve the wrapper through the context (context.parseWrapper(wrapper)) — never call wrapper.get() directly, since only the context knows the player and placeholder parser for the running invocation.
Return value
In most contexts the returned Object is converted to a string via String.valueOf(result) and substituted into the surrounding placeholder string. When a standalone $fn(...) call is the entire value of a text/component field (menu and item names/lore, titles, dialog labels — anywhere ComponentWrapper runs), the raw return value is integrated type-aware instead: an Adventure ComponentLike is spliced in directly, a StyleBuilderApplicable is applied as styling for the rest of the line, a single-element List is unwrapped, and a nested PlaceholderWrapper is resolved one level. Anything else becomes a string — and a string containing a MiniMessage tag (matched by <[^<>]+>) is deserialized and spliced in as a component, so a returned <red>, <#FFFFFF>, <gradient:…> or </bold> renders as formatting instead of literal text; strings with no tag are inserted as plain text and inherit the surrounding style. The same rule applies to every lore line. Note the detection is purely "contains a <…> pair", so literal angle-bracketed text in a returned value is handed to MiniMessage too. This lets a custom function return rich text rather than its toString().
A returned Component survives both paths: as a standalone value it is spliced in as a component, and when the $fn(...) call is embedded in surrounding text — where only a string can be substituted — it is serialized back to MiniMessage rather than dumped through toString(), so the formatting round-trips. Returning null still produces the literal "null", so prefer throwing ExecutableRunException for error cases.
Lifecycle — one instance per call site
A function instance is built once per inline call site and reused for every render. The wrapper that owns a $name(...) occurrence asks the factory to prepare the call, then keeps that prepared function and runs it again for each subsequent render, so the class lookup, constructor reflection, and argument wrapping (which for an $e(...) argument means recompiling the expression) happen once for the config string rather than once per render.
Preparation happens on first run, not at config-parse time: a config string is wrapped while it is read, which can be before the plugin is up and before the function registry exists. Two threads racing to prepare the same call is benign — the two instances are equivalent and the loser is dropped.
The consequence for function authors: your record is long-lived and shared across players and renders, possibly concurrently.
Keep implementations stateless and thread-safe. Never cache per-player data (the executor, a resolved value, a running total) in a field — per-run state belongs to
run(context)locals.Any mutable helper must be thread-confined or replaced by an immutable one.
FormatNumberFunctionkeeps itsDecimalFormatcache in aThreadLocal<Map<String, DecimalFormat>>becauseDecimalFormatis mutable;FormatDateFunctionuses a plainConcurrentHashMap<String, DateTimeFormatter>becauseDateTimeFormatteris immutable and one instance per pattern serves every thread. Follow whichever pattern matches your helper.
The prepare SPI
Only relevant when implementing a custom PlatformFunctionFactory — the Paper factory already does this for you.
Member | Behaviour |
|---|---|
| One resolved call. |
| Resolves a call once so it can be run per render. Returns a |
| As above, but told which plugin's configuration the call was written in. |
The default implementation simply delegates back to compute(name, rawArgs, parser), so a platform with nothing worth reusing needs no changes; the owner-aware overload defaults to the owner-blind one for the same reason. DefaultPaperFunctionFactory overrides both and caches the built PaperFunction behind a volatile field, building it lazily on the first compute call.
The owner is read from PluginContext.current() while the configuration file is deserialized and captured by the wrapper that claims the call — a render happens far from that read and cannot recover it. PluginContext.with(plugin, …) is what ConfigurationManager wraps every deserialization in; nesting is honoured, so one plugin's configuration pulling in another's does not misattribute either.
Step 2 — Register the Function
Call this during your plugin's onEnable:
Use registerFunctionGlobally to expose the function to every plugin on the server (used by built-ins like add, round, min). Use the per-plugin registerFunction when the function only makes sense inside your own menus — a call written in your plugin's configuration resolves against your registry first and the global one after, because the owning plugin is captured when the file is read (see The prepare SPI).
The first argument is the function name used in YAML. It may match [a-zA-Z0-9_-]+ — letters, digits, underscores, and hyphens — so dash-named functions (e.g. the built-ins format-number, format-date, and random-int) are callable inline via $name(...).
Step 3 — Use in YAML
The arguments %variables_qty%, %parameters_price%, and the literal 64000 are wrapped into PlaceholderWrapper<Number> components, resolved against the menu's placeholder context, and passed to MultiplyFunction.run.
Nullable Arguments
Mark optional components with @Nullable. The wrapper itself becomes null when the caller omits the argument, so the check is on the wrapper, not on the resolved value:
Error Handling
Throw ExecutableRunException for any user-visible problem (bad numeric input, invalid pattern, missing required state). The inline-function wrapper catches it and rethrows an InvalidWrapException reported per call: the message carries the failing call's literal text only — Failed to run inline function: $multiply(...) — not the whole string it was embedded in. Resolution of the surrounding string aborts, making it easy to trace which YAML produced the failure. If no function factory is registered at all, the same per-call form is used: No function factory registered (CoreFactories.FUNCTIONS); cannot run inline function: $multiply(...).
When the call sits inside an $e(...) expression, the message also names the enclosing expression: Error while running function $multiply(...) in expression $e(...). An expression additionally requires a numeric result, so a call that succeeds but returns something unreadable as a number fails with Function $multiply(...) in expression $e(...) must produce a number, but returned … — a formatted return value (grouping separators, a unit suffix) is the usual cause.
Built-in Examples
The Paper module ships these implementations as a reference — read them when in doubt about idiomatic function design:
AddFunction,SubtractFunction— required + required + optional cap/floor.IncrementFunction,DecrementFunction— single required value + optional bound, preserves number type.MinFunction,MaxFunction— null-tolerant binary numeric ops.RoundFunction— required value + optional decimal precision.FormatNumberFunction— required value + optionalDecimalFormatpattern (default0.00).FormatDateFunction— required epoch millis + optional mode keyword orDateTimeFormatterpattern.OrElseFunction— two required args; catches a resolution failure on the first argument, treats it as null, and returns the fallback.
All live under com.astralrealms.core.paper.model.function.impl and are registered globally in AstralCore.onEnable.