本文面向在引擎上继续开发的工程师:环境与命令、代码导航、四类常见扩展的完整步骤、测试约定、性能要点与发布流程。部署与机制原理见架构与运维手册;写剧本/插件/客户端分别看剧本编写指南、插件开发指南、协议与错误码参考。
1. 环境准备与命令全表#
pnpm install # 安装依赖(better-sqlite3 在 optionalDependencies,安装失败不影响 file 后端)
pnpm dev # 启动服务(:3000,GET / 即示例客户端)
package.json scripts 逐条:
| 命令 | 实际执行 | 说明 |
|---|---|---|
pnpm dev |
tsx src/server.ts |
开发启动;tsx 直跑 .ts,无 watch,改代码需自行重启 |
pnpm build |
tsc -p tsconfig.json |
产出 dist/;生产 node dist/server.js,worker 编译池以 dist 为准 |
pnpm typecheck |
tsc --noEmit |
仅类型检查 |
pnpm lint |
tsc --noEmit && tsx src/cli/lint.ts |
类型检查 + 枚举 STORIES_DIR 全量剧本静态检查;有 error 退出码 1 |
pnpm test |
vitest run |
全量测试(21 个文件、360 个用例) |
pnpm coverage |
vitest run --coverage |
v8 覆盖率,阈值 80%(见 §4) |
pnpm play |
tsx src/cli/play.ts |
无头试玩:pnpm play stories/xxx.story;自动选第一个选项、自动应答 host、推进计时器 |
pnpm e2e:restart |
tsx scripts/e2e-restart.ts |
端到端:停机 → 重启 → 旧 sessionId + lastSeq 重连续玩 |
pnpm e2e:plugins |
tsx scripts/e2e-plugins.ts |
端到端:真实服务器 + 插件指令 + 两路事件 + 插件存档落盘 |
pnpm bench |
tsx scripts/bench.ts |
WS 压测,参数与解读见架构与运维手册 §8.3 |
scripts/ 下另有(无 npm script,用 npx tsx 直跑):
scripts/check-story.ts [file]:写章自检——严格解析、编译零 error、标签重复/坏跳转检查、统计节点与指令数。scripts/smoke-isekai.ts:《晨星之环》全分支自动播放冒烟(注册全部游戏插件)。
2. 代码结构导航#
src/
├── server.ts # 装配根:config → loader/存储 → 插件 → manager → ws/http → watcher
├── config.ts # 环境变量 → ServerConfig(全部默认值唯一出处)
├── logger.ts # pino 封装,moduleLogger(module) 建 child logger
├── engine/ # 纯引擎,不依赖 session/transport
│ ├── lexer/ # token.ts(关键字表 KEYWORDS)→ lexer.ts(行感知扫描)
│ ├── parser/ # ast.ts(节点定义)→ parser.ts(递归下降 + recover 模式)
│ ├── compiler/ # compiler.ts(AST→指令)· optimizer.ts(折叠/不可达)
│ │ # resolver.ts(标签/事件引用检查)· diagnostics.ts · instruction.ts
│ ├── vm/ # vm.ts(状态机)· expr.ts(AST 求值器)· state.ts · vm-error.ts
│ ├── loader/ # story-loader.ts(jailPath 路径监狱 + sha1 缓存键 + reload)
│ ├── program-cache.ts # 多会话共享只读 CompiledProgram
│ ├── compile-pool.ts # worker 线程池(预热用,COMPILE_WORKERS>0)
│ ├── compile-worker.ts # worker 入口(只 import 编译链)
│ ├── runtime/ # scheduler.ts(60Hz) · host-api.ts(注册表) · event-bus.ts
│ │ # hot-reload.ts(chokidar+预热) · world.ts(形状助手)
│ └── loc/ # localization.ts(locId 查表)
├── host/ # 机制层:server-host.ts(内置 API 装配) · combat/ · ai/bt.ts
│ # pathfind.ts · audio-stub.ts
├── session/ # session.ts · session-manager.ts · session-store.ts · session-error.ts
├── transport/ # ws-server.ts · codec.ts · protocol.ts(zod) · http-routes.ts
├── persist/ # save-manager.ts · migrate.ts · schema.ts · storage.ts
│ # fs-storage.ts · sqlite-storage.ts
├── kernel/plugin-system/ # registry/lifecycle/manager/capability/loader/manifest-*/dsl-*
└── util/ # metrics.ts · async-queue.ts · sha1.ts · id.ts
依赖方向:engine ← host ← session ← transport/kernel ← server。engine 不反向依赖任何上层;session 通过 SessionSocket/SchedulerLike 等最小接口解耦传输与调度。
3. 常见扩展怎么做#
3.1 新增一个内置宿主 API#
- 打开
src/host/server-host.ts,在registerAll()里仿现有条目调用:
ts
registerHostAPI(registry, {
name: 'your_api',
params: [{ name: 'id', type: 'str' }, { name: 'count', type: 'num', optional: true }],
returns: 'num', // void | str | num | bool | any | promise
clientSide: false, // true = 转发客户端执行,回 host_result 恢复
handler: (ctx, id, count) => { /* 同步执行 */ },
});
- 约束:handler 必须同步(要异步用
returns: 'promise',VM 会挂起等 resolve,超时由会话层按HOST_TIMEOUT_MS处理);返回值与写入 world 的数据必须是纯数据(itemsOf/statsOf/storageOf/entitiesOf/combatsOf助手见engine/runtime/world.ts,只有 world 随存档保存);handler 内禁止 eval/new Function/动态 require(tests/no-eval.test.ts扫描全 src)。 returns决定该 API 是否进入表达式内置表:仅 serverSide 且返回str/num/bool/any的 API 可在剧本表达式中作为函数调用(engine/runtime/host-api.ts的buildExprBuiltins)。- 补测试(仿
tests/combat.test.ts:从 registry 取 def 直接调 handler 或走 VM),并同步《剧本编写指南》§3 的 API 表。
3.2 新增一个插件指令(零引擎改动)#
不需要动引擎。在任意插件的 onLoad 中:
ctx.script.registerDirective('your.cmd', (args, sessionId, session) => { ... });
剧本即可写 !your.cmd(1, "a")。链路:词法 !name(args) → 编译为 PCALL 指令 → VM execPluginDirective → 会话 callDirective → PluginManager.callDirective(同步执行、无返回值、计数 storyengine_directives_total、配额与健康度自动计入)。规则与坑见《插件开发指南》§7。
3.3 新增一个语法关键字(引擎改动,慎用)#
四层 + 文档都要动,完整步骤:
- 词法:
src/engine/lexer/token.ts的KEYWORDS集合加关键字(若是标点,加入SINGLE_PUNCT/DOUBLE_PUNCT);lexer.ts的行型探测(detectLineKind)与对应tokenizeXxx规则按需扩展。 - 语法:
src/engine/parser/ast.ts定义新节点;parser.ts加产生式(注意优先级层次 or < and < cmp < add < mul < unary,与块终止符BLOCK_TERMINATORS)。 - 编译:
src/engine/compiler/compiler.ts的compileNodeswitch 加分支——优先降级为现有 OpCode 组合,确有必要再在instruction.ts的OpCode联合里加新码(arg 必须纯数据,表达式以 AST 内联)。 - 运行时:
src/engine/vm/vm.ts的exec()switch 加分支;注意等待态必须落WaitState(纯数据),预算计数照常生效。 - 静态检查:若引入新引用关系,
src/engine/compiler/resolver.ts补符号收集与检查。 - 测试与文档:
tests/lexer.test.ts/parser.test.ts/compiler.test.ts/vm.test.ts补用例;《剧本编写指南》§1 语法表同步。
现有 !插件指令 语法槽(语法固定、语义开放)已覆盖绝大多数需求,先确认它不够用再走这条路。
3.4 新增一条静态检查规则#
src/engine/compiler/resolver.ts(引用类检查)或optimizer.ts(折叠/不可达类)实现规则,经DiagnosticCollector.error/warning上报,附code/message/span/hint。- 错误码按 §6 的"先定义后使用"流程取得;编译期码不得落入会话层(E4xxx)段位。
tests/error-codes.test.ts补边界用例(正例/反例各一);src/cli/lint.ts无需改动(统一走 loader 诊断输出)。- 《协议与错误码参考》§4 表格同步。
3.5 存档版本迁移#
改了 SaveState 结构时:src/persist/migrate.ts 追加 registerMigration(N, fn)(vN → vN+1,函数必须把 version 精确提升到 N+1)并提升 CURRENT_SAVE_VERSION;迁移要宽松补默认值(旧档缺字段)。范式见 tests/save.test.ts。运行期 migrateSave 只查表,链中断/未来版本一律 E5003。
4. 测试约定#
4.1 组织与分桶#
vitest.config.ts:include: ['tests/**/*.test.ts'],node 环境。当前 21 个测试文件、360 个用例,分桶逻辑:
| 文件 | 覆盖 |
|---|---|
| lexer / parser / compiler.test.ts | 编译链(含 recover 模式、深度防御) |
| vm.test.ts | 指令语义、预算熔断、宿主调用、热重载 pc 调整 |
| session.test.ts / ws-protocol.test.ts / server.test.ts | 会话生命周期、协议握手/限流/重连、HTTP 路由 |
| save.test.ts | 存读档、迁移链、SQLite 后端(绑定可用时) |
| scheduler.test.ts / events.test.ts / hot-reload.test.ts | 调度预算、事件总线、热重载 |
| plugin-system.test.ts / directives.test.ts | 插件注册表/时序/健康度/权限/配额;!指令 全链路 |
| combat.test.ts / rpg-kit.test.ts / legend.test.ts / stories.test.ts | 机制层、游戏插件、剧情剧本、全剧本可玩冒烟 |
| no-eval.test.ts | 红线:全 src 禁 eval/new Function/动态 require |
| error-codes.test.ts | 错误码边界 |
| audit-fixes.test.ts / fixes.test.ts | 回归修复锁定 |
4.2 如何为新功能补测试#
- 按被测层选文件(见上表);纯引擎测试不 import session/transport。
- 夹具习惯:临时目录用
mkdtempSync(tmpdir());插件测试直接写manifest.json+src/index.js后PluginManager.boot();Session 测试注入假时钟(now: () => 可控值)与假 socket(send收集到数组断言);调度器测试用tickOnce()手动驱动。 - 断言错误时用
err.code(不要匹配 message 文案);新增错误码必须在tests/error-codes.test.ts有边界用例。
4.3 覆盖率#
pnpm coverage(v8 provider,统计 src/**/*.ts),阈值全部 80%:
thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 }
纯类型文件 src/engine/parser/ast.ts、src/engine/vm/state.ts 不参与统计。低于阈值即测试失败。
5. 性能要点#
5.1 预算体系#
| 预算 | 默认 | 触发点 | 后果 |
|---|---|---|---|
MAX_OPS_PER_TICK(VM maxOpsPerTick) |
1000 | 单次 vm.step() 内累计指令数 |
E2001 |
LOOP_BUDGET(VM loopBudget) |
10000 | while 回跳计数(键为 JMPF 的 pc) | E2001;RET/选项跳出/前跳越过出口时计数作废 |
MAX_OPS_PER_SECOND(调度器) |
200000 | 每会话自然秒窗口 ops 求和 | onOpsExceeded → E2002 并断开 |
TICK_HZ |
60 | setInterval(1000/60) 驱动全部会话 tick |
timer 精度 <50ms |
| 事件循环停顿 ≥5s | — | 整窗 ops 不可比 | 跳过该窗超限判定(防误杀) |
调优思路:单会话 CPU 密集优先检查剧本循环(E2001/E2002 日志会带提示);全局吞吐不足再看 /metrics 的消息/会话曲线与压测分位。
5.2 编译缓存命中率#
- 命中条件:缓存条目
sourceHash === sha1(当前源码)(engine/loader/story-loader.ts)。命中则跳过 parse 之后的全部工作(parse 本身仍会执行——缓存键需要它)。 - 保障手段:启动预热把全部剧本装入缓存(
COMPILE_WORKERS>0时 worker 池并行编译,主线程校验 hash 后回填);重启后旧产物在缓存中,读档getByHash才能恢复旧存档。 - 排查"永不命中":确认对比双方都是 sha1 口径(历史教训:FNV-1a 的
hashSource()对比 sha1 永不相等)。
5.3 压测方法#
pnpm dev &
pnpm bench 10 10 dialogue.story # 并发 / 秒 / 剧本,BENCH_URL 可换目标
关注:plays/s 与 msg/s 吞吐、choice 延迟 p50/p95/p99、错误数(含预期的 E6003 建会话限速)。改动调度器/会话/存档路径后建议前后各跑一轮对比。详见架构与运维手册 §8.3。
6. 发布流程#
pnpm lint # 1. 类型检查 + 全量剧本零 error
pnpm test # 2. 360 用例全绿
pnpm coverage # 3. 覆盖率 ≥80%
# 4. 版本号:package.json "version"(当前 0.1.0)
pnpm build # 5. 产出 dist/;生产 node dist/server.js
pnpm e2e:restart && pnpm e2e:plugins # 6. 关键链路端到端(可选但推荐)
注意事项:
- worker 编译池在生产 dist 下才完整生效;tsx 开发态自动降级主线程编译,属预期。
SAVE_BACKEND=sqlite需 better-sqlite3 原生绑定与 Node ABI 匹配(prebuilt 未覆盖时pnpm rebuild better-sqlite3);绑定不可用启动自动回退 file。- 打包交付排除
node_modules/、coverage/、saves/、core dump;目标机pnpm install && pnpm test && pnpm lint全绿即为可用状态。 - 存档结构有变更时,发布说明必须写明迁移链覆盖范围(见 §3.5 与架构与运维手册 §6.3)。
7. 贡献规范#
7.1 提交信息#
使用祈使句、一行主题 + 空行 + 正文说明动机与影响面,例如:
fix(vm): RET 跨调用边界作废循环计数器
重入同一 while 时继承旧计数会提前误报 E2001,且无界增长进存档。
RET 时清空 loopCounters,选项/JMP 前跳越过出口的清理逻辑不变。
一次提交只做一件事:修复、功能、重构、文档分开;行为变更必须附带测试。
7.2 错误码:先定义、后使用#
- 新码先在对应常量表定义(集中管理,禁止散落魔法字符串):
- 编译期:
engine/compiler/(E1xxx 为主,静态检查经DiagnosticCollector); - 运行时:engine/vm/vm-error.ts的ERROR_CODES(E2xxx/E3xxx/E5xxx/E7xxx); - 会话层:session/session-error.ts的SESSION_ERROR_CODES(E4xxx); - 传输层:transport/protocol.ts/ws-server.ts(E60xx); - 插件:kernel/plugin-system/errors.ts的PluginErrorCode(E-XXXNNN-R/F/W,R 可恢复 / F 致命 / W 警告)。 - 再在使用处引用常量;
tests/error-codes.test.ts补边界用例。 - 《协议与错误码参考》§4 同步登记;码位冲突(如 E6003 同时用于出站校验失败与会话创建限速)需在文档中注明两个用法。