JavaScript 脚本系统
CoreLib 内置 JavaScript 脚本系统,用于在动作链中执行更灵活的逻辑。它基于 GraalJS 运行,可以在 Forge、Strengthen、Cooking、Gem、Skills、Item、Attribute 等模块的动作节点中通过 runjs 调用脚本。
脚本系统适合处理“普通 YAML 动作很难表达”的逻辑,例如:
- 根据上下文变量决定是否继续执行。
- 根据随机结果执行不同动作。
- 读取玩家、物品、配方、技能等上下文信息。
- 在多个动作之间共享临时状态。
- 根据模块传入的 placeholder 生成动态消息。
- 在一个脚本中组合调用 CoreLib 动作系统。
脚本系统不是让服主绕过插件 API 直接操作 Bukkit 内部对象的入口。默认配置会关闭 Host Class Lookup、IO、线程、Native Access 等危险能力,建议保持安全默认值。
启用与配置
CoreLib 默认配置位于:
plugins/EmakiCoreLib/config.yml脚本相关配置:
script:
enabled: true
engine:
type: "graaljs"
default_timeout_millis: 1000
max_timeout_millis: 5000
cache_enabled: true
recompile_on_reload: true
allow_host_access: false
allow_host_class_lookup: false
allow_io: false
allow_threads: false
allow_native_access: false
allow_environment_access: false
paths:
root: "scripts"
create_directories:
- "global"
- "forge"
- "strengthen"
- "cooking"
- "gem"
- "skills"
- "item"
- "attribute"
- "templates"
- "examples"
action:
id: "runjs"
aliases:
- "runscript"
- "javascript"
default_function: "main"
stop_on_failure: true
context:
expose_context: true
expose_player: true
expose_item: true
expose_action: true
expose_logger: true
expose_random: true
expose_shared_state: true
expose_text: true
security:
denied_path_fragments:
- ".."
- ":"
- "\\"
denied_actions_from_script:
- "runjs"
- "runscript"
- "javascript"
allow_action_dispatch: true
max_action_depth: 3
debug:
log_script_load: true
log_script_execute: false
print_stacktrace: false脚本目录结构
脚本根目录默认为:
plugins/EmakiCoreLib/scripts/首次启动或重载时,CoreLib 会按配置创建常用子目录:
scripts/
├── global/
├── forge/
├── strengthen/
├── cooking/
├── gem/
├── skills/
├── item/
├── attribute/
├── templates/
└── examples/建议按业务模块放置脚本:
| 目录 | 推荐用途 |
|---|---|
global/ | 全局通用脚本。 |
forge/ | 锻造成功、失败、品质处理脚本。 |
strengthen/ | 强化成功、失败、锻印、保护脚本。 |
cooking/ | 烹饪奖励、工位状态脚本。 |
gem/ | 镶嵌、提取、升级脚本。 |
skills/ | 技能升级、释放、触发脚本。 |
item/ | 物品触发器脚本。 |
attribute/ | 属性增益、资源处理脚本。 |
templates/ | 可复用脚本模板。 |
examples/ | 示例脚本。 |
在动作中调用脚本
脚本系统注册的动作 ID 是:
runjsrunscriptjavascript
最常见写法是在业务模块的动作列表里调用:
actions:
- 'runjs script=examples/hello.js'默认函数名是 main。如果脚本中要调用其他函数,可以通过 function 参数指定:
actions:
- 'runjs script=global/reward.js function=giveDailyReward'如果模块动作字段使用对象形式,也可以表达为类似结构:
actions:
- id: runjs
script: forge/success.js
function: main不同业务模块对动作行的解析形式可能略有不同。如果某种写法不生效,先用最简单的字符串动作行测试。
最小脚本
在 plugins/EmakiCoreLib/scripts/examples/hello.js 中:
function main(ctx) {
emaki.logger.info("Hello from Emaki JavaScript.");
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] Hello, " + emaki.player.name() + "!");
}
return true;
}然后在动作中调用:
actions:
- 'runjs script=examples/hello.js'函数签名
默认入口函数:
function main(ctx) {
// ctx 是当前 ActionContext,脚本中更推荐使用 emaki.context 封装 API。
return true;
}脚本执行时,CoreLib 会向 JS 环境注入:
- 全局对象
emaki - 全局对象
args - 函数参数
ctx
通常推荐使用 emaki.* API,而不是直接操作 ctx。
返回值规则
脚本返回值会映射成 CoreLib 的脚本执行结果。
| 返回值 | 含义 |
|---|---|
true | 执行成功。 |
false | 执行失败,消息为 Script returned false.。 |
| 字符串 | 执行成功,并把字符串作为消息。 |
null / 无返回 | 执行成功。 |
| 对象 | 按 success、skipped、message、output 字段解析。 |
对象返回示例:
function main(ctx) {
return {
success: true,
message: "reward granted",
output: {
amount: 100,
reason: "daily"
}
};
}跳过示例:
function main(ctx) {
return {
skipped: true,
message: "condition not met"
};
}失败示例:
function main(ctx) {
return {
success: false,
message: "player not found"
};
}emaki.context
emaki.context 用于读取当前动作上下文。
| 方法 | 返回 | 说明 |
|---|---|---|
phase() | string | 当前动作阶段。 |
plugin() | string | 触发脚本的插件名。 |
placeholder(key) | string | 读取占位符值。 |
attribute(key) | object | 读取上下文属性。 |
arg(key) | object | 读取 runjs 参数。 |
placeholders() | map | 读取全部 placeholders。 |
attributes() | map | 读取全部 attributes。 |
args() | map | 读取全部脚本参数。 |
示例:
function main(ctx) {
const recipeId = emaki.context.placeholder("forge_recipe_id");
const phase = emaki.context.phase();
emaki.logger.info("recipe=" + recipeId + ", phase=" + phase);
return true;
}emaki.player
emaki.player 用于读取和操作当前玩家。
| 方法 | 返回 | 说明 |
|---|---|---|
exists() | boolean | 当前上下文是否有玩家。 |
name() | string | 玩家名。 |
uuid() | string | 玩家 UUID。 |
world() | string | 玩家所在世界名。 |
hasPermission(permission) | boolean | 是否拥有权限。 |
sendMessage(message) | void | 给玩家发送消息。 |
示例:
function main(ctx) {
if (!emaki.player.exists()) {
return { success: false, message: "No player context" };
}
if (!emaki.player.hasPermission("emaki.example.reward")) {
emaki.player.sendMessage("你没有权限领取这个奖励。粗略调试请联系管理员。");
return { skipped: true, message: "missing permission" };
}
emaki.player.sendMessage("奖励脚本执行成功。玩家=" + emaki.player.name());
return true;
}emaki.item
emaki.item 用于读取上下文中的 ItemStack。它通过上下文 attribute key 取物品。
| 方法 | 返回 | 说明 |
|---|---|---|
has(attributeKey) | boolean | 指定 attribute key 是否存在物品。 |
type(attributeKey) | string | 物品材质类型,小写。 |
amount(attributeKey) | number | 物品数量。 |
displayName(attributeKey) | string | 物品有效显示名,纯文本。 |
示例:
function main(ctx) {
if (emaki.item.has("target_item")) {
const type = emaki.item.type("target_item");
const name = emaki.item.displayName("target_item");
emaki.logger.info("target item: " + type + " / " + name);
}
return true;
}可用的 attribute key 由调用模块决定。不同模块可能传入
target_item、result_item、input_item等不同名称。
emaki.action
emaki.action 允许脚本继续调用 CoreLib 动作系统。
| 方法 | 返回 | 说明 |
|---|---|---|
run(actionId, arguments) | boolean | 执行一个动作 ID。 |
runLine(line) | boolean | 执行一行动作字符串。 |
示例:
function main(ctx) {
emaki.action.run("sendmessage", {
text: "<green>脚本调用动作成功。"
});
emaki.action.runLine("playsound ENTITY_PLAYER_LEVELUP 1 1");
return true;
}安全限制:
- 默认禁止脚本再次调用
runjs、runscript、javascript,避免递归脚本。 allow_action_dispatch控制是否允许脚本分发动作。max_action_depth控制动作嵌套深度,默认 3。
emaki.logger
用于向控制台输出带脚本路径前缀的日志。
| 方法 | 说明 |
|---|---|
info(message) | 普通信息。 |
warn(message) | 警告。 |
error(message) | 错误。 |
示例:
function main(ctx) {
emaki.logger.info("脚本开始执行");
return true;
}emaki.random
随机工具。
| 方法 | 返回 | 说明 |
|---|---|---|
integer(min, max) | number | 生成闭区间整数。 |
decimal() | number | 生成 0 到 1 之间的小数。 |
chance(percent) | boolean | 按百分比判断,例如 25 表示 25%。 |
pick(values) | object | 从列表中随机选择一个元素。 |
示例:
function main(ctx) {
if (emaki.random.chance(10)) {
emaki.player.sendMessage("你触发了 10% 的额外奖励!");
}
const reward = emaki.random.pick(["gold", "gem", "exp"]);
emaki.logger.info("reward=" + reward);
return true;
}emaki.state
共享状态用于在同一次动作上下文中保存临时数据。
| 方法 | 说明 |
|---|---|
set(key, value) | 设置值。 |
get(key) | 获取值。 |
has(key) | 判断是否存在。 |
remove(key) | 删除值。 |
示例:
function main(ctx) {
emaki.state.set("example.executed", true);
if (emaki.state.has("example.executed")) {
emaki.logger.info("state exists");
}
return true;
}注意:state 不是长期数据库。它适合同一动作链中的临时状态,不适合保存玩家长期数据。
emaki.text
文本工具。
| 方法 | 返回 | 说明 |
|---|---|---|
string(value) | string | 安全转字符串。 |
blank(value) | boolean | 是否为空白。 |
notBlank(value) | boolean | 是否非空白。 |
lower(value) | string | 转小写。 |
normalizeId(value) | string | 标准化 ID。 |
示例:
function main(ctx) {
const raw = emaki.context.placeholder("item_id");
const id = emaki.text.normalizeId(raw);
emaki.logger.info("normalized item id=" + id);
return true;
}Forge 示例
examples/forge_success.js:
function main(ctx) {
const recipeId = emaki.context.placeholder("forge_recipe_id");
const quality = emaki.context.placeholder("forge_quality");
emaki.logger.info("Forge success script: recipe=" + recipeId + ", quality=" + quality);
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] 锻造脚本触发,配方: " + recipeId + " 品质: " + quality);
}
return true;
}适合挂在 Forge 的成功动作中:
action:
success:
- 'runjs script=examples/forge_success.js'Strengthen 示例
examples/strengthen_success.js:
function main(ctx) {
const recipeId = emaki.context.placeholder("strengthen_recipe_id");
const star = emaki.context.placeholder("star");
const temper = emaki.context.placeholder("temper");
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] 强化成功脚本触发: " + recipeId + " 星级=" + star + " 锻印=" + temper);
}
return true;
}适合挂在强化成功动作中:
success_actions:
- 'runjs script=examples/strengthen_success.js'Cooking 示例
examples/cooking_reward.js:
function main(ctx) {
const recipeId = emaki.context.placeholder("cooking_recipe_id") || emaki.context.placeholder("recipe_id");
const stationType = emaki.context.placeholder("cooking_station_type") || emaki.context.placeholder("station_type");
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] 烹饪奖励脚本触发: " + recipeId + " @ " + stationType);
}
return true;
}适合挂在烹饪配方成功奖励动作中。
Item 示例
examples/item_right_click.js:
function main(ctx) {
const itemId = emaki.context.placeholder("item_id");
const trigger = emaki.context.placeholder("item_trigger");
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] 物品触发脚本: " + itemId + " trigger=" + trigger);
}
return true;
}适合挂在 EmakiItem 物品触发器动作中。
Skills 示例
examples/skills_upgrade_success.js:
function main(ctx) {
const skillId = emaki.context.placeholder("skills_skill_id") || emaki.context.attribute("skill_id");
if (emaki.player.exists()) {
emaki.player.sendMessage("[EmakiJS] 技能升级成功脚本: " + skillId);
}
return true;
}适合挂在技能升级成功动作中。
Attribute 示例
examples/attribute_buff.js:
function main(ctx) {
if (!emaki.player.exists()) {
return { success: false, message: "No player context" };
}
return emaki.action.run("attribute_add", {
effect_id: "js_example_buff",
attribute: "attack",
value: "5",
duration_ticks: "10s"
});
}这个示例通过脚本调用 Attribute 注册的动作,给玩家添加临时属性效果。
安全机制
脚本系统默认更偏向安全:
| 配置 | 默认 | 说明 |
|---|---|---|
allow_host_class_lookup | false | 禁止查找 Java 类。 |
allow_io | false | 禁止脚本直接 IO。 |
allow_threads | false | 禁止创建线程。 |
allow_native_access | false | 禁止 native access。 |
allow_environment_access | false | 禁止环境变量访问。 |
denied_path_fragments | ..、:、\ | 防止路径逃逸。 |
denied_actions_from_script | runjs 等 | 防止脚本递归调用脚本。 |
max_action_depth | 3 | 限制脚本调用动作嵌套深度。 |
建议生产服保持默认安全配置。不要为了方便把 Host Class Lookup、IO、线程、Native Access 全部打开。
性能建议
- 不要在高频事件中执行复杂脚本,例如每 tick、每次移动、每次受击都做大量逻辑。
- 高频触发器必须加冷却、概率或条件。
- 脚本中不要写无限循环。
default_timeout_millis默认 1000ms,脚本应该远低于这个时间完成。- 复杂逻辑建议拆成多个简单脚本,方便定位问题。
调试方式
查看脚本加载
log_script_load: true 时,CoreLib 会在重载脚本仓库时输出加载数量。
查看脚本执行
临时开启:
debug:
log_script_execute: true可以看到脚本路径、函数和耗时。生产服不建议长期打开。
打印堆栈
临时开启:
debug:
print_stacktrace: true用于定位脚本异常。生产服排查完成后建议关闭。
最佳实践
- 脚本文件按模块分类,避免全部堆在根目录。
- 每个脚本只做一件明确的事。
- 给脚本返回清晰的
message,方便排错。 - 高风险动作先在测试服执行。
- 不要依赖显示名或 Lore 判断真实状态,优先使用 context、placeholder、PDC、Item Source 或模块 API。
- 生产服不要开启危险 GraalJS 权限。
线程安全
脚本通过 runjs 动作执行时运行在异步 IO 线程,不在主线程。直接调用 Bukkit API 会导致异常。
emaki.runSync(runnable)
将任务调度到主线程执行。如果已在主线程则直接执行。
function execute(context) {
emaki.runSync(() => {
// 这里可以安全调用 Bukkit API
const player = context.player();
player.setHealth(20);
});
return { success: true };
}emaki.runSyncAndWait(runnable)
将任务调度到主线程并返回 CompletableFuture<Void>,可用于异步脚本等待主线程结果。
function execute(context) {
const future = emaki.runSyncAndWait(() => {
context.player().sendMessage("Hello from main thread");
});
future.join(); // 等待主线程执行完成
return { success: true };
}如果脚本中需要读取或修改游戏状态(玩家、世界、实体等),必须通过
runSync或runSyncAndWait切换到主线程。
子 API 可用性
emaki 对象的各子 API 可能因配置关闭而为 null:
| 子 API | 控制配置 | 为 null 时 |
|---|---|---|
emaki.context | expose_context: false | 调用会抛出 NullPointerException。 |
emaki.player | expose_player: false | 同上。 |
emaki.item | expose_item: false | 同上。 |
emaki.action | expose_action: false | 同上。 |
建议在脚本中使用前检查子 API 是否存在:
if (emaki.player) { ... }