1.1 状态、事件、转移

叙事引擎可以被抽象成一个三元组:状态(节点、好感度、flag、物品、路径)、事件(一次选项点击)、转移(纯函数 step)。本节把《雾港夜航》的引擎压到这三个词上,并用一份 S 层脚本打印一条 25 步路径的逐步骤状态轨迹与每一步之后的 FNV-1a 摘要。

站内的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.initadd 算子
flags18 个布尔 flag全 0set 算子
items9 件线索物品的持有量全 0grant / 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 规格驱动的对话状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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