1.2 规格驱动的对话状态机

规格驱动的意思是引擎里没有一条硬编码剧情:可用的选项来自 dialogue 的 choices 加 when 求值,状态推进来自 state 的四个效果算子,终局来自 endings 的 when 判定。本节用一份 TypeScript 模块与四条断言证明这台状态机的全部行为都由 spec 的三段决定。

站内的NPC 对话状态架构 是服务端的网络与持久化架构:它要处理的是同一段对话被多个客户端同时推进、断线后怎么重连、状态怎么落进数据库。本节与它分工明确——本节是单机引擎内核:一台机器、一份规格、一个纯函数,没有网络、没有并发、没有持久化。那篇的实现里不可避免地要有会话管理与存储层,本节的实现里一行网络代码都没有,全部逻辑就是对一份 story_spec.json 的读取与推进。

上一节把引擎压成了「状态 / 事件 / 转移」三个词。这一节要回答的是:这三个词在代码里怎么落地,才能做到「引擎里没有一条硬编码剧情」。这句话是本卷的一个硬主张,值得说清楚它的可检验含义:把 config/story_spec.json 换成另一个故事,这台引擎不用改一行代码就能跑——因为它只认识 dialogue / state / endings 三段的形状,不认识任何具体的节点名、角色名或台词。

从三元组到状态机

「状态机」这个词容易被误用。严格地说,一个状态机由三样东西定义:一个状态集合、一个输入集合、一个转移函数。上一节的三元组正好是这三样:

状态机要素在本引擎里谁定义它
状态集合{node} × {aff} × {flags} × {items} × {path}spec.state(变量域)
输入集合当前节点上、when 成立的选项 idspec.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、gotoavailableChoices + advance没有图可走
stateflag / 变量 / 物品三个域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 或 nullendings
runGreedy(spec) -> {choices, state, endingName}三段全用
initState(spec) -> statestate

五个函数里没有一个是「剧情函数」——没有 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 引擎与规格的同源 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长