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 好感度的初始化与增减 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。