附录 A 叙事规格字段表、条件文法与数据集索引

《叙事策划与叙事工艺》卷一《叙事结构与可执行规格》附录 A:把全卷 16 章 48 节散落的叙事规格九段字段、嵌套数组条件文法与十一条整数不变量汇成索引表,给出卷内文件命名约定、S/P/W 三层完整复现命令与排错顺序,并附一段 Python 与 TypeScript 逐位一致的跨语言状态摘要,作为「文本是规格的渲染」的可验证落点。

卷一 48 节各自给了字段、文法、不变量与一份可跑脚本,但散在 16 章里。这份附录把规格九段字段汇成一张表、把条件文法与算子汇成一张表、把十一条不变量汇成一张表、把实体与数据集汇成一张表,最后给出 S / P / W 三层的完整复现命令。

全卷只读同一份真源 config/story_spec.json。这份索引本身也是跑出来的——下面的表由 sim/na_appendix_index.py 从真源直接生成,不是手抄的。

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

一、这份索引解决什么

本附录不做判定,只做清点:规格里到底有哪些段、每段有哪些字段、条件能写成哪些形态而实际用了多少、案例里有多少实体、怎么把全卷跑一遍、以及「跨语言一致」究竟对什么一致。判定在第 13 章的三层校验里。两者的分工是——清点告诉你规格里有什么,校验告诉你这些东西自不自洽。

二、卷内文件命名约定

对象约定例
节<前缀><章:02d>-<节>-<英文 kebab>.mdnarr-arch-11-3-dialogue-tree-traversal-validation.md
目录篇 / 附录<前缀>-toc.md / <前缀>-appendix-a.mdnarr-arch-toc.md
S 层脚本sim/<工具链前缀><章:02d>_<节>_<英文>.pysim/na_11_3_dialogue_tree.py
P 层测试core/test/<工具链前缀><章:02d>_<英文>.test.tscore/test/na_02_digest.test.ts
W 层原型web/build_<工具链前缀><章:02d>.mjsweb/build_na12.mjs
产物out/*.json / *.csv / *.svg / shots/*.pngout/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[].name5/5
I7场景边两端均存在两端在 scene_graph.nodes 内16/16
I8条件引用均已声明flag→state.flags、aff→state.vars、item→state.itemsflag 10 / aff 4 / item 8
I9效果写入域合法set→flag、add→var、grant/revoke→item越界 0
I10声明但从未写入仅报告,不判 FAILflag [] / 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.pyTypeScript core/test/na_02_digest.test.ts
#039762262983976226298
#138486049313848604931
#211980294371198029437

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'

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

  1. 先看真源——na_appendix_index.py 不通,说明 config/story_spec.json 结构坏了,后面全部无从谈起。
  2. 再看不变量——na_appendix_invariants.py 报 FAIL,说明规格自相矛盾;这时候去跑穷举是浪费,穷举只会把矛盾带进更大的状态空间。
  3. 再看内核——node --test 失败说明 P 层与 S 层对同一份规格的理解已经分叉,此时跨语言摘要必然对不上,应先修内核而不是调摘要。
  4. 最后看摘要——前三条全绿而摘要仍不一致,才轮到查摘要算法本身(整数溢出、取模、有符号处理)。

na_appendix_invariants.py 的十一条里,I10 与 I11 只报告不判 FAIL——「声明了但没用」是余量,「用了但没声明」才是缺陷。这条区分贯穿全卷:第 13 章的三层校验、第 14 章的残差与告警,用的都是同一个口径。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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