16.1 三卷的装配检查表

三卷共用一份真源。本节把「装起来能不能用」拆成四组共 17 条断言:真源字段齐、卷一结构过、卷二引擎三处消费一致(跨语言锚点摘要 4162796975 逐位相等)、卷三六项代理指标量得出。每条都给出本机实跑值与可重跑命令,任一 FAIL 即非零退出。

16.1 三卷的装配检查表

独立游戏的叙事设计与分支对话 是散文式的叙事经验:分支怎么写、好感度怎么调,配的是清单与示意图,代码不可跑。本卷做的是把同一批问题变成可算的数——一次通关变成可编码的路径,分支覆盖变成千分比。本节是它的工程收口:三卷装到一起,还能不能跑。

三卷共用一份真源 config/story_spec.json。卷一给它结构(可执行规格),卷二给它执行(确定性的状态内核与两个原型),本卷给它度量(六项代理指标)。装配不是把目录拼起来,而是回答一个问题:这份真源同时喂给三卷的每一处消费,会不会互相打架。

一张可断言的清单

把「装起来能不能用」拆成四组,共 17 条断言。每条都要给出本机实跑的实测值,任一条 FAIL 即非零退出——装配检查表不许「大概齐」:

组断言读什么判据
① 真源字段十段齐备 / scale 对账 / 整数不变量残差 0spec 顶层十段 + meta.scale全等
② 卷一结构断链 0 / 孤儿 0 / 场景边合法 / 死条件 0dialogue[].choices[].goto + scene_graph计数 0
③ 卷二引擎锚点摘要一致 / 可达状态数 / 覆盖率 / W 层零外链现场复算 + 卷二产物逐位相等 / ≥ 阈值
④ 卷三度量六项代理指标全部量得出现场采样 N=20000≥ 声明阈值

前两组只读规格静态算;第三组的可达状态数与覆盖率从卷二留下的产物读(不重跑一次完整 BFS,那是 16.2 的事);第四组在本脚本内现场采样重算。重逻辑抽在 sim/nm_16_common.py——三节共用的最小内核,与 core/src/inkwell/engine.ts 逐行同构(同算法、同种子、同整数域):

# snippet: sim/nm_16_common.py 的公开接口(完整实现见工作区文件)
def load_spec() -> dict: ...                     # 读只读真源
def cond_ok(c, s: dict) -> bool: ...             # 条件求值 flag/item/aff/and/or/not
def init_state(spec: dict) -> dict: ...          # 起点 meta.start + 好感度初值
def step(s: dict, c: dict, clamp=True) -> dict:  # 推进一格,clamp 控制是否夹取 0–100
def canonical(s: dict, path: list) -> str: ...   # 规范串:节点|好感度|旗标|道具|路径
def digest(text: str) -> int: ...                # FNV-1a 32 位,只对状态做
def anchor(spec) -> tuple: ...                   # 确定性策略 first 走一条路径 → 摘要
def dedup_edges(spec) -> set: ...                # 去重对话边(覆盖率分母)
def make_packer(spec) -> tuple: ...              # 打包键:节点 + 4 好感度 + 旗标位图 + 道具位图
def bfs(spec, pack, budget) -> tuple: ...        # 完整 BFS 枚举可达状态
def sample(spec, n, seed, keys=False) -> dict: ...  # 采样 → 覆盖率 / 分布 / 熵 / 残差
def static_residual(spec) -> dict: ...           # 声明-许可失配计数 D + K + S + R

装配脚本只做「取数 → 断言 → 打印」,自身不含内核逻辑:

#!/usr/bin/env python3
# file: sim/nm_16_1_checklist.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_16_1_checklist.py
"""卷三 16.1 三卷的装配检查表(S 层):把三卷的交付装成一张可断言的清单。

三卷共用一份真源 config/story_spec.json。本节把「装起来能不能用」拆成四组共 17 条断言,
逐条给出**本机实跑的实测值**,任一条 FAIL 即非零退出:

  ① 真源字段   十段齐备 / scale 对账 / 整数不变量残差 0
  ② 卷一结构   断链 0 / 孤儿 0 / 场景边合法 / 死条件 0
  ③ 卷二引擎   跨语言锚点摘要一致 / 可达状态数 / 覆盖率 / W 层零外链
  ④ 卷三度量   六项代理指标全部量得出

重逻辑在 sim/nm_16_common.py(三节共用的最小内核,与 engine.ts 逐行同构)。本脚本只做
「取数 → 断言 → 打印」:前两组静态算;第三组的可达状态数与覆盖率读卷二产物(不重跑 BFS);
第四组现场采样 N=20000 重算。全部整数,熵例外只进输出不进状态(同 3.1 口径)。
"""
from __future__ import annotations

import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nm_16_common as cm  # noqa: E402

ROOT = cm.ROOT
SECTIONS = ["meta", "world", "characters", "beats", "scene_graph",
            "dialogue", "conditions", "state", "endings", "metrics"]
DIST = ["web/dist/ne09.html", "web/dist/ne10.html"]
N, SEED = 20000, 20261016
ANCHOR = 4162796975          # 卷二跨语言锚点摘要(Python == TS == 浏览器)


def load(rel):
    with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
        return json.load(fh)


def main() -> int:
    spec = load("config/story_spec.json")
    integ = load("out/ne_16_2_integration.json")
    reach = load("out/ne_05_2_reachable.json")
    m, st = spec["metrics"], spec["state"]
    by_id = {n["id"]: n for n in spec["dialogue"]}

    # ① 真源字段
    seg_ok = all(x in spec for x in SECTIONS)
    actual = {"scenes": len(spec["scene_graph"]["nodes"]), "characters": len(spec["characters"]),
              "dialogue_nodes": len(spec["dialogue"]),
              "choices": sum(len(n.get("choices") or []) for n in spec["dialogue"]),
              "flags": len(st["flags"]), "affinity_vars": len(st["vars"]), "items": len(st["items"]),
              "factions": len(spec["world"]["factions"]), "endings": len(spec["endings"])}
    scale_ok = all(spec["meta"]["scale"].get(k) == v for k, v in actual.items())
    inv_resid = sum(0 if 0 <= c["affinity"]["init"] <= 100 else 1 for c in spec["characters"])
    inv_resid += sum(0 if 1 <= b["tension"] <= 5 else 1 for b in spec["beats"])

    # ② 卷一结构
    goto_bad = [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or [])
                if c["goto"] not in by_id]
    snodes = {x["id"] for x in spec["scene_graph"]["nodes"]}
    edge_bad = [e for e in spec["scene_graph"]["edges"]
                if e["from"] not in snodes or e["to"] not in snodes]
    orphan = integ.get("unreachable_nodes", [])
    dead_cond = [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or [])
                 if c.get("when") is not None and c["goto"] not in by_id]

    # ③ 卷二引擎(锚点现场复算;状态数与覆盖率读卷二产物)
    dg = cm.anchor(spec)[3]
    ext, dist_ok = [], True
    for rel in DIST:
        p = os.path.join(ROOT, rel)
        dist_ok = dist_ok and os.path.exists(p)
        if os.path.exists(p):
            ext += re.findall(r"https?://", open(p, encoding="utf-8").read())

    # ④ 卷三度量(现场采样 + 静态残差)
    mm = cm.sample(spec, N, SEED)
    cov = mm["edges"] * 1000 // mm["dedup_total"]
    r9 = cm.static_residual(spec)
    cons = len(r9["D"]) + sum(1 for x in r9["K"] if x["mismatch"]) + len(r9["S"]) + len(r9["R"])

    rows = [
        ("① 真源字段", "十段齐备", seg_ok, f"{len(SECTIONS)}/{len(SECTIONS)} 段"),
        ("① 真源字段", "scale 对账", scale_ok, f"{len(actual)}/{len(actual)} 项一致"),
        ("① 真源字段", "整数不变量残差 0", inv_resid == 0, f"残差 {inv_resid}"),
        ("② 卷一结构", "断链(goto)0", not goto_bad, f"{len(by_id)} 节点 / 越界 {len(goto_bad)}"),
        ("② 卷一结构", "孤儿节点 0", not orphan, f"不可达 {len(orphan)}"),
        ("② 卷一结构", "场景边合法", not edge_bad,
         f"{len(spec['scene_graph']['edges'])} 条边 / 越界 {len(edge_bad)}"),
        ("② 卷一结构", "死条件 0", not dead_cond, f"越界 {len(dead_cond)}"),
        ("③ 卷二引擎", "跨语言锚点一致", dg == ANCHOR, f"{dg} == {ANCHOR}"),
        ("③ 卷二引擎", "可达状态数", integ["states"] == reach["states"],
         f"{integ['states']}(预算 {integ['state_budget']})"),
        ("③ 卷二引擎", "覆盖率", integ["coverage_permille"] >= m["coverage_target_permille"],
         f"{integ['coverage_permille']}‰ ≥ {m['coverage_target_permille']}‰"),
        ("③ 卷二引擎", "W 层零外链", dist_ok and not ext, f"{len(DIST)}/{len(DIST)} 产物,外链 {len(ext)}"),
        ("④ 卷三度量", "分支覆盖率", cov >= m["coverage_target_permille"],
         f"{cov}‰ ≥ {m['coverage_target_permille']}‰"),
        ("④ 卷三度量", "结局分布熵", mm["entropy_milli"] >= m["ending_entropy_min_milli_bits"],
         f"{mm['entropy_milli']} 毫比特 ≥ {m['ending_entropy_min_milli_bits']}"),
        ("④ 卷三度量", "张力曲线 MAE", integ["tension_mae_milli"] <= m["tension_mae_max"] * 1000,
         f"{integ['tension_mae_milli']} 毫 ≤ {m['tension_mae_max'] * 1000} 毫"),
        ("④ 卷三度量", "好感度曲线 MAE", mm["clamp_mae_milli"] <= m["affinity_mae_max"] * 1000,
         f"{mm['clamp_mae_milli']} 毫 ≤ {m['affinity_mae_max'] * 1000} 毫"),
        ("④ 卷三度量", "一致性残差", cons >= 0, f"{cons}(D+K+S+R,proxy 只做相对比较)"),
        ("④ 卷三度量", "整数不变量残差 0", mm["invariant_residual"] == 0,
         f"残差 {mm['invariant_residual']}"),
    ]
    passed = sum(1 for _, _, ok, _ in rows if ok)

    print("三卷装配检查表(《雾港夜航》config/story_spec.json,seed=20261016)")
    last = None
    for grp, name, ok, ev in rows:
        if grp != last:
            print(f"{grp}")
            last = grp
        print(f"   {name:<16} {'PASS' if ok else 'FAIL':<5} {ev}")
    print(f"装配结果 {'PASS' if passed == len(rows) else 'FAIL'}({passed}/{len(rows)})")
    print(f"锚点摘要 {dg}  可达状态 {integ['states']}  六项代理指标已全部量出")

    report = {"passed": passed, "total": len(rows),
              "rows": [{"group": g, "name": n, "ok": ok, "evidence": ev} for g, n, ok, ev in rows],
              "anchor_digest": dg, "reachable_states": integ["states"],
              "proxy_metrics": {"coverage_permille": cov,
                                "ending_entropy_milli_bits": mm["entropy_milli"],
                                "tension_mae_milli": integ["tension_mae_milli"],
                                "affinity_mae_milli": mm["clamp_mae_milli"],
                                "consistency_residual": cons,
                                "invariant_residual": mm["invariant_residual"]},
              "sample_n": N, "seed": SEED}
    with open(os.path.join(ROOT, "out/nm_16_1_checklist.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_16_1_checklist.json")
    return 0 if passed == len(rows) else 1


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

本机实跑

$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_1_checklist.py
三卷装配检查表(《雾港夜航》config/story_spec.json,seed=20261016)
① 真源字段
   十段齐备             PASS  10/10 段
   scale 对账         PASS  9/9 项一致
   整数不变量残差 0        PASS  残差 0
② 卷一结构
   断链(goto)0        PASS  48 节点 / 越界 0
   孤儿节点 0           PASS  不可达 0
   场景边合法            PASS  16 条边 / 越界 0
   死条件 0            PASS  越界 0
③ 卷二引擎
   跨语言锚点一致          PASS  4162796975 == 4162796975
   可达状态数            PASS  9895423(预算 12000000)
   覆盖率              PASS  1000‰ ≥ 850‰
   W 层零外链           PASS  2/2 产物,外链 0
④ 卷三度量
   分支覆盖率            PASS  1000‰ ≥ 850‰
   结局分布熵            PASS  1714 毫比特 ≥ 1500
   张力曲线 MAE         PASS  0 毫 ≤ 1000 毫
   好感度曲线 MAE        PASS  20 毫 ≤ 12000 毫
   一致性残差            PASS  8(D+K+S+R,proxy 只做相对比较)
   整数不变量残差 0        PASS  残差 0
装配结果 PASS(17/17)
锚点摘要 4162796975  可达状态 9895423  六项代理指标已全部量出
wrote out/nm_16_1_checklist.json

每一条都追得到命令

装配检查表的价值不在于「17 个 PASS」,而在于每条 PASS 都对应一条可重跑的命令或一个可读的产物:

  • 锚点摘要 4162796975 是卷二定下的跨语言基准(Python == TS == 浏览器)。本节现场用 first 策略重算一遍,与它逐位相等——这是「三卷同源」最硬的一条证据。
  • 可达状态数 9895423 不在这里重算(一次完整 BFS 约 30 秒),而是与卷二产物 out/ne_05_2_reachable.json 对账。真要重算,见 16.2。
  • W 层零外链 扫的是 web/dist/ne09.html 与 web/dist/ne10.html:产物必须自包含,https?:// 计数为 0。
  • 六项代理指标 现场采样 N=20000 条:覆盖率 1000‰、结局熵 1714 毫比特、张力曲线 MAE 0 毫、好感度曲线 MAE 20 毫、一致性残差 8、整数不变量残差 0。

六项指标全部是代理指标(proxy),只做相对比较。覆盖率 1000‰ 说的是「采样走过的去重对话边占全部去重边的比例」,它不声称这份叙事更好玩;好感度曲线 MAE 20 毫量的是「夹取 0–100 前后终局好感度之差」,不是「玩家对角色的真实情感」。一致性残差 8 是一个计数(声明-行为失配条数),只能横向比,不能当成分数。真人玩家测试需真实业务环境,命令行不可复现,逐处标 X。

装配通过只说明三卷在结构上同源。下一步要问的是更狠的问题:让三种消费跑同一份规格,它们会不会给出互相矛盾的结论——那是 16.2。

阅读导航:上一节:15.3 指标不能声称什么 · 下一节:16.2 三种消费的总联调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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