16.3 交付卷二
独立游戏的叙事设计与分支对话 是叙事设计的总论,本节是它的工程收口。卷一交付的是结构:一份十段规格、一个确定性的状态内核、一组可复算的产物。卷二《叙事引擎与分支系统》接住「执行」,卷三《叙事体验与路径度量》接住「度量」。与 《游戏设计》卷一《玩法机制与可执行规格》 对照,两套书稿的交付形状可以并排看:那套交的是数值与规则 + 仿真,本卷交的是结构与条件 + 穷举——形状不同,纪律相同(整数化、确定性、容差声明)。
交付不是把文件打包过去,而是把接口说清楚:卷二要读哪些段、在哪些函数上扩展、拿哪些产物当回归基准、按什么判据验收。
交付清单:四类接口
脚本读规格与产物,逐项在磁盘上核对——段在顶层键里、函数在 engine.ts 里、产物在 out/ 下、判据在 metrics 里,缺一即非零退出。
#!/usr/bin/env python3
# file: sim/na_16_3_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_16_3_handoff.py
"""卷一 16.3 交付卷二:把交给卷二《叙事引擎与分支系统》的接口整理成清单并逐项自检。
交付四类:规格段 / 内核函数 / 仿真产物 / 判据与容差。
自检口径——每一项都要在磁盘上找得到:规格段在 story_spec.json 的顶层键里、
函数在 core/src/inkwell/engine.ts 的 `export function` 里、产物在 out/ 下、
判据在 spec.metrics 里。缺一即 FAIL 并非零退出。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
ENGINE = os.path.join(ROOT, "core/src/inkwell/engine.ts")
OUT = os.path.join(ROOT, "out")
SEGMENTS = ["meta", "world", "characters", "beats", "scene_graph",
"dialogue", "conditions", "state", "endings", "metrics"]
FUNCS = ["initState", "condOk", "applyEffects", "applyChoice",
"canonical", "digest", "replay", "samplePaths"]
ARTIFACTS = ["spec_check.json", "digest_py.json", "na16_rehearsal.json"]
CRITERIA = ["coverage_target_permille", "ending_entropy_min_milli_bits",
"affinity_mae_max", "tension_mae_max"]
def main() -> int:
spec = json.load(open(SPEC, encoding="utf-8"))
engine = open(ENGINE, encoding="utf-8").read()
fails: list[str] = []
seg_ok = [s for s in SEGMENTS if s in spec]
fn_ok = [f for f in FUNCS if f"export function {f}(" in engine]
art_ok = [a for a in ARTIFACTS if os.path.exists(os.path.join(OUT, a))]
crit_ok = [c for c in CRITERIA if c in spec["metrics"]]
for name, got, want in (("规格段", seg_ok, SEGMENTS), ("内核函数", fn_ok, FUNCS),
("仿真产物", art_ok, ARTIFACTS), ("判据容差", crit_ok, CRITERIA)):
miss = [x for x in want if x not in got]
if miss:
fails.append(f"{name}缺 {miss}")
print("交付清单(卷一《叙事结构与可执行规格》→ 卷二《叙事引擎与分支系统》)")
print(f"① 规格段 config/story_spec.json {len(seg_ok)}/{len(SEGMENTS)}")
print(" " + "、".join(seg_ok))
print(f"② 内核函数 core/src/inkwell/engine.ts {len(fn_ok)}/{len(FUNCS)}")
print(" " + "、".join(fn_ok))
print(f"③ 仿真产物 out/ {len(art_ok)}/{len(ARTIFACTS)}")
for a in art_ok:
sz = os.path.getsize(os.path.join(OUT, a))
print(f" {a:<24} {sz} B")
print(f"④ 判据容差 spec.metrics {len(crit_ok)}/{len(CRITERIA)}")
for c in crit_ok:
print(f" {c:<32} {spec['metrics'][c]}")
print(f"硬判据:整数不变量残差 = 0(见 sim/na_appendix_invariants.py)")
print(f"交付自检 {'PASS' if not fails else 'FAIL'}({len(seg_ok) + len(fn_ok) + len(art_ok) + len(crit_ok)}"
f"/{len(SEGMENTS) + len(FUNCS) + len(ARTIFACTS) + len(CRITERIA)})")
for f in fails:
print(f" FAIL {f}")
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_16_3_handoff.py:
交付清单(卷一《叙事结构与可执行规格》→ 卷二《叙事引擎与分支系统》)
① 规格段 config/story_spec.json 10/10
meta、world、characters、beats、scene_graph、dialogue、conditions、state、endings、metrics
② 内核函数 core/src/inkwell/engine.ts 8/8
initState、condOk、applyEffects、applyChoice、canonical、digest、replay、samplePaths
③ 仿真产物 out/ 3/3
spec_check.json 1033 B
digest_py.json 443368 B
na16_rehearsal.json 1032 B
④ 判据容差 spec.metrics 4/4
coverage_target_permille 850
ending_entropy_min_milli_bits 1500
affinity_mae_max 12
tension_mae_max 1
硬判据:整数不变量残差 = 0(见 sim/na_appendix_invariants.py)
交付自检 PASS(25/25)
四类全齐,25/25。
四类接口对应四种失败:规格段缺一段,卷二就得自己补一个字段定义,真源就裂了;函数缺一个,卷二就得重写一段逻辑,跨语言一致性随之失效;产物缺一个,卷二就没有可对照的基准;判据缺一条,验收就退化成「看着差不多」。清单脚本把这四种失败都变成非零退出——交付不合格时,它不会安静地过去。
交付的形状之所以是「清单 + 自检」而不是一份说明文档,是因为能被脚本核对的东西才叫接口。文档会过期,脚本不会:它每次跑都在磁盘上重新确认一遍段在不在、函数在不在、产物在不在。
为什么是四类,而不是一整套 API
清单只列四类,是因为卷二真正需要的是四件性质不同的东西,而不是一串函数签名:
| 类 | 性质 | 缺了会怎样 |
|---|---|---|
| 规格段 | 数据 | 卷二没有真源可读 |
| 内核函数 | 代码 | 卷二得重写求值与推进 |
| 仿真产物 | 基准 | 卷二没有回归对照 |
| 判据与容差 | 契约 | 验收没有可断言的边界 |
数据、代码、基准、契约——四类缺一,卷二都接不住。反过来,多给反而有害:把实现细节也一并交过去,卷二就会被锁死在卷一的内部结构上,扩展空间被压没了。接口要刚好够用。
规格段:卷二读哪些、为什么
| 段 | 内容 | 卷二怎么用 |
|---|---|---|
meta | 标题 / 幕数 / act_names / start / scale | 起点与规模口径 |
world | 派系 / 地点 / lore / rules | 地点解锁条件 |
characters | 目标 / 恐惧 / 秘密 / affinity | 好感度初值与阈值 |
beats | 节拍 / 幕 / 场景 / tension | 张力曲线的输入 |
scene_graph | 节点 / 边 / 进入条件 | 场景可达性 |
dialogue | 48 节点 / 115 选项 | 状态机的转移表 |
conditions | 条件文法声明 | 求值器的规范 |
state | 18 flag / 4 变量 / 9 物品 | 状态域 |
endings | 5 结局与守卫条件 | 终局判定 |
metrics | 曲线 / 覆盖率 / 熵 / MAE 上限 | 回归判据 |
meta 与 metrics 两段容易被当成「附属信息」,其实它们承担了卷二最需要的两个口径:从哪里开始(start)与到什么程度算合格(metrics 里的五个阈值)。
内核函数:卷二在什么之上扩展
| 函数 | 签名要点 | 卷二在它之上做什么 |
|---|---|---|
initState | (spec) → State | 存档的初始快照 |
condOk | (cond, state) → bool | 求值器的复杂度与可满足性 |
applyEffects | (effects, state) → State | 幂等性与差量 |
applyChoice | (spec, state, cid) → State | 门禁与状态推进 |
canonical | (state) → string | 跨语言摘要的规范串 |
digest | (state) → int | FNV-1a 32 位,只对状态 |
replay | (spec, choices) → State | 确定性回放 |
samplePaths | (spec, n, seed) → string[][] | 采样上界与剪枝 |
这八个函数是卷二的原点,不是它的上限。卷二会往上加存档、原型、性能三件事,但不改这八个的语义——一改,跨语言 digest 就不再逐位相同,卷一的地基随之失效。
仿真产物:作为回归基准
| 产物 | 内容 | 卷二怎么用 |
|---|---|---|
spec_check.json | 穷举报告:覆盖率、不可达、结局分布 | 回归基准 |
digest_py.json | 500 条路径的跨语言摘要 | digest 对照 |
na16_rehearsal.json | 预演路径与 digest | 联调基准 |
三个产物都是可重算的:只要规格不变、种子不变,重跑一次就得到同一份内容。这正是它们能当基准的原因。
判据与容差
| 判据 | 值 | 类型 |
|---|---|---|
coverage_target_permille | 850 | 下限 |
ending_entropy_min_milli_bits | 1500 | 下限 |
affinity_mae_max | 12 | 上限 |
tension_mae_max | 1 | 上限 |
| 整数不变量残差 | 0 | 恒等(硬判据) |
前四条是容差:允许偏离,只要不越界。最后一条是恒等:不设容差,必须为 0。卷二把同一组判据用在引擎的回归上。
验收口径的迁移
卷一用这五个阈值验收自己;卷二把它们原样搬过去验收引擎,只是被度量的对象从「规格」换成了「引擎跑出来的产物」:
| 判据 | 卷一度量 | 卷二度量 |
|---|---|---|
| 覆盖率 ≥ 850‰ | 规格的可达边 | 引擎的实现覆盖 |
| 结局熵 ≥ 1500 | 规格的分布 | 引擎的分布 |
| 好感度 MAE ≤ 12 | 规格对目标曲线 | 引擎对规格 |
| 张力 MAE ≤ 1 | 规格对目标曲线 | 引擎对规格 |
| 整数不变量残差 == 0 | 规格的静态断言 | 引擎的运行时断言 |
同一个阈值,换一个被度量对象,就是卷二的第一批回归测试。这不是巧合,而是「一份规格三处消费」的直接推论:判据跟着规格走,不跟着实现走。
常见误解
- 「交付 = 复制文件」:文件复制过去没有用,接口不清楚,卷二还是要猜。
- 「判据是卷一的私事」:判据是契约,卷二按同一组阈值验收,才有跨卷可比性。
- 「产物是缓存,可以丢」:产物是基准,丢了就没有回归对照,改动无法判定是否退化。
- 「函数越多越完整」:交付的是最小充分集,多给会锁死扩展空间。
- 「规格改一改没关系」:规格是唯一真源,改一处要同时重跑三处消费,改动成本远高于它看起来的样子。
卷二接住什么、不重做什么
| 类 | 卷二接住 | 卷二不重做 |
|---|---|---|
| 规格段 | 直接读 config/story_spec.json | 不改字段定义 |
| 内核函数 | 在 engine.ts 上扩展 | 不重写条件文法与效果算子 |
| 产物 | 复算 out/*.json 作为回归基准 | 不重跑 BFS 穷举 |
边界声明
- 卷一给一份文件,不是一份文档:
config/story_spec.json是唯一真源,卷二不得另立真源。 - 卷一给函数签名,不给实现细节:八个函数是接口,卷二在其上扩展,不改语义。
- 卷一给判据与容差:覆盖率 ≥ 850‰、结局熵 ≥ 1500、好感度 MAE ≤ 12、张力 MAE ≤ 1、整数不变量残差 == 0。
- 不重做的三件事:重写条件文法、重算穷举、重新定义 digest 口径——这三件都是地基,动它们会同时破坏三处消费的一致性。
- 不改九段的字段语义:卷二可以新增旁挂文件(存档格式、性能参数),但
story_spec.json九段的字段含义由卷一冻结。 - 不动种子与 PRNG:
20261016与 Lehmer MINSTD 是跨语言采样的共同前提,改了就复现不出同一批路径。
卷二如何起步
拿到这份交付之后,卷二的第一周大致是这五步:
- 读
config/story_spec.json,用initState建初始状态,确认起点与meta.start一致。 - 复算
out/digest_py.json,确认自己的canonical与digest实现与卷一逐位相同。 - 复算
out/na16_rehearsal.json里的路径,确认replay能还原同一条选择序列。 - 把四个阈值搬进引擎的回归测试,跑通
metrics里的判据。 - 只有在以上五步全绿之后,才开始加存档、原型、性能这些扩展。
先对齐,再扩展——顺序反了,后面所有对照都会失效。
交付自检表
| 检查项 | 合格标准 |
|---|---|
| 规格段齐备 | 10 段全在顶层键里 |
| 函数齐备 | 8 个导出函数在 engine.ts 里 |
| 产物齐备 | 3 个 JSON 在 out/ 下 |
| 判据齐备 | 4 个阈值在 metrics 里 |
| 交付自检 | 25/25 PASS |
卷一的收口
三节下来,卷一做了三件事:把故事写成结构(16.1 的装配检查表证明它自洽)、把结构跑成消费(16.2 的预演证明三处同源)、把消费交给下一卷(16.3 的清单证明接口清楚)。卷二在这份规格上建引擎,卷三在引擎的产物上做度量——三卷共用一份真源,这就是「一份叙事规格,三处消费」的全部含义。
阅读导航:上一节:16.2 卷一消费的预演 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。