站内的NPC 对话状态架构 是服务端的网络与持久化架构:多客户端怎么同步同一段对话、断线之后怎么重连、状态怎么落进数据库。本节与它分工明确——本节讲的是单机引擎内核:一台机器、一份规格、一个纯函数,没有网络、没有并发、没有持久化。那篇关心「同一段对话怎么让多个玩家看到一致的状态」,本节关心的是更下面一层的问题:一次点击怎么把状态从 A 推到 B,这个推进是不是确定的,能不能被复现。
卷一把故事还原成了 config/story_spec.json 这份规格。从这一节开始,卷二要回答的是:这份规格能不能被一台机器执行。 执行的抽象只有三个词——状态、事件、转移。它们不是三块代码,而是一个最小的数学模型:一个数据快照、一个外部输入、一个把前两者映射成新快照的纯函数。把引擎压到这三个词上,后面所有关于条件求值、效果应用、确定性回放、跨语言摘要的讨论才有统一的坐标系。
三元组:状态、事件、转移
| 词 | 是什么 | 在引擎里长什么样 | 一次路径推进里变几次 |
|---|---|---|---|
状态 state | 一个纯数据快照 | {node, aff, flags, items, path} | 每个事件后变一次 |
事件 event | 唯一的外部输入 | 一次选项点击(一个 choice id) | 每条路径 25 次 |
转移 step | 纯函数 step(spec, state, choice) -> state' | sim/ne_01_1_trace.py 的 step() | 每个事件调用一次 |
这三个词覆盖了引擎的全部行为。规格 spec 不是第四个词——它是常量,是函数的外部参数,在一条路径的推进里从头到尾不变。把 spec 与 state 分开,是这套体系里最重要的一次切分:spec 是作者写的(卷一的对象),state 是玩家走出来的(本卷的对象),两者在每一次 step 里相遇,但谁也不改写谁。
状态:一个纯数据快照
状态只有五个字段,全部是纯数据——没有对象引用、没有闭包、没有指针。
| 字段 | 含义 | 初值 | 谁能改它 |
|---|---|---|---|
node | 当前停在哪个对话节点 | spec.meta.start(d_pier_1) | 每次转移被 goto 改写 |
aff | 四个好感度变量 | 各角色的 affinity.init | add 算子 |
flags | 18 个布尔 flag | 全 0 | set 算子 |
items | 9 件线索物品的持有量 | 全 0 | grant / revoke 算子 |
path | 走过的节点序列 | [起点] | 每次转移追加一个节点 |
「纯数据」这个约束不是洁癖,它有直接的后果:状态可以被 json.dumps 完整序列化、可以逐字段比对、可以算摘要。如果状态里藏了一个对象引用或一个随机数生成器,这三件事全部做不了。第 11 章的存档往返、第 13 章的跨语言摘要,都建立在这条约束上。
事件:一次选项点击
事件是唯一的外部输入,也是引擎里唯一「从玩家那边来」的东西。它被压缩成一个字符串——选项的 id,比如 c_pier_1a。除此之外,引擎不接收任何别的东西:不给它随机数,不给它时钟,不给它网络包。
这不是简化,是确定性的前提。只要引擎的全部输入是「初始状态 + 一串选项 id」,那么同一串 id 必然推出同一个终局状态。第 4 章的确定性回放、第 13 章的跨语言摘要,测的都是这条性质。
事件能被「点」是有条件的:一个节点上的选项,要经过 when 条件筛选才出现在界面上。所以在某个状态下可用的选项集合是状态驱动的——这也是第 1.2 节要展开的「规格驱动」。
转移:纯函数 step
转移是一个纯函数,签名写出来只有一行:
step(spec, state, choice) -> state'
「纯」有两个具体含义:不改动入参(返回的是新字典,state 原封不动),同样的输入必然得到同样的输出(没有随机、没有 I/O)。函数体里只做四件事,对应四个效果算子:set 写 flag、add 改好感度(钳到 [0,100])、grant 加物品、revoke 清物品;最后把 choice.goto 写成新 node、把新节点追加进 path。
S 层:逐步骤的状态轨迹
sim/ne_01_1_trace.py 用「总是选第一个可用选项」的确定性策略走完一条路径,逐步骤打印状态轨迹:每步给出进入前的节点、所选选项、四个好感度、置位 flag 数、持有物品数、以及该步之后的 FNV-1a 摘要。目录篇 ne_00_toc_engine.py 只给终局摘要 4162796975,本脚本把每一步都摊开。
#!/usr/bin/env python3
# file: sim/ne_01_1_trace.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_01_1_trace.py
"""叙事引擎的最小构成:把引擎抽象成三元组 (状态, 事件, 转移)。
状态 state = {node, aff, flags, items, path} —— 一个纯数据快照
事件 event = 一次选项点击(choice id) —— 唯一的外部输入
转移 step = 纯函数 step(spec, state, choice) -> state'
本脚本用「总是选第一个可用选项」的确定性策略走完一条路径,**逐步骤**打印状态轨迹:
每步给出当前节点、所选选项、四个好感度、置位 flag 数、持有物品数、以及该步之后的
FNV-1a 摘要。目录篇 ne_00_toc_engine.py 只给终局摘要(4162796975),本脚本给出每一步。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
VARS = ["yan", "hui", "shen", "mu"]
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def cond_ok(c, s: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if k == "and":
return cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
return not cond_ok(c[1], s)
def node(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def init_state(spec: dict) -> dict:
aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
"path": [spec["meta"]["start"]]}
def step(spec: dict, s: dict, choice: dict) -> dict:
"""转移:纯函数。set->flag、add->好感度(钳到 [0,100])、grant/revoke->物品。"""
eff = choice.get("effects") or {}
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k in sorted((eff.get("set") or {})):
flags[k] = eff["set"][k]
for k in sorted((eff.get("add") or {})):
aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + eff["add"][k]))
for k in sorted((eff.get("grant") or {})):
items[k] = eff["grant"][k]
for k in sorted((eff.get("revoke") or {})):
items[k] = 0
return {"node": choice["goto"], "aff": aff, "flags": flags, "items": items,
"path": s["path"] + [choice["goto"]]}
def canonical(s: dict) -> str:
aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"
def digest(s: dict) -> int:
h = 2166136261
for ch in canonical(s):
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
return h
def trace(spec: dict) -> list[tuple]:
"""返回逐步骤轨迹:每步 (步号, 进入前的节点, 所选选项 id, 转移后的状态)。"""
s = init_state(spec)
rows: list[tuple] = [(0, s["node"], "(起点)", s)]
for i in range(64):
cur = node(spec, s["node"])
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
before = s["node"]
s = step(spec, s, opts[0])
rows.append((i + 1, before, opts[0]["id"], s))
return rows
def main() -> int:
spec = load_spec()
rows = trace(spec)
s0 = init_state(spec)
print("起点状态")
print(f" node={s0['node']} aff={s0['aff']} flags置位=0 items持有=0")
print(f" 起点摘要 {digest(s0)}")
print("轨迹 进入节点 事件(选项) yan hui shen mu flag item 步后摘要")
for i, before, cid, s in rows:
aff = " ".join(f"{s['aff'][v]:>3}" for v in VARS)
nfl = sum(1 for v in s["flags"].values() if v)
nit = sum(1 for v in s["items"].values() if v)
print(f"{i:>4} {before:<14} {cid:<18} {aff} {nfl:>4} {nit:>4} {digest(s):>10}")
final = rows[-1][3]
end = node(spec, final["node"]).get("ending", "(未终局)")
print(f"终局 节点 {final['node']} 结局 {end}")
print(f" 摘要 {digest(final)} 规范串长度 {len(canonical(final))}")
print(f" 事件总数 {len(rows) - 1} 路径长度 {len(final['path'])}")
# 整数不变量:好感度全程落在 [0,100],残差恒为 0。
bad = sum(1 for _i, _b, _c, s in rows for v in s["aff"].values() if not (AFF_LO <= v <= AFF_HI))
print(f" 好感度越界 {bad}")
if bad:
print("FAIL 好感度越界")
return 1
if digest(final) != digest(trace(spec)[-1][3]):
print("FAIL 两次轨迹摘要不一致")
return 1
print(f"OK ne_01_1_trace:三元组逐步骤轨迹跑通,终局摘要 {digest(final)}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_01_1_trace.py:
起点状态
node=d_pier_1 aff={'hui': 50, 'mu': 50, 'shen': 50, 'yan': 20} flags置位=0 items持有=0
起点摘要 4127714464
轨迹 进入节点 事件(选项) yan hui shen mu flag item 步后摘要
0 d_pier_1 (起点) 20 50 50 50 0 0 4127714464
1 d_pier_1 c_pier_1a 20 50 50 50 2 0 3085224634
2 d_pier_2 c_pier_2a 20 50 50 50 3 0 3641975660
3 d_tavern_1 c_tavern_1a 20 55 50 50 4 0 2314516345
4 d_tavern_2 c_tavern_2a 20 60 50 50 4 0 649771119
5 d_tavern_3 c_tavern_3a 20 60 50 50 4 1 3389763485
6 d_hub_a1 c_hub_a1a 20 60 50 50 4 1 3040035912
7 d_ch_1 c_ch_1a 20 60 50 50 5 1 3406712909
8 d_ch_2 c_ch_2a 20 60 50 55 5 1 760222539
9 d_ch_3 c_ch_3a 20 60 50 55 5 2 537685670
10 d_hub_a2 c_hub_a2a 20 60 50 55 6 2 168581929
11 d_lb_1 c_lb_1a 20 60 50 55 6 2 253695422
12 d_lb_2 c_lb_2a 25 60 50 55 6 2 907750077
13 d_lb_3 c_lb_3a 25 60 50 55 6 2 3255935952
14 d_tc_1 c_tc_1a 25 60 50 55 7 2 3751148729
15 d_tc_2 c_tc_2a 35 60 50 55 7 2 2928125429
16 d_tc_3 c_tc_3a 35 60 50 55 8 3 1130405027
17 d_ho_1 c_ho_1a 35 60 50 60 8 3 4150811831
18 d_ho_2 c_ho_2a 35 60 50 65 8 3 2318798367
19 d_ho_3 c_ho_3a 35 60 50 65 8 4 3800715304
20 d_hub_b2 c_hub_b2a 35 60 50 65 8 4 241919427
21 d_lt_1 c_lt_1b 45 60 50 65 8 4 1087722381
22 d_lt_2 c_lt_2a 55 60 50 65 8 4 2427991916
23 d_lt_3 c_lt_3a 65 60 50 65 8 4 3535535470
24 d_dq_1 c_dq_1c 65 60 50 65 8 4 748181400
25 d_dq_2 c_dq_2a 65 60 50 65 8 4 4162796975
终局 节点 e_lit 结局 灯守
摘要 4162796975 规范串长度 388
事件总数 25 路径长度 26
好感度越界 0
OK ne_01_1_trace:三元组逐步骤轨迹跑通,终局摘要 4162796975
判读:每一步都变了什么
终局摘要 4162796975 与目录篇逐位相同。 这不是巧合:两处脚本用的是同一条策略、同一份规格、同一个 FNV-1a 算法。摘要相同说明「这条路径的终局状态」被完整地钉死在了一个 32 位整数上——只要它变,就说明规格或推进语义变了。这是全书确定性判据的最小样本。
轨迹里 yan 与 hui 的攀升点各不相同。 起点 yan=20,直到第 12 步(d_lb_2)才升到 25,第 21 步跳到 45、第 23 步到 65;而 hui 在第 3、4 步就连升两次到 60,此后不再动。这说明好感度不是「走一步加一点」的均匀累加,而是由选项的 add 算子离散地改写——哪一步踩到带 add 的选项,哪一步才变。第 8 章会把这条曲线拆成状态机来讲。
flag 数与 item 数单调不减。 25 步里置位 flag 从 0 涨到 8,持有物品从 0 涨到 4,全程没有回落。这是这条路径的性质(它没踩到任何 revoke),不是规格的全局性质——规格里 revoke 算子是存在的。判据必须写在「这条路径上」,不能外推成「这个引擎里 flag 只增不减」。
每步摘要都不同。 26 行里没有两行摘要是相等的,说明状态摘要对状态变化是敏感的:节点变、好感度变、flag 变,摘要都跟着变。这一点是第 13 章「同源不同宿主」比对能成立的基础——如果摘要对变化不敏感,它就抓不住两侧的分歧。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三元组齐全 | 状态 / 事件 / 转移各有一个可核对的字段表 |
| 规格与状态分离 | spec 是常量、state 是变量,一次推进里 spec 不变 |
| 转移是纯函数 | 不改入参、无随机、无 I/O(第 1.2 节 P 层断言) |
| 摘要只覆盖状态 | 摘要由节点、好感度、置位 flag、持有物品、路径五段算出,不含文本 |
| 整数不变量 | 好感度全程 ∈ [0,100],越界残差 0 |
| 说法诚实 | 「flag 单调」只对这条路径声明,不外推为引擎性质 |
三元组立起来之后,下一个问题是:这三个词在引擎代码里具体长什么样——state 的字段谁来读、step 的效果谁来应用、结局谁来判定?下一节把这台引擎写成一份 TypeScript 模块,并证明它里面没有一条硬编码剧情,全部由规格的三段驱动。
阅读导航:上一节:目录 · 下一节:1.2 规格驱动的对话状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。