《叙事策划与叙事工艺》卷二《叙事引擎与分支系统》目录

《叙事策划与叙事工艺》卷二《叙事引擎与分支系统》目录:16 章 48 节,把 story_spec.json 变成可执行的对话状态机——条件求值、效果应用、确定性回放、状态空间穷举、可玩原型与跨语言摘要。本页给出分工说明、六档可运行性口径与全书 16 章 48 节完整清单。

当前复现状态(2026-10-17)

已恢复完整故事规格、公共输入与测试辅助函数,提供列明 228 条命令的三卷累计工程及独立验收入口。此前 227 条运行快照保留;随后独立审算法发现 11.3 将 BFS 层序误作拓扑序,已改为 Kahn 算法并补四项反例/参考回归,最长路径应为31条边,最大最短距离为22。旧快照的通过不能替代变化后的源码验收,完整结果按对应输入哈希与独立包报告核对。

指定故事规格此前完整 BFS 得到 9,895,423 个状态,去重键为节点、好感度、旗标和物品,不含路径历史。115 个选项与 56 条去重跳转是不同分母:单条示例路径覆盖 25/56 = 446‰,按选项计为25/115 = 217‰;全覆盖也需说明口径。

运行环境为 Python 3.14.6、Node 26 及所列浏览器依赖。按章演进、全部练习与全卷事实另验;熵、张力和 MAE 是代理指标,不能直接认证叙事文本或真人体验质量。

这是《叙事策划与叙事工艺》三卷本的第二卷。卷一回答的是「一个故事能不能先被写成一份规格」;这一卷回答的是:这份规格能不能被一台机器执行——而且是可复现地、可断言地、可跨语言地执行?

站内的叙事内容基本落在两端:一端是引擎怎么实现(game/client/ 的打字机、立绘口型、字幕时间轴、过场系统,game/server/ 的 NPC 对话状态架构),一端是散文式的叙事经验(game/indie/ 的分支对话长文、game/industry/ 的叙事设计概览)。中间那一段——同一份规格在 Python 仿真、TypeScript 内核与浏览器原型三处跑出来的状态摘要是否逐位相同——没有人占。 这一卷补的就是这一段。

做法还是那句话:一份叙事规格,三处消费。config/story_spec.json 是唯一真源,卷一让它「可穷举」,这一卷让它「可执行」——条件求值器、效果应用、状态推进、确定性回放、存档往返、可玩原型。改一个条件或一个阈值,三处同步改变且仍然互相一致。

贯穿全书的是虚构的三夜悬疑叙事 《雾港夜航》(工程名 mistport)与工具链 inkwell:config/ 放规格,sim/ 是 Python 侧的执行与穷举,core/ 是 TypeScript 确定性内核,web/ 把内核内联进单文件 HTML 供无头浏览器真玩。

这套书与站内既有内容的分工

站内已经有大量相邻内容,写清分工比重复写一遍更有价值。下面七处边界是本卷最容易混淆的,务必分清:

  • 独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:分支怎么写、条件怎么设计、好感度怎么调,配的是清单与示意图,代码不可跑。本卷第 2、6、8 章把同一批问题变成可判定的执行语义——条件变成可求值的表达式树,分支汇聚变成可判定的合法性,好感度阈值变成有不变量的状态机。
  • Phaser 分支叙事 与 Phaser 选择后果日志 是具体引擎 API 的实现:怎么在 Phaser 里挂对话、怎么记录后果。本卷第 3 章做的是语言无关的状态推进语义——效果怎么应用、为什么必须幂等、差量怎么算。
  • Godot 对话历史与后果日志 是引擎里的选择回看 UI;本卷第 4 章做的是确定性回放——同一条选择序列在两次运行里推进出的状态必须逐位相同,并给出可断言的摘要。
  • Phaser 过场系统 做的是过场编排与演出;本卷第 9、10 章做的是可玩原型与无头断言——把规格内联进单文件 HTML,用 Playwright 注入选择序列真玩,断言终局状态与结局。
  • NPC 对话状态架构 与 叙事分支状态架构 是服务端的同步与持久化(多客户端一致性、断线重连、数据库落盘)。本卷第 1、11、14 章做的是单机内核的状态机与存档往返,不涉及网络、不涉及数据库。
  • Godot 本地化与字体 是引擎里的字体与本地化实现;本卷第 12 章做的是文本表与键的一致性——键有没有漏、变体有没有覆盖、复数形式有没有缺——明确不评价译文质量。
  • 《游戏设计》卷一《玩法机制与可执行规格》 与本卷最像,但输入与对象完全不同。它的真源是数值与规则(伤害 / 资源 / 随机性),消费对象是一局战斗的状态轨迹,核心工具是仿真 + 守恒不变量;本卷的真源是结构与条件(世界 / 角色 / 弧光 / 分支 / 阈值),消费对象是故事在玩家选择下的展开路径,核心工具是穷举 + 覆盖 + 曲线。一句话:那一套证明「这份规则自洽且可解」,本卷证明「这张故事图被执行出来时状态是确定的、可断言的、三处一致的」。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但不共享任何规格字段。

一句话:本卷做的是「这份规格怎么被执行、执行得确不确定、三处是不是一致」,不是「这段故事写得好不好」,也不是「这段对话在引擎里怎么渲染」。

明确不在本卷范围内

为了让边界清楚,下面这些这一卷不讲:

  • 叙事文本的创作质量(文笔、修辞、人物是否动人)——见《孢子三部曲创作手记》。本卷第 12 章只处理文本的结构:键、变体、复数、一致性。译文质量本身标 X。
  • 引擎里的对话渲染与演出(打字机、立绘口型、字幕时间轴、过场编排)——见 game/client/ 各篇。
  • 服务端的对话状态同步、断线重连与持久化——见 game/server/ 各篇与《MMO 服务器开发》四卷。本卷的存档是单机文件往返。
  • 真实玩家路径数据、真实配音与字体成本、真人测试:本机没有,一律标 X,正文逐处显式标注「需真实业务环境,命令行不可复现」。
  • 叙事规格怎么设计(字段怎么定、角色怎么写、弧光怎么排)——那是卷一的内容。本卷假定规格已经存在且通过卷一的三层校验。
  • 任何第三方素材:贯穿案例为虚构叙事,所有 SVG / 图表均由程序化生成。

可运行性六档口径

本套沿用站内书稿的六档可运行性,每一节至少有一个 S / P / W 档的代码块:

档算什么产出断言
SPython 侧执行:条件求值 → 效果应用 → BFS 穷举可达状态集 → 死路与汇流检测 → 规模扫描JSON / CSV / 纯 xml.etree SVG脚本 exit 0;SVG 可回读;整数不变量:好感度 ∈ [0,100]、flag 为布尔、tension ∈ 1–5、同输入确定性
PTypeScript 叙事引擎内核(条件求值 / 状态推进 / 确定性回放 / 存档往返 / 跨语言摘要)core/test/*.test.tsnode --test '<glob>' fail == 0;同一选择序列跨语言逐位一致(对状态做 digest,不对文本做)
W单文件 HTML 内联规格与内核,Playwright 注入选择序列真玩web/dist/*.html + out/shots/*.png全绿 + 两遍确定性 + DOM 已绘制 + 终局状态与结局断言
D对话树格式、条件文法、不变量清单、校验清单JSON / Markdown 表lint,必须与 S / P / W 同节搭配
X真实团队协作流程 / 真实译文与字体成本 / 真人玩家测试无正文逐处显式标注「需真实业务环境,命令行不可复现」
B分支宽度 / 采样上界 / 好感度阈值 / 节拍密度扰动 → 覆盖与分布的相变JSON软告警不硬失败;exit 0;数字按对应实验输入与环境解读

「执行正确」的操作化:本卷的判据全部是可断言的整数与布尔,不做主观判断——状态推进幂等(同一效果重复应用,状态不变)、回放逐位一致(同一选择序列,两次运行的规范串相同)、跨语言摘要相同(Python 与 TypeScript 对同一路径给出同一个 FNV-1a 值)、好感度恒在 [0,100](钳位必须生效,越界计数恒为 0)。正文不会出现「更流畅」「提升 N 倍」这类表述。

贯穿案例《雾港夜航》总览

故事:雾港被浓雾与潮汐围困,灯塔守望者「砚」在三夜前失踪。玩家扮演抵达雾港的信使,在三夜之间于 6 个角色、4 个派系(船帮 / 教会 / 商会 / 守望者工会)之间做出选择,收集线索、推进关系,最终分岔到 5 个结局。「三夜」既是三幕结构的自然映射,又是可回放的三段路径——这是让「结构」「分支」「曲线」三者都可算的最小骨架。

固定规模(全书常量):

项数量
幕 / 夜3
场景节点14
场景边16(11 条无条件 + 5 条带条件)
角色6
节拍 beat16(tension 之和 = 52)
对话节点48(其中 5 个是结局节点)
选项115(其中 34 个带条件)
flag18
好感度变量4(yan / hui / shen / mu)
线索物品9
派系4(船帮 guild / 教会 church / 商会 merchant / 守望者工会 watch)
结局5(灯守 / 离港 / 真相 / 缄默 / 沉没)

本卷关心的执行侧数字(seed=20261016):

指标值
可达状态数(BFS 完整枚举)9895423
分支覆盖率(BFS 口径)1000‰(115/115 条对话边全部可达)
死路节点(无选项且非结局)0
跨语言摘要一致路径(500 条采样)500/500
同输入确定性违例0
好感度越界(钳位失效)0

设计目标是可穷举但非平凡:状态 = 节点 × 4 个好感度 × 18 个 flag × 9 个物品,朴素上界是 101⁴ × 2¹⁸ × 2⁹;真实可达状态 990 万。这个规模让「穷举」与「采样」两条路都能跑,也正是本卷反复拿来做教学对象的那一组数。

规格的两处真实特征(不是缺陷,正文按实测发现呈现):① lin / hai 两个角色声明了 affinity,却不在 state.vars 里、也从未被任何选项 add 过——即「声明域 > 状态域」;② 条件文法声明了 4 个比较算子(>= < == !=),实测只用到 >= 一种,且形态 or 声明了但一次都没用。这两处在第 2 章与第 13 章按「声明未用=报告;用了未声明=FAIL」的口径处理。

底座:执行侧的公共入口

三处消费共享同一组入口,本卷要把它们全部讲透:

入口文件本卷位置
条件求值与效果应用core/src/inkwell/engine.ts第 2 章、第 3 章
Python 侧执行与摘要sim/ne_02_*.py第 4 章、第 13 章
状态空间穷举与死路检测sim/ne_05_*.py、sim/ne_06_*.py第 5 章、第 6 章
可玩原型与无头断言web/build_ne09.mjs、web/verify_ne10.mjs第 9 章、第 10 章
存档往返与向前兼容core/src/inkwell/save.ts第 11 章

跨语言摘要是本卷的工程地基:同一选择序列在 Python 与 TypeScript 下推进出的状态摘要逐位相同(500 条路径全等)。这里只对整数状态做摘要,不对文本做——文本涉及编码与换行,把「文本逐字相等」当作判据是不可靠的。

一页引擎摘要:最小回环跑起来

目录篇不给完整教程,只给一个秒级可跑的入口,让读者先确认「这份规格能被执行,而且执行是确定的」。脚本读 config/story_spec.json,用「总是选第一个可用选项」的确定性策略走完一条路径,打印终局状态的规范串与 FNV-1a 摘要,并重跑一遍验证摘要相同:

#!/usr/bin/env python3
# file: sim/ne_00_toc_engine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_00_toc_engine.py
"""目录篇的一页引擎摘要:用「总是选第一个可用选项」的确定性策略走完一条路径。

只走一条(na_02_digest.py 采 500 条),给出终局状态的规范串与 FNV-1a 摘要,
并重跑一遍验证摘要相同。逐步骤的状态轨迹留给第 1 章。
"""
import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))


def cond_ok(c, s):
    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)          # not


def node(spec, nid):
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def init_state(spec):
    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, s, c):
    """应用一条选项:set→flag、add→好感度(钳到 [0,100])、grant/revoke→物品。"""
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
    for k, v in (eff.get("grant") or {}).items():
        items[k] = v
    for k in (eff.get("revoke") or {}):
        items[k] = 0
    return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def canonical(s):
    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):
    h = 2166136261
    for ch in canonical(s):
        h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    return h


def walk(spec):
    s, trail = init_state(spec), []
    for _ 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
        trail.append(opts[0]["id"])
        s = step(spec, s, opts[0])
    return s, trail


def main():
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    s, trail = walk(spec)
    s2, _ = walk(spec)

    print(f"起点     {spec['meta']['start']}")
    print(f"选项数   {len(trail)}")
    print(f"终局节点 {s['node']}")
    print(f"结局     {node(spec, s['node']).get('ending', '(未终局)')}")
    print(f"规范串   {canonical(s)}")
    print(f"摘要     {digest(s)}")
    print(f"重跑一致 {canonical(s2) == canonical(s)}")
    res = sum(1 for v in s["aff"].values() if not (AFF_LO <= v <= AFF_HI))
    print(f"好感度越界 {res}")
    if res or canonical(s2) != canonical(s):
        return 1
    print("OK ne_00_toc_engine:引擎最小回环跑通,两次摘要一致")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑输出(规范串中间的路径一段已折行显示):

起点     d_pier_1
选项数   25
终局节点 e_lit
结局     灯守
规范串   e_lit|hui=60,mu=65,shen=50,yan=65|cave_explored,found_yan_journal,knows_tide_rule,knows_yan_missing,met_hai,met_hui,met_mu,saw_lighthouse_light|journal,ledger_page,rope_knot,salt_letter|d_pier_1>…>d_dq_2>e_lit
摘要     4162796975
重跑一致 True
好感度越界 0
OK ne_00_toc_engine:引擎最小回环跑通,两次摘要一致

判读:规范串由四段拼成——节点 | 好感度 | 置位 flag | 持有物品 | 路径。同一策略跑两遍,摘要都是 4162796975,这就是本卷全部确定性的地基。注意摘要只覆盖状态,不覆盖文本:把台词换成任何一段别的文字,这个数都不变——因为文本是规格的渲染产物,不是状态本身(这是卷一第 1.3 节的口径)。好感度越界 0 表示钳位生效:这条路径上 yan 从初值累加到 65、mu 到 65,都没有冲破 [0,100]。

全书目录(16 章 48 节)

第一部分 · 引擎内核(第 1–4 章)

第二部分 · 分支状态空间(第 5–8 章)

第三部分 · 可玩原型与持久化(第 9–12 章)

第四部分 · 一致、回归与装配(第 13–16 章)

怎么读

  • 想先看主张:读第 1 章 的三元组与第 1.3 节 的「引擎与规格同源」,那两节把本卷的口径讲完。
  • 想直接动手:从第 2 章 的条件求值器开始写,再依次做第 3 章的效果应用与第 4 章的确定性回放。
  • 想拿结论去说服人:看第 13.1 节 的跨语言摘要与第 16.1 节 的装配检查表——这两节把「这份规格能不能被安全地实现」变成可核对的清单。
  • 想先玩起来:直接跳到第 9 章 ,把规格内联进单文件 HTML,再用第 10 章 的 Playwright 注入路径真玩。
  • 接着卷一/卷三:卷一《叙事结构与可执行规格》给出规格,卷三《叙事体验与路径度量》接住「度量」,接口见第 16.3 节 的交付说明。

可运行性总表(各章档位分布)

章章名主档位
1叙事引擎的最小构成P + S
2条件求值P + S
3状态推进与效果P + S
4确定性回放P + S
5分支状态空间与可达性S
6死路、汇流与不可达S + P
7分支宽度与深度控制S + B
8好感度系统与阈值门S + P
9W 层:可玩的分支对话原型W
10W 层:Playwright 注入路径与断言W
11存档、读档与状态快照P + S
12本地化与文本表D + P + X
13三处消费的一致S + P + W
14叙事引擎的可测试性与回归P + S
15引擎的规模与性能S + B
16装配:把规格交给卷三S + P + W

S = Python 侧执行与穷举,P = TypeScript 确定性内核(node --test),W = 浏览器可玩原型(Playwright 无头真玩),D = 规格文本与清单,X = 需真实环境、命令行不可复现(正文逐处标注),B = 参数扰动与相变扫描(软告警不硬失败)。

完整项目配套

下载 inkwell 三卷累计教学工程 。包内含当前正文、完整规格和源码来源/哈希、明确命令清单及 verify.py。解压后准备依赖并运行 python3 verify.py --browser;完整 BFS 与指标扫描需要较长时间,未运行或超时不会计作通过。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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