5.3 不可达节点与孤儿结局
Phaser 分支叙事 讲的是分支在引擎里怎么挂、怎么切;本节反过来问:挂上去的这些节点与选项,有没有哪些一次都走不到。一份规模上千节点的叙事规格里,最容易出的问题不是「玩家选错了」,而是「有一段内容写了,却因为条件永远凑不齐而从来没有被任何玩家看到过」——它既浪费了写作成本,也让设计者误以为那条线是通的。
不可达有两种口径
「不可达」这个词在这个案例里要拆成两个不同的问题,它们的判据不同、后果也不同:
| 口径 | 对象 | 判据 | 后果 |
|---|---|---|---|
| 节点不可达 | 对话节点 | 任何可达状态下 BFS 都没落到它 | 整段对话是死代码,玩家永远看不到 |
| 选项不可达 | 选项(边) | 它的 when 在所属节点的任何可达状态下都不成立 | 玩家能到那个节点,但这条边永远不可选 |
两者的区别是粒度:节点不可达是「连门都进不去」;选项不可达是「进了门,但里面有一扇永远打不开的窗」。一个节点完全可能自身可达,却挂着一条条件太苛、任何可达状态都满足不了的选项——后者用「节点可达性」是查不出来的。
孤儿结局是节点不可达的一个特例:一个结局节点,它的 when 门在到达它之前永远凑不齐,于是这个结局在任何一局里都拿不到。5 个结局里只要有一个是孤儿,就等于设计者写了一个玩家永远触不到的终局。
S 层:两种口径一起查
脚本 sim/ne_05_3_unreachable_orphan_ending.py 复用与 5.2 相同的逐层 BFS,但在扩展时同时记录访问过的节点与访问过的边:节点落进 visited_nodes,走通的选项 (node, choice_id) 落进 visited_edges。跑完之后做两次差集——全部节点减已访问节点得到「节点不可达」,全部选项减已访问选项得到「选项不可达」。结局再单独过一遍,看它的节点在不在已访问集合里。
#!/usr/bin/env python3
# file: sim/ne_05_3_unreachable_orphan_ending.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_05_3_unreachable_orphan_ending.py
"""卷二 5.3 不可达节点与孤儿结局(S 层)。
「不可达」有两种口径,本节把两者分开统计:
节点不可达 某个对话节点,在**任何**可达状态下都进不去(BFS 从未落到它)
选项不可达 某条选项的 `when` 在它所属节点的**任何**可达状态下都不成立
(BFS 从未走过这条边)
孤儿结局是节点不可达的一个特例:结局节点的 `when` 门在它之前永远凑不齐。
本案例 5 个结局全部可达,孤儿结局 0。
另外报告一个**规格层面的欠声明**(不是缺陷):节点 `d_lt_2` 声明了 `line_when`
却没有 `line_else`——渲染器按声明规则回落到 `line`。修法是补 `line_else`,
不是改台词;本节只如实呈现,不改 spec。
"""
from __future__ import annotations
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "ne_05_3_unreachable.json")
AFF_LO, AFF_HI = 0, 100
STATE_BUDGET = 12000000
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
return lo if v < lo else hi if v > hi else v
def eval_cond(c, st: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
if op == ">=":
return v >= n
if op == "<":
return v < n
if op == "==":
return v == n
return v != n
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
return not eval_cond(c[1], st) # not
def apply_effects(eff: dict, st: dict) -> dict:
aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = clamp(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 walk(spec: dict) -> dict:
by_id = {n["id"]: n for n in spec["dialogue"]}
VARS = sorted(spec["state"]["vars"])
FLAGS = sorted(spec["state"]["flags"])
ITEMS = sorted(spec["state"]["items"])
NV = len(VARS)
def canon(node, aff, flags, items):
a = ",".join(f"{k}={aff[k]}" for k in VARS)
f = ",".join(k for k in FLAGS if flags[k])
it = ",".join(k for k in ITEMS if items[k])
return f"{node}|{a}|{f}|{it}"
def pack(aff, flags, items):
code = 0
for v in VARS:
code = (code << 7) | aff[v]
fb = 0
for i, k in enumerate(FLAGS):
if flags[k]:
fb |= 1 << i
ib = 0
for i, k in enumerate(ITEMS):
if items[k]:
ib |= 1 << i
return code, fb, ib
def unpack(code, fb, ib):
aff = {VARS[i]: (code >> (7 * (NV - 1 - i))) & 127 for i in range(NV)}
flags = {FLAGS[i]: (fb >> i) & 1 for i in range(len(FLAGS))}
items = {ITEMS[i]: (ib >> i) & 1 for i in range(len(ITEMS))}
return aff, flags, items
start = spec["meta"]["start"]
aff0 = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in VARS}
flags0 = {f: 0 for f in FLAGS}
items0 = {i: 0 for i in ITEMS}
c0, b0, g0 = pack(aff0, flags0, items0)
frontier = [(start, c0, b0, g0)]
seen = {canon(start, aff0, flags0, items0)}
visited_nodes: set = set()
visited_edges: set = set()
states = 0
budget_hit = False
while frontier:
nxt = []
for node, code, fb, ib in frontier:
states += 1
visited_nodes.add(node)
cur = by_id[node]
if cur.get("ending"):
continue
aff, flags, items = unpack(code, fb, ib)
st = {"aff": aff, "flags": flags, "items": items}
for c in (cur.get("choices") or []):
if not eval_cond(c.get("when"), st):
continue
visited_edges.add((node, c["id"]))
st2 = apply_effects(c.get("effects") or {}, st)
k = canon(c["goto"], st2["aff"], st2["flags"], st2["items"])
if k not in seen:
seen.add(k)
c2, b2, g2 = pack(st2["aff"], st2["flags"], st2["items"])
nxt.append((c["goto"], c2, b2, g2))
frontier = nxt
if states > STATE_BUDGET:
budget_hit = True
break
return {"states": states, "budget_hit": budget_hit,
"visited_nodes": visited_nodes, "visited_edges": visited_edges}
def main() -> int:
t0 = time.time()
spec = load_spec()
nodes = spec["dialogue"]
ids = {n["id"] for n in nodes}
all_choices = {(n["id"], c["id"]) for n in nodes for c in (n.get("choices") or [])}
res = walk(spec)
visited_nodes, visited_edges = res["visited_nodes"], res["visited_edges"]
unreachable_nodes = sorted(ids - visited_nodes)
unreachable_choices = sorted(all_choices - visited_edges)
coverage_permille = (len(visited_edges) * 1000) // max(1, len(all_choices))
ending_nodes = [n for n in nodes if n.get("ending")]
orphan_endings = sorted(n["ending"] for n in ending_nodes if n["id"] not in visited_nodes)
# 规格层面的欠声明:声明了 line_when 却没有 line_else
underdeclared = sorted(n["id"] for n in nodes if n.get("line_when") and not n.get("line_else"))
line_when_n = sum(1 for n in nodes if n.get("line_when"))
line_else_n = sum(1 for n in nodes if n.get("line_else"))
print(f"可达状态 {res['states']}(BFS 完整枚举,预算 {STATE_BUDGET},超限={res['budget_hit']})")
print(f"对话节点 {len(ids)} 选项 {len(all_choices)}")
print(f"节点不可达 {len(unreachable_nodes)}:{unreachable_nodes}")
print(f"选项不可达 {len(unreachable_choices)}:{[f'{a}/{b}' for a, b in unreachable_choices]}")
print(f"分支覆盖率 {coverage_permille}‰({len(visited_edges)}/{len(all_choices)} 条对话边,BFS 口径)")
print(f"结局节点 {len(ending_nodes)} 个,全部可达:{sorted(n['ending'] for n in ending_nodes)}")
print(f"孤儿结局 {len(orphan_endings)}:{orphan_endings}")
print(f"欠声明:声明 line_when 却无 line_else 的节点 {underdeclared}"
f"(line_when {line_when_n} 处 / line_else {line_else_n} 处)")
print(f"耗时 {round(time.time() - t0, 1)}s")
assert not res["budget_hit"], "BFS 超预算"
assert len(visited_nodes) + len(unreachable_nodes) == len(ids), "可达+不可达应等于全部节点"
os.makedirs(OUT, exist_ok=True)
report = {
"states": res["states"],
"state_budget": STATE_BUDGET,
"nodes": len(ids),
"choices": len(all_choices),
"visited_nodes": len(visited_nodes),
"visited_edges": len(visited_edges),
"unreachable_nodes": unreachable_nodes,
"unreachable_choices": [f"{a}/{b}" for a, b in unreachable_choices],
"coverage_permille": coverage_permille,
"endings": sorted(n["ending"] for n in ending_nodes),
"orphan_endings": orphan_endings,
"underdeclared_line_when_without_else": underdeclared,
}
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote out/ne_05_3_unreachable.json")
if unreachable_nodes or unreachable_choices or orphan_endings:
print("FAIL 存在不可达节点 / 选项 / 孤儿结局")
return 1
print(f"OK ne_05_3_unreachable_orphan_ending:不可达节点 0 / 选项 0 / 孤儿结局 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_05_3_unreachable_orphan_ending.py:
可达状态 9895423(BFS 完整枚举,预算 12000000,超限=False)
对话节点 48 选项 115
节点不可达 0:[]
选项不可达 0:[]
分支覆盖率 1000‰(115/115 条对话边,BFS 口径)
结局节点 5 个,全部可达:['沉没', '灯守', '真相', '离港', '缄默']
孤儿结局 0:[]
欠声明:声明 line_when 却无 line_else 的节点 ['d_lt_2'](line_when 2 处 / line_else 1 处)
耗时 84.7s
wrote out/ne_05_3_unreachable.json
OK ne_05_3_unreachable_orphan_ending:不可达节点 0 / 选项 0 / 孤儿结局 0
判读:两种口径都是 0,意味着什么
第一,节点不可达 0 / 选项不可达 0。 48 个对话节点每一个都在至少一条真实路径上被走到;115 条选项每一条都在至少一个可达状态下可选。把「走过的边」除以「全部边」得到分支覆盖率 1000‰(115/115)——这个数是代理指标,只做相对比较,用来判断「有没有死代码」。1000‰ 的直白含义是:这份规格里没有一段内容写了却永远看不到。
第二,为什么要把两种口径分开。 如果只查「节点可达性」,一个自身可达、却挂着一条永远选不中的选项的节点会被漏掉——节点算过了,可它的某条边是死的。反过来,如果只查「选项可达性」,一个整段都进不去的节点可能因为它的选项在某条「不可达前缀」上理论可选而蒙混过关。两种口径各查一遍、都过,才算真的没有死代码。本案例两者都是 0,说明这张故事图没有孤儿节点,也没有死边。
第三,5 个结局全部可达,孤儿结局 0。 结局节点 e_lit(灯守)、e_dark(离港)、e_truth(真相)、e_silence(缄默)、e_loss(沉没)都在已访问集合里。尤其 e_lit 的门最苛(found_yan_journal 为真且 yan ≥ 40),它仍然可达——这正好印证了第 5.1 节的说法:真实可达状态有 990 万个,足以把每一个门槛都跨过去。零孤儿结局是「可穷举」这个设计目标的价值所在:它把「这个结局是不是根本拿不到」从猜测变成了一次 BFS 就能回答的判定。
第四,一处规格层面的欠声明:d_lt_2 声明了 line_when 却没有 line_else。 全规格 line_when 出现 2 处、line_else 只有 1 处,也就是说两处条件文本里有一处没有配兜底句。渲染器的声明规则是:line_when 成立时用条件文本,不成立时若有 line_else 就用它,否则回落到无条件的 line。所以这不是 bug——d_lt_2 在条件不成立时会渲染那句 line;但它是一处欠声明:作者很可能本想要一句「条件不成立时的另一句」,只是忘了写。修法是补 line_else,不是改台词。要注意它和本节的不可达无关:d_lt_2 节点本身可达,这处欠声明落在文本渲染层,不落在可达性层。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两种口径分查 | 节点不可达与选项不可达各算一次,不混为一谈 |
| 覆盖率是 proxy | 明说是代理指标、只做相对比较,不绝对化 |
| 结局单独过 | 5 个结局节点逐个查,孤儿结局单列 |
| 欠声明不改 spec | d_lt_2 如实呈现,修法是补 line_else 而非改台词 |
| 不变量成对 | 可达 + 不可达 = 全部节点;BFS 未超预算 |
可达性回答的是「走不走得到」。但一张图除了「能不能到」,还要问「到了之后出不出得来」——一个无选项且非结局的节点就是一条死路,而多条支线汇到同一个节点时,那个汇聚是否合法,又是另一个问题。这些都留给下一章。
阅读导航:上一节:5.2 可达状态的枚举 · 下一节:6.1 死路检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。