13.3 一致性校验
《游戏设计》卷一《玩法机制与可执行规格》 校验的是数值与规则的一致性:守恒式两边对不对得上、声明的资源集合和用到的资源集合差在哪。本节把同一套「声明 vs 使用」的对齐思路搬到结构与条件上:13.1 看了图的形状,13.2 看了条件成不成立,本节看的是一个更基本的前提——声明的东西和实际用的东西,是不是同一套。两套共享「整数不变量 + 确定性」的工程纪律,判据则各自不同。
一致性的定义:声明域与使用域
一致性在本套里只有一个可操作的定义:某个东西被声明了,却没被使用;或被使用了,却没被声明。两者之差就是一致性残差。两个方向的差集性质完全不同:
| 方向 | 名字 | 性质 | 处理 |
|---|---|---|---|
| 声明未用 | 冗余 | 可接受:可能是为将来预留,也可能是历史遗留 | 报告,不判失败 |
| 使用未声明 | 缺陷 | 危险:引用了一个不存在的名字,运行时才会炸 | 判失败 |
把两者分开是关键。对称差把「多声明了」和「用错了」混成一堆,判读时会失去重点。本节的报告表因此给出两列差集,而不是一列。
S 层:一致性报告表
脚本复用 sim/na_00_spec_check.py 的 load_spec,把全书各处的「声明 vs 使用」逐项对齐,输出一张 14 行的报告表。
#!/usr/bin/env python3
# file: sim/na_13_3_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_13_3_consistency.py
"""卷一 13.3 一致性校验(S 层):声明域与使用域的差集。
一致性在本套里只有一个可操作的定义:**某个东西被声明了,却没被使用;或被使用了,
却没被声明**。两者之差就是一致性残差。本脚本把全书各处的「声明 vs 使用」逐项对齐,
输出一张一致性报告表(每项:声明数 / 使用数 / 差集 / 判定)。
复用 sim/na_00_spec_check.py 的 load_spec。产出 out/narr13_consistency.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as base # noqa: E402 复用 load_spec
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out/narr13_consistency.json")
def main() -> int:
spec = base.load_spec()
st = spec["state"]
rows: list[dict] = []
def row(item: str, declared, used, verdict: str | None = None) -> None:
d, u = set(declared), set(used)
unused = sorted(d - u) # 声明了却没被使用
undeclared = sorted(u - d) # 使用了却没被声明
if verdict is None:
if undeclared:
verdict = "使用域 > 声明域"
elif unused:
verdict = "声明域 > 使用域"
else:
verdict = "一致"
rows.append({"item": item, "declared": len(d), "used": len(u),
"unused": unused, "undeclared": undeclared, "verdict": verdict})
# 1) 好感度:角色声明 affinity 与 state.vars 的差集
row("好感度(characters.affinity vs state.vars)",
[c["id"] for c in spec["characters"]], st["vars"], "声明域 > 状态域")
# 2) flag / var / item:声明域 vs 被效果写入的域
written_flag, written_var, written_item = set(), set(), set()
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
eff = c.get("effects") or {}
written_flag |= set(eff.get("set") or {})
written_var |= set(eff.get("add") or {})
written_item |= set(eff.get("grant") or {})
row("flag(state.flags vs set 写入)", st["flags"], written_flag)
row("好感度变量(state.vars vs add 写入)", st["vars"], written_var)
row("物品(state.items vs grant 写入)", st["items"], written_item)
# 3) 台词条件:有 line_when 的节点 vs 同时给了 line_else 的节点(欠声明)
with_when = [n["id"] for n in spec["dialogue"] if n.get("line_when")]
with_else = [n["id"] for n in spec["dialogue"] if n.get("line_else")]
row("台词条件(line_when vs line_else)", with_when, with_else, "欠声明")
# 4) 条件引用的名字是否都已声明
refs: dict[str, set] = {"flag": set(), "aff": set(), "item": set()}
def walk(c) -> None:
if not c:
return
k = c[0]
if k in ("flag", "aff", "item"):
refs[k].add(c[1])
return
if k in ("and", "or"):
walk(c[1])
walk(c[2])
elif k == "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"]:
walk(e.get("when"))
for loc in spec["world"]["locations"]:
walk(loc.get("unlock_when"))
for e in spec["endings"]:
walk(e.get("when"))
row("条件引用 flag", st["flags"], refs["flag"])
row("条件引用 aff", st["vars"], refs["aff"])
row("条件引用 item", st["items"], refs["item"])
# 5) goto 目标:所有选项的目标 vs 存在的节点 id
ids = {n["id"] for n in spec["dialogue"]}
gotos = [c["goto"] for n in spec["dialogue"] for c in (n.get("choices") or [])]
row("goto 目标(选项 vs 节点 id)", ids, gotos)
# 6) 结局:endings 声明 vs 带 ending 标记的节点
end_nodes = [n["id"] for n in spec["dialogue"] if n.get("ending")]
end_decl = [e["id"] for e in spec["endings"]]
row("结局(endings vs 结局节点)", end_decl, end_nodes)
# 7) 场景节点的 location 引用 vs world.locations
row("场景 location(scene_graph vs world.locations)",
[loc["id"] for loc in spec["world"]["locations"]],
[n["location"] for n in spec["scene_graph"]["nodes"]])
# 8) 说话人:对话里出现的 speaker vs characters
speakers = {n["speaker"] for n in spec["dialogue"] if n["speaker"] != "narrator"}
row("说话人(dialogue.speaker vs characters)",
[c["id"] for c in spec["characters"]], speakers)
# 9) 场景边两端 vs 场景节点
scene_ids = {n["id"] for n in spec["scene_graph"]["nodes"]}
edge_ends = [e["from"] for e in spec["scene_graph"]["edges"]] + \
[e["to"] for e in spec["scene_graph"]["edges"]]
row("场景边两端(edges vs scene nodes)", scene_ids, edge_ends)
# 10) 张力越界:声明域 [1,5] vs 实际取值
tensions = [b["tension"] for b in spec["beats"]]
bad_tension = [t for t in tensions if not (1 <= t <= 5)]
rows.append({"item": "节拍张力(beats.tension vs [1,5])", "declared": 16,
"used": len(tensions), "unused": bad_tension, "undeclared": [],
"verdict": "一致" if not bad_tension else "越界"})
residual_rows = [r for r in rows if r["unused"] or r["undeclared"]]
undeclared_rows = [r for r in rows if r["undeclared"]]
report = {"rows": rows, "residual_rows": len(residual_rows),
"undeclared_rows": len(undeclared_rows)}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
def fmt(xs: list) -> str:
return "∅" if not xs else ",".join(xs)
def dwidth(s: str) -> int:
return sum(2 if ord(ch) > 0x2E80 else 1 for ch in s)
def pad(s: str, width: int) -> str:
return s + " " * max(1, width - dwidth(s))
print(pad("检查项", 44) + pad("声明", 8) + pad("使用", 8)
+ pad("声明未用", 34) + "使用未声明")
for r in rows:
print(pad(r["item"], 44) + pad(str(r["declared"]), 8) + pad(str(r["used"]), 8)
+ pad(fmt(r["unused"]), 34) + fmt(r["undeclared"]))
print(f"\n残差非零的行 {len(residual_rows)} / {len(rows)};"
f"其中「使用域 > 声明域」的行 {len(undeclared_rows)}")
print("OK na_13_3_consistency:一致性报告已产出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_13_3_consistency.py:
检查项 声明 使用 声明未用 使用未声明
好感度(characters.affinity vs state.vars) 6 4 hai,lin ∅
flag(state.flags vs set 写入) 18 18 ∅ ∅
好感度变量(state.vars vs add 写入) 4 4 ∅ ∅
物品(state.items vs grant 写入) 9 9 ∅ ∅
台词条件(line_when vs line_else) 2 1 d_lt_2 ∅
条件引用 flag 18 11 has_lantern,hui_trusts,met_hai,met_lin,met_mu,promised_hai,shen_trusts ∅
条件引用 aff 4 4 ∅ ∅
条件引用 item 9 8 fog_glass ∅
goto 目标(选项 vs 节点 id) 48 47 d_pier_1 ∅
结局(endings vs 结局节点) 5 5 ∅ ∅
场景 location(scene_graph vs world.locations) 14 14 ∅ ∅
说话人(dialogue.speaker vs characters) 6 6 ∅ ∅
场景边两端(edges vs scene nodes) 14 14 ∅ ∅
节拍张力(beats.tension vs [1,5]) 16 16 ∅ ∅
残差非零的行 5 / 14;其中「使用域 > 声明域」的行 0
OK na_13_3_consistency:一致性报告已产出
逐行判读:五条非零与九条全零
「使用未声明」全零。 14 行里没有一行出现「使用了却没声明」——aff 引用、goto 目标、结局节点、场景地点、说话人、场景边两端全部落在各自的声明域内。这是最关键的一条:所有「用错了名字」类的缺陷为 0。
lin / hai 声明了 affinity 却不在 state.vars。 六个角色都带 affinity 段,但 state.vars 只列了四个。这两个角色在对话里是真实出场的(lin 是守望者哨所的说话人,hai 是码头与鱼市街的说话人),却从未被任何 add 写过好感度。这是规格的一处真实特征,不是笔误,不修——它带来一个可推的后果:他们的好感度被钉死在初值上,门限结构性不可达:
| 角色 | 初值 | cold(30) | warm(60) | trust(85) |
|---|---|---|---|---|
lin | 40 | 可达 | 不可达 | 不可达 |
hai | 60 | 可达 | 可达 | 不可达 |
d_lt_2 有 line_when 没有 line_else。 它是唯一一处欠声明:节点声明了条件台词,却没给条件为假时的替代台词。渲染器按声明规则回落到 line——不会崩,但语义上是「没写全」。修法是补 line_else,不是改台词。与上一条不同,这一处该修,只是它属于规格层面的欠声明,不是运行缺陷。
7 个 flag 与 fog_glass 被写却从未被读。 has_lantern、hui_trusts、met_hai、met_lin、met_mu、promised_hai、shen_trusts 这七个旗标被 set 写过,但没有任何条件引用它们;物品 fog_glass 被 grant 给过,也从没被条件引用。这类是「声明未用」的冗余——不判失败。它们可能是为将来预留,也可能是某条线改过之后留下的痕迹。
d_pier_1 从不是任何选项的目标。 它是起点,没有入边是它的定义。报告表把它列在「声明未用」里,是如实呈现,不是缺陷。
两类残差的区别
这张表真正的用处是把两类残差分开。「声明未用」是冗余:多了一个没人读的旗标、多了一段没人走的分支,代价是维护面积,不是正确性。「使用未声明」是缺陷:引用一个不存在的名字,静态检查之外只能等运行时抛错。本案例前者有 5 行、后者 0 行——说明规格在「不引用不存在的东西」这一条上是干净的,只是有几处冗余和一处欠声明。
一致性残差是代理指标,不是质量评分:残差 0 只说明「声明与使用对得上」,不说明故事好不好。它只用于相对比较——改一版规格之后,残差是升了还是降了。
自检表
| 检查项 | 合格标准 |
|---|---|
| 差集分两方向 | 报告表分别给「声明未用」与「使用未声明」,不合并 |
| 冗余不判失败 | 「声明未用」只报告,退出码不受它影响 |
| 缺陷判失败 | 「使用未声明」出现即视为缺陷 |
| 真实特征如实呈现 | lin/hai 与 d_lt_2 按实测发现写,不顺手改规格 |
| 门限后果可推 | 从未被写的角色,其好感度钉在初值,门限可达性可算 |
三层校验到此收齐:结构(13.1)问「接不接得上」,语义(13.2)问「成不成立」,一致性(13.3)问「声明与使用对不对得上」。三者都过,只能说明这份规格自洽——它离「故事讲得好」还有很远的距离。第 14 章把「叙事失调」这个概念形式化,给它一个可算的残差。
阅读导航:上一节:13.2 语义校验:可满足性与死条件 · 下一节:14.1 叙事失调的形式化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。