DOCUMENTATION · 常见问题
常见问题
本文档收集使用「插件化剧情引擎」时最常见的问题与排查步骤。 所有问题都可以按「先看页面右下角事件日志 → 再看浏览器控制台 → 最后看错误码」的顺序定位。
引擎的所有错误都是 EngineError,带机器可读的 code 字段(见 src/utils/errors.ts),
日志里会形如:
[plugin:com.example.hello] 缺少权限 "state:write",操作已被宿主拒绝 (PLUGIN_PERMISSION_DENIED)1. 插件加载失败怎么办?
现象:插件列表里该插件显示 error,事件日志出现 PLUGIN_LOAD_FAILED。
排查顺序:
路径是否正确 剧情里
plugins数组写的是运行时可访问的 URL,不是文件系统路径:"plugins": ["/plugins/hello-js/manifest.json"]注意前导
/(对应public/plugins/...)。若部署在子路径,需要带上前缀。manifest.json 是否合法
- 必须是合法 JSON(不能有注释、不能有多余逗号);
id全局唯一(两个插件同 id 会报冲突);apiVersion必须等于1(宿主的PLUGIN_API_VERSION);type必须是"js"或"wasm";entry是相对 manifest 目录的文件名(如index.js/plugin.wasm);timeoutMs若填写必须是正数。
入口文件是否真的存在 打开 Network 面板,确认
manifest.json、index.js/plugin.wasm都返回 200。 404 说明文件名拼错或目录结构不对。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而不是白屏。权限名拼写
permissions里出现非法权限名(如state:readd)会导致 manifest 校验失败。
2. WASM 实例化失败怎么办?
现象:PLUGIN_LOAD_FAILED,消息里包含 WebAssembly 或「缺少导出」。
排查顺序:
产物是否 cdylib
Cargo.toml必须包含:[lib] crate-type = ["cdylib"]否则产出的
.wasm不是可实例化的模块。ABI 导出是否齐全 引擎在实例化后会检查以下导出,缺任何一个都会报明确错误:
memory, alloc, dealloc, plugin_init, plugin_handle, plugin_result_ptr, plugin_result_len每个都要写成
#[no_mangle] pub extern "C" fn ...。完整签名见 docs/ABI.md。宿主 imports 是否匹配 插件若
import了env.host_log/host_emit/host_get_state/host_set_state, 但宿主没有提供对应函数,实例化会失败。宿主的桥接实现在 src/plugins/WasmPluginLoader.ts。是否用了不支持的目标 本项目只支持 Rust +
wasm32-unknown-unknown+ 直接 C ABI。 用 AssemblyScript / 其它语言 / wasm-bindgen 生成的产物没有对应的导入导出约定, 不在兼容范围内(详见 docs/PLUGIN_WASM.md)。是不是构建产物过期
.wasm会被浏览器缓存。改完 Rust 代码后重新npm run build:wasm,并强制刷新(Ctrl/Cmd + Shift + R)。内存问题 若报
RuntimeError: memory access out of bounds,通常是alloc/dealloc的size不匹配, 或宿主读结果时用了错误的len。请对照 docs/ABI.md 的内存约定检查。
3. 剧情不更新怎么办?
现象:改了 public/stories/demo.json,刷新后还是旧内容。
原因与解决:
- 浏览器缓存:剧情、清单请求默认带
cacheBust时间戳,但浏览器可能仍缓存。 用强制刷新(Ctrl/Cmd + Shift + R),或点页面顶部「重新开始」按钮(会重新拉取剧情文件)。 - 改错文件:确认改的是
public/stories/下的文件,而不是dist/里的旧产物。 - 没登记到清单:新增剧情要写进
public/stories/index.json,页面下拉框才会出现。 - JSON 语法错误:解析失败会报
STORY_FORMAT_ERROR并在页面顶部展示错误横幅, 该横幅会明确写出出错的节点 id 或字段。用编辑器 JSON 校验确认没有多余逗号。 - 节点跳转目标不存在:报
STORY_RUNTIME_ERROR,检查next/target指向的节点 id 是否拼对。 - dev server 需要重启的场景:只有改了
vite.config.ts或安装新依赖才需要重启; 改public/下的静态资源不需要重启。
4. 如何调试插件?
通用手段:
- 事件日志面板:页面右下角按时间倒序展示所有引擎事件,插件日志自动带
plugin:<id>前缀。 - 浏览器控制台:所有日志同时输出到 console,方便用 DevTools 过滤。
- 插件状态:插件列表里能看到每个插件的
status、error、已注册能力与失败次数。 - 手动触发:在剧情里放一个
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 插件:
- 在 Rust 侧用
host_log(level, ptr, len)输出日志,它会进入同一条日志通道; - 使用
console.error查看宿主读到的原始返回 JSON; - 返回值必须是合法 UTF-8 JSON,否则宿主会以
PARSE_ERROR处理并打印原文; - 需要逐字节排查时,可在宿主侧
plugin_handle前后打印ptr/len。
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"
}- 需要
nodes:register权限; - 注册的节点类型名不能与内置类型冲突(
say/choice/jump/set/if/wait/emit/plugin/ui/end), 冲突会报REGISTRY_CONFLICT; - 不同插件注册同名节点会互相冲突,因此建议加前缀,如
mymod:note。
6. 如何限制插件权限?
权限在 manifest.json 的 permissions 里声明,声明的才给,没声明的调用一律抛 PluginPermissionError:
{
"permissions": ["state:read", "commands:register"]
}最小权限原则:
- 只读状态 → 只给
state:read,不要给state:write; - 只发不订阅 → 只给
events:emit,不给events:listen; - 只想注册命令 → 只给
commands:register,不要给ui:register/story:control; - 不需要控制跳转的插件,不要给
story:control。
插件也可以自检:
if (ctx.hasPermission('state:write')) { /* ... */ }权限完整列表与每项含义见 docs/PERMISSIONS.md。
7. 世界模拟器 / observe 不触发怎么办?
现象:persistent: true 的剧情里世界似乎没在跑,或 observe 节点一直不跳转。
排查顺序:
世界循环是否真的启动了 世界由插件驱动:确认剧情加载了带
world:register的插件(如hello-js), 且WorldLoop已启动——页面事件日志里应持续出现world:tick。没有world:tick说明没有插件注册世界规则。ctx.world.*调用被拒绝 报PLUGIN_PERMISSION_DENIED时,需要在manifest.json的permissions里补上world:register(注册规则)/world:emit(发布事件)/world:listen(订阅事件), 权限完整说明见 docs/PERMISSIONS.md。observe 节点的事件名没对上
event必须与发布方完全一致(区分大小写),例如发布方广播alert:emergency, 剧情里写成alert:Emergency就不会触发。事件契约见 docs/EVENTS.md。condition 写错
condition用的是条件表达式 DSL(如var.crisisLevel > 5),不是 JavaScript。 求值失败会报EXPRESSION_ERROR,在事件日志里可见。next 目标不存在 触发后跳转的目标节点 id 拼错会报
STORY_RUNTIME_ERROR。检查next指向。多个 observe 同时满足 同时触发时取
priority最高者;多剧情并存时,切换到其它剧情会自动撤销原剧情的 observe 订阅。persistent 剧情「永不结束」 这是设计行为:
persistent: true的剧情不会自动结束,玩家可手动切换剧情或停止世界循环来退出。
8. 如何打包发布?
打包前端:
npm run build:all # 构建 WASM 插件 + 前端产物
# 产物在 dist/,直接部署到任意静态服务器 / CDN- 确认
dist/stories/与dist/plugins/都被拷贝(来自public/); - 部署在子路径时,设置
vite.config.ts的base,并同步修改剧情里的插件 URL 前缀。 COOP/COEP响应头不会跟着dist/走:vite.config.ts只覆盖 dev/preview, 而且默认关闭(要开用CROSS_ORIGIN_ISOLATION=1)。若你开启了,生产侧也必须由托管方回传同样的头, 否则隔离失效(SAB 静默降级)。这件事的代价是静默的,务必先读 CROSS_ORIGIN.md。
发布单个插件:把整个插件目录(manifest.json + 入口文件,WASM 再加 plugin.wasm)打包分发即可,
使用者把它放进 public/plugins/<id>/ 并在剧情里声明。
版本约定:
- 插件
version用语义化版本; - 宿主 API 变更时会提升
PLUGIN_API_VERSION,此时旧插件的apiVersion不匹配会被拒绝加载, 请按 docs/CHANGELOG.md 迁移。
安全提醒:JS 插件是在宿主页面同源执行的普通代码,权限模型只约束「宿主能力」(状态/事件/UI 注册等),
并不能阻止插件访问 document、window、发起网络请求。
因此第三方 JS 插件应视为与页面同等可信;需要强隔离时应改为 WASM 插件形态。
9. 其它常见错误码速查
| 错误码 | 含义 | 处理方向 |
|---|---|---|
PLUGIN_LOAD_FAILED | 清单 / 入口加载或校验失败 | 见问题 1、2 |
PLUGIN_MANIFEST_INVALID | manifest 字段非法(如 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_RESPONSE | COEP: require-corp 拦掉了不带 CORP 的跨域资源 | CROSS_ORIGIN.md §2 / §6 |
| 第三方统计、客服、支付 SDK 静默失效 | 同上(对方不会给你加 CORP) | 同上 |
OAuth 弹窗回调收不到、window.opener 为 null | COOP: 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. 我还需要知道什么?
- 架构与数据流:docs/ARCHITECTURE.md
- 世界模拟器(WorldLoop /
observe/persistent/world:*权限):docs/ARCHITECTURE.md + docs/STORY_FORMAT.md + docs/EVENTS.md + docs/PERMISSIONS.md - 剧情写法与表达式 DSL:docs/STORY_FORMAT.md
- JS 插件开发:docs/PLUGIN_JS.md
- Rust WASM 插件开发:docs/PLUGIN_WASM.md、docs/ABI.md
- 权限模型:docs/PERMISSIONS.md
- 构建与部署:docs/BUILD.md