16.3 交付卷二

卷一交付给卷二《叙事引擎与分支系统》的四类接口:规格段、内核函数、仿真产物、判据与容差。本节用一份自检脚本逐项核对磁盘上的段、函数、产物与阈值,给出交付清单表与验收口径的迁移表,并声明边界——卷二接住执行,不重写条件文法、不重算穷举、不另立真源。

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节点 / 边 / 进入条件场景可达性
dialogue48 节点 / 115 选项状态机的转移表
conditions条件文法声明求值器的规范
state18 flag / 4 变量 / 9 物品状态域
endings5 结局与守卫条件终局判定
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) → intFNV-1a 32 位,只对状态
replay(spec, choices) → State确定性回放
samplePaths(spec, n, seed) → string[][]采样上界与剪枝

这八个函数是卷二的原点,不是它的上限。卷二会往上加存档、原型、性能三件事,但不改这八个的语义——一改,跨语言 digest 就不再逐位相同,卷一的地基随之失效。

仿真产物:作为回归基准

产物内容卷二怎么用
spec_check.json穷举报告:覆盖率、不可达、结局分布回归基准
digest_py.json500 条路径的跨语言摘要digest 对照
na16_rehearsal.json预演路径与 digest联调基准

三个产物都是可重算的:只要规格不变、种子不变,重跑一次就得到同一份内容。这正是它们能当基准的原因。

判据与容差

判据值类型
coverage_target_permille850下限
ending_entropy_min_milli_bits1500下限
affinity_mae_max12上限
tension_mae_max1上限
整数不变量残差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 是跨语言采样的共同前提,改了就复现不出同一批路径。

卷二如何起步

拿到这份交付之后,卷二的第一周大致是这五步:

  1. 读 config/story_spec.json,用 initState 建初始状态,确认起点与 meta.start 一致。
  2. 复算 out/digest_py.json,确认自己的 canonical 与 digest 实现与卷一逐位相同。
  3. 复算 out/na16_rehearsal.json 里的路径,确认 replay 能还原同一条选择序列。
  4. 把四个阈值搬进引擎的回归测试,跑通 metrics 里的判据。
  5. 只有在以上五步全绿之后,才开始加存档、原型、性能这些扩展。

先对齐,再扩展——顺序反了,后面所有对照都会失效。

交付自检表

检查项合格标准
规格段齐备10 段全在顶层键里
函数齐备8 个导出函数在 engine.ts 里
产物齐备3 个 JSON 在 out/ 下
判据齐备4 个阈值在 metrics 里
交付自检25/25 PASS

卷一的收口

三节下来,卷一做了三件事:把故事写成结构(16.1 的装配检查表证明它自洽)、把结构跑成消费(16.2 的预演证明三处同源)、把消费交给下一卷(16.3 的清单证明接口清楚)。卷二在这份规格上建引擎,卷三在引擎的产物上做度量——三卷共用一份真源,这就是「一份叙事规格,三处消费」的全部含义。

阅读导航:上一节:16.2 卷一消费的预演 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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