Skip to content

JS Scripting ​

Since 4.8.10, EmakiCoreLib ships a GraalVM JS script extension host. Place .js files in the plugin data folder's scripts/ directory to register four kinds of extension from JavaScript: custom action stages, placeholder resolvers, expression functions, and target-selector condition types. Registered entries hold the same standing as built-in capabilities and can be referenced from any module's configuration

EmakiAttribute, EmakiSkills, EmakiItem, and EmakiCodex reuse the same host mechanism and each provide their own bindings; see each module's JS Scripting page

Safety Notice (Read First) ​

The script environment is a fully open sandbox:

  • The context is built with allowAllAccess(true), which permits Java.type and arbitrary public member access. Scripts reach any Java class and the Bukkit API with the same privileges as the server process — covering the file system, databases, and command execution
  • Only load scripts you have reviewed yourself. Scripts from unknown sources must never enter scripts/; the risk equals running an untrusted plugin on your server
  • Remaining boundaries: a single evaluation/callback is bounded by scripts.timeout_ms and interrupted once it exceeds it (an infinite loop is thereby broken and the host stays usable); all JS calls are serialized through the host lock
  • When a script calls the Bukkit API directly, thread rules are the script author's responsibility: mutating unsynchronized worlds or entities from an async thread is forbidden
  • Reload closes and rebuilds the JS context: top-level script state (global variables, caches) is fully reset. State that must survive a reload must be persisted to disk by the script itself

Script Directory Layout ​

CoreLib scans the scripts/ directory inside its own data folder (default plugins/EmakiCoreLib/) in this order:

text
plugins/EmakiCoreLib/scripts/
├── lib/                    # 1. Evaluated first: shared utilities
│   └── example_lib.js
├── *.js                    # 2. Root-level scripts (after lib/)
├── actions/                # 3. Category subdirectories by name
│   └── example_stage.js
├── conditions/
│   └── example_condition.js
├── expressions/
│   └── example_function.js
└── placeholders/
    └── example_placeholder.js
  • Files in lib/ are evaluated first; next come root-level .js files; then the remaining subdirectories in name order, files sorted by file name within each directory
  • All files are evaluated into one persistent JS context: later files can reference top-level definitions from earlier files (e.g. the exampleLib object defined by lib/example_lib.js)
  • Category subdirectories are an organizational convention only and do not isolate bindings: the host injects every binding into every file, so expressions.register(...) works fine inside actions/
  • On first startup, when scripts/ does not exist yet, the bundled example scripts are released; existing files are never overwritten by upgrades

Configuration & Reload ​

The scripts section in config.yml:

yaml
scripts:
  # 总开关; 关闭后不扫描不加载脚本
  enabled: true
  # 单次脚本求值/回调的超时时间, 单位: 毫秒; 非正数回退默认 5000
  timeout_ms: 5000
FieldTypeRequiredDefaultAllowed valuesDescription
scripts.enabledbooleanNotruetrue / falseWhen disabled, no scripts are scanned or loaded, no load report is emitted, and other features are unaffected
scripts.timeout_msintegerNo5000Positive integerTimeout in milliseconds for a single evaluation or callback; non-positive values fall back to 5000

Reload semantics: scripts reload with CoreLib's existing reload flow — first every registration from this host is unregistered (action stage and condition handles closed, placeholder resolvers unregistered one by one, expression functions removed with expression caches invalidated), then the JS context is closed and rebuilt and all files are re-evaluated in directory order. Modified script content takes effect immediately; registrations from deleted files are gone. After CoreLib rebuilds its action registry, script action stages re-register automatically per the rebuild contract

When the GraalJS runtime is missing, CoreLib logs a single "script extensions unavailable" warning and skips script loading; the plugin still enables and everything else is unaffected

actions Binding: Custom Action Stages ​

actions.register({...}) registers a JS function as a reusable action stage in the suite-wide action registry (owner: EmakiCoreLib). Once registered, any module's action lines (skills, items, Level, Forge, ...) and the scriptLines of JS skills may invoke the stage

js
actions.register({
    id: 'script_demo',
    execute: function (ctx, args) {
        return 42;
    }
});

Payload fields ​

FieldTypeRequiredDefaultDescription
idstringYes—Stage id, ID-normalized (lowercase, spaces to underscores); conflicts with a built-in or registered stage fail the registration with a load error — never silently
descriptionstringNo""Stage description
categorystringNo"script"Category name
parametersobject arrayNo[]Parameter declarations, see table below
timeoutnumberNoscripts.timeout_msPer-stage execution timeout in milliseconds; 0 or negative falls back to scripts.timeout_ms, non-numeric values fail the load
executefunctionYes—execute(ctx, args), the stage logic; missing or non-callable values fail the load

Entries of the parameters array:

FieldTypeRequiredDefaultDescription
namestringYes—Parameter name, lowercased at registration
typestringNo"string"Parameter type aligned with the built-in types: string, integer, double, boolean, entity_type, material, sound, duration, percentage (case-insensitive, - and spaces treated as _); invalid values fail the load
requiredbooleanNofalseWhether the parameter is required
defaultstringNo""Default value (text form, parsed by type)
descriptionstringNo""Parameter description

execute (ctx, args) ​

args is a plain JS object of resolved parameters (keys are parameter names). ctx is a read-only context export:

MethodDescription
getCaster()Read-only caster export, see the subject export below
getTargets()Array of target subject exports
getCurrentTarget()Current target subject export
getCurrentTargetIndex()Index of the current target
getOrigin()Origin location export, see the location export below
getPhase()Current phase name
isSilent()Whether execution is silent
getVariable(name) / hasVariable(name)Variable access; missing variables yield null
render(template)Renders a text template through the placeholder chain
getPresentKeys()Keys currently present in the context
getSourcePlugin()Name of the triggering source plugin, may be null

Subject export (getCaster() / target entries):

MethodDescription
getType()"entity" / "location" / "absent"
isValid()Whether the target is valid
getName() / getUniqueId() / getEntityType()Entity name / UUID (string) / entity type name; null for non-entity targets
isPlayer() / getGameMode()Whether the target is a player / game mode name (e.g. "CREATIVE"); null for non-players
getLocation()Location export, may be null
getWorldName() / getX() / getY() / getZ()Location summary; null when no location exists

Location export: getWorldName(), getX(), getY(), getZ(), getBlockX(), getBlockY(), getBlockZ(), getYaw(), getPitch()

Return value & execution domain ​

  • The wrapper declares target requirement NONE and execution domain ASYNC_COMPUTE (same bounds as the built-in js_compute stage): execute runs on an async compute thread — modifying world blocks or entity state directly on that thread is forbidden; side effects must be emitted through later pipeline stages instead
  • The return value of execute is stringified into the action result's script_result (same semantics as the built-in js_* stages) and does not flow back into pipeline variables
  • Timeout is treated as a skip (action.script.eval.timeout / action.script.eval.interrupted); exceptions are treated as internal errors (action.script.eval.error, message contains %error_message%), and the pipeline continues with normal action failure semantics

placeholders Binding: Custom Placeholder Resolvers ​

placeholders.register({...}) wraps a JS function as a placeholder resolver appended to the tail of the placeholder chain (after ActionContext variable resolution)

js
placeholders.register({
    id: 'script_demo_mark',
    resolve: function (text, ctx) {
        return text.split('%script_demo_mark%').join('replacement');
    }
});

Payload fields ​

FieldTypeRequiredDefaultDescription
idstringYes—Diagnostic identifier shown in warning logs
resolvefunctionYes—resolve(text, ctx): text is the text as resolved by earlier resolvers; return the new text. Returning null/undefined keeps the original text

The ctx export:

MethodDescription
getPlayer()Read-only player export, may be null: getName(), getUniqueId(), getGameMode(), isOnline(), isSneaking(), getHealth(), getFoodLevel(), getLevel(), getWorldName(), getLocation()
getPhase()Phase name; "default" without a context
isSilent()Whether execution is silent
getPlaceholder(key) / getPlaceholders()Access ActionContext placeholders

Exception semantics ​

If resolve throws or times out, that resolution returns the original text and a warning containing the id is logged (placeholders: resolver '<id>' failed: ...); the placeholder chain and other resolvers are unaffected

expressions Binding: Custom Expression Functions ​

expressions.register({...}) registers a JS function as an exp4j expression function. Once registered, expression/formula configuration points in every module (Level curves, Strengthen costs, Forge material amounts, Gem resonance, ...) can call the function directly

js
expressions.register({
    name: 'script_demo_curve',
    args: 1,
    fn: function (level) {
        return level * 2;
    }
});

Payload fields ​

FieldTypeRequiredDefaultDescription
namestringYes—Function name: starts with a letter or underscore, followed by letters/digits/underscores; must not collide with the built-in functions ceil, floor, round, log10, min, max, pow; duplicate registrations are rejected
argsintegerYes—Argument count, must be ≥ 1
fnfunctionYes—Callback receiving numeric arguments by position and returning a number

Evaluation semantics ​

  • Registering/unregistering a JS function invalidates expression caches, so stale compiled expressions can neither miss the new function nor keep failed caches
  • If fn returns a non-number, throws, or times out, that evaluation yields NaN (following the existing expression failure semantics, error message includes the function name) and a warning is logged once (expressions: function '<name>' ..., per function)
  • With no JS functions registered, expression compilation behaves exactly as with the built-in functions alone

target_conditions Binding: Target Selector Condition Types ​

target_conditions.register({...}) registers a JS predicate as a target selector condition type (owner: EmakiCoreLib). Once registered, the condition can be referenced by type in action selector condition groups:

yaml
selectors:
  my_filter:
    type: script_demo_creative
js
target_conditions.register({
    id: 'script_demo_creative',
    description: 'Target is a player in creative mode',
    test: function (ctx) {
        return ctx.target.isPlayer() && ctx.target.getGameMode() === 'CREATIVE';
    }
});

Payload fields ​

FieldTypeRequiredDefaultDescription
idstringYes—Condition type id, ID-normalized; ids matching built-in conditions are rejected (action.register.condition_reserved), duplicates of registered conditions fail as well (action.register.duplicate_id / action.register.duplicate_id_owned_by:<owner>)
descriptionstringNo""Condition description
testfunctionYes—test(ctx), called per target; missing or non-callable values fail the load

The ctx of test(ctx):

PropertyDescription
ctx.targetRead-only target subject export (same as the subject export of the actions binding)
ctx.contextRead-only pipeline context export (same as the actions binding ctx)
ctx.argumentsFields on the condition node (placeholders already substituted) as a plain JS object

Verdict semantics ​

  • Return values are converted to boolean by JS truthiness: booleans as-is; numbers are true when not 0 and not NaN; non-empty strings are true; null/undefined are false; other objects are true
  • If test throws or times out, the target is treated as "condition not met" and a warning containing the id is logged (target_conditions: condition '<id>' failed: ...); the target stream is not interrupted

Return Values & Thread Semantics ​

  • One persistent JS context per plugin. Graal contexts are not thread-safe, so every evaluation and callback is serialized through the host's re-entrant lock; concurrent callbacks from Folia region threads are safe but queue up
  • Timeout interruption: when a single evaluation/callback exceeds scripts.timeout_ms, a watchdog thread triggers context.interrupt, interrupting only that call without closing the context. During loading, a timeout skips the file with an error; during callbacks, each binding applies its own fallback (stage skipped, condition not met, original placeholder text, expression NaN, empty trigger result) and the context remains usable
  • script_result does not flow back: stage return values are recorded in the action result for inspection only; they never become pipeline variables for later stages
  • Reload resets state: the context rebuild discards all top-level script state (see the safety notice)

Troubleshooting ​

Console outputCauseFix
console.scripts.engine_unavailable: "Script extensions are unavailable; script loading was skipped: %reason%"GraalJS runtime missing, or host creation threwVerify your distribution ships the GraalJS runtime; inspect %reason%
console.scripts.disabled: "Script extensions are disabled in the config"scripts.enabled: falseEnable it if desired, then reload
console.scripts.loaded: "Script loading finished: %loaded% file (s) loaded, %skipped% skipped"Summary of every load/reloadCheck the following file_error entries when counts look wrong
console.scripts.file_error: "Script file failed to load: %file% -> %error%"Syntax error, evaluation timeout (Script evaluation timed out after N ms), or runtime exception in one file; isolated per fileFix that file per %error% (includes the JS error location)
console.scripts.register_error: "Script registration was rejected: %error%"Invalid payload (missing id, non-callable execute/test/resolve/fn, invalid parameter type, ...); id conflicts (action.register.blank_id, action.register.duplicate_id, action.register.duplicate_id_owned_by:<owner>); reserved condition (action.register.condition_reserved); built-in function name clashAdjust the registration per %error%
Action result skipped with key action.script.eval.timeout / action.script.eval.interruptedexecute timed out or was interruptedRaise the stage timeout or scripts.timeout_ms, or optimize the script
Action result failure with key action.script.eval.error (%error_message%)execute threw an exceptionFix the script per %error_message%
Warning placeholders: resolver '<id>' failed: ...resolve threw or timed outFix per the detail; that resolution keeps the original text
Warning expressions: function '<name>' ...fn returned a non-number, threw, or timed outFix the function; the evaluation yields NaN, warned once per function
Warning target_conditions: condition '<id>' failed: ...test threw or timed outFix the predicate; the target fails the condition

Example Scripts ​

The examples below ship with the plugin into scripts/ on first release; modify or delete them as needed

scripts/lib/example_lib.js ​

js
// ============================================================================
// EmakiCoreLib 示例共享库 (scripts/lib/example_lib.js)
// ----------------------------------------------------------------------------
// lib/ 目录下的文件会在所有其他脚本之前求值, 适合放置被多个脚本复用的工具函数
// 本文件定义的 `exampleLib` 是一个全局对象, 后续脚本可以直接引用
// ============================================================================

var exampleLib = {

    // 演示曲线: 把一个数值映射为另一个数值, 供动作段与表达式函数复用
    curve: function (value) {
        var level = Number(value) || 0;
        return Math.floor(level * 2 + Math.min(level, 10));
    },

    // 演示文本工具: 把名称包上一层装饰
    greet: function (name) {
        return '「脚本示例」' + (name || 'Emaki');
    }
};

scripts/actions/example_stage.js ​

js
// ============================================================================
// EmakiCoreLib 示例动作段 (scripts/actions/example_stage.js)
// ----------------------------------------------------------------------------
// actions.register ({...}) 把一个 JS 函数注册为可复用的自定义动作段
// 注册成功后, 任意 Emaki 模块的动作行 (技能, 物品, Level, Forge 等) 都可以书写:
//     script_demo amount=2
// 段落 id 必须与内置动作段不同; 重复注册会失败并在控制台输出原因
//
// execute (ctx, args) 的两个参数:
//   ctx  只读上下文导出: ctx.getCaster () / ctx.getTargets () / ctx.getCurrentTarget () /
//        ctx.getOrigin () / ctx.getPhase () / ctx.getVariable (name) / ctx.render (模板) 等
//   args 已解析参数的普通 JS 对象 (键为参数名)
// 返回值会写入本次动作结果的 script_result
// ============================================================================

actions.register({
    id: 'script_demo',
    description: '演示动作段:基于 amount 参数计算一个演示数值并返回',
    category: 'script',
    parameters: [
        {
            name: 'amount',
            type: 'double',
            required: false,
            default: '1',
            description: '参与计算的倍率'
        }
    ],
    timeout: 3000,
    execute: function (ctx, args) {
        var amount = Number(args.amount) || 1;
        var result = amount * 10 + exampleLib.curve(amount);
        logger.info('script_demo 执行:amount=' + amount + ' result=' + result
                + ' phase=' + ctx.getPhase());
        return result;
    }
});

scripts/expressions/example_function.js ​

js
// ============================================================================
// EmakiCoreLib 示例表达式函数 (scripts/expressions/example_function.js)
// ============================================================================
// expressions.register ({...}) 把一个 JS 函数注册为 exp4j 表达式函数
// 注册成功后, 全部模块的表达式/公式配置点都可以直接书写该函数, 例如:
//     formula: "script_demo_curve (%level%)"
// 字段说明:
//   name 函数名 (字母/数字/下划线, 且不得与内置函数 ceil/floor/round/log10/min/max/pow 重名)
//   args 参数个数 (>= 1)
//   fn   回调函数, 接收数值参数, 返回数值; 返回非数值或抛异常时按 NaN 处理
// ============================================================================

expressions.register({
    name: 'script_demo_curve',
    args: 1,
    fn: function (level) {
        var value = Number(level) || 0;
        return exampleLib.curve(value) * 1.5;
    }
});

scripts/placeholders/example_placeholder.js ​

js
// ============================================================================
// EmakiCoreLib 示例占位符 (scripts/placeholders/example_placeholder.js)
// ============================================================================
// placeholders.register ({...}) 把一个 JS 函数包装为占位符解析器,
// 追加到占位符解析链尾部 (在 ActionContext 变量解析之后执行)
// 字段说明:
//   id      诊断标识, 出现在日志中
//   resolve (text, ctx)  对"链上前序解析后的文本"做文本变换并返回新文本;
//          ctx 提供 ctx.getPlayer () (可为空的玩家只读导出), ctx.getPhase (),
//          ctx.isSilent (), ctx.getPlaceholder (key) 等 ActionContext 信息
// resolve 抛异常或超时时, 本次解析按原文本返回并输出含 id 的告警,
// 占位符解析链不会中断
// ============================================================================

placeholders.register({
    id: 'script_demo_mark',
    resolve: function (text, ctx) {
        var player = ctx.getPlayer() ? ctx.getPlayer().getName() : '未知玩家';
        return text.split('%script_demo_mark%').join(exampleLib.greet(player));
    }
});

scripts/conditions/example_condition.js ​

js
// ============================================================================
// EmakiCoreLib 示例目标条件 (scripts/conditions/example_condition.js)
// ============================================================================
// target_conditions.register ({...}) 把一个 JS 谓词注册为目标选择器条件类型
// 注册成功后, 可以在 action.selectors 条件组里按类型引用:
//     - type: script_demo_creative
// 字段说明:
//   id          条件 id (不得与内置条件或已注册条件重名)
//   description 可选描述
//   test (ctx)   逐目标调用; ctx 提供:
//                 ctx.target     目标只读导出 (isPlayer () /getGameMode () /
//                                getEntityType () /getLocation () /isValid () 等)
//                 ctx.context    只读管线上下文 (getOrigin () /getPhase () /getVariable () 等)
//                 ctx.arguments  条件节点上的字段 (占位符已替换)
//               返回值按 JS 真值转布尔; 异常或超时按"不成立"处理
// ============================================================================

target_conditions.register({
    id: 'script_demo_creative',
    description: '目标是处于创造模式的玩家',
    test: function (ctx) {
        return ctx.target.isPlayer() && ctx.target.getGameMode() === 'CREATIVE';
    }
});

Module Survey ​

JS script extension coverage across the Emaki suite (full survey dated 2026-09-30):

Modulev1 outcomeRationale
EmakiCoreLib4 bindings: actions/placeholders/expressions/target_conditionsThe sole StageRegistry, PlaceholderRegistry.register, the ExpressionEngine function table (reworked into static + dynamic), and TargetConditionRegistry.register are all existing registration surfaces
EmakiAttributeattributes bindingInternal AttributeRegistry.registerRuntime/clearRuntimeBySource (AttributeRegistry.java:244,257)
EmakiSkillsskills bindingPublic API SkillExtensions.registerSkillDefinition (overlay merged view)
EmakiItemitems bindingPublic API ItemExtensions.registerEffectType
EmakiCodextriggers bindingAdvancementTriggerRegistry + public API (AdvancementTrigger.java: id/priority/advancements (ctx), ctx = player/triggerId/variables); the codex logic surface sits outside action lines and offers the highest value
EmakiForge / EmakiGem / EmakiCooking / EmakiStorage / EmakiStation / EmakiAccessory / EmakiLevelNo dedicated binding (fully covered by the CoreLib bindings)Their logic surfaces are action lines + expressions: Forge's four-phase action lines, Level's fully formula-driven design (XP/costs/sources all exp4j), Gem resonance already via evaluateRandomConfig, Accessory sets = attribute + skill payloads, Cooking/Station/Storage mostly data surfaces — once actions + expressions + placeholders landed they gained JS capabilities automatically
EmakiMobsEvaluated, not included in v1Drop chance/weight are hardcoded rolls (MobDropHandler.java:79-88); JS-ifying them changes the drop configuration surface and should be a dedicated "drop expression support" project that reuses expressions
EmakiStrengthenEvaluated, not included in v1Success rate/failure penalties are hardcoded (ChanceCalculator.java:16-48); the hook surface is complex (failure parsing crosses modules, interacts with recipe limits, single-slot override semantics) — safer as its own project
Item source / GUI backendNot exposedItemSourceRegistry.bind is package-private and invoked via the coordinator (core matching infrastructure); GuiBackendRegistry is an internal rendering surface, not a content surface — risk outweighs benefit

Future candidates: the EmakiStrengthen success/penalty hooks and EmakiMobs drop chance/weight expressions, each as a dedicated project. Modules without dedicated bindings already gained JS capabilities through CoreLib's actions + expressions + placeholders