面向客户端对接者:拿着即可实现 WebSocket 客户端。所有字段、错误码、默认值以源码为唯一依据,表格中标注来源文件。

  • 主通道是 WebSocket GET /ws;HTTP 只做运维与存档搬运(src/transport/ws-server.ts、http-routes.ts)。
  • 消息均为 JSON 文本帧(ws 帧上限 1MB,ws-server.ts maxPayload: 1_000_000)。
  • 服务端每条会话内出站消息带单调递增 seq(从 1 起,session.ts push()),是断线回放与幂等判重的唯一依据。
  • 握手前(首条 hello 之前)的错误帧不带 seq(ws-server.ts sendAndClose),客户端解析需兼容两种形态。

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):

  1. 连接关闭且该连接仍是会话当前 socket → 会话 detach() 进入 detached 态,VM/pc/vars/world/outbox 全部保留;出站信箱环形保留最近 OUTBOX_LIMIT(默认 100)条消息(session.ts push())。
  2. 宽限期 RECONNECT_GRACE_MS(默认 10 分钟)内重连成功则续玩;超时后 sweep 自动存档并销毁会话(sweep())。
  3. 客户端重连:重发 hello,带 resume: <sessionId>、lastSeq: <已收最大 seq>;服务端 attach() 后 replaySince(lastSeq) 按原顺序补发所有 seq > lastSeq 的 outbox 消息。
  4. 若客户端进度超出回放窗口(outbox 最旧一条 seq > lastSeq + 1),推 E4008,客户端应放弃增量回放,走读档/重开全量恢复;回放完成后,若 VM 处于等待态且该视图未被 lastSeq 覆盖,重推一次当前 choice/host 视图(syncView(),按 seq 去重)。
  5. sessionId 不在内存(进程重启/已回收)→ 尝试从磁盘存档恢复(restoreFromDisk(),沿用原 sessionId);恢复失败推 E4010 并断开,客户端应以新会话重连。

客户端要点:

  • 记录每条消息 seq,重连取最大值作 lastSeq;seq <= 已记录最大值 的消息为重复投递(回放消息与实时消息无标记区别),忽略并按 type 正常处理。
  • 收到 session_closed 或 E4008 时,重开新会话或读档。
  • 同会话二次重连会顶替旧连接:旧连接收到 {"type":"session_closed","seq":N,"reason":"replaced"} 并被关闭(session.ts attach())。

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.ts handleHello);重连会话只 attach + 回放,不重跑 start。
  • 等待态循环:VM 推进到等待态(choice / host / event(await) / timer)即停;对应输入(choice / host_result / event)或 timer 到期(调度器 60Hz tick)后继续(session.ts tick() 等)。ended:收到 end 后 ended = true,调度器不再推进;会话仍可存/读档。
  • 运行时错误:VM 抛错 → 推 error → 会话进入"错误挂起"(停止自动推进)并断开连接(保留会话可重连读档,handleVMError)。客户端下次主动操作(choice/event/host_result)解除挂起。存档、读档、热重载都不重置 seq,只有新建会话从 1 重新计数。
  • 重启恢复:服务端停机前全量存档(server.ts stop() → 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.ts validateHostArgs);调用未注册 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 内。WS save/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.ts loadSave)。
  • 拒绝场景(均 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 语义一致)。