本文以源码为唯一依据,描述 story-engine 的分层架构、核心流程、存档体系、热重载与运维观测。开发与扩展见开发指南,写剧本见剧本编写指南,写插件见插件开发指南。

1. 分层架构总览#

 客户端(public/client.html 或自研客户端)
    │  WebSocket /ws(JSON 文本帧,出站消息带单调递增 seq)
    ▼
┌───────────────────────────── 服务端进程(src/server.ts 装配) ─────────────────────────────┐
│                                                                                          │
│  transport/   ws-server(hello 握手/限流/单连接单会话)· codec(帧编解码)                 │
│               protocol(zod 消息 schema)· http-routes(/health /metrics /api/*)        │
│      │ hello → createOrResume        ▲ 出站消息(信箱 seq)                               │
│      ▼                               │                                                   │
│  session/     session(VM 驱动 + 出站信箱 + 存读档)                                       │
│               session-manager(生命周期/回收/磁盘恢复/热重载广播)                          │
│               session-store(slot 路径监狱 + 内存镜像 + 原子落盘)                          │
│      │ step()/resume*               │ SaveState                                          │
│      ▼                              ▼                                                     │
│  engine/      vm(指令状态机)· expr(AST 求值器)· state(纯数据状态定义)                │
│               lexer → parser → compiler(optimizer/resolver/diagnostics)                │
│               loader(路径监狱 + sha1 缓存键)· program-cache(多会话共享只读产物)         │
│               compile-pool/compile-worker(编译线程池,COMPILE_WORKERS>0 时预热用)        │
│               runtime/ scheduler(60Hz)· host-api(宿主注册表)· event-bus               │
│                       hot-reload(chokidar 监视 + 预热)· world(world 形状助手)          │
│               loc/ 本地化查表(locId → 译文,未命中回退原文)                               │
│      │ host 调用/插件指令              │ 存档读写(slot, JSON)                            │
│      ▼                               ▼                                                   │
│  host/        server-host(内置宿主 API 装配)· combat/engine+rng(回合制战斗)            │
│               ai/bt(行为树)· pathfind(A* + 视线)· audio-stub(clientSide 占位)        │
│  kernel/      plugin-system(loader → registry 五表 → capability 令牌 → lifecycle         │
│               四时序 → manager 门面;剧本 !指令 与宿主 API 注入引擎)                       │
│      │                               │                                                   │
│      ▼                               ▼                                                   │
│  persist/     save-manager(盖版本号 + 迁移链)· schema(zod 三要素校验)                   │
│               storage(RawStorage 接口) ← fs-storage(tmp+rename)                         │
│                                        ← sqlite-storage(better-sqlite3,可选)           │
│  util/        metrics · async-queue · sha1 · id        config.ts / logger.ts 横切         │
└──────────────────────────────────────────────────────────────────────────────────────────┘

依赖方向:engine ← host ← session ← transport/kernel ← server。engine 不反向依赖任何上层;session 不依赖具体传输(socket 以 SessionSocket 最小接口注入)。

2. 关键设计不变量(破坏即事故)#

不变量 内容 守护
反动态执行 全程无 eval/new Function/node:vm/动态 require/字符串定时器;表达式走 AST 求值器;宿主 API 与事件均查静态 Map tests/no-eval.test.ts 扫描全 src
VM 状态纯数据 pc/vars/callStack/waiting/loopCounters 与 world 全部可 structuredClone + JSON 往返;等待态是 WaitState 数据而非 generator/async engine/vm/state.ts、存读档测试
剧本无闭包/递归实体 指令 arg 只存纯数据,表达式以 AST 内联;call 深度上限 256;while 迭代上限 LOOP_BUDGET engine/compiler/instruction.ts、VM 预算
预算三重防线 单 tick 指令数(MAX_OPS_PER_TICK,默认 1000,E2001)→ while 迭代数(LOOP_BUDGET,默认 10000,E2001)→ 每秒指令吞吐(MAX_OPS_PER_SECOND,默认 200000,超出 E2002 断开) engine/vm/vm.ts、runtime/scheduler.ts
路径监狱 剧本路径限定 storiesDir 内(含 realpath 防 symlink 逃逸);存档 slot 名 [A-Za-z0-9_-]{1,64} 且解析后必须落在 savesDir 内 engine/loader/story-loader.ts、session/session-store.ts
存档只进不坏 写盘 tmp+rename 原子替换;写成功后才更新内存镜像;导入先在内存完成校验+迁移再落盘 session/session-store.ts、persist/save-manager.ts

3. 一次"玩家输入到画面更新"的完整时序#

以 hello 开新会话、播放到选项、玩家点选为例:

客户端                        transport/ws-server           session                engine/vm
  │  hello(protocol,story)         │                         │                      │
  ├───────────────────────────────▶│ zod 校验 + 限流(120msg/s) │                      │
  │                                │  + 建会话限速(10/min/IP)  │                      │
  │                                ├─ createOrResume ───────▶│ loader.load(story)   │
  │                                │                         ├─── ProgramCache 命中?──┐
  │                                │                         │  命中:复用只读产物     │
  │                                │                         │  未命中:lexer→parser  │
  │                                │                         │  →compiler→写入缓存    │
  │                                │                         │ new Session(VM+信箱)   │
  │                                │                         │ scheduler.add(session)│
  │                                │                         │ plugins.onSessionInit │
  │   ◀── welcome(seq=1) ──────────┤ session.sendWelcome     │                      │
  │                                │                         │ session.start()→pump  │
  │                                │                         ├─ vm.step() 循环推进    │
  │   ◀── dialogue(seq=2..n) ──────┤   SAY 事件逐条 push 进信箱(seq 单调递增,环形保留   │
  │   ◀── choice(seq=n+1) ─────────┤   OUTBOX_LIMIT=100 条)  │ vm 进入 waiting.choice│
  │  (玩家点选 index=0)           │                         │                      │
  │  choice{index} ───────────────▶│ handleSessionMessage    ├─ vm.resumeChoice(0)  │
  │                                │                         │ waiting=null, pc=目标 │
  │                                │                         │ pump() 继续推进       │
  │   ◀── dialogue(seq=n+2..) ─────┤   后续台词/事件……        │                      │

要点:

  • 编译缓存:ProgramCache 按 storiesDir 内绝对路径索引,缓存命中条件是 program.sourceHash === sha1(当前源码)(engine/loader/story-loader.ts 的 load());未命中才重新编译。
  • 等待态:WAIT/HOST(clientSide)/CHOICE/AWAIT 会置 waiting 并让出主循环。timer 由调度器 60Hz 驱动恢复;choice/host_result/event 由客户端消息驱动恢复。
  • 出站信箱:Session.push() 为每条出站消息分配单调递增 seq,环形保留最近 OUTBOX_LIMIT(默认 100)条。断线重连时按 lastSeq 回放,窗口不足回 E4008。
  • 驱动来源汇总:客户端消息(choice/host_result/event)、调度器 tick(timer 到期、host 超时 E3004、空闲自动泵)、热重载(hot_reload 后补泵)。

4. 各模块详解#

4.1 src/server.ts — 装配根#

  • 启动顺序:initLogger → Fastify(bodyLimit 2MB,日志统一走 pino)→ ProgramCache/StoryLoader → 存档后端(SAVE_BACKEND=sqlite 时动态 import SqliteStorage,绑定不可用自动回退 file 并告警)→ SaveManager → PluginManager + 会话钩子(onEmit/onSaveState/onLoadSave)→ 本地化(locales/zh-CN.json 存在则加载,否则从全部剧本 SAY 指令生成种子模板)→ SessionManager → Scheduler → 注册 HTTP/WS 路由 → StoryWatcher。
  • bundle.start():plugins.boot() → syncHostAPIsTo(插件宿主 API 合入引擎注册表并刷新既有会话内置表)→ 预热剧本缓存(COMPILE_WORKERS>0 走 worker 池,否则主线程同步)→ watcher.start() → scheduler.start() → app.listen。ADMIN_TOKEN 未设置时打启动告警。
  • 停机顺序(bundle.stop()):manager.saveAll()(全量存档,重启恢复的数据来源)→ watcher.stop() → scheduler.stop() → manager.destroyAll() → plugins.shutdown()(逆序卸载)→ app.close()。
  • 进程级兜底:SIGINT/SIGTERM 走优雅停机(退出码 0);uncaughtException 也先优雅停机(退出码 1,存档失败不伪装成功);unhandledRejection 只记日志不退出(单点异常不杀全部会话)。

4.2 src/config.ts / src/logger.ts#

  • config.ts 纯函数 loadConfig(env):环境变量 → ServerConfig,全部默认值集中于此(见 §9.1 环境变量表)。PORT=0 合法(随机端口,e2e 用)。
  • logger.ts:pino 单例,ISO 时间戳、level 用标签字符串;各模块用 moduleLogger('xxx') 建 child logger,日志字段 module 标识来源。LOG_LEVEL 控制级别(默认 info)。

4.3 src/transport/ — 接入层#

  • ws-server.ts:GET /ws(@fastify/websocket,帧上限 1MB)。首条消息必须是 hello(否则 E6010 断开);每连接限流 120 msg/s(超限 E6002 断开);每 IP 每分钟建会话限速 10 次(超限 E6003 断开)。单连接单会话:重连会踢掉旧连接(推 session_closed{reason:"replaced"}),旧连接迟到的 close 不会误杀新连接(ownsSocket 守卫)。
  • codec.ts:帧 → ClientMessage(Buffer/ArrayBuffer/string 均可,非法 E6010);出站 encodeServerFrame 先过协议校验,服务端自身 bug 降级为 error 帧(E6003)。
  • protocol.ts:zod schema 双向定义;PROTOCOL_VERSION = 1;入站解析失败 E6010,出站校验失败 E6003。
  • http-routes.ts:GET /(示例客户端 client.html)、GET /health、GET /metrics、GET /api/stories、GET /api/plugins、GET /api/saves、GET /api/saves/:slot/export、POST /api/saves/import。/api/saves/* 与 /api/plugins 受 ADMIN_TOKEN 鉴权(Authorization: Bearer 或 x-admin-token 头,sha256 后 timingSafeEqual 时序安全比较)。

4.4 src/session/ — 会话层#

  • session.ts:一个会话 = 一个 VM + 一个 EventBus + 一个出站信箱。职责:驱动 VM(pump 直到等待态/剧本结束)、把 VM 事件转协议消息、断线宽限(detach 记录 detachedAt)、存读档、clientSide 宿主调用超时(默认 5s,超时推 E3004 并按 undefined 结果恢复)。运行时 VMError 会挂起自动推进(防 60Hz 错误风暴)并断开连接(会话保留可重连)。pureClone 做纯数据校验(失败 E5002)。
  • session-manager.ts:createOrResume(内存命中 → attach;未命中 → restoreFromDisk;仍失败 E4010)、sweep(空闲/断线超时 → 自动存档 + 销毁;存档失败跳过本轮回收,宁晚回收不丢进度)、hotReload(按 storyPath 广播)、saveAll/destroyAll。恢复路径同样受 maxSessions 约束。
  • session-store.ts:slot 路径监狱第一道闸(SLOT_PATTERN);文件后端 tmp+rename 原子写;内存镜像兜底读(先落盘后更新镜像);形状校验收口到 persist/schema.ts 的 zod(version/programHash/pc 三要素)。
  • session-error.ts:会话层错误码 E4xxx(E4010 不存在、E4003 超限、E4005 状态非法、E4006 slot 非法、E4007 存档不匹配、E4004 空闲回收)。

4.5 src/engine/ — 编译与运行时#

  • lexer/:行感知词法分析(剧本是面向行的语言),台词/选项文本整段 TEXT token;关键字表在 token.ts(set if elif else while end call ret wait await emit on not and or true false)。
  • parser/:递归下降,优先级 or < and < cmp < add < mul < unary;recover: true 模式记录错误跳行继续(lint 用);表达式/语句递归深度防御。
  • compiler/:compiler.ts AST→指令(OpCode 共 13 个:SAY/CHOICE/JMP/JMPF/SET/CALL/RET/HOST/AWAIT/EMIT/WAIT/PCALL/END);optimizer.ts 常量折叠 + 不可达代码检查;resolver.ts 符号收集与引用检查(E1001 未定义标签、E1002 重复标签、E4002 await 不可达等);diagnostics.ts 诊断收集与格式化;instruction.ts 指令集与 isPureData 深校验。locId = sha1(sourcePath|节点类型|序号).slice(0,16)。
  • vm/:vm.ts 指令状态机(每会话独享实例,共享只读 program);expr.ts AST 求值器(类型严格:- * / % 仅数值 E7001,比较仅数值 E7002,条件必须布尔 E2005,函数只查 builtins 表);state.ts 纯数据状态定义;vm-error.ts 错误码。
  • loader/story-loader.ts:路径监狱 jailPath(resolve + realpath 双重校验,防 symlink 逃逸);load() 缓存命中口径为 sha1;reload() 供热重载;getByHash() 供读档回退。
  • program-cache.ts:byPath Map 缓存,getOrSet(hash 一致才复用)、getByHash(线性扫描,读档回退用)。
  • compile-pool.ts / compile-worker.ts:worker 线程池,仅用于启动预热(见 §5.3)。
  • runtime/:scheduler.ts 固定频率调度(setInterval(1000/tickHz)),按自然秒窗口统计每会话 ops,超 maxOpsPerSecond 触发 onOpsExceeded(E2002 断开);事件循环停顿 ≥5s 的窗口整窗跳过判定(防误杀);单会话 tick 抛错不毒化本轮。host-api.ts 宿主注册表(Map)+ 签名校验(E3001/E3002)+ 由注册表派生表达式内置表(仅收录 serverSide、非 void/promise 返回)。event-bus.ts 每会话独立 Map 总线,通配 * 兜底。hot-reload.ts chokidar 监视 + 预热函数。world.ts world 形状(items/stats/entities/storage/combats)与缺省补容器助手。
  • loc/localization.ts:t(locId, fallback) 查表未命中回退原文。

4.6 src/host/ — 机制层#

server-host.ts 是全部内置宿主 API 的装配点:clientSide(sfx/bgm/anim/open_ui,转发客户端执行)、serverSide(wait/spawn/destroy/give_item/remove_item/has_item/get_stat/set_stat/foe_add/foe_clear/start_combat/combat_result/path_find/path_cost/can_see/storage_set/storage_get)。战斗数值在 combat/engine.ts(种子随机 rng.ts,战报可复现),寻路视线在 pathfind.ts,敌人决策在 ai/bt.ts。

4.7 src/kernel/plugin-system/ — 插件内核#

加载(loader.ts 扫描 pluginsDir)→ manifest 校验(manifest-schema/manifest-validator.ts)→ 注册表五表(registry.ts:插件/依赖/服务/指令/订阅)→ 能力令牌(capability.ts)→ 生命周期四时序(lifecycle.ts:boot/onSessionInit/onSessionDestroy/shutdown,含健康度降级与熔断)→ manager.ts 门面。剧本 !name(args) 指令经 PCALL 指令 → 会话层 callDirective → PluginManager.callDirective(计数 storyengine_directives_total)。详见插件开发指南。

4.8 src/persist/ — 持久层#

见 §6 存档体系。

5. 编译管线#

5.1 主链路#

.story 源文件
  → Lexer(行感知,产出带 span 的 Token 流)
  → Parser(递归下降 → AST;recover 模式可收集多错误)
  → Compiler:
      1) foldProgram 常量折叠(optimizer,只产字面量 AST,不执行用户代码)
      2) checkUnreachable 不可达代码(optimizer)
      3) collectSymbols + checkReferences(resolver:标签/事件/await 可达性)
      4) 发射指令(if/while 展平为 JMPF/JMP;CHOICE 目标先记 patch 后回填)
  → CompiledProgram { instructions, labels, eventTable, locIds, sourceHash(sha1), sourcePath }

severity 为 error 的诊断会让 load() 抛出(E1xxx 等)。诊断格式:

[E1001] 未定义标签 "room"
  --> stories/dialogue.story:12:8
   |
 12 | * 推门进去 -> room
   |              ^
   = 提示:...

5.2 ProgramCache 的 hash 校验与回退查找#

  • 写入:编译成功后 cache.set(absPath, program),条目记录 sourceHash。
  • 命中:loader.load() 先 parseSource(解析错误无法缓存),再计算 sha1(source) 与缓存条目比对,一致才复用——早期用 FNV-1a 对比 sha1 导致永不命中的教训已修复,命中口径必须与 compiler 写入的 sourceHash 一致。
  • 回退查找:读档恢复时若剧本已改变(program.sourceHash !== 存档.programHash),走 loader.getByHash(saveState.programHash) 在缓存中找旧编译产物。这依赖启动预热把全部剧本装入缓存——重启后即使剧本文件已变,旧产物仍在缓存中,旧存档可恢复。
  • worker 池化预热时,主线程校验 program.sourceHash === sha1(source) 才注入缓存,防止污染。

5.3 编译线程池(COMPILE_WORKERS)#

  • COMPILE_WORKERS=0(默认):主线程同步编译,请求路径不变。
  • COMPILE_WORKERS>0:createCompilePool(n) 起 n 个 worker 线程(compile-worker.ts 只 import 编译链),仅在启动预热(warmProgramCachePool)时把 lexer→parser→compiler 移出主线程;生产用 dist/compile-worker.js,tsx 开发态回退 .ts(execArgv: ['--import', 'tsx']),worker 起不来会走 error 事件并优雅降级为主线程编译。
  • 请求按 round-robin 分发;单次编译 30s 超时;worker 崩溃/退出即移出池并拒绝在途请求(E9002),后续请求路由到存活 worker。

6. 存档体系#

6.1 写入路径与原子性#

session.saveState()
  → vm.save()(cloneData 校验纯数据)+ world/pendingHost/plugins 合并
  → SaveManager.save:盖 version = CURRENT_SAVE_VERSION(当前 2)
  → SessionStore.save:
      pureClone(structuredClone + JSON 往返,失败 E5002)
      → file 后端:writeFileSync(`${file}.tmp`) → renameSync 原子替换
        sqlite 后端:INSERT OR REPLACE(事务性,无两步)
      → 成功后才更新内存镜像(防"假新档"掩盖持久化故障)

6.2 slot 路径监狱#

  • slot 必须匹配 /^[A-Za-z0-9_-]{1,64}$/(session-error.ts 的 SLOT_PATTERN)。
  • fileFor() 再做一道解析校验:resolve(savesDir, slot + '.sav') 必须以 resolve(savesDir) + sep 开头,越界抛 E4006。默认存档 slot 是会话 id。

6.3 读取路径与迁移链#

SessionStore.load(内存镜像 → 文件/后端 → JSON.parse → zod 三要素校验)
  → SaveManager.load → migrateSave(persist/migrate.ts):
      version 缺失/非法 → E5003
      version > 当前(来自未来的档)→ E5003,拒绝猜测性解析
      沿链逐跳迁移(Map<fromVersion, fn>,每跳必须显式注册),链中断 → E5003
  → Session.loadSave:校验 programHash(不匹配 E4007)→ vm.load(pc 越界 E5001)
      → world 整体替换 → pendingHost 恢复 → syncView 重推等待视图 → 插件 loadSnapshot

内置迁移:v1 → v2 补 ended=false、world={}、savedAt=0。新增字段时在 persist/migrate.ts 追加注册并提升 CURRENT_SAVE_VERSION。

6.4 两种后端与回退#

后端 启用 实现 特性
file(默认) SAVE_BACKEND=file 或不设 saves/<slot>.sav JSON 文件 tmp+rename 原子写
sqlite SAVE_BACKEND=sqlite better-sqlite3 单文件 saves/saves.db,WAL 模式,单表 kv(key,value) 事务写、大量 slot 时 list/read 更快

回退逻辑:server.ts 动态 import SqliteStorage,原生绑定不可用(未安装/ABI 不匹配)时 catch 后回退 file 后端并打 warn,不影响启动。会话层(SessionStore)只依赖 RawStorage 接口(read/write/delete/keys),后端可插拔。

6.5 停机全量存档与重启恢复#

  • 停机:bundle.stop() 第一步 manager.saveAll(),逐会话 store.save(id, session.saveState()),返回成功数;单个失败记 warn 继续其余。
  • 重启恢复(restoreFromDisk 三步):① 按存档 programPath 重新加载,hash 一致直接用;② hash 不一致 → getByHash 找缓存旧产物(依赖预热);③ 都失败 → 放弃恢复,客户端收到 E4010 可开新会话。
  • 回收(sweep)路径同样先存档后销毁;存档失败跳过本轮回收(下轮重试,maxSessions 兜底)。
  • 导出/导入:base64(JSON);导入先在内存完成校验+迁移,成功才落盘(防止坏档覆盖原存档)。

7. 热重载#

  1. 检测:StoryWatcher 用 chokidar 监视 storiesDir(ignoreInitial、depth: 2、awaitWriteFinish.stabilityThreshold: 50ms),只处理 .story 的 add/change 事件,按文件防抖合并(150ms)。
  2. 重编译:读文件 → loader.reload()(同一条编译链,severity=error 则抛出)。失败时保留旧 program,仅记 warn(hot reload failed, keeping old program)。
  3. 会话迁移:成功后回调 manager.hotReload(rel, program),所有跑同一 storyPath 的会话执行 vm.hotReload(newProgram): - 保留 pc/vars/callStack/world;循环计数器全部作废(旧 JMPF pc 不再对应新程序);callStack 中越界帧丢弃。 - pc 落在已删除指令上(pc >= 新指令数)→ 回退到最近的 label(pc <= 原 pc 中最大者),adjusted=true,客户端收到 E6001 提示存档或重开。 - choice 等待态的选项目标按 targetLabel 在新 label 表中重解析。 - 客户端收到 hot_reload{story, adjusted};会话随后补泵或重推等待视图。
  4. 对旧存档的影响:热重载只改内存中的 program 与在线会话;磁盘存档仍记录旧 programHash,读档时若与当前剧本 hash 不符走 §5.2 的 getByHash 回退或 E4007 拒绝。预热缓存的旧产物因此是存档兼容性的关键。

8. 运维观测#

8.1 GET /metrics(Prometheus 文本格式)#

指标 类型 含义 递增点
storyengine_ws_connections_total counter WS 连接接受数 每次连接建立
storyengine_ws_messages_total counter 入站消息数 每条消息(限流前计数)
storyengine_ws_errors_total counter WS handler 错误数 消息处理抛错
storyengine_directives_total counter 插件指令执行数 PluginManager.callDirective
storyengine_sessions_created_total counter 会话创建数 createOrResume/restoreFromDisk
storyengine_saves_total counter 存档次数 WS save 消息
storyengine_sessions_active gauge 活跃会话数 抓取时即时计算(manager.size)
storyengine_uptime_seconds gauge 进程运行秒数 抓取时即时计算

内容类型 text/plain; version=0.0.4。新增指标见开发指南 §5。

8.2 日志约定#

  • pino JSON 行,字段:level(标签)、time(ISO)、module(来源模块:server/session/session-manager/ws-server/hot-reload/save-manager 等)、err。
  • 关键日志:启动/监听端口、plugin system booted、program cache warmed、hot reload applied、sessions persisted before shutdown、save backend 选择与回退、ADMIN_TOKEN 未设置 告警。
  • 开发调试可 LOG_LEVEL=debug(可见存档写入、连接关闭细节);生产建议 info 并落盘收集。本地美化可用 devDependencies 中的 pino-pretty 管道。

8.3 压测(scripts/bench.ts)#

pnpm bench [并发数=20] [持续秒数=10] [剧本=dialogue.story]
# 目标地址可用 BENCH_URL 覆盖(默认 ws://127.0.0.1:3000/ws)
  • 行为:N 个并发客户端循环"hello → 播到 end → 重开";choice 自动选 0,host 自动回 null;错误后退避 1.5s 重开;30s 硬超时防挂死。
  • 输出:完整播放次数与 plays/s、消息吞吐 msg/s、错误次数、choice→下一条消息延迟 p50/p95/p99(样本数 n)。
  • 注意:服务端默认有会话创建限速(10/min/IP),高重开频率时错误计数会包含 E6003,属预期。
  • 容量参考(沙箱实测):10 并发 choice p99 约 6ms;瓶颈通常在调度器 tick 与存档 IO。

9. 部署清单#

9.1 环境变量(src/config.ts)#

变量 默认 说明
HOST 0.0.0.0 监听地址
PORT 3000 监听端口;0 = 随机端口(e2e)
STORIES_DIR stories 剧本目录(路径监狱根)
SAVES_DIR saves 存档目录
LOCALES_DIR locales 本地化目录
LOG_LEVEL info pino 级别
SESSION_TIMEOUT_MS 1800000(30 分钟) 活跃会话空闲回收
MAX_SESSIONS 200 并发会话上限(恢复路径同样受限)
TICK_HZ 60 调度器频率
MAX_OPS_PER_SECOND 200000 单会话每秒指令预算(超出 E2002 断开)
MAX_OPS_PER_TICK 1000 单 tick 指令预算(E2001)
LOOP_BUDGET 10000 while 迭代上限(E2001)
HOST_TIMEOUT_MS 5000 宿主调用超时(E3004 后按默认值继续)
RECONNECT_GRACE_MS 600000(10 分钟) 断线宽限,超时会话存档并回收
OUTBOX_LIMIT 100 重连回放窗口(条)
PLUGINS_DIR plugins 插件根目录(不存在时空转)
ADMIN_TOKEN 空(关闭鉴权) 设置后 /api/saves/*、/api/plugins 需 Bearer/x-admin-token
SAVE_BACKEND file sqlite 启用 better-sqlite3(不可用自动回退 file)
COMPILE_WORKERS 0 编译线程池大小(仅影响预热)

9.2 启动 / 停机顺序#

# 启动
pnpm build && node dist/server.js      # 生产
pnpm dev                               # 开发(tsx 直跑)
# 停机
kill -TERM <pid>                       # 触发优雅停机:saveAll → watcher/scheduler 停止
                                       # → destroyAll → plugins.shutdown → app.close

停止顺序不可调换:必须先 saveAll 再销毁会话。存档失败时进程以退出码 1 结束(uncaughtException 同样先尝试优雅停机),进程管理器应据此告警。

9.3 数据目录布局#

stories/    *.story 剧本(只放剧本)
saves/      <slot>.sav(file 后端)或 saves.db + saves.db-wal + saves.db-shm(sqlite 后端)
locales/    zh-CN.json(首次启动自动生成种子模板)
plugins/    <plugin-id>/manifest.json + 入口(详见插件文档)
public/     client.html(GET / 示例客户端)
dist/       tsc 构建产物(生产部署以 dist 为准)

9.4 备份与恢复建议#

  • saves/ 与 stories/ 纳入常规备份;sqlite 后端备份需包含 saves.db-wal(或先停机再拷贝,WAL 会归档)。
  • 升级版本前确认 persist/migrate.ts 迁移链覆盖旧档版本;跨大版本先在预发用真实存档跑 loadWithVersion 验证。
  • 恢复流程:停机 → 还原 saves/(与 stories/ 同批)→ 启动(预热会重建编译缓存)→ 客户端用原 sessionId 重连或 load 消息读档。

9.5 常见故障处置#

症状 排查 处置
端口占用(启动即退出) 日志 server failed to start;lsof -i :3000 换 PORT 或释放端口;PORT=0 可随机分配
存档损坏(load 报 E5001/JSON 错误) 日志 存档文件损坏;检查 .sav 是否截断 删除或用 export/import 恢复单档;确认磁盘空间与文件系统;file 后端本身原子写,截断多为外部干预
存档 programHash 不匹配(E4007) 剧本在存档后被修改 重启服务让预热装入旧产物(getByHash 可恢复);否则要求玩家重开
编译超时 / worker 崩溃(E9002) 日志 warmup skipped;剧本过大或死循环 主线程路径自动兜底;修正剧本;COMPILE_WORKERS=0 关闭线程池
会话数暴涨(E4003 超限) /metrics 看 sessions_active;检查客户端是否泄漏连接 调低 SESSION_TIMEOUT_MS/RECONNECT_GRACE_MS,或调高 MAX_SESSIONS;检查是否单 IP 高频建会话(E6003 会拦截)
插件把会话拖垮 /api/plugins 看 deadLetters 与状态 插件健康度熔断会自动卸载;定位问题插件后从 plugins/ 移除重启

10. 相关文档#