附录 A 对话树格式、条件文法与数据集索引

《叙事策划与叙事工艺》卷二《叙事引擎与分支系统》附录 A:把全卷 16 章 48 节散落的对话树节点与选项字段、嵌套数组条件文法与四类效果算子汇成索引表,给出本卷 S/P/W/D 四层共 43 个仿真脚本、31 个内核模块与 37 份产物的实盘清点,并附一段可跑的 TypeScript 对话树格式检查器——它连同三个注入负例证明格式约定不是纸面条文。

卷二 48 节各自给了字段、文法、算子与一份可跑脚本,但散在 16 章里。这份附录把它们汇成三张表——对话树格式、条件文法与效果算子、本卷落盘的数据集——并给出 S / P / W 三层的复现命令。全卷只读同一份真源 config/story_spec.json;下面的表由 sim/ne_appendix_index.py 直接从真源与工作区生成,不是手抄的。

字段与算子一律以整数为准:违反「好感度与张力一律整数、随机一律自实现可播种 PRNG、config/ 禁浮点」三条纪律里的任何一条,跨语言对照必然崩。

一、这份索引解决什么

本附录不做判定,只做清点:节点与选项各有哪些字段、条件能写成哪些形态而执行时实际用到多少、效果算子的用量分布、本卷落了多少个可跑文件。判定在第 13 章的三方一致与第 16 章的装配检查表里——清点告诉你引擎要用什么,校验告诉你这些东西自不自洽。卷一附录 A 清点「规格里有什么字段」,本附录清点「引擎执行时要用到哪些字段」,同一份真源的两侧。

二、卷内文件命名约定

对象约定例
节<前缀><章:02d>-<节>-<英文 kebab>.mdnarr-sys-13-2-same-origin-different-host.md
目录篇 / 附录<前缀>-toc.md / <前缀>-appendix-a.mdnarr-sys-toc.md
S 层脚本sim/<工具链前缀><章:02d>_<节>_<英文>.pysim/ne_15_2_cost.py
P 层源码core/src/inkwell/<工具链前缀><章:02d>_<英文>.tscore/src/inkwell/ne_07_3_control.ts
P 层测试core/test/<工具链前缀><章:02d>_<英文>.test.tscore/test/ne_14_1_property.test.ts
W 层脚本web/build_<前缀><章:02d>.mjs / web/verify_<前缀><章:02d>.mjsweb/verify_ne13.mjs
D 层配置config/<前缀><章:02d>_<英文>.jsonconfig/ne_12_2_plural_rules.json
产物out/*.json / *.csv / *.svg / shots/*.pngout/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必填跳转目标,必须存在于节点集合内

四类效果算子(实测用量):

算子写入域实测用量语义
setflag56赋值(幂等)
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

排错按「由内到外」走,因为外层的失败几乎总是内层的失败被延迟暴露:

  1. 先看真源——ne_appendix_index.py 不通,说明 config/story_spec.json 结构坏了,后面全部无从谈起。
  2. 再看格式——ne_appendix_dialogue_tree.test.ts 报 FAIL,说明对话树的节点与选项不自洽;这时跑穷举是浪费,穷举只会把矛盾带进更大的状态空间。
  3. 再看一致——web/verify_ne13.mjs 的三宿主摘要对不上,说明 Python 与 TypeScript 对同一份规格的理解已经分叉,应先修内核而不是调摘要。
  4. 最后看规模——前三条全绿而第 15 章的耗时仍异常,才轮到查遍历实现(键的表示、去重策略、内存回收)。

本附录的清单里,revoke 用量 0 与 or 形态用量 0 只报告不判 FAIL——「声明了但没用」是余量,「用了但没声明」才是缺陷。这条区分贯穿全卷:第 13 章的三方一致、第 14 章的属性测试、第 16 章的装配检查表,用的都是同一个口径。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长