13.3 一致性校验

一致性 = 声明域与使用域的差集。S 层脚本输出一张一致性报告表:14 项逐项给出声明数、使用数、两个方向的差集与判定。实测「使用未声明」为 0;真实发现是 lin/hai 声明了 affinity 却不在 state.vars、d_lt_2 有 line_when 无 line_else,以及 7 个 flag 与 fog_glass 被写却从未被读。

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)
lin40可达不可达不可达
hai60可达可达不可达

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 叙事失调的形式化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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