当前复现状态(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/indie/的分支对话长文、game/industry/的叙事设计概览)。**中间那一段——把「这段叙事体验好不好」还原成覆盖率、分布熵、曲线 MAE 这类可算的整数,并且明确说清它们不能声称什么——没有人占。**这一卷补的就是这一段。做法还是那句话:一份叙事规格,三处消费。
config/story_spec.json是唯一真源,卷一让它「可穷举」,卷二让它「可执行」,这一卷让它「可度量」——路径采样、覆盖缺口、结局分布、张力曲线、一致性残差。改一个阈值或一个节拍标注,度量结果同步改变,且仍能用同一套命令复现。贯穿全书的是虚构的三夜悬疑叙事 《雾港夜航》(工程名mistport)与工具链inkwell:sim/是 Python 侧的路径采样与曲线计算,core/是 TypeScript 的度量内核,web/把路径批跑挂到无头浏览器上,out/收全部 JSON / CSV / SVG 产物。
这套书与站内既有内容的分工
站内已经有大量相邻内容,写清分工比重复写一遍更有价值。下面七处边界是本卷最容易混淆的,务必分清:
- 《游戏设计》卷二《策略涌现与体验度量》 与本卷最像,也最容易被混为一谈。它的真源是机制数值与规则,度量对象是一局战斗的动力学(回合数、动作密度、资源守恒残差),张力来自 HP / 资源 / 回合节奏,玩家侧建模的是动因与类型。本卷的真源是叙事结构与条件,度量对象是玩家路径的形状(覆盖率、结局分布、节拍张力曲线),张力来自作者标注的叙事节拍沿路径的序列,玩家侧只做路径选择策略、不建模动因。一句话:那一套证明「这份规则自洽且可解」,本卷证明「这张故事图可达、可覆盖、走出来的情感形状符合设计意图」。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但不共享任何规格字段。
- 独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:分支怎么写、好感度怎么调,配的是清单与示意图,代码不可跑。本卷第 1、2、4、7、8 章把同一批问题变成可算的数——一次通关变成可编码的路径,分支覆盖变成千分比,假选择变成可判定的检测,节奏变成密度序列。
- 《游戏设计》卷一《玩法机制与可执行规格》 与本套卷二给出的是可执行的真源与内核;本卷第 6、14、15 章接住它们的输出做度量与回归——这一卷不新造规格字段,只读已有的。
- 《游戏设计》卷三《原型可玩性与品类》 做的是通用可玩性测试设计;本卷第 12 章收口径为叙事路径覆盖 + 结局分布的批跑与断言,真人测试标 X。
- 游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本卷第 9 章做的是设定与行为对齐的回归——把「叙事失调」写成一个可算的残差并给出告警阈值。
- 《游戏设计》卷二 的玩家动因与类型建模;本卷第 11 章做的是模拟玩家的路径策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型,§11.3 标题即写死这一分工。
- 《游戏设计》卷二
的情绪曲线与张力来自机制动力学;本卷第 5 章的张力来自叙事节拍结构(
beats[].tension)沿玩家路径的序列,§5.3 标题即写死「与机制张力的分工」。
一句话:本卷做的是「这些路径的形状能不能被算出来、算出来的数说明了什么、以及这些数不能说明什么」,不是「这段故事写得好不好」,也不是「这份规则好不好玩」。
明确不在本卷范围内
为了让边界清楚,下面这些这一卷不讲:
- 叙事文本的创作质量(文笔、修辞、人物是否动人)——见《孢子三部曲创作手记》。本卷只对文本做计数(选项数、字数密度),不评价文本本身。
- 机制层的张力、动因与体验度量——见《游戏设计》卷二《策略涌现与体验度量》。本卷的张力输入是叙事节拍,不是数值动力学。
- 引擎里的对话渲染与演出——见
game/client/各篇。 - 服务端的对话状态同步、断线重连与持久化——见
game/server/各篇。本卷的路径全部在单机内核上跑。 - 叙事规格怎么设计(字段怎么定、角色怎么写、弧光怎么排)——那是卷一的内容;引擎怎么执行——那是卷二的内容。本卷假定规格已存在、内核已可用。
- 真实玩家路径数据、真人测试、真实译文与配音成本:本机没有,一律标 X,正文逐处显式标注「需真实业务环境,命令行不可复现」。
- 任何第三方素材:贯穿案例为虚构叙事,所有 SVG / 图表均由
xml.etree程序化生成。
可运行性六档口径
本套沿用站内书稿的六档可运行性,每一节至少有一个 S / P / W / B 档的代码块:
| 档 | 算什么 | 产出 | 断言 |
|---|---|---|---|
| S | Python 侧度量:路径采样 → 覆盖率与缺口 → 结局分布熵 → 张力曲线与 MAE → 一致性残差 → 反推与演化回归 | JSON / CSV / 纯 xml.etree SVG | 脚本 exit 0;SVG 可回读;整数不变量:覆盖率 ∈ [0,1000]‰、熵 ≥ 0、好感度 ∈ [0,100] |
| P | TypeScript 度量内核(路径编码 / 覆盖统计 / 曲线计算 / 反推) | core/test/nm*.test.ts | node --test '<glob>' fail == 0;同一路径序列在 Python 与 TypeScript 下算出的整数指标逐位一致 |
| W | 单文件 HTML 内联规格与内核,Playwright 批跑路径并断言 | web/dist/nm*.html + out/shots/*.png | 全绿 + 两遍确定性 + DOM 已绘制 + 结局分布断言 |
| D | 指标定义表、口径声明、可比性清单、反推参数表 | JSON / Markdown 表 | lint,必须与 S / P / W 同节搭配 |
| X | 真人玩家测试 / 真实译文与配音成本 / 真实团队评审流程 | 无 | 正文逐处显式标注「需真实业务环境,命令行不可复现」 |
| B | 采样规模 / 阈值 / 节拍密度 / 参数扰动的相变扫描 | JSON | 软告警不硬失败;exit 0;数字按对应实验输入与环境解读 |
「叙事质量」的操作化——本卷全部指标都是 proxy:分支覆盖率(走过的对话边 / 全部对话边)、结局分布熵、张力曲线 MAE(实际路径曲线 vs 目标曲线)、一致性残差(设定-行为失配计数)、整数不变量残差(好感度越界、tension 越界,恒为 0)。这些数只做相对比较,禁「更感人」「提升 N 倍」「玩家满意度 X%」这类表述。第 15 章专门写「指标不能声称什么」,把这一条讲透。
贯穿案例《雾港夜航》总览
故事:雾港被浓雾与潮汐围困,灯塔守望者「砚」在三夜前失踪。玩家扮演抵达雾港的信使,在三夜之间于 6 个角色、4 个派系(船帮 / 教会 / 商会 / 守望者工会)之间做出选择,收集线索、推进关系,最终分岔到 5 个结局。「三夜」既是三幕结构的自然映射,又是可回放的三段路径——这是让「结构」「分支」「曲线」三者都可算的最小骨架。
固定规模(全书常量):
| 项 | 数量 |
|---|---|
| 幕 / 夜 | 3 |
| 场景节点 | 14 |
| 角色 | 6 |
| 节拍 beat | 16(tension 之和 = 52,取值 1–5 整数) |
| 对话节点 | 48(其中 5 个是结局节点) |
| 对话边(选项) | 115 |
| flag | 18 |
| 好感度变量 | 6 个角色均带 affinity 声明;其中 4 个(yan / hui / shen / mu)进状态表并被效果与条件引用 |
| 线索物品 | 9 |
| 结局 | 5(灯守 / 离港 / 真相 / 缄默 / 沉没) |
本卷关心的度量侧数字(历史基线 seed=20261016;当前验证范围见页首):
| 指标 | 值 |
|---|---|
| 单条路径(确定性首选项策略)覆盖对话边 | 446‰ |
| 该路径覆盖场景 | 9 / 14 |
| 该路径覆盖节拍 | 11 / 16 |
| 该路径到达的结局 | 1 / 5 |
| 该路径的张力 MAE | 0 毫(阈值 1000 毫) |
| 结局熵上界(5 结局均匀分布) | 2321 毫比特(阈值 1500 毫比特) |
| 全部对话边(BFS 口径,卷二实测) | 115 / 115,即 1000‰ |
| 可达状态数(BFS 完整枚举,卷二实测) | 9895423 |
这组数就是全卷的动机:一条路径只能覆盖 44.6% 的对话边、9 / 14 的场景、11 / 16 的节拍,只到达 1 / 5 的结局——而 BFS 口径下全部 115 条边都是可达的。单条路径的覆盖远远不够,所以必须采样;采样到什么程度才算够,就是第 2 章要回答的问题。
规格的两处真实特征(不是缺陷,正文按实测发现呈现):① beats[].tension 与 metrics.target_tension_curve 在真源里逐项相同,所以当前规格下张力 MAE 恒为 0——这不是指标失效,而是说明标注与目标此刻是一致的;MAE 真正起作用是在规格演化时(第 14 章),改了节拍标注而没同步目标曲线,它就会抬起来。② 条件文法声明了 4 个比较算子,实测只用到 >= 一种——度量代码必须对未用到的形态也有定义,否则规格一改就崩。
底座:度量侧的公共入口
三处消费共享同一组入口,本卷只读它们、不新造字段:
| 入口 | 文件 | 本卷位置 |
|---|---|---|
规格真源(含 beats / metrics) | config/story_spec.json | 全卷 |
| 路径推进与规范串 | core/src/inkwell/engine.ts、sim/ne_02_*.py | 第 1 章、第 14 章 |
| 可达状态集与覆盖率基准 | sim/ne_05_*.py、sim/ne_06_*.py | 第 2 章、第 3 章 |
| 可玩原型与批跑入口 | web/build_ne09.mjs、web/verify_ne10.mjs | 第 12 章 |
纯 xml.etree SVG 出图 | sim/na_*.py(卷一既有) | 第 10 章 |
本卷的地基是「同一条路径在三处算出的整数指标逐位一致」:Python 采样器与 TypeScript 度量内核必须对同一条路径给出同一个覆盖率、同一个 MAE。这里只对整数做比对,不对文本做——文本涉及编码与换行,把「文本逐字相等」当作判据是不可靠的(这是卷一第 1.3 节定下的口径)。
一页度量摘要:一条路径的形状量出来
目录篇不给完整教程,只给一个秒级可跑的入口,让读者先确认「一条路径的形状确实能被算出来,而且算出来的是整数」。脚本读 config/story_spec.json,用「总是选第一个可用选项」的确定性策略走完一条路径,量出它的张力曲线、分支覆盖率与结局熵上界:
#!/usr/bin/env python3
# file: sim/nm_00_toc_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_00_toc_metric.py
"""目录篇的一页度量摘要:走一条确定性路径,量出它的张力曲线与分支覆盖率。
与 ne_00_toc_engine.py 分工:那个量「引擎跑不跑得通」,本脚本量「跑出来这条路
的体验形状」。只量一条(卷二 ne_02 采样 500 条),给出三个整数:
张力 MAE(千分位)、分支覆盖率(千分位)、结局熵(毫比特)。
全部走定点整数,不出现浮点。
"""
import json
import os
import sys
from math import log2
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):
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 walk(spec):
"""确定性策略:每步选第一个条件成立的选项。同 ne_00_toc_engine.py。"""
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() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
s, _ = walk(spec)
path = s["path"]
m = spec["metrics"]
# 张力曲线:路径经过的场景 → 该场景的节拍;目标值取 target_tension_curve 同下标
scenes = {node(spec, nid)["scene"] for nid in path}
pairs = [(b["tension"], m["target_tension_curve"][i])
for i, b in enumerate(spec["beats"]) if b["scene"] in scenes]
mae_milli = sum(abs(a - b) for a, b in pairs) * 1000 // len(pairs)
# 分支覆盖率:走过的对话边 / 全部对话边(千分位)
dl = spec["dialogue"]
all_edges = {(n["id"], c["goto"]) for n in dl for c in (n.get("choices") or [])}
walked = {(path[i], path[i + 1]) for i in range(len(path) - 1)}
cov_milli = len(walked & all_edges) * 1000 // len(all_edges)
# 结局分布熵:这条路径只到一个结局,故按「5 结局均匀分布」给出上界作参照
ends = spec["endings"]
ent_milli_bits = int(log2(len(ends)) * 1000) if ends else 0
print(f"起点 {spec['meta']['start']}")
print(f"节点数 {len(path)}")
print(f"终局节点 {s['node']}")
print(f"结局 {node(spec, s['node']).get('ending', '(未终局)')}")
print(f"覆盖场景 {len(scenes)} / {len(spec['scene_graph']['nodes'])}")
print(f"覆盖节拍 {len(pairs)} / {len(spec['beats'])}")
print(f"结局覆盖 1 / {len(ends)}")
print(f"张力曲线 {' '.join(str(b['tension']) for b in spec['beats'] if b['scene'] in scenes)}")
print(f"目标曲线 {' '.join(str(m['target_tension_curve'][i]) for i, b in enumerate(spec['beats']) if b['scene'] in scenes)}")
print(f"张力 MAE {mae_milli} 毫(阈值 {m['tension_mae_max'] * 1000} 毫)")
print(f"分支覆盖率 {cov_milli} 千分(目标 {m['coverage_target_permille']} 千分)")
print(f"结局熵上界 {ent_milli_bits} 毫比特(阈值 {m['ending_entropy_min_milli_bits']} 毫比特)")
bad = []
if mae_milli > m["tension_mae_max"] * 1000:
bad.append("张力 MAE 超阈值")
if cov_milli < m["coverage_target_permille"]:
bad.append("分支覆盖率未达目标")
if ent_milli_bits < m["ending_entropy_min_milli_bits"]:
bad.append("结局熵上界低于阈值")
for b in bad:
print(f"WARN {b}")
if len(bad) >= 2:
return 1
print("OK nm_00_toc_metric:一条路径的张力曲线、覆盖率与熵上界已量出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
起点 d_pier_1
节点数 26
终局节点 e_lit
结局 灯守
覆盖场景 9 / 14
覆盖节拍 11 / 16
结局覆盖 1 / 5
张力曲线 1 2 3 2 3 4 4 3 4 4 2
目标曲线 1 2 3 2 3 4 4 3 4 4 2
张力 MAE 0 毫(阈值 1000 毫)
分支覆盖率 446 千分(目标 850 千分)
结局熵上界 2321 毫比特(阈值 1500 毫比特)
WARN 分支覆盖率未达目标
OK nm_00_toc_metric:一条路径的张力曲线、覆盖率与熵上界已量出
判读:四个数各有各的读法。张力 MAE = 0 说明这条路径经历过的 11 个节拍上,作者的标注与设计目标完全吻合——不是指标没用,是当前规格自洽;一旦改了节拍标注而没同步目标曲线,这个数立刻抬起来(第 14 章)。分支覆盖率 446‰ 远低于 850‰ 的目标,脚本给出 WARN 但仍 exit 0——这正是 B 层口径:单条路径达不到全局覆盖目标,不是错误,是采样必要性的证据。结局熵上界 2321 毫比特是按「5 个结局均匀分布」算的理论上界,单条路径只到 1 个结局,所以它是上界而不是实测值——这个区分在第 3 章会展开。
脚本只量一条(第 2 章采样 500 条、第 3 章做 BFS 全量口径),并且全程不出现浮点:MAE 与覆盖率都按千分位取整数地板除,熵按毫比特取整。
全书目录(16 章 48 节)
第一部分 · 路径、覆盖与平衡(第 1–4 章)
- 第 1 章 玩家路径
- 第 2 章 路径的采样与覆盖
- 第 3 章 结局分布与分支平衡
- 第 4 章 选择的权重与后果可见性
第二部分 · 曲线、张力与一致性(第 5–9 章)
- 第 5 章 叙事张力与释放
- 第 6 章 情感曲线的代理指标
- 第 7 章 叙事节奏与密度
- 第 8 章 好感度曲线的形状
- 第 9 章 叙事一致性的回归
第三部分 · 可视化、模拟与测试(第 10–12 章)
- 第 10 章 曲线的可视化
- 第 11 章 模拟玩家与路径策略
- 第 12 章 叙事规格的可玩性测试
第四部分 · 反推、演化与收官(第 13–16 章)
第 13 章 从度量反推设计
第 14 章 叙事规格演化与回归
第 15 章 叙事质量的代理指标总表
第 16 章 装配:三卷总收官
附录
怎么读
- 想先看主张:读第 1 章 的路径表示与第 15.3 节 的「指标不能声称什么」,那两节把本卷的口径讲完。
- 想直接动手:从第 2 章 的采样与覆盖率开始写,再依次做第 3 章的结局分布与第 5 章的张力曲线。
- 想拿结论去说服人:看第 9.1 节 的一致性残差与第 15.1 节 的指标定义总表——这两节把「这份叙事的体验形状」变成可核对的数。
- 想先看图 / 跑真路径:直接跳到第 10 章
用纯
xml.etree把张力曲线与路径热力图出成 SVG,再看第 12.2 节 的 W 层批跑。 - 接着卷一/卷二:卷一《叙事结构与可执行规格》给出规格,卷二《叙事引擎与分支系统》给出内核,本卷接住「度量」。三卷的接口见第 16.1 节 的装配检查表。
可运行性总表(各章档位分布)
| 章 | 章名 | 主档位 |
|---|---|---|
| 1 | 玩家路径 | S |
| 2 | 路径的采样与覆盖 | S + B |
| 3 | 结局分布与分支平衡 | S + B |
| 4 | 选择的权重与后果可见性 | S |
| 5 | 叙事张力与释放 | S + D |
| 6 | 情感曲线的代理指标 | S |
| 7 | 叙事节奏与密度 | S |
| 8 | 好感度曲线的形状 | S + B |
| 9 | 叙事一致性的回归 | S + B |
| 10 | 曲线的可视化 | S |
| 11 | 模拟玩家与路径策略 | S + B + D |
| 12 | 叙事规格的可玩性测试 | W + S + X |
| 13 | 从度量反推设计 | S + B |
| 14 | 叙事规格演化与回归 | S + B |
| 15 | 叙事质量的代理指标总表 | S + D |
| 16 | 装配:三卷总收官 | S + P + W + B |
S = Python 侧度量与曲线,P = TypeScript 度量内核(node --test),W = 浏览器路径批跑(Playwright 无头真玩),D = 指标定义与清单,X = 需真实环境、命令行不可复现(正文逐处标注),B = 参数扰动与相变扫描(软告警不硬失败)。
完整项目配套
下载 inkwell 三卷累计教学工程
。包内含当前正文、完整规格和源码来源/哈希、明确命令清单及 verify.py。解压后准备依赖并运行 python3 verify.py --browser;完整 BFS 与指标扫描需要较长时间,未运行或超时不会计作通过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。