16.1 三卷的装配检查表
独立游戏的叙事设计与分支对话 是散文式的叙事经验:分支怎么写、好感度怎么调,配的是清单与示意图,代码不可跑。本卷做的是把同一批问题变成可算的数——一次通关变成可编码的路径,分支覆盖变成千分比。本节是它的工程收口:三卷装到一起,还能不能跑。
三卷共用一份真源 config/story_spec.json。卷一给它结构(可执行规格),卷二给它执行(确定性的状态内核与两个原型),本卷给它度量(六项代理指标)。装配不是把目录拼起来,而是回答一个问题:这份真源同时喂给三卷的每一处消费,会不会互相打架。
一张可断言的清单
把「装起来能不能用」拆成四组,共 17 条断言。每条都要给出本机实跑的实测值,任一条 FAIL 即非零退出——装配检查表不许「大概齐」:
| 组 | 断言 | 读什么 | 判据 |
|---|---|---|---|
| ① 真源字段 | 十段齐备 / scale 对账 / 整数不变量残差 0 | spec 顶层十段 + meta.scale | 全等 |
| ② 卷一结构 | 断链 0 / 孤儿 0 / 场景边合法 / 死条件 0 | dialogue[].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 三种消费的总联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。