站内的NPC 对话状态架构
是服务端的网络与持久化架构:它要处理的是同一段对话被多个客户端同时推进、断线后怎么重连、状态怎么落进数据库。本节与它分工明确——本节是单机引擎内核:一台机器、一份规格、一个纯函数,没有网络、没有并发、没有持久化。那篇的实现里不可避免地要有会话管理与存储层,本节的实现里一行网络代码都没有,全部逻辑就是对一份 story_spec.json 的读取与推进。
上一节把引擎压成了「状态 / 事件 / 转移」三个词。这一节要回答的是:这三个词在代码里怎么落地,才能做到「引擎里没有一条硬编码剧情」。这句话是本卷的一个硬主张,值得说清楚它的可检验含义:把 config/story_spec.json 换成另一个故事,这台引擎不用改一行代码就能跑——因为它只认识 dialogue / state / endings 三段的形状,不认识任何具体的节点名、角色名或台词。
从三元组到状态机
「状态机」这个词容易被误用。严格地说,一个状态机由三样东西定义:一个状态集合、一个输入集合、一个转移函数。上一节的三元组正好是这三样:
| 状态机要素 | 在本引擎里 | 谁定义它 |
|---|---|---|
| 状态集合 | {node} × {aff} × {flags} × {items} × {path} | spec.state(变量域) |
| 输入集合 | 当前节点上、when 成立的选项 id | spec.dialogue[].choices[] |
| 转移函数 | advance(spec, state, choiceId) | spec.dialogue[].choices[].effects + goto |
关键在于:这三样东西没有一样是写死在代码里的。 状态集合的变量域来自 spec.state.vars / flags / items;输入集合来自每个节点声明的 choices 经过 when 筛选;转移函数的效果来自每个选项的 effects 对象。代码里出现的只有「怎么读」,没有「读到什么」。这就是「规格驱动」的全部含义。
三段驱动:dialogue / state / endings
本卷反复说「三段驱动」,指的就是规格里承担执行语义的这三段。它们的分工是干净的:
| 段 | 提供什么 | 引擎里的消费者 | 缺了它会怎样 |
|---|---|---|---|
dialogue | 节点、选项、when、effects、goto | availableChoices + advance | 没有图可走 |
state | flag / 变量 / 物品三个域 | initState(建初始状态) | 状态没有定义域 |
endings | 结局 id 与 when 条件 | resolveEnding | 终局无法判定 |
三段的消费方式各有一个要点。dialogue 的要点是可用性由状态决定:同一个节点,在不同的状态下可用的选项集合可能不同,这正是「选择是状态驱动」的形式化。state 的要点是域先于值:变量有哪些是规格声明的,初值也是规格声明的(角色的 affinity.init),引擎不预设任何默认域。endings 的要点是判定与节点标签是两回事:终局节点的 ending 字段给的是结局名,而 endings[].when 给的是条件;两者应当一致,一致性本身可以被断言。
两种做法:把剧情写进代码 vs 把剧情写成数据
「规格驱动」是相对于另一条路说的。把剧情写进代码的做法长这样:
if node == "d_pier_1":
if flags["knows_yan_missing"]: show("问他灯塔为什么没亮")
show("塞给他一枚铜板")
每个节点一个分支,每次改剧情都改代码。这条路在分支少时很直接,但它有三个代价:
| 代价 | 具体表现 | 规格驱动怎么消掉 |
|---|---|---|
| 改剧情要改代码 | 加一个选项要动引擎源码、重新编译 | 只改 JSON,引擎不动 |
| 剧情无法穷举 | 逻辑散在 if 里,程序读不出「总共有几个节点」 | 节点、选项、条件是数据,可直接计数 |
| 无法跨语言 | 换一个宿主语言要重写一遍逻辑 | 同一份规格在 Python / TS / 浏览器三处消费 |
这三行代价后面都有对应的实测:第 5 章数得出状态空间有多大(第二行),第 13 章证明三处消费摘要一致(第三行)。规格驱动不是风格偏好,它是「可穷举 + 可跨语言」这两件事的前提。
引擎里硬编码的是什么:文法,不是剧情
说「没有硬编码剧情」要诚实:引擎里确实硬编码了四样东西,但四样都不是剧情。
| 硬编码的东西 | 是什么 | 为什么必须硬编码 |
|---|---|---|
| 条件文法 | 6 种形式(flag / aff / item / and / or / not) | 它是语言,不是内容 |
| 效果算子 | 4 个(set / add / grant / revoke) | 语义的唯一出口,不能由数据自造 |
| 状态形状 | 五个字段:节点、好感度、flag、物品、路径 | 摘要与序列化的公共契约 |
| 摘要算法 | FNV-1a 32 位 | 跨语言比对的唯一基准 |
判断标准很简单:换一个故事,这四样会不会变? 不会——换掉 story_spec.json,文法还是那 6 种形式、算子还是那 4 个、状态还是那 5 个字段。它们定义的是引擎的语言,而不是用这门语言写出来的故事。硬编码语言是必须的(否则引擎没有语义),硬编码故事才是要避免的。这条区分会在第 13 章变成「声明未用 = 报告;用了未声明 = FAIL」的判据。
P 层:一台规格驱动的状态机
core/src/inkwell/ne_01_2_machine.ts 只 import 既有内核 engine.ts 的公共函数(condOk / applyChoice / initState / canonical / digest / nodeById),绝不修改它——engine.ts 是卷一交付的、三处消费共用的同源基准。本模块在它之上加三件事:选项筛选、结局判定、确定性走法。
// file: core/src/inkwell/ne_01_2_machine.ts
// tier: P
// verify: node --test 'core/test/ne_01_2_machine.test.ts'
// 规格驱动的对话状态机:引擎里没有一条硬编码剧情,
// 全部由 spec 的 dialogue / state / endings 三段驱动。
// 只 import 既有 engine.ts 的公共内核(initState / applyChoice / condOk / canonical / digest),
// 绝不修改它——它是卷一交付的三处消费同源基准。
import { condOk, applyChoice, initState, canonical, digest, nodeById } from "./engine.ts";
import type { Spec, State, Choice, Ending } from "./engine.ts";
/** 当前节点上可用的选项:规格里声明、且 when 条件成立。可用性由状态决定,不是硬编码。 */
export function availableChoices(spec: Spec, s: State): Choice[] {
return (nodeById(spec, s.node).choices ?? []).filter((c) => condOk(c.when, s));
}
/** 转移:纯函数,返回新状态。事件就是一次选项点击(choice id)。 */
export function advance(spec: Spec, s: State, choiceId: string): State {
return applyChoice(spec, s, choiceId);
}
/** 结局判定:读 spec.endings,返回第一条条件成立的结局。规格驱动,非硬编码。 */
export function resolveEnding(spec: Spec, s: State): Ending | null {
for (const e of spec.endings) if (condOk(e.when, s)) return e;
return null;
}
/** 确定性策略:总是选第一个可用选项,走到终局节点。返回选择序列、终局状态与结局名。 */
export function runGreedy(spec: Spec): { choices: string[]; state: State; endingName: string } {
let s = initState(spec);
const choices: string[] = [];
for (let i = 0; i < 200; i += 1) {
const cur = nodeById(spec, s.node);
if (cur.ending) break;
const opts = availableChoices(spec, s);
if (opts.length === 0) break;
choices.push(opts[0].id);
s = advance(spec, s, opts[0].id);
}
return { choices, state: s, endingName: nodeById(spec, s.node).ending ?? "" };
}
export { canonical, digest };
四条断言把「规格驱动」从口号变成可检验的事实。第一条最能说明问题:同一个节点,只改状态里的一个 flag,可用选项数就从 2 变成 3——如果选项是硬编码的,这条断言不可能成立。
// file: core/test/ne_01_2_machine.test.ts
// tier: P
// verify: node --test 'core/test/ne_01_2_machine.test.ts'
// 规格驱动的对话状态机:验证引擎行为完全由 spec 驱动,引擎里没有硬编码剧情。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { initState, digest, canonical } from "../src/inkwell/engine.ts";
import type { Spec, State } from "../src/inkwell/engine.ts";
import { availableChoices, advance, resolveEnding, runGreedy } from "../src/inkwell/ne_01_2_machine.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
test("可用选项由状态决定:同一节点 d_tavern_1,flag 不同则可选数不同", () => {
const off = initState(spec);
off.node = "d_tavern_1";
const on: State = { ...off, flags: { ...off.flags, knows_yan_missing: 1 } };
assert.equal(availableChoices(spec, off).length, 2);
assert.equal(availableChoices(spec, on).length, 3);
});
test("转移是纯函数:入参状态不被改动", () => {
const s0 = initState(spec);
const before = canonical(s0);
advance(spec, s0, "c_pier_1a");
assert.equal(canonical(s0), before);
});
test("贪心走完一条路径:25 个事件、终局节点 e_lit、结局 灯守、摘要 4162796975", () => {
const r = runGreedy(spec);
assert.equal(r.choices.length, 25);
assert.equal(r.state.node, "e_lit");
assert.equal(r.endingName, "灯守");
assert.equal(digest(r.state), 4162796975);
});
test("结局判定读 spec.endings:终局状态满足 e_lit 的 when", () => {
const r = runGreedy(spec);
const e = resolveEnding(spec, r.state);
assert.ok(e, "终局状态应命中至少一条结局条件");
assert.equal(e.id, "e_lit");
assert.equal(e.name, "灯守");
});
本机实跑 node --test 'core/test/ne_01_2_machine.test.ts':
✔ 可用选项由状态决定:同一节点 d_tavern_1,flag 不同则可选数不同 (0.607375ms)
✔ 转移是纯函数:入参状态不被改动 (0.227209ms)
✔ 贪心走完一条路径:25 个事件、终局节点 e_lit、结局 灯守、摘要 4162796975 (0.406917ms)
✔ 结局判定读 spec.endings:终局状态满足 e_lit 的 when (0.167833ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 302.159875
D 档:状态机的公共接口
本模块对外只暴露五个函数,它们的签名就是这台状态机的全部能力:
| 函数 | 签名 | 读规格哪一段 |
|---|---|---|
availableChoices | (spec, state) -> Choice[] | dialogue |
advance | (spec, state, choiceId) -> state' | dialogue |
resolveEnding | (spec, state) -> Ending 或 null | endings |
runGreedy | (spec) -> {choices, state, endingName} | 三段全用 |
initState | (spec) -> state | state |
五个函数里没有一个是「剧情函数」——没有 openLighthouse()、没有 meetHui()。剧情是数据,只走 advance 这一个入口;引擎提供的只是通用的推进能力。这是「规格驱动」在接口层面的样子:接口按机制命名,不按剧情命名。
判读:四条断言各证明什么
「可选数 2 → 3」证明选择是状态驱动的。 d_tavern_1 在规格里声明了 3 个选项,其中 c_tavern_1c 的 when 是 ["flag", "knows_yan_missing", 1]。flag 为 0 时它被筛掉,可用 2 个;把 flag 置 1,可用 3 个。引擎没有为这个节点写任何特例——它只是忠实地对每个选项求了一次 when。这就是「规格驱动」在最小尺度上的样子。
「转移是纯函数」证明状态可被安全地共享。 断言先记下初始状态的规范串,调一次 advance 之后再比对,两者必须相同。这条性质让「状态」可以当作值来传递、存档、比对——第 11 章的存档往返、第 13 章的跨语言比对,都建立在「推进不会偷偷改掉旧状态」之上。
「摘要 4162796975」证明本模块与目录篇、1.1 节用的是同一条路径。 三处(目录篇的 Python、1.1 节的 Python、这里的 TypeScript)对同一条选择序列给出同一个 32 位摘要。这里只对整数状态做摘要,不对文本做——文本涉及编码与换行,把「文本逐字相等」当判据是不可靠的。
「结局判定读 spec.endings」证明终局不是硬编码的。 resolveEnding 遍历 spec.endings,返回第一条 when 成立的结局;终局状态下命中 e_lit(灯守),与终局节点的 ending 标签一致。这条断言同时是一次交叉校验:节点标签说的结局,与 endings[].when 算出的结局,必须对上。
边界:规格驱动不承诺什么
「规格驱动」是一条很强的性质,但它不承诺下面这些,写清楚免得被误读:
- 不承诺剧情写得好。 引擎只保证「这份规格能被推进」,不评价规格里写了什么。台词质量划界到《孢子三部曲创作手记》——那一套做的是文字本身的工艺。
- 不承诺规格没有错。 孤儿节点、死条件、不可达结局,引擎照样能推进到别处;查这些是卷一第 13 章校验层的事,不是执行层的事。
- 不承诺任意规格都能跑。 引擎要求规格满足基本形状:
goto指向存在的节点、when用声明过的文法。形状不合法时它抛错,而不是猜。 - 不承诺性能。 状态空间的规模由规格决定,剪枝与规模告警是第 7 章与第 15 章的对象。
这四条边界与「规格驱动」并不矛盾:引擎的职责是忠实执行,不是替作者把关。执行层与校验层分开,是这套体系的一个基本分工——这也正是卷一第 13 章与卷二第 14 章各自独立成章的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 无硬编码剧情 | 模块里不出现任何具体节点名 / 角色名 / 台词,只读规格形状 |
| 不修改公共内核 | 只 import engine.ts,一行都不改(git status 无 M) |
| 三段各司其职 | dialogue 供图、state 供域、endings 供判定 |
| 断言可复现 | node --test fail 0,摘要与目录篇 / 1.1 节一致 |
| 交叉校验 | 终局节点标签与 endings[].when 判定结果一致 |
| 只对状态摘要 | 摘要输入是整数状态,不含文本 |
一台规格驱动的状态机立起来之后,还剩一个问题没回答:这台引擎与它读的规格,凭什么保证是同源的? Python 侧也有一台同样的引擎(卷一交付的仿真脚本),两侧跑同一条选择序列,凭什么保证结果相同?下一节把「同源」写成一个可断言的跨语言对照——同一份规格、同一串选项,两侧的整数状态摘要必须逐位相同。
阅读导航:上一节:1.1 状态、事件、转移 · 下一节:1.3 引擎与规格的同源 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。