16.1 引擎的装配检查表
Phaser 里的分支叙事实现 做的是一个具体游戏的分支叙事:选项、条件、状态推进、结局判定都在一个可玩的工程里。本章是卷二的收口——把 15 章交付的引擎、状态空间工具、W 层原型与回归基线装配成一张可执行的检查表,并把接口交给卷三做度量。十五节之后零件都在了:状态机与求值(第 1–2 章)、效果与确定性(第 3 章)、回放与摘要(第 4 章)、状态空间与可达性(第 5–6 章)、剪枝与采样(第 7 章)、好感度不变量(第 8 章)、W 层原型(第 9–10 章)、存档(第 11 章)、文本表(第 12 章)、跨语言摘要(第 13 章)、性质测试与规模告警(第 14–15 章)。整卷的章节地图见目录 。
本节不引入新概念,只做一件事:把零件装起来,并逐项检查装配结果。「装配完成」不能是一句感觉,它必须是十二项能跑出来的断言——每一项都返回真或假,不依赖任何人点头。
十二项检查与它们的判据
| # | 检查项 | 判据 | 口径 |
|---|---|---|---|
| A1 | 规格可解析 | meta…metrics 十段全在 | 顶层键存在性 |
| A2 | 引用完整 | goto 目标、场景边两端、条件引用的名字都存在 | 名字解析 |
| A3 | 写入域合法 | set→flag、add→好感度、grant/revoke→物品 | 命名空间 |
| A4 | 整数不变量残差 0 | 好感度初值 ∈ [0,100]、区间 = [0,100]、tension ∈ [1,5]、id 唯一 | 静态断言 |
| A5 | 规模对账 | meta.scale 的声明 == 实测 | 计数比对 |
| A6 | BFS 与基线一致 | 深度 20 的逐层累计 == 第 5 章产物 | 逐层对账 |
| A7 | goto 全部存在 | 115 个选项的目标全部命中 | 名字解析 |
| A8 | 结局全部可达 | 5 个结局在采样里全部命中 | 有界采样 |
| A9 | 跨语言锚点一致 | 本脚本摘要 == 第 1 章产物的 first 策略摘要 | 整数摘要 |
| A10 | W 层零外部依赖 | 两个原型产物存在且不含外部 URL | 文本扫描 |
| A11 | 覆盖率达目标 | 覆盖率 ≥ coverage_target_permille | 有界采样 |
| A12 | 无卡死路径 | 采样里「无选项且非结局」的路径 = 0 | 有界采样 |
十二项里有四项(A8–A12)要真的把状态空间走一遍,前八项是静态的、秒级可判。这个分层不是巧合:静态项保证引擎自洽,动态项保证这张图真的走得通。两者都过,才叫装配完成。
S 层:把清单跑成一张 PASS/FAIL 表
脚本同时是本章的共享入口——条件求值、效果应用、状态键、规范串、摘要都定义在这里,16.2 与 16.3 直接 import 复用,不重复实现。检查表本身只负责把十二项组织成一张表。
#!/usr/bin/env python3
# file: sim/ne_16_1_assembly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_16_1_assembly.py
"""卷二 16.1 引擎的装配检查表:把全卷交付的零件装成一张可断言的检查表。
十二项检查,每项返回真或假,任一项 FAIL 即 exit 非 0。判据分两类来源:阈值
(覆盖率下限)从 config/story_spec.json 的 metrics 段读;基线(逐层累计可达状态、
确定性策略摘要)从 out/ 下既有产物读 —— 两者都不写死在脚本里。
本模块同时是本章的共享入口:16.2 / 16.3 复用这里的求值 / 效果 / 状态键 / 规范串。
"""
from __future__ import annotations
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SECTIONS = ["meta", "world", "characters", "beats", "scene_graph",
"dialogue", "conditions", "state", "endings", "metrics"]
DIST = ["web/dist/ne09.html", "web/dist/ne10.html"]
SAMPLE_N, SEED, BFS_LEVELS = 20000, 20261016, 20
AFF_LO, AFF_HI = 0, 100
def load(rel):
with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, s):
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if k == "and":
return eval_cond(c[1], s) and eval_cond(c[2], s)
if k == "or":
return eval_cond(c[1], s) or eval_cond(c[2], s)
return not eval_cond(c[1], s)
def apply_effects(eff, s):
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"aff": aff, "flags": flags, "items": items}
def init_state(spec):
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"aff": aff, "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def state_key(node, s, VARS, FLAGS, ITEMS):
"""去重键 = 节点 × 好感度 × flag 位 × 物品位(不含路径)。"""
return (node, tuple(s["aff"][v] for v in VARS), tuple(s["flags"][f] for f in FLAGS),
tuple(s["items"][i] for i in ITEMS))
def bfs_layers(spec, by_id, max_level):
"""逐层 BFS 到 max_level,返回每层累计状态数(与 5.2 基线同口径)。"""
VARS, FLAGS, ITEMS = (sorted(spec["state"][k]) for k in ("vars", "flags", "items"))
frontier = [state_key(spec["meta"]["start"], init_state(spec), VARS, FLAGS, ITEMS)]
seen, out, states = set(frontier), [], 0
for _ in range(max_level + 1):
out.append(states + len(frontier))
nxt = []
for node, av, fv, iv in frontier:
states += 1
st = {"aff": dict(zip(VARS, av)), "flags": dict(zip(FLAGS, fv)),
"items": dict(zip(ITEMS, iv))}
for c in (by_id[node].get("choices") or []):
if not eval_cond(c.get("when"), st):
continue
k = state_key(c["goto"], apply_effects(c.get("effects") or {}, st), VARS, FLAGS, ITEMS)
if k not in seen:
seen.add(k)
nxt.append(k)
frontier = nxt
return out
def sample(spec, by_id, n, seed):
"""有界采样:返回(结局分布, 走过的对话边, 卡死路径数)。"""
rng, dist, edges, dead = seed, {}, set(), 0
for _ in range(n):
s, node = init_state(spec), spec["meta"]["start"]
for _step in range(200):
cur = by_id[node]
if cur.get("ending"):
nm = cur["ending"]
break
opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), s)]
if not opts:
nm = "(卡死)"
break
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
edges.add((node, c["id"]))
s, node = apply_effects(c.get("effects") or {}, s), c["goto"]
else:
nm = "(未终局)"
dist[nm] = dist.get(nm, 0) + 1
dead += 1 if nm in ("(卡死)", "(未终局)") else 0
return dist, edges, dead
def canonical(node, s, path):
aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
return f"{node}|{aff}|{fl}|{it}|{'>'.join(path)}"
def digest(text):
h = 2166136261
for ch in text:
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
return h
def main():
spec = load("config/story_spec.json")
st, by_id = spec["state"], {n["id"]: n for n in spec["dialogue"]}
all_ch = {(n["id"], c["id"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
snodes = {x["id"] for x in spec["scene_graph"]["nodes"]}
ends = {e["name"] for e in spec["endings"]}
refs = {}
def walk(c):
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"])]
bad_ref = ((refs.get("flag", set()) - set(st["flags"])) | (refs.get("aff", set()) - set(st["vars"]))
| (refs.get("item", set()) - set(st["items"])))
goto_bad = [c["id"] for n in spec["dialogue"] for c in (n.get("choices") or []) if c["goto"] not in by_id]
edge_bad = [e for e in spec["scene_graph"]["edges"] if e["from"] not in snodes or e["to"] not in snodes]
resid = sum(1 for c in spec["characters"] if not (AFF_LO <= c["affinity"]["init"] <= AFF_HI)
or list(c["affinity"]["range"]) != [AFF_LO, AFF_HI])
resid += 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]
resid += 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"])}
layers = bfs_layers(spec, by_id, BFS_LEVELS)
base = [r["cumulative"] for r in load("out/ne_05_2_reachable.json")["layers"]]
dist, edges, dead = sample(spec, by_id, SAMPLE_N, SEED)
cov = (len(edges) * 1000) // len(all_ch)
target = spec["metrics"]["coverage_target_permille"]
s, node, trail, i = init_state(spec), spec["meta"]["start"], [spec["meta"]["start"]], 0
while i < 64 and not by_id[node].get("ending"):
opts = [c for c in (by_id[node].get("choices") or []) if eval_cond(c.get("when"), s)]
if not opts:
break
s, node = apply_effects(opts[0].get("effects") or {}, s), opts[0]["goto"]
trail.append(node)
i += 1
anchor = digest(canonical(node, s, trail))
ext, dist_ok = [], True
for rel in DIST:
p = os.path.join(ROOT, rel)
dist_ok = dist_ok and os.path.exists(p)
ext += re.findall(r"https?://", open(p, encoding="utf-8").read()) if os.path.exists(p) else []
rows = [
("A1 规格可解析", all(x in spec for x in SECTIONS), f"{len(SECTIONS)}/{len(SECTIONS)} 段"),
("A2 引用完整", not (bad_ref or edge_bad or goto_bad),
f"条件引用 {sum(len(v) for v in refs.values())} 名、场景边 {len(spec['scene_graph']['edges'])} 条"),
("A3 写入域合法", not bad_write, f"set/add/grant 越界 {len(bad_write)}"),
("A4 整数不变量残差 0", resid == 0, f"残差 {resid}"),
("A5 scale 对账", all(spec["meta"]["scale"].get(k) == v for k, v in actual.items()),
f"{len(actual)}/{len(actual)} 项一致"),
("A6 BFS 与基线一致", layers == base[:BFS_LEVELS + 1], f"深度 {BFS_LEVELS} 累计 {layers[-1]}"),
("A7 goto 全部存在", not goto_bad, f"{len(all_ch)}/{len(all_ch)}"),
("A8 结局全部可达", sum(1 for k in dist if k in ends) == len(ends),
f"{sum(1 for k in dist if k in ends)}/{len(ends)}"),
("A9 跨语言锚点一致", anchor == load("out/ne_01_3_py.json")[0]["digest"], f"锚点 {anchor}"),
("A10 W 层零外部依赖", dist_ok and not ext, f"{len(DIST)}/{len(DIST)} 产物,外链 {len(ext)}"),
("A11 覆盖率达目标", cov >= target, f"{cov}‰ ≥ {target}‰"),
("A12 无卡死路径", dead == 0, f"采样 {SAMPLE_N} 条,卡死 {dead}"),
]
print("装配检查表(《雾港夜航》config/story_spec.json)")
for name, ok, ev in rows:
print(f"{name:<20} {'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)})")
report = {"passed": passed, "total": len(rows),
"rows": [{"id": n.split()[0], "ok": ok, "evidence": ev} for n, ok, ev in rows],
"scale_actual": actual, "coverage_permille": cov, "dead_ends": dead,
"bfs_depth": BFS_LEVELS, "bfs_cumulative": layers[-1], "anchor_digest": anchor,
"ending_paths": dist, "sample_n": SAMPLE_N, "seed": SEED}
with open(os.path.join(ROOT, "out/ne_16_1_assembly.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=1)
fh.write("\n")
return 0 if passed == len(rows) else 1
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_16_1_assembly.py:
装配检查表(《雾港夜航》config/story_spec.json)
A1 规格可解析 PASS 10/10 段
A2 引用完整 PASS 条件引用 22 名、场景边 16 条
A3 写入域合法 PASS set/add/grant 越界 0
A4 整数不变量残差 0 PASS 残差 0
A5 scale 对账 PASS 9/9 项一致
A6 BFS 与基线一致 PASS 深度 20 累计 357845
A7 goto 全部存在 PASS 115/115
A8 结局全部可达 PASS 5/5
A9 跨语言锚点一致 PASS 锚点 4162796975
A10 W 层零外部依赖 PASS 2/2 产物,外链 0
A11 覆盖率达目标 PASS 1000‰ ≥ 850‰
A12 无卡死路径 PASS 采样 20000 条,卡死 0
装配结果 PASS(12/12)
十二项全 PASS,脚本 exit 0。
判读:这张表里哪几行是真的信息
前八项都是 PASS,但它们的性质不同。A1、A5 是存在性:十段字段在、声明规模与实测规模九项全对,说明这份规格没有被写残。A2、A3、A7 是命名空间:115 个选项的目标全部命中、16 条场景边两端都在、条件引用的 22 个名字全部已声明,写错名字的选项一条都没有。A4 的「残差 0」是全书反复引用的那条硬判据,它恒为 0 不是运气——是因为写入域(A3)先把能越界的地方堵死了。
真正需要看的是后四项。A6 的 357845 是深度 20 的累计可达状态数,逐层与第 5 章产物的 layers[].cumulative 对账一致,说明这次装配用的枚举口径与当时没有分歧。A9 的 4162796975 是确定性策略 first 走到底的整数摘要,与第 1 章产物里的同一个数逐位相同。A10 的 外链 0 说明两个原型产物是真正自包含的——不是靠 CDN 撑起来的页面。A11 的 1000‰ 说明 115 条对话边在采样里全部被走到,A12 的 0 说明没有一条采样路径会走进「既没有选项、也不是结局」的死胡同。
| 观察 | 数字 | 读法 |
|---|---|---|
| 覆盖率达上限 | 115/115 = 1000‰ | 分支全部可达,设计目标 850‰ 留了余量 |
| 跨语言锚点一致 | 4162796975 | Python 与第 1 章的 TS 实现在同一个数上 |
| W 层外链 0 | 0 | 原型产物自包含,可离线打开 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 检查项可断言 | 每一项返回真或假,不依赖人工判断 |
| 判据来自规格与产物 | 阈值取自 metrics,基线取自 out/,都不写死 |
| 失败即非零退出 | 任一项 FAIL,脚本 exit 非 0 |
| 可被复用 | 求值 / 效果 / 状态键 / 规范串 / 摘要作为共享入口导出 |
装配表是静态的「合格证」。但一张合格证不等于这份规格真的能被三处消费——下一节把穷举、执行、度量在同一份规格上串一遍,把真实输出贴出来。
阅读导航:上一节:15.3 规模告警 · 下一节:16.2 三处消费的联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。