English
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 permitsJava.typeand 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_msand 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.jsfiles; 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
exampleLibobject defined bylib/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 insideactions/ - 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| Field | Type | Required | Default | Allowed values | Description |
|---|---|---|---|---|---|
scripts.enabled | boolean | No | true | true / false | When disabled, no scripts are scanned or loaded, no load report is emitted, and other features are unaffected |
scripts.timeout_ms | integer | No | 5000 | Positive integer | Timeout 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | — | 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 |
description | string | No | "" | Stage description |
category | string | No | "script" | Category name |
parameters | object array | No | [] | Parameter declarations, see table below |
timeout | number | No | scripts.timeout_ms | Per-stage execution timeout in milliseconds; 0 or negative falls back to scripts.timeout_ms, non-numeric values fail the load |
execute | function | Yes | — | execute(ctx, args), the stage logic; missing or non-callable values fail the load |
Entries of the parameters array:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Parameter name, lowercased at registration |
type | string | No | "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 |
required | boolean | No | false | Whether the parameter is required |
default | string | No | "" | Default value (text form, parsed by type) |
description | string | No | "" | 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:
| Method | Description |
|---|---|
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):
| Method | Description |
|---|---|
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
NONEand execution domainASYNC_COMPUTE(same bounds as the built-injs_computestage):executeruns 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
executeis stringified into the action result'sscript_result(same semantics as the built-injs_*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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | — | Diagnostic identifier shown in warning logs |
resolve | function | Yes | — | 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:
| Method | Description |
|---|---|
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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | 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 |
args | integer | Yes | — | Argument count, must be ≥ 1 |
fn | function | Yes | — | 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
fnreturns a non-number, throws, or times out, that evaluation yieldsNaN(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_creativejs
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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | — | 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>) |
description | string | No | "" | Condition description |
test | function | Yes | — | test(ctx), called per target; missing or non-callable values fail the load |
The ctx of test(ctx):
| Property | Description |
|---|---|
ctx.target | Read-only target subject export (same as the subject export of the actions binding) |
ctx.context | Read-only pipeline context export (same as the actions binding ctx) |
ctx.arguments | Fields 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
0and notNaN; non-empty strings are true;null/undefinedare false; other objects are true - If
testthrows 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 triggerscontext.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, expressionNaN, empty trigger result) and the context remains usable script_resultdoes 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 output | Cause | Fix |
|---|---|---|
console.scripts.engine_unavailable: "Script extensions are unavailable; script loading was skipped: %reason%" | GraalJS runtime missing, or host creation threw | Verify your distribution ships the GraalJS runtime; inspect %reason% |
console.scripts.disabled: "Script extensions are disabled in the config" | scripts.enabled: false | Enable it if desired, then reload |
console.scripts.loaded: "Script loading finished: %loaded% file (s) loaded, %skipped% skipped" | Summary of every load/reload | Check 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 file | Fix 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 clash | Adjust the registration per %error% |
Action result skipped with key action.script.eval.timeout / action.script.eval.interrupted | execute timed out or was interrupted | Raise 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 exception | Fix the script per %error_message% |
Warning placeholders: resolver '<id>' failed: ... | resolve threw or timed out | Fix per the detail; that resolution keeps the original text |
Warning expressions: function '<name>' ... | fn returned a non-number, threw, or timed out | Fix the function; the evaluation yields NaN, warned once per function |
Warning target_conditions: condition '<id>' failed: ... | test threw or timed out | Fix 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):
| Module | v1 outcome | Rationale |
|---|---|---|
| EmakiCoreLib | 4 bindings: actions/placeholders/expressions/target_conditions | The sole StageRegistry, PlaceholderRegistry.register, the ExpressionEngine function table (reworked into static + dynamic), and TargetConditionRegistry.register are all existing registration surfaces |
| EmakiAttribute | attributes binding | Internal AttributeRegistry.registerRuntime/clearRuntimeBySource (AttributeRegistry.java:244,257) |
| EmakiSkills | skills binding | Public API SkillExtensions.registerSkillDefinition (overlay merged view) |
| EmakiItem | items binding | Public API ItemExtensions.registerEffectType |
| EmakiCodex | triggers binding | AdvancementTriggerRegistry + 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 / EmakiLevel | No 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 |
| EmakiMobs | Evaluated, not included in v1 | Drop 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 |
| EmakiStrengthen | Evaluated, not included in v1 | Success 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 backend | Not exposed | ItemSourceRegistry.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