面向客户端对接者:拿着即可实现 WebSocket 客户端。所有字段、错误码、默认值以源码为唯一依据,表格中标注来源文件。
- 主通道是 WebSocket
GET /ws;HTTP 只做运维与存档搬运(src/transport/ws-server.ts、http-routes.ts)。 - 消息均为 JSON 文本帧(ws 帧上限 1MB,
ws-server.tsmaxPayload: 1_000_000)。 - 服务端每条会话内出站消息带单调递增
seq(从 1 起,session.tspush()),是断线回放与幂等判重的唯一依据。 - 握手前(首条
hello之前)的错误帧不带seq(ws-server.tssendAndClose),客户端解析需兼容两种形态。
1. 传输总览与握手#
1.1 端点与限流(src/transport/ws-server.ts)#
- WS 端点:
GET /ws(@fastify/websocket),JSON 文本帧,帧上限 1MB。 - 每连接限流:120 条/秒(自然秒窗口),超限推
E6002后以 close code 1008 断开。 - 会话创建限流:每 IP 每分钟 10 次
hello建会话,超限推E6003后断开。
1.2 握手流程#
客户端首条消息必须是 hello,否则推 E6010 并断开;重复 hello 回 E6010(不断开)。
{ "type": "hello", "protocol": 1, "story": "dialogue.story", "resume": "sess_ab12CD34_-", "lastSeq": 42 }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"hello" |
是 | 固定值 |
protocol |
整数 ≥1 | 是 | 协议版本,当前 1(protocol.ts PROTOCOL_VERSION) |
story |
string 1~128 | 否 | 剧本相对 stories/ 路径;缺省 dialogue.story(session-manager.ts) |
resume |
string 1~64 | 否 | 断线重连:此前 welcome 下发的 sessionId |
lastSeq |
整数 ≥0 | 否 | 客户端已收到的最大 seq(回放起点),与 resume 搭配 |
服务端应答 welcome(走会话信箱,带 seq):
{ "type": "welcome", "seq": 1, "sessionId": "sess_ab12CD34_-", "story": "dialogue.story", "protocol": 1, "resumed": false }
sessionId:会话唯一 id(sess_前缀,util/id.ts),后续resume用。story:实际加载的剧本相对路径;protocol:服务端协议版本。resumed:true= 复用既有会话(内存重连或磁盘恢复);false= 新会话,服务端在welcome后立即开始推进剧本。
1.3 心跳#
发 {"type":"ping"},回 {"type":"pong","seq":N}。心跳也占 seq 并进回放窗口(session.ts sendPong)。服务端不因心跳超时断开;回收依据是空闲/断线时长(§4)。
1.4 断线重连与 lastSeq 回放#
断线后服务端行为(session.ts / session-manager.ts):
- 连接关闭且该连接仍是会话当前 socket → 会话
detach()进入detached态,VM/pc/vars/world/outbox 全部保留;出站信箱环形保留最近OUTBOX_LIMIT(默认 100)条消息(session.tspush())。 - 宽限期
RECONNECT_GRACE_MS(默认 10 分钟)内重连成功则续玩;超时后 sweep 自动存档并销毁会话(sweep())。 - 客户端重连:重发
hello,带resume: <sessionId>、lastSeq: <已收最大 seq>;服务端attach()后replaySince(lastSeq)按原顺序补发所有seq > lastSeq的 outbox 消息。 - 若客户端进度超出回放窗口(outbox 最旧一条
seq > lastSeq + 1),推E4008,客户端应放弃增量回放,走读档/重开全量恢复;回放完成后,若 VM 处于等待态且该视图未被lastSeq覆盖,重推一次当前choice/host视图(syncView(),按seq去重)。 - sessionId 不在内存(进程重启/已回收)→ 尝试从磁盘存档恢复(
restoreFromDisk(),沿用原 sessionId);恢复失败推E4010并断开,客户端应以新会话重连。
客户端要点:
- 记录每条消息
seq,重连取最大值作lastSeq;seq <= 已记录最大值的消息为重复投递(回放消息与实时消息无标记区别),忽略并按type正常处理。 - 收到
session_closed或E4008时,重开新会话或读档。 - 同会话二次重连会顶替旧连接:旧连接收到
{"type":"session_closed","seq":N,"reason":"replaced"}并被关闭(session.tsattach())。
2. 客户端 → 服务端消息#
以 protocol.ts ClientMessageSchema(zod discriminatedUnion)为准,共 9 类。不匹配 schema 的消息 → E6010;hello 之前的非 hello 消息 → E6010 并断开。hello 见 §1.2(必须为首条消息)。
2.1 choice — 回复选项#
{ "type": "choice", "index": 1 }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"choice" |
是 | |
index |
整数 ≥0 | 是 | 服务端 choice 消息中 options 数组元素的 index 字段值(不是本地列表下标) |
何时发送:收到 choice 且玩家选定后。下标越界回 E2007;不在选项等待态回 E2008(vm.ts resumeChoice)。客户端主动操作会解除服务端"错误挂起"(session.ts clearSuspension)。
2.2 host_result — 应答 clientSide 宿主调用#
{ "type": "host_result", "requestId": "req_9f8e7d6c", "result": 42 }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"host_result" |
是 | |
requestId |
string 1~64 | 是 | 原样回填 host 请求的 requestId |
result |
任意 JSON 值 | 否(字段可省) | 宿主 API 返回值;须可结构化克隆(否则 E5002)。省略 = "无值" |
何时发送:收到 host(clientSide API)后尽快应答。超时 HOST_TIMEOUT_MS(默认 5000ms)后服务端已按默认值继续,迟到的 host_result 被静默丢弃(vm.ts resumeHost:requestId 不匹配直接 return)。
2.3 event — 客户端事件#
{ "type": "event", "event": "combat_end", "args": [true, "boss_01"] }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"event" |
是 | |
event |
string 1~64 | 是 | 事件名 |
args |
数组 ≤32 元素 | 是(可为 []) |
事件参数,纯 JSON 数据 |
三种用途(session.ts handleClientEvent / vm.ts resumeEvent):① 服务端下发过 await(VM 挂起等事件)→ 同名事件恢复执行;② 剧本有 on <event>(...) 块且 VM 空闲 → 触发该处理块;③ 都不命中 → 仅广播给插件监听者,不推进 VM。
2.4 save — 存档#
{ "type": "save", "slot": "checkpoint_1" }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"save" |
是 | |
slot |
string ≤64 | 否 | 缺省用 sessionId;须匹配 ^[A-Za-z0-9_-]{1,64}$,否则 E4006(session-error.ts SLOT_PATTERN) |
成功收 saved;slot 非法回 E4006;状态不可克隆回 E5002。
2.5 load — 读档#
{ "type": "load", "slot": "checkpoint_1" }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"load" |
是 | |
slot |
string 1~64 | 是 | 与 save 不同,必填(protocol.ts LoadSchema) |
成功收 loaded;若恢复出的是已完结会话还会补推一条 end(session.ts loadSave)。存档与当前剧本 hash 不一致回 E4007;版本/格式问题回 E5001/E5003;slot 不存在回 E4010。
2.6 export_save — 导出存档#
{ "type": "export_save", "slot": "checkpoint_1" }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"export_save" |
是 | |
slot |
string ≤64 | 否 | 缺省用 sessionId |
成功收 save_export(slot + base64 data)。
2.7 import_save — 导入存档并读档#
{ "type": "import_save", "slot": "checkpoint_1", "data": "eyJ2ZXJzaW9uIjoyLC4uLn0=" }
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
"import_save" |
是 | |
slot |
string 1~64 | 否 | 缺省用 sessionId |
data |
string 1~1MB | 是 | 存档 JSON 的 base64 |
服务端先在内存完成 base64/JSON/三要素校验 + 版本迁移,成功才落盘,然后自动读档。成功收 loaded;非法 base64/JSON/缺字段回 E4007(save-manager.ts importData);版本过高回 E5003。
2.8 ping — 心跳#
{ "type": "ping" }
无其他字段,服务端回 pong。
3. 服务端 → 客户端消息#
以 protocol.ts ServerMessageSchema 为准,共 14 类(welcome 见 §1.2)。除握手前错误帧外每条都带 seq。
3.1 dialogue — 台词#
{ "type": "dialogue", "seq": 2, "speaker": "Narrator", "text": "推门进去", "locId": "d5f3a1c2" }
| 字段 | 说明 |
|---|---|
speaker |
说话者名(剧本原文) |
text |
已本地化查表的文本;locales/zh-CN.json 未命中该 locId 时回退剧本原文(session.ts,Localization.t) |
locId |
本地化键,客户端可自建本地化映射 |
3.2 choice — 选项#
{ "type": "choice", "seq": 3, "options": [ { "text": "掀开地毯", "locId": "opt_01", "index": 0 }, { "text": "离开房间", "locId": "opt_02", "index": 2 } ] }
| 字段 | 说明 |
|---|---|
options[].text / .locId |
选项文本(已本地化)/ 本地化键 |
options[].index |
回填到 {"type":"choice"} 的下标;index 可能不连续(条件不满足的选项已在服务端过滤),必须回传 index 字段值 |
客户端应渲染选项列表并等待玩家选择。
3.3 host — clientSide 宿主调用请求#
{ "type": "host", "seq": 4, "name": "ui_confirm", "args": ["确定要离开吗?"], "requestId": "req_9f8e7d6c" }
| 字段 | 说明 |
|---|---|
name / args |
宿主 API 名 / 参数数组(≤32 元素) |
requestId |
应答回填键 |
客户端执行对应本地逻辑并回 host_result。超时 HOST_TIMEOUT_MS(默认 5000ms)后服务端推 E3004 并以 undefined 继续(session.ts tick()),该请求作废。
3.4 await — VM 挂起等待事件#
{ "type": "await", "seq": 5, "event": "door_opened", "args": [] }
剧本执行到 await 语句挂起。客户端在合适时机发同名 event 消息恢复;挂起期间无其他剧本消息。
3.5 emit — 脚本事件广播#
{ "type": "emit", "seq": 6, "event": "bgm_change", "args": ["battle.mp3"] }
脚本 emit 且无脚本内接收者时下发(session.ts handleVMEvent)。VM 不等待,客户端自行决定是否响应(如播 BGM);同名事件也已转发插件监听者。
3.6 end — 剧本结局#
{ "type": "end", "seq": 7 }
剧本走到结局,不再推进。会话仍存活(可存已完结状态、可重连);恢复已完结存档时服务端会补推 end(session.ts loadSave)。
3.7 error — 错误#
{ "type": "error", "seq": 8, "code": "E4008", "message": "重连回放窗口不足,请重新加载存档", "hint": "服务端仅保留最近 100 条消息" }
| 字段 | 说明 |
|---|---|
code / message / hint? |
错误码(§6)/ 人类可读描述(简体中文)/ 可选处置提示 |
握手前错误帧无 seq 且随后连接必断(close code 1008);会话内错误帧带 seq,多数不断开。
3.8 pong#
{ "type": "pong", "seq": 9 }
ping 的应答。
3.9 saved / loaded — 存读档确认#
{ "type": "saved", "seq": 10, "slot": "checkpoint_1" }
{ "type": "loaded", "seq": 11, "slot": "checkpoint_1" }
loaded 后服务端已整体替换 VM 状态并重推当前等待视图(syncView()),客户端应按重推视图刷新 UI。seq 不因读档回退,继续单调递增。
3.10 save_export — 存档导出#
{ "type": "save_export", "seq": 12, "slot": "checkpoint_1", "data": "eyJ2ZXJzaW9uIjoyLC4uLn0=" }
data 为存档 JSON 的 base64,可持久到本地;用 import_save 或 HTTP POST /api/saves/import 导回。
3.11 hot_reload — 剧本热重载#
{ "type": "hot_reload", "seq": 13, "story": "dialogue.story", "adjusted": true }
服务端监听 stories/ 目录,文件变更后所有跑同一剧本的会话原地换新编译产物(session-manager.ts hotReload)。adjusted: true 表示 VM 原 pc 落在新程序已删除的指令上,已回退到最近 label,并紧跟一条 E6001 错误消息(session.ts hotReload)。adjusted 为 true 时建议提示玩家读档获得一致状态。
3.12 session_closed — 会话关闭#
{ "type": "session_closed", "seq": 14, "reason": "session_reaped" }
| reason | 含义 | 客户端处置 |
|---|---|---|
replaced |
被同会话新连接顶替 | 停止收发,勿再用此连接 resume |
server_closed |
服务端主动关闭(close() 默认值) |
可尝试重连 |
session_reaped |
会话被 sweep 回收(已自动存档) | resume 重连可从磁盘恢复;否则开新会话 |
ops_budget |
指令吞吐超每秒预算(伴随 E2002) |
剧本问题,联系服务端 |
4. 会话生命周期状态机#
会话状态:active → detached → closed(session.ts SessionStatus)。
hello(新会话)
[无会话] ────────────────► active ──► 结局(end,会话仍 active)
│ ▲
socket 断开 │ │ hello(resume) 重连
▼ │
detached ──── 宽限期 RECONNECT_GRACE_MS 内可重连
│
active 空闲 SESSION_TIMEOUT_MS 或 detached 超宽限期
──► 自动存档 + closed(session_closed/session_reaped)
- attach → start:新会话
hello后服务端建 VM、attach(socket)、推welcome、start()开始推进(ws-server.tshandleHello);重连会话只attach+ 回放,不重跑start。 - 等待态循环:VM 推进到等待态(choice / host / event(await) / timer)即停;对应输入(choice / host_result / event)或 timer 到期(调度器 60Hz tick)后继续(
session.tstick()等)。ended:收到end后ended = true,调度器不再推进;会话仍可存/读档。 - 运行时错误:VM 抛错 → 推
error→ 会话进入"错误挂起"(停止自动推进)并断开连接(保留会话可重连读档,handleVMError)。客户端下次主动操作(choice/event/host_result)解除挂起。存档、读档、热重载都不重置seq,只有新建会话从 1 重新计数。 - 重启恢复:服务端停机前全量存档(
server.tsstop()→saveAll())。重启后resume + lastSeq重连:内存无此会话 → 磁盘存档恢复(沿用原 sessionId);outbox 是内存态,回放不可用,超窗收E4008,客户端须读档或接受syncView重推的当前视图。
5. clientSide 宿主调用的应答协议#
时序:VM 执行 CALL(clientSide API)→ 服务端推 host 消息,VM 挂起(waiting.type === "host",记超时基准 hostAskedAt)→ 客户端执行本地逻辑并回 host_result → VM 恢复,result 成为表达式求值结果,剧本继续(vm.ts CALL/resumeHost、session.ts tick())。
- 超时:调度器 tick 检查
now - hostAskedAt >= HOST_TIMEOUT_MS(默认 5000ms,config.ts)→ 推E3004错误 → 以undefined作为返回值继续推进。超时不会卡死会话,表达式中该 API 的值为空。 - 迟到应答:requestId 不匹配时
resumeHost直接返回,无错误、无副作用。 - serverSide 宿主调用(
clientSide: false)不下发host消息,服务端内部 Promise 完成后自动恢复;同样受超时控制。签名校验 clientSide/serverSide 统一在服务端做,不匹配抛E3001(host-api.tsvalidateHostArgs);调用未注册 API 抛E3002。 - result 要求:纯 JSON 数据(可结构化克隆),含函数/symbol →
E5002。
6. 错误码总表#
统一形状 code / message / hint?(vm-error.ts、session-error.ts、diagnostics.ts)。码段:E0xxx 词法、E1xxx 编译诊断、E2xxx 解析/运行时、E3xxx 宿主、E4xxx 事件/会话、E5xxx 存档、E6xxx 传输/热重载、E7xxx 表达式、E9xxx 引擎内部;插件系统用 E-XXXnnn-级 码。级别:error(中断)/ warning(提示);插件后缀 -R 可恢复、-F 致命、-W 警告。
6.1 词法(E0xxx,加载剧本时;来源 src/engine/lexer/lexer.ts)#
| 码 | 含义 | 常见原因 | 处置 |
|---|---|---|---|
| E0001 | 非法字符 | 误用全角符号 | 改半角 |
| E0002 | 未终止的字符串 | 缺右引号 | 补引号 |
| E0003 | 非法转义序列(含非法 \u) |
\q 类未知转义 |
用支持的转义 |
| E0004 | 标签声明格式错误 | 应为 === 名称 === |
参照示例修正 |
| E0005 | 选项行格式错误 | 缺 * / 缺 ] / 缺 -> 目标 / 文本为空 / 尾部多余 |
修正选项行 |
| E0006 | 宿主调用缺 API 名 | @ 后为空 |
补 API 名 |
| E0007 | 跳转语句格式错误 | 应为 -> 标签名 |
修正跳转 |
6.2 编译诊断(E1xxx + 事件静态检查;resolver.ts / optimizer.ts / compiler.ts / loader/story-loader.ts)#
| 码 | 级别 | 含义 | 常见原因 | 处置 |
|---|---|---|---|---|
| E1001 | error | 未定义标签 | 跳转目标未定义/拼写错 | 补标签定义 |
| E1002 | error | 重复标签 | 同名标签定义两次 | 重命名 |
| E1003 | warning | 不可达代码 | 位于 JMP/RET 之后 | 删除或调整 |
| E1004 | error | 所有选项条件恒为假(死锁) | 条件写错 | 留一个无条件选项 |
| E1005 | warning | while 缺迭代上限声明 | 无界循环 | 服务端按 LOOP_BUDGET(默认 10000)强停 |
| E1008 | warning | 重复事件声明 | 后者覆盖前者 | 确认意图或重命名 |
| E1010 | error | 剧本路径越出 stories/ 目录 | ../ 逃逸路径监狱 |
用相对路径 |
| E4001 | warning | 事件无接收者(编译期;运行时 emit 无接收者也报此码,走 VM 诊断回调,不出现在客户端 error 流) | 无 on 块也无客户端/宿主派发 |
可忽略或补接收者 |
| E4002 | error | await 的事件永远不会被 emit | 无 emit/on 声明 | 补声明或改宿主事件 |
6.3 解析错误(E2xxx 编译期;src/engine/parser/parser.ts,与 VM 运行时同码段)#
| 码 | 含义(编译期) | 处置 |
|---|---|---|
| E2001 | 期望某 token 不匹配 | 按提示补语法 |
| E2002 | 期望关键字不匹配 | 检查关键字拼写 |
| E2003 | 语句结束后缺换行 | 换行分隔语句 |
| E2004 | 期望标识符 | 修正 |
| E2005 | 块结束后多余 token | 检查 if/while/on 是否缺 end |
| E2006 | 无法解析的语句 | 检查起始 token |
| E2007 | 选项块为空 | 至少一个选项 |
| E2008 | 选项缺文本 | 补文本 |
| E2009 | wait 后缺秒数 |
如 wait 1.5 |
| E2010 | wait 秒数非法 |
传正数 |
| E2011 | on 后缺事件名 |
补事件名 |
| E2012 | 表达式无法解析 | 检查表达式语法 |
| E2013 | 事件名/插件指令名格式错误 | ! 后须标识符;括号形式首参须字符串 |
| E2014 | 嵌套超限(表达式 200 层 / 语句 100 层,MAX_EXPR_DEPTH/MAX_STMT_DEPTH) |
拆分嵌套 |
6.4 VM 运行时(E2xxx 运行时复用;vm.ts / vm-error.ts ERROR_CODES;出现即推 error,会话错误挂起并断开,可重连读档)#
| 码 | 含义 | 常见原因 | 处置 |
|---|---|---|---|
| E2001 | 指令预算耗尽 / 循环超上限 / call 栈溢出(深度 >256) | 无限循环、无限递归 call | 修剧本;预算见 §8 |
| E2005 | 条件表达式非布尔(CONDITION_NOT_BOOL,码位保留) |
— | — |
| E2006 | ret 时调用栈为空 |
剧本结构错误 | 修剧本 |
| E2007 | 选项下标越界 | 传了不存在的 index | 回传服务端下发的 index 值 |
| E2008 | 当前不在对应等待状态 | 重复 choice、错时 host_result | 校正客户端状态机 |
| E2009 | 所有选项均不可用(运行时死锁) | 条件运行时全为假 | 补无条件选项 |
6.5 宿主(E3xxx;vm-error.ts / vm.ts / host-api.ts / session.ts)#
| 码 | 含义 | 常见原因 | 处置 |
|---|---|---|---|
| E3001 | 宿主 API 签名不匹配(参数个数/类型) | args 与注册定义不符 | 按注册定义传参 |
| E3002 | 未注册的宿主 API / 插件指令 | API 未注册、插件未加载 | 服务端补注册;客户端提示 |
| E3003 | 宿主/插件指令执行失败 | handler 抛错 | 看 message 细节 |
| E3004 | clientSide 宿主调用超时(默认 5000ms) | 未及时回 host_result | 服务端已按 undefined 继续;优化客户端响应 |
6.6 会话与传输(E4xxx / E6xxx;session-error.ts / session-manager.ts / session.ts / ws-server.ts / protocol.ts / codec.ts)#
| 码 | 含义 | 常见原因 | 客户端处置 |
|---|---|---|---|
| E4003 | 并发会话数超限(MAX_SESSIONS,默认 200) |
服务端满载 | 稍后重试 |
| E4004 | 会话已因空闲被回收(SESSION_TIMEOUT_MS) |
空闲超时 | 开新会话或读档 |
| E4005 | 状态错误:连接已被新重连顶替 / 会话已关闭仍操作 | 同会话二次重连 | 使用最新连接 |
| E4006 | 存档 slot 名非法 | 不匹配 ^[A-Za-z0-9_-]{1,64}$ |
改 slot 名 |
| E4007 | 存档与当前剧本不一致(programHash 不匹配) | 剧本在存档后被改 | 重新开始;服务端可按 hash 找回旧编译产物 |
| E4008 | 断线回放窗口不足(outbox 仅保留最近 OUTBOX_LIMIT 条) |
断线太久、丢消息过多 | 全量恢复:读档或重新开始 |
| E4009 | 服务端内部错误 | 服务端 bug | 重试/上报 |
| E4010 | 会话不存在或已回收(错误 sessionId / 磁盘恢复失败) | resume 了失效 id | 以新会话方式连接 |
| E6001 | 热重载后 pc 失效,已回退最近 label(伴随 hot_reload adjusted=true) |
剧本改动破坏执行点 | 读档或重开 |
| E6002 | 消息频率超限(120 条/秒/连接),随后断开 | 客户端刷屏 | 限速后重连 |
| E6003 | 会话创建频率超限(10 次/分钟/IP),随后断开 | 频繁重连 | 退避重试 |
| E6010 | 协议帧非法:非 JSON / schema 不匹配 / 首条非 hello / 重复 hello | 客户端 bug | 修正消息格式 |
注:E6003 在 protocol.ts 中另被复用为"出站消息不符合协议"的内部断言码(服务端自身 bug,客户端不会作为限速语义遇到)。
6.7 存档(E5xxx;vm-error.ts / session.ts pureClone / persist/migrate.ts / persist/save-manager.ts / vm.ts load())#
| 码 | 含义 | 常见原因 | 处置 |
|---|---|---|---|
| E5001 | 存档版本号非法 / pc 越界 / programHash 不匹配(VM 层校验) | 存档损坏或篡改 | 重新存档 |
| E5002 | 数据不可结构化克隆(含 function/symbol/JSON 往返失败) | 宿主返回值、事件 args、world 含非纯数据 | 改为纯 JSON 数据 |
| E5003 | 存档版本高于当前支持 / 迁移链缺失 / 存档不是对象 | 来自更新版本的存档 | 升级服务端或放弃该档 |
6.8 运行时表达式(E7xxx;src/engine/vm/expr.ts、vm-error.ts)#
| 码 | 含义 | 常见原因 | 处置 |
|---|---|---|---|
| E7001 | 算术运算类型不匹配 | - * / % 用于非 number;+ 两侧非 number/string |
检查变量类型 |
| E7002 | 比较运算类型不匹配 | > < >= <= 用于非 number |
检查变量类型 |
| E7003 | 求值遇到未知 AST 节点 | 服务端内部错误 | 上报 |
| E7004 | 引用未定义变量 | 变量未初始化 | 先赋值(码位定义于 vm-error.ts) |
| E7005 | 表达式产出非标量值 | 表达式返回对象/数组 | 用标量 |
6.9 引擎基础设施与插件系统(E1000 / E9xxx / E-XXXnnn-级)#
E1000/E9xxx 不进客户端消息流:E1000 编译 worker 内错误兜底码(compile-worker.ts);E9001 编译产物纯数据不变量校验失败(compiler/compiler.ts);E9002 编译池 worker 崩溃/退出/销毁(compile-pool.ts)。
插件系统码(来源 kernel/plugin-system/errors.ts、manager.ts、lifecycle.ts、capability.ts)主要出现在插件加载/调用链路与 /api/plugins 观测中;剧本 ! 指令 执行失败在 VM 层包装为 E3003 上报客户端。后缀 -R 可恢复、-F 致命、-W 警告。
| 码 | 含义 |
|---|---|
| E-REG001-R | 插件 ID 重复注册 |
| E-REG002-R | manifest 校验失败(含权限条件 DSL 语法错误) |
| E-REG003-F | 依赖缺失(含服务解析不到可用版本) |
| E-REG004-F | 循环依赖(整个 boot 失败) |
| E-CAP001-R | 能力令牌缺失 |
| E-CAP002-R | 能力令牌过期 |
| E-CAP003-R | 权限不足 |
| E-CAP004-R | 能力令牌已撤销(含复用死令牌场景) |
| E-QTA001-W | 配额超限(订阅数/定时器数/存储体积/调用频次) |
| E-EVT001-W | 事件命名不合规 |
| E-EVT002-R | 事件监听超时(异步监听器被强杀) |
| E-HLT001-W | 健康降级(连续报错第 3 次) |
| E-HLT002-R | 熔断卸载(连续报错第 5 次) |
| E-SER001-F | 插件状态不可序列化(函数/循环引用/BigInt) |
7. HTTP 接口#
来源:src/transport/http-routes.ts。Fastify bodyLimit 2MB(server.ts)。
| 路由 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
/ |
GET | 公开 | 内置浏览器示例客户端(public/client.html) |
/health |
GET | 公开 | 健康检查 |
/metrics |
GET | 公开 | Prometheus 文本指标 |
/api/stories |
GET | 公开 | 剧本列表 |
/api/plugins |
GET | ADMIN_TOKEN | 插件状态与死信数 |
/api/saves |
GET | ADMIN_TOKEN | 存档 slot 列表 |
/api/saves/:slot/export |
GET | ADMIN_TOKEN | 导出存档 JSON |
/api/saves/import |
POST | ADMIN_TOKEN | 导入存档 |
鉴权:Authorization: Bearer <ADMIN_TOKEN> 或 x-admin-token 头(isAdmin(),sha256 + timingSafeEqual 时序安全比较)。ADMIN_TOKEN 未设置(空串)时鉴权关闭——仅限本机/可信网络部署(启动日志会警告)。鉴权失败回 401 {"error": "缺少管理令牌(Authorization: Bearer <ADMIN_TOKEN>)"}。
GET /health
→ 200 {"ok": true, "sessions": 3, "uptime": 7200}
GET /api/stories
→ 200 {"stories": ["dialogue.story", "tutorial.story"]}
GET /api/saves (带鉴权)
→ 200 {"slots": ["sess_ab12", "checkpoint_1"]}
GET /api/saves/checkpoint_1/export
→ 200 {"slot": "checkpoint_1", "data": "<存档 JSON 文本>"} (404 = 存档不存在)
POST /api/saves/import (Body: {"slot": "checkpoint_1", "data": "<base64>"})
→ 200 {"ok": true, "slot": "checkpoint_1"} (400 = 缺 slot/data 或导入失败)
GET /api/plugins
→ 200 {"booted": true, "plugins": [...], "deadLetters": 0}
/metrics 内容类型 text/plain; version=0.0.4,指标前缀 storyengine_(连接/消息/WS 错误/存档/会话创建计数 + 活跃会话 gauge,util/metrics.ts)。
8. 配置环境变量全表#
来源:src/config.ts loadConfig()。数值型变量空/非有限/≤0 时回退默认值(PORT 例外,0 = 随机临时端口)。
| 变量 | 默认值 | 作用 |
|---|---|---|
HOST |
0.0.0.0 |
监听地址 |
PORT |
3000 |
监听端口(0 = 随机端口,测试用) |
STORIES_DIR |
stories |
剧本目录(路径监狱根) |
SAVES_DIR |
saves |
存档目录 |
LOCALES_DIR |
locales |
本地化目录(加载 zh-CN.json,缺失时自动生成种子模板) |
PLUGINS_DIR |
plugins |
插件根目录(不存在时插件系统空转) |
LOG_LEVEL |
info |
pino 日志级别 |
TICK_HZ |
60 |
调度器频率(timer 精度、宿主超时检查间隔) |
MAX_SESSIONS |
200 |
并发会话上限(含磁盘恢复路径) |
SESSION_TIMEOUT_MS |
1800000(30 分钟) |
active 会话空闲回收 |
RECONNECT_GRACE_MS |
600000(10 分钟) |
断线宽限期;超时自动存档并销毁 |
OUTBOX_LIMIT |
100 |
回放窗口条数(环形保留最近 N 条出站消息) |
MAX_OPS_PER_TICK |
1000 |
VM 单 tick 指令预算(耗尽 E2001) |
MAX_OPS_PER_SECOND |
200000 |
单会话每秒指令吞吐(超限 E2002 并断开) |
LOOP_BUDGET |
10000 |
while 循环迭代上限(超限 E2001) |
HOST_TIMEOUT_MS |
5000 |
clientSide 宿主调用超时(超时 E3004,按 undefined 继续) |
ADMIN_TOKEN |
空(= 关闭鉴权) | 管理 API 令牌 |
SAVE_BACKEND |
file |
存档后端:file 或 sqlite(其他值一律按 file) |
COMPILE_WORKERS |
0 |
编译 worker 线程数(0 = 主线程同步编译) |
9. 存档格式与版本#
来源:src/session/session-store.ts、src/persist/(save-manager.ts、migrate.ts、schema.ts、fs-storage.ts、sqlite-storage.ts)。
9.1 slot 命名与存放路径#
- slot 必须匹配
^[A-Za-z0-9_-]{1,64}$,否则E4006;路径监狱保证文件只落在SAVES_DIR内。WSsave/export_save缺省 slot =sessionId(ws-server.ts),load必填 slot。 - file 后端:
<SAVES_DIR>/<slot>.sav,tmp + rename 原子写;写盘成功后才更新内存镜像(故障时不返回假成功)。 - sqlite 后端:
<SAVES_DIR>/saves.db(WAL 模式,kv(key,value)单表)。 - 存档 JSON 三要素(zod 校验,
persist/schema.ts):version(整数 ≥1)、programHash(string)、pc(number);写盘时由 SaveManager 盖当前版本号。
9.2 版本与迁移链#
- 当前版本
CURRENT_SAVE_VERSION = 2(persist/migrate.ts);版本史:v1 = 初始(VM 状态 + sessionId/programPath/world),v2 = 新增ended、补world/savedAt默认值。 - 读档流程:读文件 →
migrateSave()沿迁移链逐跳升级 → 校验programHash(会话层,不匹配E4007)→ 整体恢复 VM/world(session.tsloadSave)。 - 拒绝场景(均
E5003,发生在任何写盘之前):存档不是对象、版本号非法、版本 > 当前版本、迁移链缺中间跳、迁移函数未正确 +1。
9.3 两种后端差异与回退#
| 项 | file(默认) | sqlite |
|---|---|---|
| 落盘 | 每 slot 一个 .sav JSON 文件 |
单文件 saves.db(WAL,另有 -wal/-shm) |
| 写入 | tmp + rename 两步原子写 | 事务性 INSERT OR REPLACE |
| list/read | O(n) 目录扫描 | O(log n) 索引 |
| 启用 | 默认 | SAVE_BACKEND=sqlite |
回退行为(server.ts):SAVE_BACKEND=sqlite 时动态 import better-sqlite3;原生绑定不可用(未安装/构建失败)则记 warn 并自动回退 file 后端,不影响启动。两种后端对客户端协议完全透明(exportData/importData 语义一致)。