本文是 story-engine 插件系统的开发参考,目标"细致到可以照着做"。所有钩子名、字段名、默认值、错误码均已对照源码核实,标注了出处文件(均位于 src/kernel/plugin-system/)。

参考实现(真实存在、可直接阅读的插件):

  • plugins/example-stat — session 作用域:订阅事件、storage 持久化、关键字、服务、状态存档
  • plugins/rpg-kit — 全局作用域:22 个 kit_* 宿主 API,状态存 world.storage 随会话存档
  • plugins/rpg-commands — 插件指令 !语法 扩展示例

1. 系统总览#

1.1 设计原则#

  1. 状态纯数据:manifest、插件状态、事件参数都必须是可 JSON 序列化的纯数据。含函数/循环引用/BigInt/undefined 的状态在存档路径直接 panic(E-SER001-F),宁可丢一个插件的状态也不产出坏存档(context.ts 的 assertSerializable、lifecycle.ts 的 collectState 注释)。
  2. 异常不外抛:插件回调抛错绝不炸引擎主流程。事件派发逐监听器 try/catch(lifecycle.ts#dispatch);剧本 emit → 插件是 fire-and-forget(manager.ts#dispatchFromEngine);插件 boot 失败只进 failed 报告,服务照常启动。
  3. 健康度分桶:错误按来源(sessionId,缺省 global)独立分桶计数(registry.ts#PluginRecord.healthBuckets)。分桶的理由写明在 lifecycle.ts#recordError:若跨会话累计,5 个坏会话各错 1 次就能熔断插件,打挂所有正常会话。

1.2 与宿主的边界#

  • 插件只通过 ctx(PluginContext)与内核交互,不 import 引擎内部模块(plugins/example-stat/src/index.ts 头部注释明确了这条约定)。
  • 宿主对插件的全部调用点收敛在 PluginManager(manager.ts):boot/shutdown、会话时序、事件派发、存档快照、剧本层解析(关键字/函数/指令/宿主 API)。
  • 插件能扩展剧本的四样东西:事件监听(events.on)、宿主 API(@api() / 表达式可用)、剧本函数与关键字(registerFunction / registerKeyword)、指令新句式(!name(args))。

1.3 模块地图#

文件 职责
manifest-schema.ts manifest 的 zod 结构校验 + semver 解析/范围匹配
manifest-validator.ts 语义校验:entry 路径监狱、DSL 语法、依赖引用合法性
permission.ts 权限串 resource:action:scope 的序列化与 glob 匹配
capability.ts HMAC-SHA256 能力令牌签发/校验/撤销
registry.ts 四表注册器(插件/订阅/注册项/服务 + 宿主 API 表)与依赖拓扑排序
context.ts PluginContext 通道实现 + hardened Proxy 隔离 + 纯数据校验
loader.ts 插件目录发现、manifest 读取、动态 import 入口模块
lifecycle.ts 四时序生命周期、事件派发、健康度、死信/审计
manager.ts 门面 PluginManager:令牌签发、存储、定时器、配额强制点
plugin.ts IPlugin 契约与默认配额 DEFAULT_QUOTA
errors.ts 错误码常量与 PluginError

2. 快速上手:最小可运行插件#

2.1 目录结构#

plugins/my.greet/
├── manifest.json        # 身份与权限声明(纯数据,禁止写代码)
├── package.json         # 可选
└── src/index.ts         # 入口(entry 指向)

进程启动时 loader.ts#discoverPlugins 扫描插件根目录:每个含 manifest.json 的子目录视为一个插件,按目录名排序逐个加载;单个目录失败只进 DiscoverResult.errors,不阻断其他插件。

2.2 manifest.json#

{
  "id": "my.greet",
  "name": "打招呼插件",
  "version": "1.0.0",
  "engineVersion": "*",
  "apiVersion": 1,
  "entry": "./src/index.ts",
  "scope": "global",
  "permissions": [
    { "resource": "script", "action": "execute", "scope": "*" }
  ]
}

2.3 src/index.ts#

// 加载器要求:入口模块必须导出 onLoad 与 onUnload 函数(loader.ts#loadPluginFromDir)。
// 代码内 manifest 可省略;若提供,id/version/entry 必须与 manifest.json 一致。
export const manifest = {
  id: 'my.greet',
  version: '1.0.0',
  entry: './src/index.ts',
};

export async function onLoad(ctx) {
  ctx.script.registerHostAPI('greet_hi', {
    params: [{ name: 'who', type: 'str' }],
    returns: 'str',
    handler: (_hostCtx, who) => `你好,${who}`,
  });
}

export function onUnload() {
  // 宿主 API 随插件 teardown 自动清理(registry.removeHostAPIsOf),无需手工注销。
}

2.4 如何被加载、如何在剧本里用#

  1. 把目录放到 plugins/ 下(由服务器装配时的 PluginManagerOptions.pluginsDir 决定,见 manager.ts)。
  2. 启动进程,PluginManager.boot() 完成"发现 → 校验 → 令牌签发 → onLoad",并把注册的宿主 API 合入引擎注册表(syncHostAPIsTo,同时刷新既有会话的表达式内置表)。
  3. 剧本中直接调用:
set msg = greet_hi("冒险者")
say $msg

3. manifest.json 逐字段说明#

结构 schema 见 manifest-schema.ts(zod),语义校验见 manifest-validator.ts。所有结构/语义校验失败统一抛 E-REG002-R,details 携带字段级路径;boot 时该插件进 BootReport.failed,不阻断其他插件。

字段 类型 必填 校验与默认行为(出处)
id string 是 必须匹配 /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/(命名空间.名称,两段均小写开头)— manifest-schema.ts#PLUGIN_ID_PATTERN
name string 是 非空
version string 是 仅做非空校验;建议 semver。代码内 manifest 与它不一致时加载拒绝(loader.ts)
engineVersion string 是 非空;boot 时用 matchesSemverRange(引擎版本, 此范围) 匹配,不匹配拒绝加载(E-REG003-F,lifecycle.ts#boot)。支持 *、^x.y.z、~x.y.z、比较符、1.2.x、\|\|
apiVersion number 是 必须 === 1(PLUGIN_API_VERSION,superRefine 校验)
entry string 是 路径监狱(manifest-validator.ts#checkEntryPath):必须 ./ 开头;拒绝绝对路径/file:///盘符;不得含 ./../空路径段;扩展名限 .js/.mjs/.cjs/.ts;normalize 后不得越出插件目录。entry 为 .js 而磁盘只有同名 .ts 时回退加载 .ts(开发期便利,loader.ts#resolveEntryFile)
scope 'global' \| 'session' 是 枚举。global = 进程级加载一次;session = 每个新会话触发 onSessionInit,其状态参与会话存档
permissions PermissionSpec[] 是 数组(可为空)。元素 { resource, action, scope?, condition? },action 枚举 read/write/execute/emit/listen。condition 只做 DSL 语法校验(只解析不求值,dsl-parser.ts#parseCondition)
dependencies Record<pluginId, semverRange> 否 key 必须是合法插件 ID、value 必须可解析的 semver range;不得依赖自身;缺失硬依赖 → 该插件不加载(E-REG003-F);循环依赖 → 整个 boot 失败(E-REG004-F)
optionalDependencies string[] 否 每项必须是合法插件 ID;缺失不阻断加载
quota PluginQuota 否 缺省用 DEFAULT_QUOTA(见 §3.1)。逐项可覆盖
providesServices { name, version }[] 否 version 必须是合法 semver。ctx.services.provide 前会核对声明
consumesServices { name, version }[] 否 version 必须是合法 semver。ctx.services.resolve 前会核对声明
storageSchemaVersion number 否 非负整数。私有状态结构版本;与存档中 __schemaVersion 不匹配时自动调 migrateState(lifecycle.ts#restoreState)

完整示例(取自真实插件,字段全覆盖):

{
  "id": "example.stat",
  "name": "示例统计插件",
  "version": "1.0.0",
  "engineVersion": ">=0.1.0",
  "apiVersion": 1,
  "entry": "./src/index.ts",
  "scope": "session",
  "permissions": [
    { "resource": "event",   "action": "listen",  "scope": "*" },
    { "resource": "event",   "action": "emit",    "scope": "*" },
    { "resource": "storage", "action": "read",    "scope": "*" },
    { "resource": "storage", "action": "write",   "scope": "*" },
    { "resource": "script",  "action": "execute", "scope": "*" },
    { "resource": "service", "action": "write",   "scope": "*" },
    { "resource": "service", "action": "read",    "scope": "*" }
  ],
  "dependencies": { "isekai.core": "^1.0.0" },
  "optionalDependencies": ["nice.extra"],
  "quota": {
    "maxEventSubscriptions": 50,
    "maxHostCallsPerSec": 100,
    "maxStorageBytes": 1048576,
    "maxTimers": 10
  },
  "providesServices": [{ "name": "example.stat.reader", "version": "1.0.0" }],
  "consumesServices":  [{ "name": "other.svc", "version": "^1.0.0" }],
  "storageSchemaVersion": 1
}

3.1 默认配额(plugin.ts#DEFAULT_QUOTA)#

配额项 默认值 超限表现
maxEventSubscriptions 50 events.on 抛 E-QTA001-W,订阅不落表
maxHostCallsPerSec 100 滑动 1 秒窗口(manager.ts#countHostCall),超限抛 E-QTA001-W
maxStorageBytes 1 MB(1048576 字节) storage.set 时按整份 JSON 字节数检查,超限抛 E-QTA001-W
maxTimers 10 建定时器抛 E-QTA001-W;另有限制:间隔最小 10ms

计入 maxHostCallsPerSec 的通道(manager.ts 内核实现逐条核对):关键字/函数调用(wrapHostCall)、storage.get/storage.set、events.emit、events.awaitEvent、指令 handler(callDirective)。


4. IPlugin 全部 8 个生命周期钩子#

契约定义在 plugin.ts#IPlugin。入口模块除 manifest 外需导出 onLoad/onUnload(必须),其余 6 个钩子可选。签名一律以 ctx: PluginContext 为第一参数(除 healthCheck)。

4.1 onLoad(ctx): Promise<void>#

  • 调用时机:boot 的最后一步,按依赖拓扑序逐个调用(lifecycle.ts#boot 第 5 步)。session 作用域插件此处拿到的是 sessionId = null 的全局上下文。
  • 能做什么:注册事件订阅、服务、关键字/函数/宿主 API/指令;做初始化 IO。
  • 返回值/异常:任一插件 onLoad 抛错 → 该插件标记 failed、摘除其半途注册的所有痕迹(五张表 + 定时器 + 令牌,teardownPlugin),然后已加载插件按逆序全部回滚卸载,BootReport.started 清空(fail-fast + 回滚,lifecycle.ts#boot)。
  • 启动次数持久化的范例见 plugins/example-stat/src/index.ts(ctx.storage 记 boots)。

4.2 onUnload(ctx): void | Promise<void>#

  • 调用时机:三个场景——① shutdown 按加载逆序;② boot 回滚逆序;③ 熔断卸载(unloadOne,lifecycle.ts)。
  • 能做什么:释放 onLoad 申请的一切。订阅/注册项/服务/宿主 API/定时器/令牌由内核统一摘除,无需(也不应)手工逐个注销。
  • 异常处理:onUnload 抛错只记 warn 日志,不影响卸载流程继续(unloadOne 内 try/catch)。
  • 注意:从未 active 的插件(如 onLoad 失败)shutdown 时不会再被调 onUnload,只摘表(lifecycle.ts#shutdown 的 isServeable 判断)。

4.3 onSessionInit?(ctx, session): void#

  • 调用时机:仅 scope: "session" 的插件、且处于可服务状态(active/degraded);会话创建时按加载顺序逐个调用(lifecycle.ts#onSessionInit)。
  • 能做什么:初始化会话级状态。只有 onSessionInit 成功过的会话才会被记入 rec.sessionIds,其状态才会参与存档(collectState 的过滤条件)。
  • 返回值/异常:成功 → recordSuccess(健康度清零该来源桶);抛错 → recordError(..., 'onSessionInit', sessionId),计入健康度。异步 init 期间会话已关闭时跳过绑定(防死会话 id 泄漏)。

4.4 onSessionDestroy?(ctx, session): void#

  • 调用时机:会话销毁时逆序调用;且只对真正 onSessionInit 成功过的会话调用(init 未完成即销毁的竞态下跳过)。
  • 异常处理:抛错只记 warn,不计健康度(lifecycle.ts#onSessionDestroy 的 try/catch 只打日志)。
  • 随后内核自动:移除该会话的订阅、删除该会话的健康桶、清理该会话名下的全部插件定时器、撤销该会话的全部令牌。

4.5 healthCheck?(): { status: 'ok' | 'degraded' | 'failed'; message?: string }#

  • 调用时机:PluginManager.healthCheckOf(pluginId) 巡检时;也可经 /api/plugins 观察。
  • 异常处理:healthCheck 自身抛错按 failed 处理并带上错误消息(manager.ts#healthCheckOf)。插件记录已是 failed(熔断/禁用)时直接报"插件已被熔断/禁用"。

4.6 serializeState?(session): Record<string, unknown>#

  • 调用时机:会话存档时(lifecycle.ts#collectState),仅 session 作用域 + 已绑定该会话 + 可服务的插件参与。
  • 返回值处理:返回 undefined → 不并入存档;返回值先过 assertSerializable 纯数据校验,含函数/symbol/BigInt/undefined/循环引用 → E-SER001-F panic,该插件状态不并入存档并计入健康度(错误位置 serializeState)。
  • 收集结果以 manifest.id 为键并入 SaveState.plugins。

4.7 deserializeState?(session, state): void#

  • 调用时机:读档时(lifecycle.ts#restoreState),在 migrateState(如需)之后调用。state 先过纯数据校验。
  • 副作用:成功后该会话被记入 rec.sessionIds(后续存档继续收集),并 recordSuccess;抛错计健康度(位置 deserializeState)。

4.8 migrateState?(oldState: unknown, fromVersion: number): unknown#

  • 调用时机:读档时,若 manifest 声明了 storageSchemaVersion 且与存档数据中的 __schemaVersion 不相等(lifecycle.ts#restoreState)。fromVersion 取存档中的版本号(非数字时传 0)。
  • 异常处理:抛错计健康度(位置 migrateState),该插件本次状态不恢复。
  • 迁移写法范例见 plugins/example-stat/src/index.ts 的 migrateState(v0 → v1 补默认字段)。

5. PluginContext(ctx)通道详解#

ctx 经 hardened Proxy 隔离交付(见 §6)。字段白名单(context.ts#ALLOWED_KEYS,共 10 个):pluginId / token / manifest / logger / events / services / script / storage / timer / serialize。

5.1 身份字段与 logger#

API 说明
ctx.pluginId 插件 ID(只读)
ctx.manifest 权威 manifest 视图(来自 manifest.json)
ctx.token 能力令牌只读视图:tokenId/pluginId/sessionId/permissions/issuedAt/expiresAt/signature(capability.ts#CapabilityToken)
ctx.logger.info/warn/error(msg, meta?) pino 子 logger,自动带 plugin: <id> 字段(manager.ts#makePluginLogger)

5.2 events 通道(context.ts)#

需要权限:on/awaitEvent → event:listen;emit → event:emit。

API 签名要点 行为
events.on(event, handler, opts?) opts:{ priority?: number, timeoutMs?: number },默认 priority: 0、timeoutMs: 500 返回 Disposable(dispose() 退订)。事件名必须匹配点分小写段 /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/,否则 E-EVT001-W。handler 可同步可异步;异步超时被强杀(E-EVT002-R)。权限 scope 按 sessionId ?? '*' 运行时校验,因此 manifest 里 scope 写 "*" 才能覆盖任意会话
events.emit(event, args) args 数组 派发给其他监听者;计入调用频次配额
events.awaitEvent(event, timeoutMs?) 默认超时 5000ms 一次性等待,到达返回 args 数组;超时自动摘除内部订阅并抛 E-EVT002-R(manager.ts#awaitEvent,超时 timer 也登记管理,防 unhandledRejection)

派发时序(lifecycle.ts#dispatch):事件名校验 → 收集订阅(全会话监听的也命中)→ priority 降序 → 同步直调 / 异步带超时强杀 → 逐个计健康度 → 无任何监听者时进死信队列(环形 200 条,DEAD_LETTER_LIMIT)。

5.3 services 通道#

API 权限 额外约束
services.resolve(name, versionRange) service:read 必须先在 manifest consumesServices 声明,否则 E-CAP003-R。解析在满足 range 的提供者中取版本最高者;找不到抛 E-REG003-F(registry.ts#resolveService)
services.provide(name, version, impl) service:write 必须先在 manifest providesServices 声明,否则 E-CAP003-R。version 必须合法 semver;同插件同版本重复提供视为覆盖(幂等)

5.4 script 通道(四个注册器对比)#

全部需要权限 script:execute,全部返回 Disposable。名字合法性在 registry.ts#addRegistration 校验,重名抛 E-REG001-R(与是谁注册的无关)。

API 名字规则(registry.ts) 剧本侧用法 关键差异
registerKeyword(name, handler) /^@[a-z]+\.[a-z]+:[a-z_]+$/,如 @ex.stat:show 引擎侧关键字语法(预留槽位) handler 必须同步;经 wrapHostCall 包装:计入频次配额、抛错计健康度、返回 Promise 直接拒绝(E-REG002-R,表达式通道是同步求值,async 会污染 vars 和存档)
registerFunction(name, handler) /^[a-z]+\.[a-z]+(_[a-z]+)*$/,如 my.fn 表达式中 my.fn(...) 同上(同步 + 配额 + 健康度)
registerHostAPI(name, def) 非空字符串 剧本 @api() / 表达式调用 def 形状见下。跨插件重名抛 E-REG001-R,同插件重复注册视为覆盖(registry.ts#registerHostAPI)。boot 后经 syncHostAPIsTo 合入引擎注册表并刷新内置表
registerDirective(name, handler) 与事件名同模式(点分小写段),如 rpg.roll 剧本语句 !rpg.roll(2) 语句级、无返回值;handler 额外收到 (args, sessionId, session),可调 session.getVars() 拿活引用直接写会话变量。计入频次配额;抛错计健康度后重新抛出,VM 包装为 E3003 上报客户端(vm.ts 的 onDirective catch)

registerHostAPI 与 registerDirective 的差异(最容易混淆的一对):

维度 registerHostAPI registerDirective
剧本写法 表达式内 name(args) / @name() 语句行 !name(args)
有无返回值 有,参与表达式求值 无(语句级)
handler 签名 (hostCtx, ...args),hostCtx 为 { world, vars, emit }(host-api.ts#HostContext) (args, sessionId, session?),session 是 Session 对象
参数校验 引擎按 params 类型表校验(不匹配 E3001) 无类型校验,args 原样数组
剧本中取值 直接 set x = name(...) 写会话变量间接传值

HostAPIDef 参数/返回类型(types/context.d.ts#HostAPIDefInput、engine/runtime/host-api.ts):

  • params: Array<{ name: string, type: 'str' | 'num' | 'bool' | 'any', optional?: boolean }>;类型按 typeof 匹配,个数不匹配或类型不符 → E3001
  • returns: 'void' | 'str' | 'num' | 'bool' | 'any' | 'promise'。注意:returns 为 void 或 promise 的 API 不会进入表达式内置表(buildExprBuiltins 过滤),即不能在表达式中求值
  • clientSide: true:调用转发客户端执行,服务端 handler 不被调用,客户端回 host_result 后 resumeHost 恢复(host-api.ts、vm.ts#resumeHost)
  • 剧本调用未注册的 API → E3002;handler 抛错 → E3003

5.5 storage 通道#

需要权限:get → storage:read;set → storage:write。

  • 按插件隔离的 KV,落盘为 savesDir/plugins/<pluginId>.json(manager.ts#PluginStorageStore),带内存缓存,写盘走 tmp 文件 + rename 原子替换。
  • 每个插件的 storage 操作强制串行排队(manager.ts#enqueueStorage):"await get → 改 → await set" 的读改写在并发事件下不会丢更新。
  • set 超过 maxStorageBytes(整份 JSON 字节数)抛 E-QTA001-W。
  • 与 serialize 是两条独立链路:storage 即时落盘(跨会话/跨重启),serialize 随会话存档走。

5.6 timer 通道#

  • timer.setTimeout(fn, ms) / timer.setInterval(fn, ms) → 返回字符串 id;timer.clear(id) 清除(无需权限)。
  • 建/清定时器需权限 timer:write;受 maxTimers 配额;间隔最小 10ms,ms 非有限数或 <10 抛 E-QTA001-W(manager.ts#addTimer)。
  • 回调统一走 safeRun:同步抛错或 async rejection 计健康度(位置 timer),成功才记录成功(manager.ts#safeRun)。
  • 插件被摘除(卸载/熔断/禁用)或会话销毁时,名下定时器全部清除。

5.7 serialize 通道#

工具型通道,等价于手动触发实例的状态钩子(context.ts#serialize):

  • serialize.save() → 调实例 serializeState(当前会话),返回前过 assertSerializable(不纯 → E-SER001-F);无会话或未实现钩子时返回 {}。
  • serialize.load(state) → state 先过纯数据校验,再转发给实例 deserializeState(当前会话, state)。

5.8 权限申请与对照表#

怎么申请:在 manifest.json 的 permissions 数组里逐条声明,每条是一个 PermissionSpec:

{
  "permissions": [
    { "resource": "script",  "action": "execute", "scope": "*" },
    { "resource": "event",   "action": "listen",  "scope": "*" },
    { "resource": "storage", "action": "write",   "scope": "*", "condition": "..." }
  ]
}
  • resource:资源类别,任意非空字符串;引擎内置检查点使用的资源有 event / service / script / storage / timer(manifest-schema.ts#permissionSpecSchema)
  • action:枚举,只有五个 —— read / write / execute / emit / listen(zod enum,写别的值 manifest 校验直接失败)
  • scope:可选,缺省 *;global 插件按会话匹配时通常填 *(scope 按运行时 sessionId 匹配)
  • condition:可选的条件 DSL 字符串;加载期只做语法解析不求值,语法错误 → E-REG002-R(manifest-validator.ts#92)

匹配规则(permission.ts):

  • 序列化格式 resource:action:scope;已授予串与本次请求做三方比对
  • resource 与 scope 支持 * 通配(含空串),action 必须精确相等——通配不放行"读"当"写",防止权限被放大
  • 校验失败按原因映射:缺失 E-CAP001 / 过期 E-CAP002 / 权限不足 E-CAP003 / 已撤销 E-CAP004(capability.ts);拒绝即抛 PluginError,并计入插件健康度分桶

通道 → 权限对照表:

通道调用 所需权限(resource:action) 额外门槛
events.on / events.awaitEvent event:listen 事件名格式;scope 按会话匹配
events.emit event:emit 事件名格式
services.resolve service:read manifest consumesServices 已声明
services.provide service:write manifest providesServices 已声明
script.* 四个注册器(宿主 API / 指令 / 关键字 / 关键字别名) script:execute 名字规则 + 重名检查(重名 E-REG001-R)
storage.get / storage.set storage:read / storage:write 字节配额(maxStorageBytes)
timer.setTimeout / setInterval timer:write 数量(maxTimers)与间隔(≥10ms)配额
timer.clear、logger.*、serialize.* 无 —

内置插件的实际声明(参照):

插件 permissions 声明 为什么
rpg-kit script:execute:*、event:listen:*、event:emit:* 注册宿主 API + emit combat_start 等事件 + 监听会话事件
rpg-commands script:execute:*、storage:read:*、storage:write:* !rpg.roll 结果要写会话存储(last_roll)
isekai-core(本地) script:execute:*、event:listen:*、event:emit:* 溯回/时间事件双通道
isekai-codex(本地) script:execute:*、event:listen:* 只注册 API + 监听,不 emit

漏申报的实际表现:插件代码调用了未声明权限的通道(例如没写 event:emit 就 ctx.events.emit(...))→ 能力令牌校验失败 → E-CAP003 PluginError 抛出 → 该插件健康度 +1(连续 3 次降级、5 次熔断,见 §7)。manifest 校验期不会发现这种"用到才爆"的漏报——上插件前对着 §5.8 的表把用到的通道核一遍。

5.9 全权限申请完整示例(已通过真实服务器装配验证)#

下面是一个声明并使用全部 8 条权限的可运行插件(仓库本地 plugins/perm-demo/,pnpm e2e:plugins 装配验证通过)。抄走改个 ID 就能用。

manifest.json(8 条权限逐条对应代码里的用法):

{
  "id": "perm.demo",
  "name": "全权限申请示例插件",
  "version": "1.0.0",
  "engineVersion": "*",
  "apiVersion": 1,
  "entry": "./src/index.ts",
  "scope": "session",
  "permissions": [
    { "resource": "script",  "action": "execute", "scope": "*" },
    { "resource": "event",   "action": "listen",  "scope": "*" },
    { "resource": "event",   "action": "emit",    "scope": "*" },
    { "resource": "storage", "action": "read",    "scope": "*" },
    { "resource": "storage", "action": "write",   "scope": "*" },
    { "resource": "timer",   "action": "write",   "scope": "*" },
    { "resource": "service", "action": "read",    "scope": "*" },
    { "resource": "service", "action": "write",   "scope": "*" }
  ],
  "providesServices": [
    { "name": "perm.demo.clock", "version": "1.0.0" }
  ],
  "consumesServices": [
    { "name": "perm.demo.clock", "version": "1.0.0" }
  ],
  "quota": {
    "maxEventSubscriptions": 10,
    "maxHostCallsPerSec": 50,
    "maxStorageBytes": 65536,
    "maxTimers": 4
  },
  "storageSchemaVersion": 1
}

src/index.ts(注释里的【权限 n】与 manifest 逐条对应):

/** 最小上下文契约(与引擎 PluginContext 对齐的子集,避免依赖引擎内部路径)。 */
interface PluginContextLike {
  readonly pluginId: string;
  readonly logger: {
    info(msg: string, meta?: Record<string, unknown>): void;
    warn(msg: string, meta?: Record<string, unknown>): void;
  };
  readonly events: {
    on(event: string, handler: (args: unknown[]) => void | Promise<void>,
       opts?: { priority?: number; timeoutMs?: number }): { dispose(): void };
    emit(event: string, args: unknown[]): void;
  };
  readonly services: {
    provide(name: string, version: string, impl: unknown): void;
    resolve<T>(name: string, versionRange: string): T;
  };
  readonly script: {
    registerHostAPI(name: string, def: {
      params: Array<{ name: string; type: 'str' | 'num' | 'bool' | 'any' }>;
      returns: 'void' | 'str' | 'num' | 'bool' | 'any';
      handler: (hostCtx: unknown, ...args: never[]) => unknown;
    }): unknown;
    registerDirective(
      name: string,
      handler: (args: unknown[], sessionId: string | null, session?: unknown) => void,
    ): { dispose(): void };
  };
  readonly storage: {
    get<T>(key: string): Promise<T | undefined>;
    set<T>(key: string, value: T): Promise<void>;
  };
  readonly timer: {
    setTimeout(fn: () => void, ms: number): string;
    setInterval(fn: () => void, ms: number): string;
    clear(id: string): void;
  };
  readonly serialize: {
    save(): Record<string, unknown>;
    load(state: Record<string, unknown>): void;
  };
}

interface SessionLike {
  readonly id: string;
}

interface DemoState {
  booted: boolean;
  sessions: number;
  ticks: number;
}

const state: DemoState = { booted: false, sessions: 0, ticks: 0 };

/** 与 manifest.json 保持一致的三元组(manifest.json 才是权威)。 */
export const manifest = {
  id: 'perm.demo',
  version: '1.0.0',
  entry: './src/index.ts',
};

/** 服务名(须与 manifest.providesServices 声明一致)。 */
const SERVICE = 'perm.demo.clock';

// ---------------------------------------------------------------- 1) 启动:全局一次

export async function onLoad(ctx: PluginContextLike): Promise<void> {
  state.booted = true;

  // 【权限 4+5 storage:read/write】插件私有持久化(按插件隔离,随存档保存)。
  const seen = (await ctx.storage.get<number>('boot_count')) ?? 0;
  await ctx.storage.set('boot_count', seen + 1);

  // 【权限 2 event:listen】监听剧本/客户端派发的事件。
  ctx.events.on('demo_ping', (args) => {
    ctx.logger.info({ args }, 'perm-demo 收到 demo_ping');
  });

  // 【权限 8 service:write】提供服务;其他插件可 resolve('perm.demo.clock', '^1.0.0')。
  ctx.services.provide(SERVICE, '1.0.0', {
    now(): number {
      return Date.now();
    },
  });

  // 【权限 7 service:read】消费服务(需 manifest.consumesServices 声明)。
  const clock = ctx.services.resolve<{ now(): number }>(SERVICE, '^1.0.0');
  ctx.logger.info({ now: clock.now() }, 'perm-demo 服务自测通过');

  // 【权限 1 script:execute → 宿主 API】有返回值,剧本里 set x = demo_hi("...")。
  ctx.script.registerHostAPI('demo_hi', {
    params: [{ name: 'who', type: 'str' }],
    returns: 'str',
    handler: (_hostCtx, who) => `你好,${String(who)}(来自 perm-demo)`,
  });

  // 【权限 1 script:execute → 插件指令】语句级,剧本里 !demo.roll(3)。
  ctx.script.registerDirective('demo.roll', (args, _sessionId, session) => {
    const n = Math.max(1, Math.min(10, Number(args[0]) || 1));
    const roll = 1 + Math.floor(Math.random() * 6) * n;
    if (session && typeof session === 'object') {
      const vars = (session as { getVars?: () => Record<string, unknown> }).getVars?.();
      if (vars) vars['last_roll'] = roll;
    }
    ctx.logger.info(`demo.roll → ${roll}`);
  });

  ctx.logger.info({ boot_count: seen + 1 }, 'perm-demo loaded(全权限示例)');
}

// ---------------------------------------------------------------- 2) 会话级:每个会话一次

export function onSessionInit(ctx: PluginContextLike, session: SessionLike): void {
  state.sessions += 1;

  // 【权限 3 event:emit】向会话广播事件(客户端可监听,剧本 on/await 可接收)。
  ctx.events.emit('demo_hello', [session.id]);

  // 【权限 6 timer:write】定时器必须挂在具体会话上才会被该会话的调度器 tick。
  //   ——这就是为什么示例放在 onSessionInit 而不是 onLoad:global 上下文没有会话,定时器不会跑。
  const id = ctx.timer.setTimeout(() => {
    state.ticks += 1;
    ctx.logger.info({ session: session.id }, 'perm-demo 定时器触发(1s)');
  }, 1000);
  ctx.logger.info({ timer: id }, 'perm-demo 已注册会话定时器');
}

export function onSessionDestroy(_ctx: PluginContextLike, _session: SessionLike): void {
  // 清理可在事件 on() 的 dispose() / timer.clear(id) 中做;本示例无长驻资源。
}

// 【硬性要求】入口模块必须同时导出 onLoad 与 onUnload,缺任何一个加载失败(E-REG002-R)。
export function onUnload(ctx: PluginContextLike): void {
  ctx.logger.info({ sessions: state.sessions, ticks: state.ticks }, 'perm-demo unloaded');
}

// ---------------------------------------------------------------- 3) 存档:状态进出

export function serializeState(): Record<string, unknown> {
  return { __schemaVersion: 1, sessions: state.sessions, ticks: state.ticks };
}

export function deserializeState(_session: SessionLike, saved: Record<string, unknown>): void {
  if (typeof saved.sessions === 'number') state.sessions = saved.sessions;
  if (typeof saved.ticks === 'number') state.ticks = saved.ticks;
}

剧本里的用法:

set msg = demo_hi("冒险者")     # 宿主 API(有返回值)
!demo.roll(3)                   # 插件指令(语句级,写会话变量 last_roll)

本示例编写过程中真实踩到的三个坑(都已在示例中规避):

  1. consumesServices[].version 是精确 semver,不接受 ^/~ 范围——写了直接 E-REG002-R。范围匹配只出现在运行时 services.resolve(name, versionRange) 的第二个参数里。
  2. 入口模块必须同时导出 onLoad 与 onUnload,缺任何一个(哪怕你不需要清理)都在加载期报"入口模块必须导出 onLoad(ctx) 与 onUnload(ctx)"(E-REG002-R)。
  3. timer:write 的定时器要挂在具体会话上才会被调度——session 作用域插件把定时器放 onSessionInit,别放 onLoad(global 上下文没有会话,定时器永远不跑)。

验证方式:把目录放进 plugins/,跑 pnpm e2e:plugins,日志出现 perm-demo loaded(全权限示例) 即装配成功。


6. 隔离模型:hardened Proxy 与能力令牌#

6.1 ctx 的 Proxy 陷阱(context.ts#createPluginContext)#

  • get:白名单字段返回 hardened 包装值;敏感字段集合(fs/process/globalThis/require/constructor/__proto__/eval/Function 等,见 SENSITIVE_KEYS)返回 undefined(不抛错)——避免插件用 try/catch 探测内核;其余字段一律抛 E-CAP003-R。
  • set / deleteProperty / defineProperty:一律抛 E-CAP003-R(上下文只读)。
  • ownKeys:只返回白名单键;getOwnPropertyDescriptor 恒返回 undefined——否则插件可经 Object.getOwnPropertyDescriptor(ctx, 'events').value 拿到未经包装的原始实现(源码注释记载了实测 PoC)。
  • hardened 包装递归到底:通道函数(hardenFn)与子对象(hardenObj)同样被 Proxy 包住:constructor/prototype/__proto__/call/apply/bind 等属性不可见、getPrototypeOf 恒为 null、symbol 访问返回 undefined、写入即抛。堵死 ctx.events.constructor.constructor('return process')() 式原型链逃逸——这段攻击路径在 context.ts 头注释里有原文记载。

插件视角:插件能看到的只有白名单内的 10 个字段及其方法;ctx.fs、ctx.process 静默返回 undefined;ctx.foo 抛 E-CAP003-R;Object.keys(ctx) 只得到白名单;任何写入/删除/定义属性的操作抛 E-CAP003-R。

6.2 能力令牌与权限模型#

令牌是插件获得内核能力的唯一凭证(capability.ts):

  • token = HMAC-SHA256(pluginId + tokenId + issuedAt, secret);secret 进程启动时 crypto.randomBytes(32) 随机生成,不落盘不入存档——重启后旧令牌自然失效,因此存档恢复必然伴随重新签发。
  • 校验顺序(CapabilityAuthority.verify):令牌缺失/签名不符 → E-CAP001-R;已撤销 → E-CAP004-R;已过期 → E-CAP002-R;权限不覆盖 → E-CAP003-R。签名比较用 timingSafeEqual 防侧信道。
  • 撤销粒度:单令牌 / 单插件全部 / 单会话全部(revokeToken / revokePlugin / revokeSession)。插件卸载/熔断/禁用、会话销毁都会触发撤销。

manifest 权限与令牌的关系:boot 时 serializePermissions(manifest.permissions)(plugin.ts)把每条声明序列化为 resource:action:scope 字符串集合,签进令牌。之后 ctx 每个通道方法调用前都拿令牌做 verify(required)。

权限匹配规则(permission.ts):

  • 权限串三段 resource:action:scope,scope 缺省视为 *;
  • resource 与 scope 支持 * 通配(glob,匹配任意串);action 必须精确相等(防"读"被当"写"放行);
  • 未声明对应权限(或声明不含所需 action/scope)时调用同步抛 E-CAP003-R,消息形如 权限不足:需要 storage:write:*。

7. 健康度系统#

阈值常量(lifecycle.ts):HEALTH_DEGRADE_THRESHOLD = 3、HEALTH_CIRCUIT_THRESHOLD = 5。

7.1 计数与降级/熔断#

  • 错误按来源分桶:bucketKey = sessionId ?? 'global',桶计数 = "该来源连续报错次数"(recordError)。
  • 同一来源连续第 3 次 → 降级:状态转 degraded,审计 health.degrade,日志 E-HLT001-W。降级期间插件仍接收事件、仍可被调用(isServeable 含 degraded)。
  • 同一来源连续第 5 次 → 熔断卸载:状态转 failed,审计 health.circuit_break,日志 E-HLT002-R,异步执行卸载(订阅/注册项/服务/宿主 API/定时器全部摘除、令牌撤销、引擎侧 API 副本同步删除)。
  • 恢复条件:该来源一次成功即清零该桶;但全部桶都清零才解除降级(recordSuccess 的注释解释了原因:否则 B 会话的一次成功会洗掉 A 会话造成的降级)。熔断(failed)不可自愈,只能经 PluginManager.enable(pluginId) 重走 onLoad 恢复(manager.ts#enable,恢复时清空健康桶)。

7.2 计入错误的点(逐处核对源码)#

位置 出处 说明
事件 handler 抛错/超时 lifecycle.ts#dispatch 位置 event:<名字>
onSessionInit 抛错 lifecycle.ts#onSessionInit 位置 onSessionInit
serializeState 校验失败/抛错 lifecycle.ts#collectState 存档路径
migrateState / deserializeState 抛错 lifecycle.ts#restoreState 读档路径
定时器回调抛错 / async rejection manager.ts#safeRun 位置 timer
指令 handler 抛错 manager.ts kernel registerDirective 位置 directive,先计数再 rethrow
关键字/函数 handler 抛错、async handler manager.ts#wrapHostCall 位置 hostCall

对剧本调用的实际影响:插件降级后剧本侧暂无感知(API 仍可调用);一旦熔断,该插件的宿主 API 从引擎注册表删除(onPluginTornDown 钩子同步摘除),剧本再调用即报 E3002(未注册),指令同理。


8. 状态序列化与存档#

8.1 什么能进 storage / serializeState#

值 结果
null / boolean / number / string 可以
纯对象 / 数组(内容递归可序列化) 可以
function / symbol / bigint / undefined E-SER001-F(context.ts#assertSerializable,报错带字段路径)
循环引用 E-SER001-F
NaN/Infinity 等非标准 JSON number 校验器不拦截,但 JSON.stringify 后变形,自行避免

8.2 存档/读档时插件的钩子顺序#

存档(lifecycle.ts#collectState,经 manager.saveSnapshot 并入 SaveState.plugins):

  1. 遍历插件(按加载顺序),逐个过滤:session 作用域 + sessionIds 含当前会话 + 可服务;
  2. 调 serializeState(session) → assertSerializable → 失败计健康度并跳过该插件;
  3. 结果以 manifest.id 为键并入存档。

读档(lifecycle.ts#restoreState,经 manager.loadSnapshot):

  1. 对存档 plugins 里的每个插件(按存档键顺序):
  2. 若声明了 storageSchemaVersion 且数据带 __schemaVersion 且不等 → 先调 migrateState(old, fromVersion);
  3. 迁移结果过 assertSerializable → 调 deserializeState(session, state);
  4. 成功 → 会话记入 sessionIds(后续存档继续收集)+ recordSuccess;失败 → 计健康度,该插件状态不恢复。

约定要点:

  • global 作用域插件不参与会话存档,跨会话数据走自己的 ctx.storage;
  • __schemaVersion 字段是插件自己在 serializeState 返回值里写的(见 plugins/example-stat),内核只读取比对;
  • 插件状态在存档中的键 = manifest.id(registry.ts#PluginRecord.stateKey)。

9. 进阶示例:状态 + 宿主 API + 指令三件套#

目录:plugins/my.counter/(manifest 同 §2.2,权限另加 storage 与 script)。

{
  "id": "my.counter",
  "name": "计数器插件",
  "version": "1.0.0",
  "engineVersion": "*",
  "apiVersion": 1,
  "entry": "./src/index.ts",
  "scope": "global",
  "permissions": [
    { "resource": "script",  "action": "execute", "scope": "*" },
    { "resource": "storage", "action": "read",    "scope": "*" },
    { "resource": "storage", "action": "write",   "scope": "*" }
  ],
  "storageSchemaVersion": 1
}
// plugins/my.counter/src/index.ts
export const manifest = {
  id: 'my.counter',
  version: '1.0.0',
  entry: './src/index.ts',
};

export async function onLoad(ctx) {
  // ① 宿主 API:表达式可求值(returns: 'num')。
  ctx.script.registerHostAPI('counter_get', {
    params: [],
    returns: 'num',
    handler: () => total,
  });

  // ② 指令:剧本写 !counter.add(n) 即触发,直接写会话变量。
  ctx.script.registerDirective('counter.add', (args, _sessionId, session) => {
    total += Number(args[0]) || 1;
    const vars = session?.getVars?.();
    if (vars) vars['counter_total'] = total;   // 活引用,下一句剧本立即可读
    // async IIFE 必须 self-catch,否则 storage 异常裸奔成 unhandledRejection
    void ctx.storage.set('total', total).catch((err) => ctx.logger.warn({ err }, '持久化失败'));
  });
}

export function onUnload() { /* 注册项随 teardown 自动清理 */ }

剧本用法(指令与表达式混用):

!counter.add(3)
if counter_total >= 3
    say "计数已达 $counter_total"
end

设计要点(提炼自 plugins/rpg-kit 与 plugins/rpg-commands 的真实写法):

  • 领域校验在插件内闭环:非法输入返回 false/空串而非抛错——抛错会进健康度,连续 5 次熔断插件;
  • 需要值给表达式用就注册宿主 API(returns 不能是 void/promise);需要副作用就注册指令;
  • 指令 handler 同步执行于 VM 指令流内,不要在里面调 session.handle* 之类驱动方法(重入风险);写变量、写 storage、ctx.logger 是安全副作用;
  • 插件自身 storage 放跨会话数据;会话内游戏状态可学 rpg-kit 存 world.storage(随会话存档自动进出)。

10. 调试与测试#

10.1 日志与观测#

  • 插件日志是 pino 结构化输出:ctx.logger 自动带 plugin: <id> 字段;内核侧 module 字段为 plugin-manager / plugin-lifecycle。
  • boot 报告 BootReport:started(按加载顺序)/ failed(含每个失败的 PluginError);manifest 校验失败、依赖缺失、onLoad 抛错都只进 failed,服务照常启动。
  • 审计日志环形 500 条(AUDIT_LIMIT):每次订阅/派发/服务/定时器/健康度事件都有 AuditEntry;死信队列环形 200 条保留"无监听者"的事件,用于排查订阅时机。
  • HTTP 巡检:curl localhost:3000/api/plugins(src/transport/http-routes.ts);配置了 ADMIN_TOKEN 时需带 Bearer / x-admin-token 头(src/config.ts)。
  • 端到端链路验证:pnpm e2e:plugins(package.json scripts,对应 scripts/e2e-plugins.ts)。

10.2 如何写插件的单元测试#

参照 tests/plugin-system.test.ts(约 1200 行、十组用例)的成熟套路:

  1. 临时目录落盘插件:在项目根下建临时目录,writeFileSync 写 manifest.json 与入口文件(该测试文件顶部 tmpDir/writePlugin 助手);每个用例独立子目录,规避动态 import 的模块缓存。
  2. loadPluginFromDir(dir) 做加载断言(manifest 校验失败 / 路径穿越 / 模块形状)。
  3. new PluginManager({ pluginsDir, savesDir, ... }) 做端到端:boot → onSessionInit(需传 sessions 查找表)→ dispatch → saveSnapshot → shutdown。
  4. manager.internals 暴露 { registry, authority } 供测试探针(manager.ts#get internals)。
  5. 隔离行为测试用 createPluginContext + 内核间谍(kernelSpy,每个方法 vi.fn())直接构造 ctx 断言 Proxy 行为。

游戏子系统型插件可参照 tests/rpg-kit.test.ts:直接调宿主 API 的 handler(hostCtx, ...) 断言领域逻辑,不经 VM。

10.3 常见错误对照表(以 errors.ts 为准)#

码 含义 典型触发场景
E-REG001-R ID / 注册项 / 宿主 API 重名 两个插件注册同名宿主 API;同插件重复注册同一关键字
E-REG002-R manifest 校验失败 / 插件异常兜底 manifest 缺字段、entry 越狱、JSON 非法、缺 onLoad/onUnload 导出、条件 DSL 语法错、async 剧本函数
E-REG003-F 依赖缺失 / 服务解析失败 / 引擎版本不兼容 / 未注册指令 硬依赖不在、resolveService 找不到、engineVersion 范围不匹配、剧本调了未注册指令(客户端表现为 E3003)
E-REG004-F 循环依赖 A 依赖 B、B 依赖 A——整个 boot 失败
E-CAP001-R 令牌缺失/无效 令牌被伪造或进程重启后复用旧令牌
E-CAP002-R 令牌过期 签发时设了 ttl/expiresAt 且已到期
E-CAP003-R 权限不足 / 上下文越权 未声明权限调通道、写 ctx 字段、访问非白名单字段、services 未在 manifest 声明
E-CAP004-R 令牌已撤销 插件被卸载/熔断/禁用或会话销毁后仍持旧令牌调用
E-QTA001-W 配额超限 订阅数、宿主调用频次、storage 字节、定时器数或间隔 <10ms
E-EVT001-W 事件名不合规 事件名不是点分小写段
E-EVT002-R 事件监听/等待超时 异步监听器超过 timeoutMs(默认 500ms);awaitEvent 超时(默认 5000ms)
E-HLT001-W 健康降级 同一来源连续报错 3 次
E-HLT002-R 熔断卸载 同一来源连续报错 5 次
E-SER001-F 状态不可序列化 serializeState/storage 值含函数、symbol、BigInt、undefined 或循环引用

剧本层错误码(src/engine/vm/vm-error.ts 的 ERROR_CODES):E3001 宿主 API 签名不匹配、E3002 未注册的宿主 API/指令、E3003 宿主调用失败(含指令 handler 抛错的包装)、E5002 存档不可序列化。完整协议层对照见 protocol-and-error-codes.md。


11. 相关文档#