6.3 结局的可达性
站内的独立游戏的叙事设计与分支对话 讲的是分支汇聚的经验:哪里合、哪里分,配的是清单,代码不可跑。本节回答一个更硬的问题:所有结局都够得着吗?5 个结局要给每个找一条真实可回放的到达路径,再报它们的出现频率——因为「可达」和「易达」是两回事。
两件事:可达与易达
结局检测要分两个独立的问题,混在一起看会漏掉最要紧的东西:
- 可达是布尔:存在一条从起点到该结局的合法选项序列吗?用 BFS 求存在性,回答「是 / 否」。
- 易达是频率:在均匀随机的选择下,玩家落到该结局的概率是多少?用采样估计。
一个结局可以可达但稀有——BFS 说它存在,采样却几乎碰不到。这正是要抓的情形:结构上没断,体验上等于没有。所以本节两个都报,并排看。
S 层:BFS 找路径 + 采样算分布
脚本 sim/ne_06_3_ending_reachability.py 做两件事:① 对每个结局节点,在投影状态上 BFS,记录父指针,回溯出一条最短到达路径(一串真实选项 id),再回放这条路径校验终点;② 用 Lehmer MINSTD(seed 20261016)均匀采样 20000 条路径,统计结局分布。
#!/usr/bin/env python3
# file: sim/ne_06_3_ending_reachability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_06_3_ending_reachability.py
"""卷二 6.3 结局的可达性(S 层):5 个结局全部可达,但可达不等于易达。
两件事分开做:
1) 可达性——对每个结局节点,用 BFS(投影到条件可读变量)找出一条**具体的到达路径**
(一串真实可执行的选项 id),并回放校验终局节点;
2) 分布——均匀随机采样 20000 条路径(Lehmer MINSTD,seed 20261016),统计结局分布。
「可达」是布尔,「易达」是频率:两者一起看,才能发现「可达但稀有」的结局。
注意两个口径:`endings[].id` 是机器名(e_lit 等),`dialogue[].ending` 存的是结局**名**
(灯守 等)。产出 out/ne_06_3_ending_reachability.json。exit 0 表示 5 个结局全部可达。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
AFF_LO, AFF_HI = 0, 100
def load_spec() -> dict:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def read_vars(spec: dict):
rf: set = set()
ri: set = set()
ra: set = set()
def walk(c) -> None:
if not c:
return
k = c[0]
if k in ("and", "or"):
walk(c[1])
walk(c[2])
elif k == "not":
walk(c[1])
elif k == "flag":
rf.add(c[1])
elif k == "item":
ri.add(c[1])
elif k == "aff":
ra.add(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["endings"]:
walk(e.get("when"))
return sorted(ra), sorted(rf), sorted(ri)
def main() -> int:
spec = load_spec()
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
start = spec["meta"]["start"]
RA, RF, RI = read_vars(spec)
AR = {v: i for i, v in enumerate(RA)}
FR = {v: i for i, v in enumerate(RF)}
IR = {v: i for i, v in enumerate(RI)}
end_by_node = {n["id"]: n["ending"] for n in nodes if n.get("ending")}
id_of_name = {e["name"]: e["id"] for e in spec["endings"]}
name_of_id = {e["id"]: e["name"] for e in spec["endings"]}
def cond(c, aff, fl, it) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return ((fl >> FR[c[1]]) & 1) == c[2]
if k == "item":
return ((it >> IR[c[1]]) & 1) == c[2]
if k == "aff":
v, op, n = aff[AR[c[1]]], c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "and":
return cond(c[1], aff, fl, it) and cond(c[2], aff, fl, it)
if k == "or":
return cond(c[1], aff, fl, it) or cond(c[2], aff, fl, it)
return not cond(c[1], aff, fl, it)
def step(eff, aff, fl, it):
aff = list(aff)
for k, v in (eff.get("set") or {}).items():
if k in FR:
fl = (fl | (1 << FR[k])) if v else (fl & ~(1 << FR[k]))
for k, v in (eff.get("add") or {}).items():
if k in AR:
nv = aff[AR[k]] + v
aff[AR[k]] = AFF_LO if nv < AFF_LO else AFF_HI if nv > AFF_HI else nv
for k, v in (eff.get("grant") or {}).items():
if k in IR:
it = (it | (1 << IR[k])) if v else (it & ~(1 << IR[k]))
for k in (eff.get("revoke") or {}):
if k in IR:
it &= ~(1 << IR[k])
return tuple(aff), fl, it
aff0 = tuple(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in RA)
# ---- 1) BFS 找每个结局的一条具体到达路径(最短) ----
root = (start, aff0, 0, 0)
parent: dict = {root: None}
frontier = [root]
found: dict = {}
while frontier and len(found) < len(end_by_node):
nxt = []
for st in frontier:
node, aff, fl, it = st
if node in end_by_node:
if node not in found:
found[node] = st
continue
for c in by[node]["choices"]:
if not cond(c.get("when"), aff, fl, it):
continue
a2, f2, i2 = step(c.get("effects") or {}, aff, fl, it)
k = (c["goto"], a2, f2, i2)
if k not in parent:
parent[k] = (st, c["id"])
nxt.append(k)
frontier = nxt
def path_of(st) -> list:
seq = []
cur = st
while parent[cur] is not None:
prev, cid = parent[cur]
seq.append(cid)
cur = prev
return list(reversed(seq))
# 回放校验:用找到的选项序列重走一遍,确认终局节点
def replay(seq) -> str:
node, aff, fl, it = start, aff0, 0, 0
for cid in seq:
c = next(x for x in by[node]["choices"] if x["id"] == cid)
assert cond(c.get("when"), aff, fl, it), cid
aff, fl, it = step(c.get("effects") or {}, aff, fl, it)
node = c["goto"]
return node
reach = {}
for node, st in found.items():
seq = path_of(st)
assert replay(seq) == node
reach[node] = {"ending": end_by_node[node], "ending_id": id_of_name[end_by_node[node]],
"choices": seq, "steps": len(seq)}
# ---- 2) 均匀随机采样 20000 条,统计结局分布 ----
rng = 20261016
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
dist = {e["name"]: 0 for e in spec["endings"]}
for _ in range(20000):
node, aff, fl, it = start, aff0, 0, 0
for _step in range(200):
cur = by[node]
if cur.get("ending"):
dist[cur["ending"]] += 1
break
opts = [c for c in cur["choices"] if cond(c.get("when"), aff, fl, it)]
c = opts[rnd(len(opts))]
aff, fl, it = step(c.get("effects") or {}, aff, fl, it)
node = c["goto"]
out = {"reachable": {k: v for k, v in sorted(reach.items())},
"ending_id_to_name": name_of_id,
"sample_n": 20000, "distribution": dist}
os.makedirs(os.path.join(HERE, "../out"), exist_ok=True)
with open(os.path.join(HERE, "../out/ne_06_3_ending_reachability.json"), "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"结局节点 {len(end_by_node)} 个,可达 {len(reach)} 个")
for node, info in sorted(reach.items()):
print(f" {info['ending_id']} {info['ending']}({node}){info['steps']} 步:{' > '.join(info['choices'])}")
print(f"采样 20000 条路径的结局分布:{dist}")
rare = min(dist, key=dist.get)
print(f"最稀有结局 {rare} {dist[rare]}/20000({dist[rare] / 200:.1f}‰),最常结局 "
f"{max(dist, key=dist.get)} {max(dist.values())}/20000")
missing = [e["name"] for e in spec["endings"] if e["name"] not in [v["ending"] for v in reach.values()]]
if missing:
print(f"FAIL 不可达结局 {missing}")
return 1
print("OK ne_06_3_ending_reachability:5 个结局全部可达且各给出一条到达路径")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_06_3_ending_reachability.py:
结局节点 5 个,可达 5 个
e_dark 离港(e_dark)22 步:c_pier_1a > c_pier_2a > c_tavern_1a > c_tavern_2a > c_tavern_3a > c_hub_a1a > c_ch_1a > c_ch_2a > c_ch_3a > c_hub_a2b > c_mr_1a > c_mr_2a > c_mr_3a > c_ho_1a > c_ho_2a > c_ho_3a > c_hub_b2a > c_lt_1b > c_lt_2a > c_lt_3a > c_dq_1c > c_dq_2e
e_lit 灯守(e_lit)25 步:c_pier_1a > c_pier_2a > c_tavern_1a > c_tavern_2a > c_tavern_3a > c_hub_a1a > c_ch_1a > c_ch_2a > c_ch_3a > c_hub_a2a > c_lb_1a > c_lb_2a > c_lb_3a > c_tc_1a > c_tc_2a > c_tc_3a > c_ho_1a > c_ho_2a > c_ho_3a > c_hub_b2a > c_lt_1b > c_lt_2a > c_lt_3a > c_dq_1c > c_dq_2a
e_loss 沉没(e_loss)22 步:c_pier_1a > c_pier_2a > c_tavern_1a > c_tavern_2a > c_tavern_3a > c_hub_a1a > c_ch_1a > c_ch_2a > c_ch_3a > c_hub_a2b > c_mr_1a > c_mr_2a > c_mr_3a > c_ho_1a > c_ho_2a > c_ho_3a > c_hub_b2a > c_lt_1b > c_lt_2a > c_lt_3a > c_dq_1c > c_dq_2d
e_silence 缄默(e_silence)22 步:c_pier_1a > c_pier_2a > c_tavern_1a > c_tavern_2a > c_tavern_3a > c_hub_a1a > c_ch_1a > c_ch_2a > c_ch_3a > c_hub_a2b > c_mr_1a > c_mr_2a > c_mr_3a > c_ho_1a > c_ho_2a > c_ho_3a > c_hub_b2a > c_lt_1b > c_lt_2a > c_lt_3a > c_dq_1c > c_dq_2c
e_truth 真相(e_truth)22 步:c_pier_1a > c_pier_2b > c_chapel_1a > c_chapel_2a > c_chapel_3a > c_hub_a1a > c_ch_1a > c_ch_2a > c_ch_3a > c_hub_a2b > c_mr_1a > c_mr_2a > c_mr_3b > c_db_1a > c_db_2c > c_db_3a > c_hub_b2a > c_lt_1b > c_lt_2a > c_lt_3a > c_dq_1b > c_dq_2b
采样 20000 条路径的结局分布:{'灯守': 1018, '离港': 10074, '真相': 178, '缄默': 3223, '沉没': 5507}
最稀有结局 真相 178/20000(0.9‰),最常结局 离港 10074/20000
OK ne_06_3_ending_reachability:5 个结局全部可达且各给出一条到达路径
结果怎么读
5 个结局全部可达,各有一条回放通过的路径。 每条路径都是真实可执行的选项 id 序列,脚本用 replay 重走一遍、断言终点节点,所以这不是「理论上存在」,是「照这串选择走,确实到得了」。
关键读数是分布。 20000 条均匀随机路径落在五个结局上:
| 结局 | 采样数 | 占比 |
|---|---|---|
| 离港 | 10074 | 50.4% |
| 沉没 | 5507 | 27.5% |
| 缄默 | 3223 | 16.1% |
| 灯守 | 1018 | 5.1% |
| 真相 | 178 | 0.9% |
可达 ≠ 易达,真相就是例子。 BFS 说 e_truth 可达(22 步那条路径就在上面),但均匀随机下 20000 条里只有 178 条落到真相——0.9‰ 级别。也就是说:结构上没坏,体验上几乎摸不到。如果设计预期「真相」是一个玩家能经常撞见的结局,这个数字就是警报;如果预期它是隐藏结局,0.9% 反而是设计目标。判据只报频率,不替你定目标。
对照 e_lit(灯守)25 步——它是五条路径里最长的,但采样占比 5.1% 高于真相。这说明路径长度不等于稀有度:灯守的可达状态多、岔口多,真相则被几个条件卡得只剩一条窄缝。
两个口径:id 与 ending 不是一回事
这里有个容易踩的坑,规格里两个字段名字很像、存的东西不同:
| 字段 | 存什么 | 例子 |
|---|---|---|
endings[].id | 结局的机器名 | e_lit |
endings[].name | 结局的中文名 | 灯守 |
dialogue[].ending | 存的是结局名(不是 id) | 灯守 |
dialogue 里标记终局的节点,ending 字段写的是名字(灯守),不是 id(e_lit)。所以脚本必须先建 end_by_node(节点 → 名),再经 id_of_name(名 → id)才能把节点和 endings 表对上。把两者当同一个口径,按 id 去 dialogue 里找结局节点会一条都找不到——这个坑在 S 与 P 两层都埋了一次,两边都必须显式做这层映射。
P 层:负例——把一条入边掐断
「5 个结局都可达」也要能被证伪。P 层用一条唯一的入边做负例:真相节点 e_truth 只有一条入边 c_dq_2b(来自 d_dq_2)。把这条边的 when 改成恒假(["flag", "__never_set__", 1]),真相就断了。
本机实跑 node --test 'core/test/ne_06_3_ending_reachability.test.ts':
e_silence:22步 e_loss:22步 e_dark:22步 e_truth:22步 e_lit:25步
✔ 5 个结局全部可达,且每条路径回放后确实落在该结局节点 (3198.113209ms)
✔ 采样 20000 条:分布与 Python 侧逐项一致 (216.01475ms)
✔ 负例 · 断开唯一入边 c_dq_2b:真相变成不可达,检测器报出 (5569.727125ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 9270.307417
三条测试各挡一面:第一条回放校验每条路径确实落在对应结局节点(不是「找得到路径」就算);第二条断言采样分布与 Python 侧逐项一致({灯守:1018, 离港:10074, 真相:178, 缄默:3223, 沉没:5507})——两套独立实现给出同一个分布,采样才可信;第三条掐断 c_dq_2b,断言可达结局数从 5 掉到 4、e_truth 不在其中。第三条最关键:它证明「5 个可达」不是因为检测器永远返回 5,而是它真的会随图变化而变。
自检表
| 检查项 | 合格标准 |
|---|---|
| 可达可回放 | 每个结局给一条真实选项序列,回放后断言落在该节点 |
| 可达易达分开 | 布尔(BFS 存在性)与频率(采样占比)并排报 |
| 稀有可读 | 报出最稀有结局及其占比,指出可达不等于易达 |
| 口径厘清 | 明确 endings[].id 与 dialogue[].ending(存名)不是一回事 |
| 负例必给 | 掐断一条唯一入边,可达数下降且检测器报出 |
第 6 章到此收束:死路(走进去出不来)、冗余汇流(合流丢了信息)、结局可达性(够不够得着)三件缺陷都变成了可跑出计数的判据。下一章换一个维度——不再问「有没有坏」,而是问「分支有多宽」,把结构度量出来。
阅读导航:上一节:6.2 汇流的合法性 · 下一节:7.1 分支宽度的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。