简体中文
JS 脚本扩展
EmakiCoreLib 自 4.8.10 起内置基于 GraalVM JS 的脚本扩展宿主. 将 .js 文件放入插件数据目录的 scripts/ 目录, 即可通过 JavaScript 注册四类扩展: 自定义动作段, 占位符解析器, 表达式函数与目标选择器条件类型; 注册成功的条目与内置能力同等生效, 可被全系模块的配置直接引用
EmakiAttribute, EmakiSkills, EmakiItem, EmakiCodex 共用同一宿主机制, 并各自提供专属绑定, 详见对应模块的 JS 脚本扩展 页
安全声明 (务必阅读)
脚本运行环境是全开放沙箱:
- Context 以
allowAllAccess(true)构建, 放行Java.type与任意公开成员访问. 脚本可访问任意 Java 类与 Bukkit API, 权限与服务器进程同级 -- 覆盖文件系统, 数据库与命令执行 - 只加载已由你自己审查过的脚本. 来源不明的脚本不得放入
scripts/目录, 其风险等同于在服务器上运行陌生插件 - 保留的边界: 单次求值/回调受
scripts.timeout_ms约束, 超时即被中断 (死循环因此被打断, 宿主保持可用); 全部 JS 调用经宿主锁串行化 - 脚本直接访问 Bukkit API 时, 线程规则由脚本作者负责: 禁止在异步线程操作未同步的世界与实体
- 重载会关闭并重建 JS Context: 脚本顶层定义的全局变量, 缓存等状态全部重置; 需要跨重载保留的数据须自行写入磁盘
脚本目录结构
CoreLib 在自身数据目录 (默认 plugins/EmakiCoreLib/) 下扫描 scripts/ 目录, 加载顺序如下:
text
plugins/EmakiCoreLib/scripts/
├── lib/ # 1. 最先求值: 放共享工具函数
│ └── example_lib.js
├── *.js # 2. 根级脚本 (位于 lib 之后)
├── actions/ # 3. 分类子目录按名称排序
│ └── example_stage.js
├── conditions/
│ └── example_condition.js
├── expressions/
│ └── example_function.js
└── placeholders/
└── example_placeholder.jslib/目录内的.js文件最先求值; 随后是scripts/根级.js文件; 最后按目录名顺序求值其余子目录, 目录内文件按文件名排序- 全部文件求值进同一个持久化 JS Context: 后加载的文件可以直接引用先加载文件的顶层定义 (例如示例中
lib/example_lib.js定义的exampleLib) - 分类子目录只是组织约定, 不隔离绑定: 宿主把全部绑定注入每个文件,
actions/里同样可以写expressions.register(...) - 首次启用且
scripts/目录不存在时, 会自动释放内置示例脚本; 已存在的文件不会被升级覆盖
配置与重载
config.yml 中的 scripts 节:
yaml
scripts:
# 总开关; 关闭后不扫描不加载脚本
enabled: true
# 单次脚本求值/回调的超时时间, 单位: 毫秒; 非正数回退默认 5000
timeout_ms: 5000| 字段 | 类型 | 必填 | 默认值 | 取值范围 | 说明 |
|---|---|---|---|---|---|
scripts.enabled | boolean | 否 | true | true / false | 关闭后不扫描不加载脚本, 不输出加载报告, 其余功能不受影响 |
scripts.timeout_ms | integer | 否 | 5000 | 正整数 | 单次求值或回调的超时毫秒数; 非正数回退 5000 |
重载行为: 脚本随 CoreLib 既有 reload 流程重载 -- 先注销本轮加载的全部注册 (关闭动作段与条件句柄, 逐个反注册占位符解析器, 注销表达式函数并失效表达式缓存), 再关闭并重建 JS Context, 按目录顺序重新求值. 修改后的脚本内容立即生效, 已删除文件的注册不再存在. CoreLib 重建动作注册表后, 脚本动作段按注册约定自动重注册
GraalJS 运行库缺失时, CoreLib 输出一条"脚本扩展不可用"告警并跳过脚本加载, 插件照常启用, 其余功能不受影响
actions 绑定: 自定义动作段
actions.register({...}) 把 JS 函数注册为可复用的动作段, 进入全系唯一的动作注册表 (owner 为 EmakiCoreLib). 注册成功后, 任意模块的动作行 (技能, 物品, Level, Forge 等) 与 JS 技能的 scriptLines 均可引用该段落
js
actions.register({
id: 'script_demo',
execute: function (ctx, args) {
return 42;
}
});payload 字段
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
id | string | 是 | — | 段落 id, 经 ID 规范化 (小写, 空格转下划线); 与内置段或已注册段冲突时注册失败并输出载入错误, 不会无提示失败 |
description | string | 否 | "" | 段落描述 |
category | string | 否 | "script" | 分类名 |
parameters | object 数组 | 否 | [] | 参数声明, 见下表 |
timeout | number | 否 | scripts.timeout_ms | 该段单次执行超时 (毫秒); 写 0 或负数回退 scripts.timeout_ms, 非数值报载入错误 |
execute | function | 是 | — | execute(ctx, args), 段落逻辑; 缺失或不可调用报载入错误 |
parameters 数组元素:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
name | string | 是 | — | 参数名, 注册时统一转小写 |
type | string | 否 | "string" | 参数类型, 对齐内置参数类型: string, integer, double, boolean, entity_type, material, sound, duration, percentage (大小写不敏感, - 与空格视为 _); 非法值报载入错误 |
required | boolean | 否 | false | 是否必填 |
default | string | 否 | "" | 默认值 (文本形式, 按类型解析) |
description | string | 否 | "" | 参数描述 |
execute (ctx, args)
args 是已解析参数的普通 JS 对象 (键为参数名). ctx 是只读上下文导出:
| 方法 | 说明 |
|---|---|
getCaster() | 施法者只读导出, 见下方 subject 导出 |
getTargets() | 目标 subject 导出数组 |
getCurrentTarget() | 当前目标 subject 导出 |
getCurrentTargetIndex() | 当前目标下标 |
getOrigin() | 源位置导出, 见下方 location 导出 |
getPhase() | 当前阶段名 |
isSilent() | 是否为无提示执行 |
getVariable(name) / hasVariable(name) | 变量读取, 缺失返回 null |
render(模板) | 走占位符链渲染文本模板 |
getPresentKeys() | 上下文已设置的键名列表 |
getSourcePlugin() | 触发来源插件名, 可为 null |
subject 导出 (getCaster() / 目标元素):
| 方法 | 说明 |
|---|---|
getType() | "entity" / "location" / "absent" |
isValid() | 目标是否有效 |
getName() / getUniqueId() / getEntityType() | 实体名称 / UUID (字符串) / 实体类型名; 非实体目标为 null |
isPlayer() / getGameMode() | 是否玩家 / 游戏模式名 (如 "CREATIVE"); 非玩家为 null |
getLocation() | 位置导出, 可为 null |
getWorldName() / getX() / getY() / getZ() | 位置摘要; 无位置为 null |
location 导出: getWorldName(), getX(), getY(), getZ(), getBlockX(), getBlockY(), getBlockZ(), getYaw(), getPitch()
返回值与执行域
- 包装器声明目标要求
NONE, 执行域ASYNC_COMPUTE(与内置js_compute段同界):execute在异步计算线程被调用, 禁止在该线程直接修改世界方块或实体状态, 需要产生副作用时经由管线后续动作段执行 execute的返回值会被字符串化后写入本次动作结果的script_result(与内置js_*段行为一致), 不回流管线变量- 执行超时按跳过处理 (
action.script.eval.timeout/action.script.eval.interrupted), 异常按内部错误处理 (action.script.eval.error, 消息含%error_message%), 管线按动作失败规则继续
placeholders 绑定: 自定义占位符解析器
placeholders.register({...}) 把 JS 函数包装为占位符解析器, 追加到占位符解析链尾部 (在 ActionContext 变量解析之后执行)
js
placeholders.register({
id: 'script_demo_mark',
resolve: function (text, ctx) {
return text.split('%script_demo_mark%').join('替换文本');
}
});payload 字段
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
id | string | 是 | — | 诊断标识, 出现在告警日志中 |
resolve | function | 是 | — | resolve(text, ctx): text 为链上前序解析后的文本, 返回新文本; 返回 null/undefined 按原文本返回 |
ctx 导出:
| 方法 | 说明 |
|---|---|
getPlayer() | 玩家只读导出, 可为 null: getName(), getUniqueId(), getGameMode(), isOnline(), isSneaking(), getHealth(), getFoodLevel(), getLevel(), getWorldName(), getLocation() |
getPhase() | 阶段名; 无上下文时为 "default" |
isSilent() | 是否为无提示模式 |
getPlaceholder(key) / getPlaceholders() | 读取 ActionContext 占位符 |
异常处理规则
resolve 抛异常或超时时, 该次解析按原文本返回, 并输出含 id 的告警 (placeholders: resolver '<id>' failed: ...); 占位符解析链与其余解析器不受影响
expressions 绑定: 自定义表达式函数
expressions.register({...}) 把 JS 函数注册为 exp4j 表达式函数. 注册成功后, 全部模块的表达式/公式配置点 (Level 曲线, Strengthen 费用, Forge 材料数量, Gem 共鸣等) 均可直接书写该函数
js
expressions.register({
name: 'script_demo_curve',
args: 1,
fn: function (level) {
return level * 2;
}
});payload 字段
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
name | string | 是 | — | 函数名: 以字母或下划线开头, 其余为字母/数字/下划线; 不得与内置函数 ceil, floor, round, log10, min, max, pow 重名; 重复注册被拒绝 |
args | integer | 是 | — | 参数个数, 必须 ≥ 1 |
fn | function | 是 | — | 回调, 按位置接收数值参数, 返回数值 |
求值规则
- 注册/注销 JS 函数时会同步失效表达式缓存, 避免旧编译产物缺失新函数或残留失败缓存
fn返回非数值, 抛异常或超时时, 该次求值按NaN处理 (沿用既有表达式失败规则, 错误消息含函数名), 并输出一次告警 (expressions: function '<name>' ..., 同名函数只告警一次)- 未注册任何 JS 函数时, 表达式编译行为与内置函数完全一致
target_conditions 绑定: 目标选择器条件类型
target_conditions.register({...}) 把 JS 谓词注册为目标选择器条件类型 (owner 为 EmakiCoreLib). 注册成功后, 可在动作选择器条件组中按类型引用:
yaml
selectors:
my_filter:
type: script_demo_creativejs
target_conditions.register({
id: 'script_demo_creative',
description: '目标是处于创造模式的玩家',
test: function (ctx) {
return ctx.target.isPlayer() && ctx.target.getGameMode() === 'CREATIVE';
}
});payload 字段
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
id | string | 是 | — | 条件类型 id, 经 ID 规范化; 与内置条件同名的 id 会被拒绝 (action.register.condition_reserved), 与已注册条件重名同样报错 (action.register.duplicate_id / action.register.duplicate_id_owned_by:<owner>) |
description | string | 否 | "" | 条件描述 |
test | function | 是 | — | test(ctx), 逐目标调用; 缺失或不可调用报载入错误 |
test(ctx) 的 ctx:
| 属性 | 说明 |
|---|---|
ctx.target | 目标 subject 只读导出 (同 actions 绑定的 subject 导出) |
ctx.context | 只读管线上下文导出 (同 actions 绑定的 ctx) |
ctx.arguments | 条件节点上的字段 (占位符已替换), 普通 JS 对象 |
判定规则
- 返回值按 JS 真值转布尔: 布尔原样; 数值非
0且非NaN为真; 非空字符串为真;null/undefined为假; 其余对象为真 test抛异常或超时时, 该目标按"条件不成立"处理, 并输出含 id 的告警 (target_conditions: condition '<id>' failed: ...), 目标流不中断
返回值与线程规则
- 每插件一个持久化 JS Context. Graal Context 非线程安全, 因此所有求值与回调调用都经宿主可重入锁串行化, Folia 多区域线程并发回调是安全的, 但回调之间会排队
- 超时中断: 单次求值/回调超过
scripts.timeout_ms时由看门狗线程触发context.interrupt, 只中断该次调用, 不关闭整个 Context. 载入期超时=该文件跳过并报错; 回调期超时=按各绑定的行为规则回退 (动作段跳过, 条件不成立, 占位符原文本, 表达式NaN, 触发器空集合), Context 继续可用 script_result不回流: 动作段返回值只写入动作结果供查看, 不会成为管线变量参与后续动作- 重载即重置: Context 重建后脚本顶层状态全部丢失 (见安全声明)
错误排查
| 控制台输出 | 原因 | 处置 |
|---|---|---|
console.scripts.engine_unavailable: "脚本扩展不可用, 已跳过脚本加载: %reason%" | GraalJS 运行库缺失, 或宿主创建时抛出异常 | 确认所用发行版包含 GraalJS 运行库; 按 %reason% 进一步排查 |
console.scripts.disabled: "脚本扩展已在配置中禁用" | scripts.enabled: false | 需要时开启后 reload |
console.scripts.loaded: "脚本加载完成: %loaded% 个文件成功, %skipped% 个跳过" | 每次加载/重载的汇总 | 成功/跳过计数异常时查看随后的 file_error |
console.scripts.file_error: "脚本文件加载失败: %file% -> %error%" | 单文件语法错误, 求值超时 (消息为 Script evaluation timed out after N ms) 或运行时异常; 单文件隔离, 其余文件继续 | 按 %error% (含 JS 错误位置) 修复该文件 |
console.scripts.register_error: "脚本注册被拒绝: %error%" | payload 不合法 (缺 id, execute/test/resolve/fn 不可调用, 参数类型非法等); id 冲突 (action.register.blank_id, action.register.duplicate_id, action.register.duplicate_id_owned_by:<owner>); 内置条件保留 (action.register.condition_reserved); 内置函数重名 | 按 %error% 修改对应注册 |
动作结果 skipped, 消息键 action.script.eval.timeout / action.script.eval.interrupted | execute 执行超时或被中断 | 调大段落 timeout 或 scripts.timeout_ms, 或优化脚本逻辑 |
动作结果失败, 消息键 action.script.eval.error (%error_message%) | execute 抛出异常 | 按 %error_message% 修复脚本 |
告警 placeholders: resolver '<id>' failed: ... | resolve 抛异常或超时 | 按 detail 修复; 该次解析按原文本返回, 链不中断 |
告警 expressions: function '<name>' ... | fn 返回非数值, 抛异常或超时 | 修复函数; 该次求值按 NaN 处理, 同名函数只告警一次 |
告警 target_conditions: condition '<id>' failed: ... | test 抛异常或超时 | 修复谓词; 该目标按条件不成立处理 |
示例脚本
以下示例随插件首次释放到 scripts/ 目录, 可直接修改或删除
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';
}
});全模块普查结论
Emaki 系列各模块的 JS 脚本扩展接入结论如下 (2026-09-30 全模块普查):
| 模块 | v1 处置 | 依据 |
|---|---|---|
| EmakiCoreLib | 4 个绑定: actions/placeholders/expressions/target_conditions | 唯一 StageRegistry, PlaceholderRegistry.register, ExpressionEngine 静态函数表 (改造为静态+动态), TargetConditionRegistry.register 均为既有注册面 |
| EmakiAttribute | attributes 绑定 | 内部 AttributeRegistry.registerRuntime/clearRuntimeBySource (AttributeRegistry.java:244,257) |
| EmakiSkills | skills 绑定 | 公开 API SkillExtensions.registerSkillDefinition (overlay 合并视图) |
| EmakiItem | items 绑定 | 公开 API ItemExtensions.registerEffectType |
| EmakiCodex | triggers 绑定 | AdvancementTriggerRegistry + 公开 API (AdvancementTrigger.java: id/priority/advancements (ctx), ctx=player/triggerId/variables); 图鉴逻辑面不在动作行内, 价值最高 |
| EmakiForge / EmakiGem / EmakiCooking / EmakiStorage / EmakiStation / EmakiAccessory / EmakiLevel | 无独立绑定 (被 CoreLib 绑定全局覆盖) | 逻辑面=动作行+表达式: Forge 四阶段动作行, Level 全公式化 (XP/费用/经验来源均 exp4j), Gem 共鸣已 evaluateRandomConfig, Accessory 套装=属性+技能载荷, Cooking/Station/Storage 数据面为主 -- actions+expressions+placeholders 落地后自动获得 JS 能力 |
| EmakiMobs | 评估后不纳入 v1 | 掉落 chance/weight 为硬编码掷点 (MobDropHandler.java:79-88), JS 化属掉落行为配置面变更, 宜随"掉落表达式支持"单独立项; expressions 落地后届时直接重复使用 |
| EmakiStrengthen | 评估后不纳入 v1 | 成功率/失败惩罚硬编码 (ChanceCalculator.java:16-48), 钩子面设计复杂 (失败解析四元组跨界, 与 recipe limits 交互, 单槽覆盖规则), 单独立项更稳 |
| 物品来源 / GUI 后端 | 不暴露 | ItemSourceRegistry.bind 包私有经协调器调用 (核心匹配基础设施), GuiBackendRegistry 为内部渲染面非内容面, 风险大于收益 |
后续候选: EmakiStrengthen 的成功率/失败惩罚钩子, EmakiMobs 的掉落 chance/weight 表达式化, 均单独立项后接入; 当前无需独立绑定的模块, 通过 CoreLib 的 actions + expressions + placeholders 已自动获得 JS 扩展能力