卷二 48 节各自给了字段、文法、算子与一份可跑脚本,但散在 16 章里。这份附录把它们汇成三张表——对话树格式、条件文法与效果算子、本卷落盘的数据集——并给出 S / P / W 三层的复现命令。全卷只读同一份真源 config/story_spec.json;下面的表由 sim/ne_appendix_index.py 直接从真源与工作区生成,不是手抄的。
字段与算子一律以整数为准:违反「好感度与张力一律整数、随机一律自实现可播种 PRNG、config/ 禁浮点」三条纪律里的任何一条,跨语言对照必然崩。
一、这份索引解决什么
本附录不做判定,只做清点:节点与选项各有哪些字段、条件能写成哪些形态而执行时实际用到多少、效果算子的用量分布、本卷落了多少个可跑文件。判定在第 13 章的三方一致与第 16 章的装配检查表里——清点告诉你引擎要用什么,校验告诉你这些东西自不自洽。卷一附录 A 清点「规格里有什么字段」,本附录清点「引擎执行时要用到哪些字段」,同一份真源的两侧。
二、卷内文件命名约定
| 对象 | 约定 | 例 |
|---|---|---|
| 节 | <前缀><章:02d>-<节>-<英文 kebab>.md | narr-sys-13-2-same-origin-different-host.md |
| 目录篇 / 附录 | <前缀>-toc.md / <前缀>-appendix-a.md | narr-sys-toc.md |
| S 层脚本 | sim/<工具链前缀><章:02d>_<节>_<英文>.py | sim/ne_15_2_cost.py |
| P 层源码 | core/src/inkwell/<工具链前缀><章:02d>_<英文>.ts | core/src/inkwell/ne_07_3_control.ts |
| P 层测试 | core/test/<工具链前缀><章:02d>_<英文>.test.ts | core/test/ne_14_1_property.test.ts |
| W 层脚本 | web/build_<前缀><章:02d>.mjs / web/verify_<前缀><章:02d>.mjs | web/verify_ne13.mjs |
| D 层配置 | config/<前缀><章:02d>_<英文>.json | config/ne_12_2_plural_rules.json |
| 产物 | out/*.json / *.csv / *.svg / shots/*.png | out/ne_15_1_scale.svg |
三卷的工具链前缀依次是 na / ne / nm。前缀是跨卷不重名的唯一保证——三卷各有自己的章号序列,若共用前缀会在同一目录里静默覆盖。
三、索引生成器
#!/usr/bin/env python3
# file: sim/ne_appendix_index.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_appendix_index.py
"""附录 A 的索引生成器(执行侧):清点对话树格式、条件求值形态与本卷数据集。
与卷一 na_appendix_index.py 分工:那个清点「规格里有什么字段」,本脚本清点
「引擎执行时要用到哪些字段、哪些条件形态、以及本卷落了多少个可跑文件」。
只清点,不判定——判定在第 13 章的三方一致与第 16 章的装配检查表里。
"""
from __future__ import annotations
import glob
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..")
SPEC = os.path.join(ROOT, "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 inv(rel: str, pat: str) -> int:
return len(glob.glob(os.path.join(ROOT, rel, pat)))
def main() -> int:
with open(SPEC, encoding="utf-8") as fh:
spec = json.load(fh)
dl = spec["dialogue"]
nodes = len(dl)
with_choices = sum(1 for n in dl if n.get("choices"))
choices = sum(len(n.get("choices") or []) for n in dl)
gotos = {c["goto"] for n in dl for c in (n.get("choices") or [])}
with_effects = sum(1 for n in dl for c in (n.get("choices") or []) if c.get("effects"))
terminal = [n["id"] for n in dl if n.get("ending")]
print("== 对话树格式(执行侧要用到的字段) ==")
print(f"节点 {nodes} 带选项的节点 {with_choices} 选项 {choices} 唯一跳转目标 {len(gotos)}")
print(f"带 effects 的选项 {with_effects} 终局节点 {len(terminal)} {terminal}")
print(f"节点字段并集 {' '.join(sorted({k for n in dl for k in n}))}")
ck = sorted({k for n in dl for c in (n.get("choices") or []) for k in c})
print(f"选项字段并集 {' '.join(ck)}")
eff = {"set": 0, "add": 0, "grant": 0, "revoke": 0}
for n in dl:
for c in n.get("choices") or []:
for k in eff:
eff[k] += len((c.get("effects") or {}).get(k) or {})
print("\n== 效果算子用量 ==")
print(" " + " ".join(f"{k}={v}" for k, v in eff.items()) + f" 合计 {sum(eff.values())}")
conds = [n.get("line_when") for n in dl]
conds += [c.get("when") for n in dl 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 spec["world"]["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("== 条件形态用量 ==")
print(" " + " ".join(f"{k}={forms[k]}" for k in sorted(forms)))
st = spec["state"]
print("\n== 状态域(引擎初始化时逐项建表) ==")
print(f"flag {len(st['flags'])} 变量 {len(st['vars'])} 物品 {len(st['items'])}"
f" 声明位点合计 {len(st['flags']) + len(st['vars']) + len(st['items'])}")
print("\n== 本卷数据集索引(工作区实盘) ==")
rows = [("S 层脚本", "sim", "ne_*.py"), ("P 层源码", "core/src/inkwell", "ne_*.ts"),
("P 层测试", "core/test", "ne_*.test.ts"), ("W 层脚本", "web", "*ne*.mjs"),
("D 层配置", "config", "ne_*.json"), ("产物 JSON", "out", "ne_*.json"),
("产物 CSV", "out", "ne_*.csv"), ("产物 SVG", "out", "ne_*.svg"),
("产物 PNG", "out/shots", "ne*.png"), ("原型 HTML", "web/dist", "ne*.html")]
for label, rel, pat in rows:
print(f" {label:<10} {rel + '/' + pat:<22} {inv(rel, pat)}")
return 0
if __name__ == "__main__":
sys.exit(main())
真跑输出:
== 对话树格式(执行侧要用到的字段) ==
节点 48 带选项的节点 43 选项 115 唯一跳转目标 47
带 effects 的选项 99 终局节点 5 ['e_lit', 'e_dark', 'e_truth', 'e_silence', 'e_loss']
节点字段并集 choices ending id line line_else line_when scene speaker
选项字段并集 effects goto id text when
== 效果算子用量 ==
set=56 add=66 grant=16 revoke=0 合计 138
== 条件比较算子用量 ==
aff >= 15
== 条件形态用量 ==
aff=15 and=2 flag=25 item=19 not=2
== 状态域(引擎初始化时逐项建表) ==
flag 18 变量 4 物品 9 声明位点合计 31
== 本卷数据集索引(工作区实盘) ==
S 层脚本 sim/ne_*.py 43
P 层源码 core/src/inkwell/ne_*.ts 31
P 层测试 core/test/ne_*.test.ts 33
W 层脚本 web/*ne*.mjs 6
D 层配置 config/ne_*.json 5
产物 JSON out/ne_*.json 37
产物 CSV out/ne_*.csv 10
产物 SVG out/ne_*.svg 2
产物 PNG out/shots/ne*.png 4
原型 HTML web/dist/ne*.html 2
48 个节点里 43 个带选项、5 个是终局节点——节点集合被切成互不重叠的两半:非终局节点必有选项、终局节点必无选项,这是第四节第一条断言。115 条选项只指向 47 个唯一目标,说明大量选项汇聚到同一节点,正是第 6 章「汇流合法性」要判的东西。
四、对话树格式
节点与选项的字段并集由生成器实测得出:
| 对象 | 字段 | 是否必填 | 含义 |
|---|---|---|---|
| 节点 | id | 必填 | 节点唯一标识,也是跳转目标的名字空间 |
| 节点 | scene | 必填 | 所属场景,对应 scene_graph.nodes |
| 节点 | speaker | 必填 | 说话角色 id |
| 节点 | line | 必填 | 默认台词(条件不命中时的回落) |
| 节点 | line_when | 可空 | 条件台词的门(命中则改说 line_else) |
| 节点 | line_else | 可选 | 条件台词的另一支;缺省时回落 line |
| 节点 | choices | 二选一 | 非终局节点必有,终局节点必无 |
| 节点 | ending | 二选一 | 终局节点必填,取值是结局名(中文) |
| 选项 | id | 必填 | 选项唯一标识,也是 W 层注入用的 data-choice-id |
| 选项 | text | 必填 | 选项文本 |
| 选项 | when | 可空 | 可用条件;为 null 表示恒可用 |
| 选项 | effects | 必填 | 四类算子的字典 |
| 选项 | goto | 必填 | 跳转目标,必须存在于节点集合内 |
四类效果算子(实测用量):
| 算子 | 写入域 | 实测用量 | 语义 |
|---|---|---|---|
set | flag | 56 | 赋值(幂等) |
add | 好感度变量 | 66 | 增量(不幂等,钳到 [0,100]) |
grant | 物品 | 16 | 授予(幂等) |
revoke | 物品 | 0 | 收回(声明了但全篇未用) |
revoke 用量为 0,与卷一附录 A 里 or 形态用量为 0 同源:声明域大于使用域。判定口径不变——声明了但没用,报告即可;用了但没声明,才是 FAIL。
五、条件求值的形态
条件文法是嵌套数组而非字符串表达式:第一个元素是算子名,其余是参数。求值器按这个结构递归下降,不需要词法分析器。
| 形态 | 数组形态 | 求值语义 | 实测用量 |
|---|---|---|---|
flag | ["flag", "<flag_id>", 1] | 旗标已置位 | 25 |
item | ["item", "<item_id>", 1] | 持有某物品 | 19 |
aff | ["aff", "<变量>", "<op>", "<整数>"] | 好感度比较 | 15 |
and | ["and", "<cond>", "<cond>"] | 短路合取 | 2 |
not | ["not", "<cond>"] | 取反 | 2 |
or | ["or", "<cond>", "<cond>"] | 短路析取 | 0 |
比较算子声明了四种(>= < == !=),实测只用到 >= 一种,15 次。and 只有 2 次、or 一次没用——真实叙事规格的条件树远比其它树浅,这是「分支靠状态而非靠复杂布尔」的直接证据。
六、对话树格式检查器
第四节的表是纸面约定。core/src/inkwell/ne_appendix_dialogue_tree.ts 把它写成可执行断言:非终局节点必须有选项、goto 必须落在节点集合内、终局节点的结局名必须与 endings[].name 对得上。它只 import type 复用 engine.ts 的类型,不改公共内核。
# snippet: 一个正例加三个注入负例;负例必须 fail,否则检查器等于没写
node --test 'core/test/ne_appendix_dialogue_tree.test.ts'
真跑输出:
节点 48 选项 115
格式违规:节点 0 选项 0 跳转 0 结局 0
负例一 · 违规 ["d_pier_2: 非终局节点必须有选项"]
负例二 · 悬空跳转 ["d_pier_2/c_pier_2a -> d__ghost__"]
负例三 · 未声明结局 5 处,例 e_lit: ending 假结局 未声明
ℹ tests 4
ℹ pass 4
ℹ fail 0
负例三值得多说一句:终局节点的 ending 存的是结局名(中文),而 endings[].id 是结局 id(e_lit 一类)。把「名」直接和「id」比会必然 FAIL——那是判据写错,不是规格出错。凡是发现检查器「恒 FAIL」,先怀疑判据。
七、状态域与规模
状态域共 31 个声明位点(18 flag + 4 好感度变量 + 9 物品)。引擎初始化时逐项建表:flag 与物品初始为 0,好感度变量取角色的 affinity.init。这 31 个位点加上 48 个节点,构成状态空间的三根轴——第 5 章量的 9 895 423 个可达状态就是从这三根轴里剪出来的,而第 15 章量的是规模继续长大时四轴各自的变化率。
八、可运行性命令速查
# snippet: S 层用全路径解释器;for 循环逐个执行,避免只跑第一个文件
for f in sim/ne_*.py; do /opt/homebrew/bin/python3 "$f" || exit 1; done
# snippet: node --test 接收 glob 而非目录,必须加引号让 node 自己展开
node --test 'core/test/ne_*.test.ts'
# snippet: 复用全局 Playwright 必须经 createRequire,NODE_PATH 对 ESM import 无效
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne13.mjs
node --test 传裸目录会 MODULE_NOT_FOUND,必须让 node 自己展开 glob;而 NODE_PATH 对 ESM 的 import 不生效,复用全局安装的 Playwright 只能走 createRequire(import.meta.url)。
九、一键复现与排错顺序
# snippet: 由内到外——真源 → S 层清点 → P 层内核 → W 层原型
/opt/homebrew/bin/python3 sim/ne_appendix_index.py
node --test 'core/test/ne_appendix_dialogue_tree.test.ts'
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne13.mjs
排错按「由内到外」走,因为外层的失败几乎总是内层的失败被延迟暴露:
- 先看真源——
ne_appendix_index.py不通,说明config/story_spec.json结构坏了,后面全部无从谈起。 - 再看格式——
ne_appendix_dialogue_tree.test.ts报 FAIL,说明对话树的节点与选项不自洽;这时跑穷举是浪费,穷举只会把矛盾带进更大的状态空间。 - 再看一致——
web/verify_ne13.mjs的三宿主摘要对不上,说明 Python 与 TypeScript 对同一份规格的理解已经分叉,应先修内核而不是调摘要。 - 最后看规模——前三条全绿而第 15 章的耗时仍异常,才轮到查遍历实现(键的表示、去重策略、内存回收)。
本附录的清单里,revoke 用量 0 与 or 形态用量 0 只报告不判 FAIL——「声明了但没用」是余量,「用了但没声明」才是缺陷。这条区分贯穿全卷:第 13 章的三方一致、第 14 章的属性测试、第 16 章的装配检查表,用的都是同一个口径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。