卷一 48 节各自给了字段、文法、不变量与一份可跑脚本,但散在 16 章里。这份附录把规格九段字段汇成一张表、把条件文法与算子汇成一张表、把十一条不变量汇成一张表、把实体与数据集汇成一张表,最后给出 S / P / W 三层的完整复现命令。
全卷只读同一份真源 config/story_spec.json。这份索引本身也是跑出来的——下面的表由 sim/na_appendix_index.py 从真源直接生成,不是手抄的。
字段与算子一律以整数为准:违反「好感度与张力一律整数、随机一律自实现可播种 PRNG、config/ 禁浮点」三条纪律里的任何一条,跨语言对照必然崩。
一、这份索引解决什么
本附录不做判定,只做清点:规格里到底有哪些段、每段有哪些字段、条件能写成哪些形态而实际用了多少、案例里有多少实体、怎么把全卷跑一遍、以及「跨语言一致」究竟对什么一致。判定在第 13 章的三层校验里。两者的分工是——清点告诉你规格里有什么,校验告诉你这些东西自不自洽。
二、卷内文件命名约定
| 对象 | 约定 | 例 |
|---|---|---|
| 节 | <前缀><章:02d>-<节>-<英文 kebab>.md | narr-arch-11-3-dialogue-tree-traversal-validation.md |
| 目录篇 / 附录 | <前缀>-toc.md / <前缀>-appendix-a.md | narr-arch-toc.md |
| S 层脚本 | sim/<工具链前缀><章:02d>_<节>_<英文>.py | sim/na_11_3_dialogue_tree.py |
| P 层测试 | core/test/<工具链前缀><章:02d>_<英文>.test.ts | core/test/na_02_digest.test.ts |
| W 层原型 | web/build_<工具链前缀><章:02d>.mjs | web/build_na12.mjs |
| 产物 | out/*.json / *.csv / *.svg / shots/*.png | out/narr07_scene_graph.svg |
三卷的工具链前缀依次是 na / ne / nm。前缀是跨卷不重名的唯一保证——卷二、卷三各有自己的章号序列,若共用前缀会在同一目录里静默覆盖。
三、索引生成器
#!/usr/bin/env python3
# file: sim/na_appendix_index.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_appendix_index.py
"""附录 A 的索引生成器:清点 story_spec.json 的九段结构、实体清单与算子用量。
与 na_appendix_invariants.py 分工:那个判「不变量成立与否」(残差必须为 0),本脚本只清点、不判定。
"""
from __future__ import annotations
import json
import os
import sys
SPEC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../config/story_spec.json")
def walk(c, ops, forms) -> None:
"""递归统计条件的比较算子与形态用量。"""
if not c:
return
k = c[0]
forms[k] = forms.get(k, 0) + 1
if k == "aff":
ops[c[2]] = ops.get(c[2], 0) + 1
elif k in ("and", "or"):
walk(c[1], ops, forms)
walk(c[2], ops, forms)
elif k == "not":
walk(c[1], ops, forms)
def main() -> int:
with open(SPEC, encoding="utf-8") as fh:
spec = json.load(fh)
print("== 九段结构 ==")
for name, val in spec.items():
kind = type(val).__name__
n = len(val) if isinstance(val, (list, dict)) else 1
keys = " ".join((val[0] if isinstance(val, list) else val).keys()) if n else ""
print(f"{name:<14} {kind:<5} {n:>3} {keys}")
st, w = spec["state"], spec["world"]
ents = [("派系", len(w["factions"])), ("地点", len(w["locations"])), ("lore", len(w["lore"])),
("规则", len(w["rules"])), ("角色", len(spec["characters"])), ("节拍", len(spec["beats"])),
("场景", len(spec["scene_graph"]["nodes"])), ("场景边", len(spec["scene_graph"]["edges"])),
("对话节点", len(spec["dialogue"])),
("选项", sum(len(n.get("choices") or []) for n in spec["dialogue"])),
("flag", len(st["flags"])), ("变量", len(st["vars"])), ("物品", len(st["items"])),
("结局", len(spec["endings"]))]
print("\n== 实体清单 ==")
for label, n in ents:
print(f"{label:<10} {n}")
conds = [n.get("line_when") for n in spec["dialogue"]]
conds += [c.get("when") for n in spec["dialogue"] for c in (n.get("choices") or [])]
conds += [e.get("when") for e in spec["scene_graph"]["edges"]]
conds += [e.get("when") for e in spec["endings"]]
conds += [n.get("enter_when") for n in spec["scene_graph"]["nodes"]]
conds += [loc.get("unlock_when") for loc in w["locations"]]
ops, forms = {}, {}
for c in conds:
walk(c, ops, forms)
print("\n== 条件比较算子用量 ==")
for k in sorted(ops):
print(f" aff {k:<3} {ops[k]}")
print("== 条件形态用量 ==")
for k in sorted(forms):
print(f" {k:<6} {forms[k]}")
return 0
if __name__ == "__main__":
sys.exit(main())
真跑输出:
== 九段结构 ==
meta dict 7 title code spec_version start acts act_names scale
world dict 4 factions locations lore rules
characters list 6 id name role faction goals fears secret affinity arc
beats list 16 id act scene tension summary
scene_graph dict 2 nodes edges
dialogue list 48 id scene speaker line line_when choices
conditions dict 4 grammar ops truthy forms
state dict 3 flags vars items
endings list 5 id name when
metrics dict 5 target_tension_curve coverage_target_permille ending_entropy_min_milli_bits affinity_mae_max tension_mae_max
九段里三段是列表(characters / beats / dialogue),六段是字典。列表段的字段名就是每条记录的键,字典段的键由本段自行定义。meta.scale 把设计目标写死成常量(14 场景 / 6 角色 / 48 对话节点 / 115 选项 / 5 结局),它和实测值必须逐项相等——这是第 16 章装配检查表的第 0 项。
四、条件文法与算子
文法是嵌套数组而非字符串表达式:第一个元素是算子名,其余是参数。
| 形态 | 数组形态 | 含义 |
|---|---|---|
flag | ["flag", "<flag_id>", 1] | 旗标已置位 |
aff | ["aff", "<角色 id>", "<op>", "<整数>"] | 好感度比较 |
item | ["item", "<item_id>", 1] | 持有某物品 |
and | ["and", "<cond>", "<cond>"] | 合取 |
or | ["or", "<cond>", "<cond>"] | 析取 |
not | ["not", "<cond>"] | 取反 |
这样条件天然是可解析的树,不需要写词法分析器,也不会出现括号优先级歧义。truthy 固定为整数 1——旗标与物品只判「有 / 无」,不判真值。
实测用量(由上面的脚本扫全部条件得出):
| 形态 | 声明 | 实测用量 | 说明 |
|---|---|---|---|
flag | ✅ | 25 | 用量最大的形态 |
item | ✅ | 19 | 持有判定 |
aff | ✅ | 15 | 好感度比较 |
and | ✅ | 2 | 复合条件 |
not | ✅ | 2 | 取反 |
or | ✅ | 0 | 声明了但全篇未用 |
比较算子声明了四种(>= < == !=),实测只用到 >= 一种,15 次;< / == / != 全部 0 次。
这不是缺陷,而是声明域大于使用域的又一类实例,与第 13 章一致性校验报告的两处(
lin/hai有affinity却不在state.vars、d_lt_2有line_when无line_else)同源。判定口径是:声明了但没用,报告即可;用了但没声明,才是 FAIL。 前者是余量,后者是缺陷。
五、不变量清单
| # | 不变量 | 判据 | 实测 |
|---|---|---|---|
| I1 | 好感度初值落在声明区间 | range[0] ≤ init ≤ range[1] | 6/6 |
| I2 | 好感度区间统一 [0,100] | 全部角色 range == [0,100] | 6/6 |
| I3 | 节拍张力 ∈ [1,5] | 整数且不越界 | 16/16 |
| I4 | 各段 id 唯一 | characters/dialogue/beats/endings/locations | 唯一 |
| I5 | 全部 goto 目标存在 | 目标在 dialogue[].id 内 | 115 条选项 |
| I6 | 结局节点与声明一致 | 节点 id ↔ endings[].id;ending 名 ↔ endings[].name | 5/5 |
| I7 | 场景边两端均存在 | 两端在 scene_graph.nodes 内 | 16/16 |
| I8 | 条件引用均已声明 | flag→state.flags、aff→state.vars、item→state.items | flag 10 / aff 4 / item 8 |
| I9 | 效果写入域合法 | set→flag、add→var、grant/revoke→item | 越界 0 |
| I10 | 声明但从未写入 | 仅报告,不判 FAIL | flag [] / var [] |
| I11 | 有 affinity 但不在 vars | 仅报告,不判 FAIL | ['lin', 'hai'] |
I1 好感度初值在声明区间内 6/6
I2 好感度区间统一 [0,100] 6/6
I3 节拍张力 ∈ [1,5] 16/16
I4 各段 id 唯一 characters/dialogue/beats/endings/locations
I5 全部 goto 目标存在 115 条选项
I6 结局节点与声明一致 5/5(id 与名双口径)
I7 场景边两端均存在 16/16
I8 条件引用均已声明 flag 10 / aff 4 / item 8
I9 效果写入域全部合法 越界 0
I10 声明但从未写入(仅报告) flag [] / var []
I11 有 affinity 但不在 vars ['lin', 'hai'](仅报告,见第 13 章)
不变量残差 0
OK na_appendix_invariants:11 条不变量全部通过
I6 是本清单里唯一有双口径的一条:dialogue[].ending 存的是结局名(中文),endings[].id 是结局 id(e_dark 一类),而对话节点的 id 才是与 endings[].id 对应的那一侧。把「名」直接和「id」比会必然 FAIL——那是判据写错,不是规格出错。凡是发现不变量「恒 FAIL」,先怀疑判据。
六、实体与数据集索引
派系 4 地点 14 lore 6 规则 4
角色 6 节拍 16 场景 14 场景边 16
对话节点 48 选项 115 flag 18 变量 4
物品 9 结局 5
这张表就是 meta.scale 的实测对照(scale 只列了九项,本表补齐了 lore / 规则 / 场景 / 场景边 / 节拍)。第 16 章的装配检查表第一项就要求这两组数字逐项相等——设计目标与实测规模一旦漂移,后面所有覆盖率与分布指标都失去参照。
七、可运行性命令速查
# snippet: S 层用全路径解释器;for 循环逐个执行,避免只跑第一个文件
for f in sim/na_appendix_*.py; do /opt/homebrew/bin/python3 "$f" || exit 1; done
# snippet: node --test 接收 glob 而非目录,必须加引号让 node 自己展开
node --test 'core/test/na_*.test.ts'
# snippet: 复用全局 Playwright 必须经 createRequire,NODE_PATH 对 ESM import 无效
node web/verify_na12.mjs
三条命令覆盖 S / P / W 三层。node --test 传裸目录会 MODULE_NOT_FOUND,必须让 node 自己展开 glob;而 NODE_PATH 对 ESM 的 import 不生效,复用全局安装的 Playwright 只能走 createRequire(import.meta.url)。
八、跨语言摘要的可验证点
「三处消费」要能被证伪,就得有一个跨语言可比的量。本卷取的是状态摘要:对同一组选择序列,Python 与 TypeScript 各自推进状态,再对状态算一个 32 位摘要。两侧前三条路径的实测结果:
| 路径 | Python sim/na_02_digest.py | TypeScript core/test/na_02_digest.test.ts |
|---|---|---|
| #0 | 3976226298 | 3976226298 |
| #1 | 3848604931 | 3848604931 |
| #2 | 1198029437 | 1198029437 |
500 条路径全部逐位一致,TS 侧 pass 4 / fail 0。
摘要只对状态做,不对文本做。 状态是整数与布尔(好感度、旗标、物品),两侧的整数运算逐位可复现;文本涉及字符编码与换行约定,把它纳入摘要只会引入与叙事无关的差异。所以全卷的跨语言主张一律写成「同一选择序列推进出的状态摘要逐位相同」,而不是任何关于文本的相等性陈述。
九、一键复现与排错顺序
# snippet: 由内到外——真源 → S 层清点 → P 层内核 → W 层原型
/opt/homebrew/bin/python3 sim/na_appendix_index.py
/opt/homebrew/bin/python3 sim/na_appendix_invariants.py
node --test 'core/test/na_*.test.ts'
排错按「由内到外」的顺序走,因为外层的失败几乎总是内层的失败被延迟暴露:
- 先看真源——
na_appendix_index.py不通,说明config/story_spec.json结构坏了,后面全部无从谈起。 - 再看不变量——
na_appendix_invariants.py报 FAIL,说明规格自相矛盾;这时候去跑穷举是浪费,穷举只会把矛盾带进更大的状态空间。 - 再看内核——
node --test失败说明 P 层与 S 层对同一份规格的理解已经分叉,此时跨语言摘要必然对不上,应先修内核而不是调摘要。 - 最后看摘要——前三条全绿而摘要仍不一致,才轮到查摘要算法本身(整数溢出、取模、有符号处理)。
na_appendix_invariants.py 的十一条里,I10 与 I11 只报告不判 FAIL——「声明了但没用」是余量,「用了但没声明」才是缺陷。这条区分贯穿全卷:第 13 章的三层校验、第 14 章的残差与告警,用的都是同一个口径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。