当前复现状态(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 档的代码块:
| 档 | 算什么 | 产出 | 断言 |
|---|---|---|---|
| S | Python 侧执行:条件求值 → 效果应用 → BFS 穷举可达状态集 → 死路与汇流检测 → 规模扫描 | JSON / CSV / 纯 xml.etree SVG | 脚本 exit 0;SVG 可回读;整数不变量:好感度 ∈ [0,100]、flag 为布尔、tension ∈ 1–5、同输入确定性 |
| P | TypeScript 叙事引擎内核(条件求值 / 状态推进 / 确定性回放 / 存档往返 / 跨语言摘要) | core/test/*.test.ts | node --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 |
| 节拍 beat | 16(tension 之和 = 52) |
| 对话节点 | 48(其中 5 个是结局节点) |
| 选项 | 115(其中 34 个带条件) |
| flag | 18 |
| 好感度变量 | 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 章)
- 第 1 章 叙事引擎的最小构成
- 第 2 章 条件求值
- 第 3 章 状态推进与效果
- 第 4 章 确定性回放
第二部分 · 分支状态空间(第 5–8 章)
- 第 5 章 分支状态空间与可达性
- 第 6 章 死路、汇流与不可达
- 第 7 章 分支宽度与深度控制
- 第 8 章 好感度系统与阈值门
第三部分 · 可玩原型与持久化(第 9–12 章)
- 第 9 章 W 层:可玩的分支对话原型
- 第 10 章 W 层:Playwright 注入路径与断言
- 第 11 章 存档、读档与状态快照
- 第 12 章 本地化与文本表
第四部分 · 一致、回归与装配(第 13–16 章)
第 13 章 三处消费的一致
第 14 章 叙事引擎的可测试性与回归
第 15 章 引擎的规模与性能
第 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 |
| 9 | W 层:可玩的分支对话原型 | W |
| 10 | W 层: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 与指标扫描需要较长时间,未运行或超时不会计作通过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。