9.3 条件与状态的闭包检验

闭包检验问三件事:条件引用的名字是否都声明过、是否都在某条可达路径上被写过、每个条件位点是否都在至少一个可达状态上取真。S 层脚本重跑一次完整枚举给出报告:52 个条件位点全部取真过,死条件 0 个;另附敏感性自检,证明检验器对注入的静态与动态缺陷都会报警。

9.3 条件与状态的闭包检验

独立游戏的叙事设计与分支对话 里那张条件清单是靠人核对的:哪个旗标忘了设、哪个分支永远走不到,只能靠通读。本节把「核对」变成一条可判定的检验——9.1 把条件定成了表达式树、9.2 把效果定成了带写入域的四算子,这两件事合起来,就足以让「有没有恒假的条件」成为一个能算出答案的问题。

四个问题

闭包检验问四件事,前三件问规格,第四件问检验器自己:

问题判据不成立意味着什么
① 引用闭包条件里引用的名字 ⊆ state 声明规格引用了一个不存在的名字
② 写入闭包被引用的名字在某条可达路径上被写过该条件恒假——它读到的永远是初值
③ 死条件每个条件位点在至少一个可达状态上取真这个条件写了,但永远不会成立
④ 敏感性注入的已知缺陷必须被抓到检验器失灵,报告里的「0」不可信

第四个问题不是客套。这套检验在规格的早期版本里抓到过 4 个恒假条件,修掉之后才轮到本节记录 0 这个结果。而一个永远报 0 的检验器和一份干净的规格,输出长得一模一样——所以脚本必须能证明自己对已知缺陷有反应。

位点口径与可达性

先说「位点」怎么数。52 个条件位点分布在五处:对话行 2、选项 34、场景边 5、地点解锁 7、结局 4。这里的 52 是位点数,而 9.1 数出的 58 是表达式树节点数——一个 ["and", A, B] 是 1 个位点、3 个节点,两个数都对,只是量的不是同一件事。再说可达性:对话行、选项、结局三类位点有明确语义,走到那个节点时求值;边与地点没有自己的推进语义,它们的条件按该场景全部可达状态这个上下文求值——这是本节为了让它们也进入检验而定的口径,写在这里以免被当成精确语义。

S 层:重跑闭包检验

脚本对真实规格做一次完整枚举(状态 = 节点 × 4 好感度 × 18 flag × 9 物品),沿途记录每个位点是否取真过、每个被引用的名字是否出现过非初值,然后注入两个已知缺陷复跑,确认检验器会报警。

#!/usr/bin/env python3
# file: sim/na_09_3_closure_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_09_3_closure_check.py
"""卷一 9.3 条件与状态的闭包检验(S 层)。

① 引用闭包 条件引用的名字都在 state 段声明过吗?
② 写入闭包 被引用的名字在某条可达路径上被写过吗?(否则该条件恒假)
③ 死条件   每个条件位点都在至少一个可达状态上取真吗?
④ 敏感性   注入已知缺陷(一个静态、一个动态),检验器都报得出来吗?
②③ 用一次完整 BFS 判定;边与地点的条件按「该场景全部可达状态」这个上下文求值。
产出 out/narr09_closure.json,真实规格上存在死条件即非零退出。
"""
from __future__ import annotations

import copy
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
OUT = os.path.join(ROOT, "out/narr09_closure.json")
BUDGET, SELFTEST_BUDGET = 12000000, 2000
AFF_LO, AFF_HI = 0, 100
SITE_KINDS = ("line", "choice", "edge", "location", "ending")
FLAG_ORDER: list[str] = []
ITEM_ORDER: list[str] = []
VAR_ORDER: list[str] = []
FLAG_BIT: dict[str, int] = {}
ITEM_BIT: dict[str, int] = {}
VAR_POS: dict[str, int] = {}


def walk(c, out: list) -> None:
    if c is None:
        return
    if c[0] in ("flag", "item", "aff"):
        out.append((c[0], c[1]))
    elif c[0] == "not":
        walk(c[1], out)
    else:
        walk(c[1], out)
        walk(c[2], out)


def ev(c, aff: tuple, fl: int, it: int) -> bool:
    """在打包状态上求值;未声明的名字按 0 处理(与内核的 `?? 0` 一致)。"""
    if c is None:
        return True
    k = c[0]
    if k in ("flag", "item"):
        bits = FLAG_BIT if k == "flag" else ITEM_BIT
        b = bits.get(c[1])
        return (0 if b is None else ((fl if k == "flag" else it) >> b) & 1) == c[2]
    if k == "aff":
        p = VAR_POS.get(c[1])
        v, op, n = (0 if p is None else aff[p]), 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 ev(c[1], aff, fl, it) and ev(c[2], aff, fl, it)
    if k == "or":
        return ev(c[1], aff, fl, it) or ev(c[2], aff, fl, it)
    return not ev(c[1], aff, fl, it)


def apply_eff(eff: dict, aff: tuple, fl: int, it: int) -> tuple:
    for k, v in (eff.get("set") or {}).items():
        b = 1 << FLAG_BIT[k]
        fl = (fl | b) if v else (fl & ~b)
    if eff.get("add"):
        lst = list(aff)
        for k, d in eff["add"].items():
            p = VAR_POS[k]
            nv = lst[p] + d
            lst[p] = AFF_LO if nv < AFF_LO else AFF_HI if nv > AFF_HI else nv
        aff = tuple(lst)
    for k, v in (eff.get("grant") or {}).items():
        b = 1 << ITEM_BIT[k]
        it = (it | b) if v else (it & ~b)
    for k in (eff.get("revoke") or {}):
        it &= ~(1 << ITEM_BIT[k])
    return aff, fl, it


def build_sites(spec: dict) -> tuple:
    nodes = spec["dialogue"]
    of_scene: dict[str, list[str]] = {}
    for n in nodes:
        of_scene.setdefault(n["scene"], []).append(n["id"])
    sites: list[dict] = []
    per_node: dict[str, list[int]] = {n["id"]: [] for n in nodes}

    def add(kind: str, sid: str, cond, where: list) -> None:
        if cond is None:
            return
        sites.append({"id": sid, "kind": kind, "cond": cond, "at": list(where)})
        for nid in where:
            per_node[nid].append(len(sites) - 1)

    for n in nodes:
        add("line", f"line:{n['id']}", n.get("line_when"), [n["id"]])
        for c in (n.get("choices") or []):
            add("choice", f"choice:{c['id']}", c.get("when"), [n["id"]])
    for e in spec["scene_graph"]["edges"]:
        add("edge", f"edge:{e['from']}->{e['to']}", e.get("when"), of_scene.get(e["from"], []))
    for loc in spec["world"]["locations"]:
        add("location", f"location:{loc['id']}", loc.get("unlock_when"), of_scene.get(loc["id"], []))
    for ed in spec["endings"]:
        at = [n["id"] for n in nodes if n.get("ending") == ed["name"]]
        add("ending", f"ending:{ed['id']}", ed.get("when"), at)
    return sites, per_node


def closure(spec: dict, budget: int = BUDGET) -> dict:
    global FLAG_ORDER, ITEM_ORDER, VAR_ORDER, FLAG_BIT, ITEM_BIT, VAR_POS
    FLAG_ORDER = sorted(spec["state"]["flags"])
    ITEM_ORDER = sorted(spec["state"]["items"])
    VAR_ORDER = sorted(spec["state"]["vars"])
    FLAG_BIT = {f: i for i, f in enumerate(FLAG_ORDER)}
    ITEM_BIT = {k: i for i, k in enumerate(ITEM_ORDER)}
    VAR_POS = {v: i for i, v in enumerate(VAR_ORDER)}
    sites, per_node = build_sites(spec)
    nodes = spec["dialogue"]
    idx_of = {n["id"]: i for i, n in enumerate(nodes)}
    declared = {"flag": set(spec["state"]["flags"]), "item": set(spec["state"]["items"]),
                "aff": set(spec["state"]["vars"])}

    # ① 引用闭包
    refs: list[tuple] = []
    for s in sites:
        walk(s["cond"], refs)
    undeclared = sorted({f"{ns}:{n}" for ns, n in refs if n not in declared[ns]})

    # ②a 静态写入集
    written = {"flag": set(), "item": set(), "aff": set()}
    for n in nodes:
        for c in (n.get("choices") or []):
            eff = c.get("effects") or {}
            written["flag"] |= set(eff.get("set") or {})
            written["aff"] |= set(eff.get("add") or {})
            written["item"] |= set(eff.get("grant") or {}) | set(eff.get("revoke") or {})
    never_written = sorted({f"{ns}:{n}" for ns, n in refs if n not in written[ns]})

    # ②b / ③ BFS
    init_aff = tuple(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in VAR_ORDER)
    choices_of = [[(c.get("when"), c.get("effects") or {}, idx_of[c["goto"]]) for c in (n.get("choices") or [])]
                  for n in nodes]

    def key(ni: int, aff: tuple, fl: int, it: int) -> int:
        code = 0
        for v in aff:
            code = (code << 7) | v
        return (ni << 55) | (code << 27) | (fl << 9) | it

    start = idx_of[spec["meta"]["start"]]
    seen = {key(start, init_aff, 0, 0)}
    frontier = [(start, init_aff, 0, 0)]
    exhausted, satisfied = True, [False] * len(sites)
    flag_ever, item_ever, states = 0, 0, 0
    aff_lo, aff_hi = list(init_aff), list(init_aff)
    while frontier:
        nxt = []
        for ni, aff, fl, it in frontier:
            states += 1
            flag_ever |= fl
            item_ever |= it
            for p, v in enumerate(aff):
                if v < aff_lo[p]:
                    aff_lo[p] = v
                if v > aff_hi[p]:
                    aff_hi[p] = v
            for si in per_node[nodes[ni]["id"]]:
                if not satisfied[si] and ev(sites[si]["cond"], aff, fl, it):
                    satisfied[si] = True
            if nodes[ni].get("ending"):
                continue
            for cond, eff, tgt in choices_of[ni]:
                if not ev(cond, aff, fl, it):
                    continue
                a2, f2, i2 = apply_eff(eff, aff, fl, it)
                k2 = key(tgt, a2, f2, i2)
                if k2 not in seen:
                    seen.add(k2)
                    nxt.append((tgt, a2, f2, i2))
        frontier = nxt
        if states > budget:
            exhausted = False
            break

    # ②b 动态写入
    never_observed = []
    for ns, name in sorted(set(refs)):
        if name not in declared[ns]:
            continue  # 未声明的引用已由 ① 报出,这里不重复
        if ns == "flag":
            ok = bool((flag_ever >> FLAG_BIT[name]) & 1)
        elif ns == "item":
            ok = bool((item_ever >> ITEM_BIT[name]) & 1)
        else:
            p = VAR_POS[name]
            ok = aff_lo[p] != init_aff[p] or aff_hi[p] != init_aff[p]
        if not ok:
            never_observed.append(f"{ns}:{name}")

    return {
        "exhausted": exhausted,
        "declared": {k: len(v) for k, v in declared.items()},
        "sites_total": len(sites),
        "sites_satisfied": sum(1 for x in satisfied if x),
        "sites_by_kind": {k: sum(1 for s in sites if s["kind"] == k) for k in SITE_KINDS},
        "undeclared": undeclared,
        "never_written": never_written,
        "never_observed": never_observed,
        "dead": [{"id": s["id"], "kind": s["kind"]} for s, ok in zip(sites, satisfied) if not ok],
        "write_only": sorted(f"{ns}:{n}" for ns in written for n in written[ns] if (ns, n) not in set(refs)),
        "affinity_range": {v: [aff_lo[p], aff_hi[p]] for p, v in enumerate(VAR_ORDER)},
    }


def main() -> int:
    with open(SPEC, encoding="utf-8") as fh:
        spec = json.load(fh)
    rep = closure(spec, BUDGET)
    # ④ 敏感性自检:注入两个已知缺陷,检验器必须都报得出来
    mut_a = copy.deepcopy(spec)
    mut_a["dialogue"][0]["choices"][0]["when"] = ["flag", "no_such_flag", 1]
    caught_a = "flag:no_such_flag" in closure(mut_a, SELFTEST_BUDGET)["undeclared"]
    mut_b = copy.deepcopy(spec)
    mut_b["dialogue"][0]["choices"][0]["when"] = ["aff", "yan", ">=", 101]
    caught_b = "choice:c_pier_1a" in [d["id"] for d in closure(mut_b, SELFTEST_BUDGET)["dead"]]
    rep["sensitivity"] = {"static_injected": "flag:no_such_flag", "static_caught": caught_a,
                          "dynamic_injected": "aff:yan >= 101(好感度上界之外,恒假)", "dynamic_caught": caught_b}

    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    with open(OUT, "w", encoding="utf-8") as fh:
        json.dump(rep, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print(f"可达状态完整枚举={rep['exhausted']}(预算 {BUDGET},超限即退化为采样口径)")
    print(f"声明域:{rep['declared']}")
    print(f"条件位点 {rep['sites_total']} 个 {rep['sites_by_kind']},取真过的 {rep['sites_satisfied']} 个")
    print(f"① 引用未声明的名字:{rep['undeclared'] or '(无)'}")
    print(f"② 引用但从未被任何效果写过:{rep['never_written'] or '(无)'}")
    print(f"② 引用但可达状态里从未出现非初值:{rep['never_observed'] or '(无)'}")
    print(f"③ 死条件 {len(rep['dead'])} 个:{[d['id'] for d in rep['dead']]}")
    print(f"   只写不读的名字:{rep['write_only'] or '(无)'}")
    print(f"好感度实测区间:{rep['affinity_range']}")
    print(f"④ 敏感性:静态缺陷 {rep['sensitivity']['static_injected']} 被抓={caught_a};"
          f"动态缺陷 {rep['sensitivity']['dynamic_injected']} 被抓={caught_b}")
    if rep["undeclared"] or rep["never_written"] or rep["never_observed"] or rep["dead"]:
        print("FAIL 闭包检验未通过")
        return 1
    if not (caught_a and caught_b):
        print("FAIL 敏感性自检未通过:检验器对已知缺陷无反应")
        return 1
    print("OK na_09_3_closure_check:引用闭包成立、写入闭包成立、死条件 0 个、敏感性自检通过")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/na_09_3_closure_check.py:

可达状态完整枚举=True(预算 12000000,超限即退化为采样口径)
声明域:{'flag': 18, 'item': 9, 'aff': 4}
条件位点 52 个 {'line': 2, 'choice': 34, 'edge': 5, 'location': 7, 'ending': 4},取真过的 52 个
① 引用未声明的名字:(无)
② 引用但从未被任何效果写过:(无)
② 引用但可达状态里从未出现非初值:(无)
③ 死条件 0 个:[]
   只写不读的名字:['flag:has_lantern', 'flag:hui_trusts', 'flag:met_hai', 'flag:met_lin', 'flag:met_mu', 'flag:promised_hai', 'flag:shen_trusts', 'item:fog_glass']
好感度实测区间:{'hui': [10, 97], 'mu': [25, 100], 'shen': [45, 100], 'yan': [0, 100]}
④ 敏感性:静态缺陷 flag:no_such_flag 被抓=True;动态缺陷 aff:yan >= 101(好感度上界之外,恒假) 被抓=True
OK na_09_3_closure_check:引用闭包成立、写入闭包成立、死条件 0 个、敏感性自检通过

判读

三条闭包全部成立,死条件 0 个。 引用未声明、引用但从未被写、引用但可达状态里从未出现非初值,三项都是空的;52 个位点里 52 个取真过。这不是「检查没跑」,因为 ④ 已经证明检验器对注入的静态缺陷(引用 no_such_flag)与动态缺陷(yan >= 101,越出好感度上界)都会报警。「只写不读」有 8 个名字:has_lantern、hui_trusts、met_hai、met_lin、met_mu、promised_hai、shen_trusts 与物品 fog_glass 都被效果写过,但没有任何条件读它们。这和 9.1 的 or、9.2 的 revoke 是同一类现象:写了但暂时没人读。它不是错误,但也不是可以忽略的——写而不读的旗标是「作者记了账、系统还没用上」的地方,值得在下一轮设计里回头看一眼。

枚举给出真实上下界,采样给不出。 这里四个好感度变量的实测区间是 yan [0, 100]、hui [10, 97]、shen [45, 100]、mu [25, 100]。把它和 9.2 那趟 2000 条随机路径的区间对照一下:yan 在采样里只到 90,枚举里到了 100。采样漏掉的那一段,正是枚举存在的理由——要断言「某个值可达/不可达」,只能枚举。

自检表

检查项合格标准
三层都查引用、静态写入、动态可达三层各自独立判,不合并成一个布尔
位点全覆盖对话行、选项、场景边、地点解锁、结局五类都进检查
判据可反驳报告给出具体位点 id,而不是只给一个总数
检验器自检注入已知缺陷必须被抓到,否则「0」不算数

条件、状态、闭包三件事做完,条件系统这一章就闭合了:条件能读什么、状态能被谁写、读到的名字有没有被写过,都成了可判定的性质。下一章转向状态里最需要连续量的那一类——好感度,看它怎么初始化、怎么增减、阈值门又怎么设。

阅读导航:上一节:9.2 状态变量 · 下一节:10.1 好感度的初始化与增减 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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