本文以源码为唯一依据,描述 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(bodyLimit2MB,日志统一走 pino)→ProgramCache/StoryLoader→ 存档后端(SAVE_BACKEND=sqlite时动态 importSqliteStorage,绑定不可用自动回退 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.tsAST→指令(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.tsAST 求值器(类型严格:- * / %仅数值 E7001,比较仅数值 E7002,条件必须布尔 E2005,函数只查 builtins 表);state.ts纯数据状态定义;vm-error.ts错误码。loader/story-loader.ts:路径监狱jailPath(resolve + realpath 双重校验,防 symlink 逃逸);load()缓存命中口径为 sha1;reload()供热重载;getByHash()供读档回退。program-cache.ts:byPathMap 缓存,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.tschokidar 监视 + 预热函数。world.tsworld 形状(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. 热重载#
- 检测:
StoryWatcher用 chokidar 监视storiesDir(ignoreInitial、depth: 2、awaitWriteFinish.stabilityThreshold: 50ms),只处理.story的 add/change 事件,按文件防抖合并(150ms)。 - 重编译:读文件 →
loader.reload()(同一条编译链,severity=error 则抛出)。失败时保留旧 program,仅记 warn(hot reload failed, keeping old program)。 - 会话迁移:成功后回调
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};会话随后补泵或重推等待视图。 - 对旧存档的影响:热重载只改内存中的 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/ 移除重启 |