16.1 叙事规格的装配检查表

装配检查表把前面十五节的判定收成十项可断言的检查:九段字段齐备、引用完整、写入域合法、整数不变量残差 0、规模对账、结局节点一致、死条件 0、覆盖率达目标、结局全部可达、卡死路径 0。S 层脚本逐项跑出 PASS/FAIL 表,十项全绿才算装配完成。

16.1 叙事规格的装配检查表

独立游戏的叙事设计与分支对话 把叙事设计的经验写成了一篇散文:怎么让分支有意义、怎么让选择有后果、怎么让人物立得住。那是经验。本卷做的是把同一批问题形式化——写成字段、写成条件、写成可判定的性质。十五节之后零件都在了:规格字段(第 2 章)、世界与角色(第 3–5 章)、节拍与场景图(第 6–7 章)、分支与条件(第 8–9 章)、好感度与对话树(第 10–11 章)、校验与一致性(第 13–14 章)。本节不引入新概念,只做一件事:把零件装配起来,并逐项检查装配结果。整卷的章节地图见目录 。

「装配完成」不能是一句感觉,它必须是十项能跑出来的断言——每一项都返回真或假,不依赖任何人点头。

十项检查与它们的判据

#检查项判据口径
C1九段字段齐备world…metrics 九段全在顶层键存在性
C2引用完整goto 目标、场景边两端、条件引用的名字都存在名字解析
C3写入域合法set→flag、add→好感度、grant/revoke→物品命名空间
C4整数不变量残差 0好感度初值 ∈ [0,100]、区间 = [0,100]、tension ∈ [1,5]、id 唯一静态断言
C5规模对账meta.scale 的声明 == 实测计数比对
C6结局节点与声明一致5 个结局的 id 与名都对得上双口径
C7无死条件每个带条件的选项都至少被走通一次穷举
C8分支覆盖率达目标覆盖率 ≥ coverage_target_permille穷举
C9结局全部可达5 个结局在穷举里全部命中穷举
C10无卡死路径采样里「无选项且非结局」的路径 = 0有界采样

十项里有四项(C7–C10)要真的把状态空间走一遍,前六项是静态的、秒级可判。这个分层不是巧合:静态项保证规格自洽,动态项保证这张图真的走得通。两者都过,才叫装配完成。

S 层:把清单跑成一张 PASS/FAIL 表

脚本复用第 2 章与第 13 章的共享入口 sim/na_00_spec_check.py——条件求值、效果应用、状态键都从那里拿。检查表不重复实现任何判定逻辑,只负责把十项组织成一张表。

#!/usr/bin/env python3
# file: sim/na_16_1_assembly_checklist.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_16_1_assembly_checklist.py
"""卷一 16.1 装配检查表:把清单逐项跑成可断言的检查,输出 PASS/FAIL 表。

C1 九段字段齐备  C2 引用完整   C3 写入域合法   C4 整数不变量残差 0  C5 scale 对账
C6 结局节点与声明一致  C7 无死条件  C8 分支覆盖率达目标  C9 结局全部可达  C10 无卡死路径
复用共享入口 sim/na_00_spec_check.py 的求值 / 效果 / 状态键,不重复实现。
"""
from __future__ import annotations

import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as C  # noqa: E402

SECTIONS = ["world", "characters", "beats", "scene_graph", "dialogue",
            "conditions", "state", "endings", "metrics"]
SAMPLE_N, SEED = 20000, 20261016


def prep(spec: dict):
    """给共享入口设好状态键的位序;返回节点索引与初始状态。"""
    C.VAR_ORDER = sorted(spec["state"]["vars"])
    C.FLAG_ORDER = sorted(spec["state"]["flags"])
    C.ITEM_ORDER = sorted(spec["state"]["items"])
    aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
           for v in C.VAR_ORDER}
    init = {"aff": aff, "flags": {f: 0 for f in C.FLAG_ORDER}, "items": {i: 0 for i in C.ITEM_ORDER}}
    return {n["id"]: n for n in spec["dialogue"]}, init


def bfs(spec: dict, by_id: dict, init: dict):
    """完整枚举可达状态;返回覆盖到的 (节点, 选项)、节点集、结局命中。"""
    start = spec["meta"]["start"]
    seen, frontier = {C.state_key(start, init)}, [(start, init)]
    edges, nodes = set(), set()
    hits = {e["name"]: 0 for e in spec["endings"]}
    while frontier:
        nxt = []
        for node, st in frontier:
            nodes.add(node)
            if by_id[node].get("ending"):
                hits[by_id[node]["ending"]] += 1
                continue
            for c in (by_id[node].get("choices") or []):
                if not C.eval_cond(c.get("when"), st):
                    continue
                edges.add((node, c["id"]))
                st2 = C.apply_effects(c.get("effects") or {}, st)
                k = C.state_key(c["goto"], st2)
                if k not in seen:
                    seen.add(k)
                    nxt.append((c["goto"], st2))
        frontier = nxt
    return edges, nodes, hits


def sample_dead_ends(spec: dict, by_id: dict, init: dict) -> int:
    """有界采样:返回「无选项且非结局」的路径数。"""
    rng, dead = SEED, 0
    for _ in range(SAMPLE_N):
        st, node = {k: dict(v) for k, v in init.items()}, spec["meta"]["start"]
        for _step in range(200):
            if by_id[node].get("ending"):
                break
            opts = [c for c in (by_id[node].get("choices") or []) if C.eval_cond(c.get("when"), st)]
            if not opts:
                dead += 1
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            st, node = C.apply_effects(c.get("effects") or {}, st), c["goto"]
        else:
            dead += 1
    return dead


def main() -> int:
    spec = C.load_spec()
    st = spec["state"]
    by_id, init = prep(spec)
    all_ch = {(n["id"], c["id"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    cond_ch = {(n["id"], c["id"]) for n in spec["dialogue"]
               for c in (n.get("choices") or []) if c.get("when")}
    snodes = {x["id"] for x in spec["scene_graph"]["nodes"]}
    refs: dict[str, set] = {}

    def walk(c) -> None:
        if not c:
            return
        if c[0] in ("flag", "aff", "item"):
            refs.setdefault(c[0], set()).add(c[1])
        elif c[0] in ("and", "or"):
            walk(c[1]), walk(c[2])
        elif c[0] == "not":
            walk(c[1])

    for n in spec["dialogue"]:
        walk(n.get("line_when"))
        for c in (n.get("choices") or []):
            walk(c.get("when"))
    for e in spec["scene_graph"]["edges"] + spec["endings"]:
        walk(e.get("when"))

    bad_write = []
    for n in spec["dialogue"]:
        for c in (n.get("choices") or []):
            e = c.get("effects") or {}
            bad_write += [k for k in (e.get("set") or {}) if k not in set(st["flags"])]
            bad_write += [k for k in (e.get("add") or {}) if k not in set(st["vars"])]
            bad_write += [k for k in list(e.get("grant") or {}) + list(e.get("revoke") or {})
                          if k not in set(st["items"])]

    res = sum(1 for ch in spec["characters"]
              if not (0 <= ch["affinity"]["init"] <= 100) or list(ch["affinity"]["range"]) != [0, 100])
    res += sum(1 for b in spec["beats"] if not (1 <= b["tension"] <= 5))
    for seg in (spec["dialogue"], spec["beats"], spec["endings"], spec["characters"]):
        ids = [x["id"] for x in seg]
        res += 1 if len(ids) != len(set(ids)) else 0

    actual = {"scenes": len(spec["scene_graph"]["nodes"]), "characters": len(spec["characters"]),
              "dialogue_nodes": len(spec["dialogue"]), "choices": len(all_ch),
              "flags": len(st["flags"]), "affinity_vars": len(st["vars"]),
              "items": len(st["items"]), "factions": len(spec["world"]["factions"]),
              "endings": len(spec["endings"])}
    end_nodes = {n["id"] for n in spec["dialogue"] if n.get("ending")}
    end_names = {n["ending"] for n in spec["dialogue"] if n.get("ending")}
    bad_ref = (refs.get("flag", set()) - set(st["flags"])) | (refs.get("aff", set()) - set(st["vars"]))
    bad_ref |= refs.get("item", set()) - set(st["items"])

    edges, nodes, hits = bfs(spec, by_id, init)
    cov = (len(edges) * 1000) // len(all_ch)
    target = spec["metrics"]["coverage_target_permille"]
    dead = sample_dead_ends(spec, by_id, init)
    n_edge = len(spec["scene_graph"]["edges"])
    goto_ok = not [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or [])
                   if c["goto"] not in by_id]
    edge_ok = not [(e["from"], e["to"]) for e in spec["scene_graph"]["edges"]
                   if e["from"] not in snodes or e["to"] not in snodes]
    scale_ok = all(spec["meta"]["scale"].get(k) == v for k, v in actual.items())
    end_ok = (end_nodes == {e["id"] for e in spec["endings"]}
              and end_names == {e["name"] for e in spec["endings"]})

    rows = [
        ("C1  九段字段齐备", all(s in spec for s in SECTIONS), f"{len(SECTIONS)}/{len(SECTIONS)}(+ meta)"),
        ("C2  引用完整", goto_ok and edge_ok and not bad_ref,
         f"goto {len(all_ch)}/{len(all_ch)}、场景边 {n_edge}/{n_edge}、条件引用 {sum(len(v) for v in refs.values())} 名"),
        ("C3  写入域合法", not bad_write, f"set/add/grant 越界 {len(bad_write)}"),
        ("C4  整数不变量残差 0", res == 0, f"残差 {res}"),
        ("C5  scale 对账", scale_ok, f"{len(actual)}/{len(actual)} 项一致"),
        ("C6  结局节点与声明一致", end_ok, f"{len(end_nodes)}/{len(spec['endings'])}(id 与名双口径)"),
        ("C7  无死条件", cond_ch <= edges, f"带条件选项 {len(cond_ch)}/{len(cond_ch)} 可达"),
        ("C8  分支覆盖率达目标", cov >= target, f"{cov}‰ ≥ {target}‰"),
        ("C9  结局全部可达", all(v > 0 for v in hits.values()),
         f"{sum(1 for v in hits.values() if v > 0)}/{len(hits)}"),
        ("C10 无卡死路径", dead == 0, f"采样 {SAMPLE_N} 条,卡死 {dead}"),
    ]
    print("装配检查表(《雾港夜航》config/story_spec.json)")
    for name, ok, ev in rows:
        print(f"{name:<22} {'PASS' if ok else 'FAIL':<5} {ev}")
    passed = sum(1 for _, ok, _ in rows if ok)
    print(f"装配结果 {'PASS' if passed == len(rows) else 'FAIL'}({passed}/{len(rows)})")
    return 0 if passed == len(rows) else 1


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_16_1_assembly_checklist.py:

装配检查表(《雾港夜航》config/story_spec.json)
C1  九段字段齐备             PASS  9/9(+ meta)
C2  引用完整               PASS  goto 115/115、场景边 16/16、条件引用 22 名
C3  写入域合法              PASS  set/add/grant 越界 0
C4  整数不变量残差 0          PASS  残差 0
C5  scale 对账           PASS  9/9 项一致
C6  结局节点与声明一致          PASS  5/5(id 与名双口径)
C7  无死条件               PASS  带条件选项 34/34 可达
C8  分支覆盖率达目标           PASS  1000‰ ≥ 850‰
C9  结局全部可达             PASS  5/5
C10 无卡死路径              PASS  采样 20000 条,卡死 0
装配结果 PASS(10/10)

十项全 PASS,脚本 exit 0。

判读:这张表里哪几行是真的信息

前六项都是 PASS,但它们的性质不同。C1 与 C5 是存在性:九段字段在、声明规模与实测规模一致,说明这份规格没有被写残。C2、C3 是命名空间:goto 的 115 个目标全部命中、场景边两端都在、条件引用的 22 个名字全部已声明,写错名字的选项一条都没有。

C4 的「残差 0」是全书反复引用的那条硬判据:好感度初值落在 [0,100]、六名角色的声明区间都是 [0,100]、16 个节拍的 tension 都在 [1,5]、各段 id 无重复。它恒为 0 不是运气,是因为写入域(C3)先把能越界的地方堵死了。

真正需要看的是后面四项。C7 的 34/34 说明这份规格里没有死条件——34 个带条件的选项,每一个在穷举里都至少被走通一次。C8 的 1000‰ 说明 115 条对话边全部可达,远超 850‰ 的设计目标。C9 的 5/5 说明五个结局都能被走到,没有哪个结局是摆设。C10 的 0 说明没有一条路径会走进「既没有选项、也不是结局」的死胡同。

观察数字读法
覆盖率 1000‰ 高于目标 850‰115/115 vs 850‰这份规格的分支全部可达,留了余量
死条件 034/34没有「写了条件却永远不成立」的选项
卡死 00/20000每条采样路径都走到了结局或明确停止
静态残差 04 类断言结构自洽先于可达性成立

自检表

检查项合格标准
检查项可断言每一项返回真或假,不依赖人工判断
静态与动态分层前六项秒级,后四项走状态空间
判据来自规格阈值取自 metrics,不写死在脚本里
复用共享入口求值 / 效果 / 状态键不重复实现
失败即非零退出任一项 FAIL,脚本 exit 非 0

装配表是静态的「合格证」。但一张合格证不等于这份规格真的能被三处消费——下一节把卷一的三处消费在同一份规格上串一遍,把真实输出贴出来。

阅读导航:上一节:15.3 协作与版本管理 · 下一节:16.2 卷一消费的预演 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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