16.1 叙事规格的装配检查表
独立游戏的叙事设计与分支对话 把叙事设计的经验写成了一篇散文:怎么让分支有意义、怎么让选择有后果、怎么让人物立得住。那是经验。本卷做的是把同一批问题形式化——写成字段、写成条件、写成可判定的性质。十五节之后零件都在了:规格字段(第 2 章)、世界与角色(第 3–5 章)、节拍与场景图(第 6–7 章)、分支与条件(第 8–9 章)、好感度与对话树(第 10–11 章)、校验与一致性(第 13–14 章)。本节不引入新概念,只做一件事:把零件装配起来,并逐项检查装配结果。整卷的章节地图见目录 。
「装配完成」不能是一句感觉,它必须是十项能跑出来的断言——每一项都返回真或假,不依赖任何人点头。
十项检查与它们的判据
| # | 检查项 | 判据 | 口径 |
|---|---|---|---|
| C1 | 九段字段齐备 | world…metrics 九段全在 | 顶层键存在性 |
| C2 | 引用完整 | goto 目标、场景边两端、条件引用的名字都存在 | 名字解析 |
| C3 | 写入域合法 | set→flag、add→好感度、grant/revoke→物品 | 命名空间 |
| C4 | 整数不变量残差 0 | 好感度初值 ∈ [0,100]、区间 = [0,100]、tension ∈ [1,5]、id 唯一 | 静态断言 |
| C5 | 规模对账 | meta.scale 的声明 == 实测 | 计数比对 |
| C6 | 结局节点与声明一致 | 5 个结局的 id 与名都对得上 | 双口径 |
| C7 | 无死条件 | 每个带条件的选项都至少被走通一次 | 穷举 |
| C8 | 分支覆盖率达目标 | 覆盖率 ≥ coverage_target_permille | 穷举 |
| C9 | 结局全部可达 | 5 个结局在穷举里全部命中 | 穷举 |
| C10 | 无卡死路径 | 采样里「无选项且非结局」的路径 = 0 | 有界采样 |
十项里有四项(C7–C10)要真的把状态空间走一遍,前六项是静态的、秒级可判。这个分层不是巧合:静态项保证规格自洽,动态项保证这张图真的走得通。两者都过,才叫装配完成。
S 层:把清单跑成一张 PASS/FAIL 表
脚本复用第 2 章与第 13 章的共享入口 sim/na_00_spec_check.py——条件求值、效果应用、状态键都从那里拿。检查表不重复实现任何判定逻辑,只负责把十项组织成一张表。
#!/usr/bin/env python3
# file: sim/na_16_1_assembly_checklist.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_16_1_assembly_checklist.py
"""卷一 16.1 装配检查表:把清单逐项跑成可断言的检查,输出 PASS/FAIL 表。
C1 九段字段齐备 C2 引用完整 C3 写入域合法 C4 整数不变量残差 0 C5 scale 对账
C6 结局节点与声明一致 C7 无死条件 C8 分支覆盖率达目标 C9 结局全部可达 C10 无卡死路径
复用共享入口 sim/na_00_spec_check.py 的求值 / 效果 / 状态键,不重复实现。
"""
from __future__ import annotations
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as C # noqa: E402
SECTIONS = ["world", "characters", "beats", "scene_graph", "dialogue",
"conditions", "state", "endings", "metrics"]
SAMPLE_N, SEED = 20000, 20261016
def prep(spec: dict):
"""给共享入口设好状态键的位序;返回节点索引与初始状态。"""
C.VAR_ORDER = sorted(spec["state"]["vars"])
C.FLAG_ORDER = sorted(spec["state"]["flags"])
C.ITEM_ORDER = sorted(spec["state"]["items"])
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in C.VAR_ORDER}
init = {"aff": aff, "flags": {f: 0 for f in C.FLAG_ORDER}, "items": {i: 0 for i in C.ITEM_ORDER}}
return {n["id"]: n for n in spec["dialogue"]}, init
def bfs(spec: dict, by_id: dict, init: dict):
"""完整枚举可达状态;返回覆盖到的 (节点, 选项)、节点集、结局命中。"""
start = spec["meta"]["start"]
seen, frontier = {C.state_key(start, init)}, [(start, init)]
edges, nodes = set(), set()
hits = {e["name"]: 0 for e in spec["endings"]}
while frontier:
nxt = []
for node, st in frontier:
nodes.add(node)
if by_id[node].get("ending"):
hits[by_id[node]["ending"]] += 1
continue
for c in (by_id[node].get("choices") or []):
if not C.eval_cond(c.get("when"), st):
continue
edges.add((node, c["id"]))
st2 = C.apply_effects(c.get("effects") or {}, st)
k = C.state_key(c["goto"], st2)
if k not in seen:
seen.add(k)
nxt.append((c["goto"], st2))
frontier = nxt
return edges, nodes, hits
def sample_dead_ends(spec: dict, by_id: dict, init: dict) -> int:
"""有界采样:返回「无选项且非结局」的路径数。"""
rng, dead = SEED, 0
for _ in range(SAMPLE_N):
st, node = {k: dict(v) for k, v in init.items()}, spec["meta"]["start"]
for _step in range(200):
if by_id[node].get("ending"):
break
opts = [c for c in (by_id[node].get("choices") or []) if C.eval_cond(c.get("when"), st)]
if not opts:
dead += 1
break
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
st, node = C.apply_effects(c.get("effects") or {}, st), c["goto"]
else:
dead += 1
return dead
def main() -> int:
spec = C.load_spec()
st = spec["state"]
by_id, init = prep(spec)
all_ch = {(n["id"], c["id"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
cond_ch = {(n["id"], c["id"]) for n in spec["dialogue"]
for c in (n.get("choices") or []) if c.get("when")}
snodes = {x["id"] for x in spec["scene_graph"]["nodes"]}
refs: dict[str, set] = {}
def walk(c) -> None:
if not c:
return
if c[0] in ("flag", "aff", "item"):
refs.setdefault(c[0], set()).add(c[1])
elif c[0] in ("and", "or"):
walk(c[1]), walk(c[2])
elif c[0] == "not":
walk(c[1])
for n in spec["dialogue"]:
walk(n.get("line_when"))
for c in (n.get("choices") or []):
walk(c.get("when"))
for e in spec["scene_graph"]["edges"] + spec["endings"]:
walk(e.get("when"))
bad_write = []
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
e = c.get("effects") or {}
bad_write += [k for k in (e.get("set") or {}) if k not in set(st["flags"])]
bad_write += [k for k in (e.get("add") or {}) if k not in set(st["vars"])]
bad_write += [k for k in list(e.get("grant") or {}) + list(e.get("revoke") or {})
if k not in set(st["items"])]
res = sum(1 for ch in spec["characters"]
if not (0 <= ch["affinity"]["init"] <= 100) or list(ch["affinity"]["range"]) != [0, 100])
res += sum(1 for b in spec["beats"] if not (1 <= b["tension"] <= 5))
for seg in (spec["dialogue"], spec["beats"], spec["endings"], spec["characters"]):
ids = [x["id"] for x in seg]
res += 1 if len(ids) != len(set(ids)) else 0
actual = {"scenes": len(spec["scene_graph"]["nodes"]), "characters": len(spec["characters"]),
"dialogue_nodes": len(spec["dialogue"]), "choices": len(all_ch),
"flags": len(st["flags"]), "affinity_vars": len(st["vars"]),
"items": len(st["items"]), "factions": len(spec["world"]["factions"]),
"endings": len(spec["endings"])}
end_nodes = {n["id"] for n in spec["dialogue"] if n.get("ending")}
end_names = {n["ending"] for n in spec["dialogue"] if n.get("ending")}
bad_ref = (refs.get("flag", set()) - set(st["flags"])) | (refs.get("aff", set()) - set(st["vars"]))
bad_ref |= refs.get("item", set()) - set(st["items"])
edges, nodes, hits = bfs(spec, by_id, init)
cov = (len(edges) * 1000) // len(all_ch)
target = spec["metrics"]["coverage_target_permille"]
dead = sample_dead_ends(spec, by_id, init)
n_edge = len(spec["scene_graph"]["edges"])
goto_ok = not [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or [])
if c["goto"] not in by_id]
edge_ok = not [(e["from"], e["to"]) for e in spec["scene_graph"]["edges"]
if e["from"] not in snodes or e["to"] not in snodes]
scale_ok = all(spec["meta"]["scale"].get(k) == v for k, v in actual.items())
end_ok = (end_nodes == {e["id"] for e in spec["endings"]}
and end_names == {e["name"] for e in spec["endings"]})
rows = [
("C1 九段字段齐备", all(s in spec for s in SECTIONS), f"{len(SECTIONS)}/{len(SECTIONS)}(+ meta)"),
("C2 引用完整", goto_ok and edge_ok and not bad_ref,
f"goto {len(all_ch)}/{len(all_ch)}、场景边 {n_edge}/{n_edge}、条件引用 {sum(len(v) for v in refs.values())} 名"),
("C3 写入域合法", not bad_write, f"set/add/grant 越界 {len(bad_write)}"),
("C4 整数不变量残差 0", res == 0, f"残差 {res}"),
("C5 scale 对账", scale_ok, f"{len(actual)}/{len(actual)} 项一致"),
("C6 结局节点与声明一致", end_ok, f"{len(end_nodes)}/{len(spec['endings'])}(id 与名双口径)"),
("C7 无死条件", cond_ch <= edges, f"带条件选项 {len(cond_ch)}/{len(cond_ch)} 可达"),
("C8 分支覆盖率达目标", cov >= target, f"{cov}‰ ≥ {target}‰"),
("C9 结局全部可达", all(v > 0 for v in hits.values()),
f"{sum(1 for v in hits.values() if v > 0)}/{len(hits)}"),
("C10 无卡死路径", dead == 0, f"采样 {SAMPLE_N} 条,卡死 {dead}"),
]
print("装配检查表(《雾港夜航》config/story_spec.json)")
for name, ok, ev in rows:
print(f"{name:<22} {'PASS' if ok else 'FAIL':<5} {ev}")
passed = sum(1 for _, ok, _ in rows if ok)
print(f"装配结果 {'PASS' if passed == len(rows) else 'FAIL'}({passed}/{len(rows)})")
return 0 if passed == len(rows) else 1
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_16_1_assembly_checklist.py:
装配检查表(《雾港夜航》config/story_spec.json)
C1 九段字段齐备 PASS 9/9(+ meta)
C2 引用完整 PASS goto 115/115、场景边 16/16、条件引用 22 名
C3 写入域合法 PASS set/add/grant 越界 0
C4 整数不变量残差 0 PASS 残差 0
C5 scale 对账 PASS 9/9 项一致
C6 结局节点与声明一致 PASS 5/5(id 与名双口径)
C7 无死条件 PASS 带条件选项 34/34 可达
C8 分支覆盖率达目标 PASS 1000‰ ≥ 850‰
C9 结局全部可达 PASS 5/5
C10 无卡死路径 PASS 采样 20000 条,卡死 0
装配结果 PASS(10/10)
十项全 PASS,脚本 exit 0。
判读:这张表里哪几行是真的信息
前六项都是 PASS,但它们的性质不同。C1 与 C5 是存在性:九段字段在、声明规模与实测规模一致,说明这份规格没有被写残。C2、C3 是命名空间:goto 的 115 个目标全部命中、场景边两端都在、条件引用的 22 个名字全部已声明,写错名字的选项一条都没有。
C4 的「残差 0」是全书反复引用的那条硬判据:好感度初值落在 [0,100]、六名角色的声明区间都是 [0,100]、16 个节拍的 tension 都在 [1,5]、各段 id 无重复。它恒为 0 不是运气,是因为写入域(C3)先把能越界的地方堵死了。
真正需要看的是后面四项。C7 的 34/34 说明这份规格里没有死条件——34 个带条件的选项,每一个在穷举里都至少被走通一次。C8 的 1000‰ 说明 115 条对话边全部可达,远超 850‰ 的设计目标。C9 的 5/5 说明五个结局都能被走到,没有哪个结局是摆设。C10 的 0 说明没有一条路径会走进「既没有选项、也不是结局」的死胡同。
| 观察 | 数字 | 读法 |
|---|---|---|
| 覆盖率 1000‰ 高于目标 850‰ | 115/115 vs 850‰ | 这份规格的分支全部可达,留了余量 |
| 死条件 0 | 34/34 | 没有「写了条件却永远不成立」的选项 |
| 卡死 0 | 0/20000 | 每条采样路径都走到了结局或明确停止 |
| 静态残差 0 | 4 类断言 | 结构自洽先于可达性成立 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 检查项可断言 | 每一项返回真或假,不依赖人工判断 |
| 静态与动态分层 | 前六项秒级,后四项走状态空间 |
| 判据来自规格 | 阈值取自 metrics,不写死在脚本里 |
| 复用共享入口 | 求值 / 效果 / 状态键不重复实现 |
| 失败即非零退出 | 任一项 FAIL,脚本 exit 非 0 |
装配表是静态的「合格证」。但一张合格证不等于这份规格真的能被三处消费——下一节把卷一的三处消费在同一份规格上串一遍,把真实输出贴出来。
阅读导航:上一节:15.3 协作与版本管理 · 下一节:16.2 卷一消费的预演 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。