DOCUMENTATION · 常见问题

常见问题

本文档收集使用「插件化剧情引擎」时最常见的问题与排查步骤。 所有问题都可以按「先看页面右下角事件日志 → 再看浏览器控制台 → 最后看错误码」的顺序定位。

同步自 docs/FAQ.md(327 行) · commit b2b4124 · 读源文件 ↗

引擎的所有错误都是 EngineError,带机器可读的 code 字段(见 src/utils/errors.ts), 日志里会形如:

[plugin:com.example.hello] 缺少权限 "state:write",操作已被宿主拒绝 (PLUGIN_PERMISSION_DENIED)

1. 插件加载失败怎么办?

现象:插件列表里该插件显示 error,事件日志出现 PLUGIN_LOAD_FAILED。

排查顺序:

  1. 路径是否正确 剧情里 plugins 数组写的是运行时可访问的 URL,不是文件系统路径:

    "plugins": ["/plugins/hello-js/manifest.json"]

    注意前导 /(对应 public/plugins/...)。若部署在子路径,需要带上前缀。

  2. manifest.json 是否合法

    • 必须是合法 JSON(不能有注释、不能有多余逗号);
    • id 全局唯一(两个插件同 id 会报冲突);
    • apiVersion 必须等于 1(宿主的 PLUGIN_API_VERSION);
    • type 必须是 "js" 或 "wasm";
    • entry 是相对 manifest 目录的文件名(如 index.js / plugin.wasm);
    • timeoutMs 若填写必须是正数。
  3. 入口文件是否真的存在 打开 Network 面板,确认 manifest.json、index.js / plugin.wasm 都返回 200。 404 说明文件名拼错或目录结构不对。

  4. JS 插件导出格式 入口模块必须能解析出插件对象,支持以下三种写法:

    // 1) 默认导出
    export default { init(ctx) { /* ... */ } }
    
    // 2) 具名导出(在 manifest 里用 "export": "myPlugin" 指定,或直接用导出的钩子)
    export function init(ctx) { /* ... */ }
    
    // 3) 工厂函数
    export function createPlugin() { return { init(ctx) {} } }

    引擎用 try/catch 包裹动态 import(),语法错误会给出明确的 PLUGIN_LOAD_FAILED 而不是白屏。

  5. 权限名拼写 permissions 里出现非法权限名(如 state:readd)会导致 manifest 校验失败。


2. WASM 实例化失败怎么办?

现象:PLUGIN_LOAD_FAILED,消息里包含 WebAssembly 或「缺少导出」。

排查顺序:

  1. 产物是否 cdylib Cargo.toml 必须包含:

    [lib]
    crate-type = ["cdylib"]

    否则产出的 .wasm 不是可实例化的模块。

  2. ABI 导出是否齐全 引擎在实例化后会检查以下导出,缺任何一个都会报明确错误:

    memory, alloc, dealloc, plugin_init, plugin_handle, plugin_result_ptr, plugin_result_len

    每个都要写成 #[no_mangle] pub extern "C" fn ...。完整签名见 docs/ABI.md。

  3. 宿主 imports 是否匹配 插件若 import 了 env.host_log / host_emit / host_get_state / host_set_state, 但宿主没有提供对应函数,实例化会失败。宿主的桥接实现在 src/plugins/WasmPluginLoader.ts。

  4. 是否用了不支持的目标 本项目只支持 Rust + wasm32-unknown-unknown + 直接 C ABI。 用 AssemblyScript / 其它语言 / wasm-bindgen 生成的产物没有对应的导入导出约定, 不在兼容范围内(详见 docs/PLUGIN_WASM.md)。

  5. 是不是构建产物过期 .wasm 会被浏览器缓存。改完 Rust 代码后重新 npm run build:wasm,并强制刷新(Ctrl/Cmd + Shift + R)。

  6. 内存问题 若报 RuntimeError: memory access out of bounds,通常是 alloc / dealloc 的 size 不匹配, 或宿主读结果时用了错误的 len。请对照 docs/ABI.md 的内存约定检查。


3. 剧情不更新怎么办?

现象:改了 public/stories/demo.json,刷新后还是旧内容。

原因与解决:

  1. 浏览器缓存:剧情、清单请求默认带 cacheBust 时间戳,但浏览器可能仍缓存。 用强制刷新(Ctrl/Cmd + Shift + R),或点页面顶部「重新开始」按钮(会重新拉取剧情文件)。
  2. 改错文件:确认改的是 public/stories/ 下的文件,而不是 dist/ 里的旧产物。
  3. 没登记到清单:新增剧情要写进 public/stories/index.json,页面下拉框才会出现。
  4. JSON 语法错误:解析失败会报 STORY_FORMAT_ERROR 并在页面顶部展示错误横幅, 该横幅会明确写出出错的节点 id 或字段。用编辑器 JSON 校验确认没有多余逗号。
  5. 节点跳转目标不存在:报 STORY_RUNTIME_ERROR,检查 next / target 指向的节点 id 是否拼对。
  6. dev server 需要重启的场景:只有改了 vite.config.ts 或安装新依赖才需要重启; 改 public/ 下的静态资源不需要重启。

4. 如何调试插件?

通用手段:

  1. 事件日志面板:页面右下角按时间倒序展示所有引擎事件,插件日志自动带 plugin:<id> 前缀。
  2. 浏览器控制台:所有日志同时输出到 console,方便用 DevTools 过滤。
  3. 插件状态:插件列表里能看到每个插件的 status、error、已注册能力与失败次数。
  4. 手动触发:在剧情里放一个 plugin 节点调用你的命令,或直接在控制台里操作页面。

JS 插件:

export default {
  init(ctx) {
    ctx.logger.debug('init 开始')                       // 自动带 plugin 前缀
    ctx.commands.register('greet', (args, api) => {
      api.logger.info('greet 收到参数', args)
      return { ok: true, message: 'hi' }
    })
  }
}

在插件里直接打断点即可(入口是普通 ES 模块,DevTools Sources 面板能映射到源码)。

WASM 插件:


5. 如何新增剧情节点类型?

插件可以注册任意自定义节点类型,无需改动引擎核心:

export default {
  init(ctx) {
    // 注册名为 "note" 的节点类型
    ctx.nodes.register('note', (node, api) => {
      api.logger.info('note 节点内容:', node.text)
      // 返回 next 决定下一个节点;不返回则由节点自身的 next 决定
      return { next: node.next ?? null }
    })
  }
}

剧情里即可使用:

{
  "type": "note",
  "text": "这是一条自定义节点",
  "next": "end"
}

6. 如何限制插件权限?

权限在 manifest.json 的 permissions 里声明,声明的才给,没声明的调用一律抛 PluginPermissionError:

{
  "permissions": ["state:read", "commands:register"]
}

最小权限原则:

插件也可以自检:

if (ctx.hasPermission('state:write')) { /* ... */ }

权限完整列表与每项含义见 docs/PERMISSIONS.md。


7. 世界模拟器 / observe 不触发怎么办?

现象:persistent: true 的剧情里世界似乎没在跑,或 observe 节点一直不跳转。

排查顺序:

  1. 世界循环是否真的启动了 世界由插件驱动:确认剧情加载了带 world:register 的插件(如 hello-js), 且 WorldLoop 已启动——页面事件日志里应持续出现 world:tick。没有 world:tick 说明没有插件注册世界规则。

  2. ctx.world.* 调用被拒绝 报 PLUGIN_PERMISSION_DENIED 时,需要在 manifest.json 的 permissions 里补上 world:register(注册规则)/ world:emit(发布事件)/ world:listen(订阅事件), 权限完整说明见 docs/PERMISSIONS.md。

  3. observe 节点的事件名没对上 event 必须与发布方完全一致(区分大小写),例如发布方广播 alert:emergency, 剧情里写成 alert:Emergency 就不会触发。事件契约见 docs/EVENTS.md。

  4. condition 写错 condition 用的是条件表达式 DSL(如 var.crisisLevel > 5),不是 JavaScript。 求值失败会报 EXPRESSION_ERROR,在事件日志里可见。

  5. next 目标不存在 触发后跳转的目标节点 id 拼错会报 STORY_RUNTIME_ERROR。检查 next 指向。

  6. 多个 observe 同时满足 同时触发时取 priority 最高者;多剧情并存时,切换到其它剧情会自动撤销原剧情的 observe 订阅。

  7. persistent 剧情「永不结束」 这是设计行为:persistent: true 的剧情不会自动结束,玩家可手动切换剧情或停止世界循环来退出。


8. 如何打包发布?

打包前端:

npm run build:all   # 构建 WASM 插件 + 前端产物
# 产物在 dist/,直接部署到任意静态服务器 / CDN

发布单个插件:把整个插件目录(manifest.json + 入口文件,WASM 再加 plugin.wasm)打包分发即可, 使用者把它放进 public/plugins/<id>/ 并在剧情里声明。

版本约定:

安全提醒:JS 插件是在宿主页面同源执行的普通代码,权限模型只约束「宿主能力」(状态/事件/UI 注册等), 并不能阻止插件访问 document、window、发起网络请求。 因此第三方 JS 插件应视为与页面同等可信;需要强隔离时应改为 WASM 插件形态。


9. 其它常见错误码速查

错误码含义处理方向
PLUGIN_LOAD_FAILED清单 / 入口加载或校验失败见问题 1、2
PLUGIN_MANIFEST_INVALIDmanifest 字段非法(如 timeoutMs 为负)修正 manifest
PLUGIN_PERMISSION_DENIED调用了未授权能力在 manifest 补权限,或改调用方
PLUGIN_TIMEOUT插件调用超时(默认 3000ms,可用 timeoutMs 调整)定位插件内耗时逻辑
PLUGIN_DISABLED插件连续失败 3 次被熔断禁用修好插件后刷新页面重新加载
STORY_FORMAT_ERROR剧情 JSON 结构错误用 JSON 校验器检查,看错误横幅提示的节点
STORY_RUNTIME_ERROR剧情运行时错误(跳转目标缺失等)检查 next / target
EXPRESSION_ERROR条件表达式 DSL 词法/语法/求值错误见 docs/STORY_FORMAT.md 的 DSL 语法
REGISTRY_CONFLICT注册名冲突(重复或与内置/其它插件冲突)给自定义节点/命令加前缀
TIMEOUT通用超时包装返回一般出现在插件调用链路上

10. 部署后"资源没了 / 弹窗失灵 / 隔离没生效"?

这类问题的共同点是没有明显报错,根因基本都在跨域隔离(COOP / COEP)。速查:

症状最可能原因去哪看
字体/图标/样式缺失,控制台 net::ERR_BLOCKED_BY_RESPONSECOEP: require-corp 拦掉了不带 CORP 的跨域资源CROSS_ORIGIN.md §2 / §6
第三方统计、客服、支付 SDK 静默失效同上(对方不会给你加 CORP)同上
OAuth 弹窗回调收不到、window.opener 为 nullCOOP: same-origin 切断了 opener同上
iframe 里的引擎"能跑但很慢"父页未隔离 → crossOriginIsolated === false → SAB 通路降级CROSS_ORIGIN.md §5
跨域 iframe 里 window.__storyRuntime 是 undefined跨域窗口属性不可读,需要 postMessage 桥同上 §5.2
本地 npm run dev 正常、上线就出问题头只在 dev/preview 生效,生产侧没复刻同上 §4

注意:本引擎默认不使用 SharedArrayBuffer,所以"没开隔离"本身不是问题; 问题是"有人为了 SAB 开了隔离,却不知道它的副作用"。先想清楚要不要 SAB,再决定开不开。


11. 我还需要知道什么?