3.3 结局可达性的回归
《游戏设计》卷二《策略涌现与体验度量》 里的回归是机制层的分布与平衡——改一个数值,一局战斗的动力学怎么变;本节做的是叙事层的结局分布与分支平衡里的回归——改一处条件,某个结局会不会从此走不到。前两节量的是当前这张图的形状(熵、偏斜);这一节量的是改动前后的差异:哪个结局从可达变成了不可达。
回归的精确含义
「回归」在日常语言里是「变差了」,在度量里必须写死:基线可达、改动后不可达。只有同时满足这两个条件的结局,才算一次回归。这个定义要求两样东西:
- 一个基线可达集:改动之前,哪些结局是可达的。这是精确口径——BFS 从起点枚举可达状态,收集能到达的结局名,找到全部目标数就早退。
- 一个改动后的可达集:改了之后,哪些结局还可达。
回归检测器本身是一个纯函数 regressions(baseline, current),只做集合差。难点不在检测,在怎么判「改动后不可达」——全量 BFS 一次要 20 秒左右,而很多改动(比如「掐断某结局的唯一入边」)用便宜的静态口径就能判出来:一条边引用未声明的 flag / item / 好感度变量,就是结构死条件,它的目标不可达。
S 层:基线可达集与回归检测器
sim/nm_03_3_reachability_regression.py 用 BFS 精确口径算基线可达集,再用一个负例演示回归:把真相结局 e_truth 的唯一入边 c_dq_2b 的条件改成引用一个未声明的 flag __never_set__,静态口径立刻判出真相不可达。它复用 sim/nm_03_common.py 的 bfs_packed。
#!/usr/bin/env python3
# file: sim/nm_03_3_reachability_regression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_03_3_reachability_regression.py
"""卷三 3.3 结局可达性的回归(S 层):某个结局会不会因一次改动而变得不可达。
「回归」= 基线可达、改动后不可达。给三样:① BFS 精确口径的基线可达集;② 回归检测器
`regressions()` 纯函数;③ 负例——把 e_truth 唯一入边 c_dq_2b 的条件改成引用未声明 flag
(结构恒假),静态死条件分析判出「真相不可达」,报出回归。静态口径对「掐断唯一入边」与
BFS 结论一致,但省一次全量枚举。公共内核 sim/nm_03_common.py。proxy:可达性是布尔结构
量,不声称体验。产出 out/nm_03_3_reachability_regression.json。
"""
from __future__ import annotations
import json
import os
import sys
from nm_03_common import OUT, bfs_packed, load_spec
def declared_names(spec: dict) -> dict:
return {"flag": set(spec["state"]["flags"]),
"item": set(spec["state"]["items"]),
"aff": set(spec["state"]["vars"])}
def cond_dead(c, declared: dict) -> bool:
"""结构死条件:引用未声明的 flag / item / 好感度变量。"""
if c is None:
return False
if c[0] in ("flag", "item", "aff"):
return c[1] not in declared[c[0]]
if c[0] in ("and", "or"):
return cond_dead(c[1], declared) or cond_dead(c[2], declared)
return cond_dead(c[1], declared) if c[0] == "not" else False
def static_unreachable_endings(spec: dict) -> list:
"""静态口径:入边**全部**是死条件的结局节点不可达(唯一入边被掐断时精确)。"""
declared = declared_names(spec)
ends = {n["id"]: n["ending"] for n in spec["dialogue"] if n.get("ending")}
in_edges = {nid: [] for nid in ends}
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
if c["goto"] in in_edges:
in_edges[c["goto"]].append(c)
return sorted(name for nid, name in ends.items()
if not in_edges[nid] or all(cond_dead(c.get("when"), declared) for c in in_edges[nid]))
def reachable_endings(spec: dict, target_count=None):
"""精确口径:BFS 枚举可达状态,收集能到达的结局名;target_count 命中即早退。"""
by_node = {n["id"]: n.get("ending") for n in spec["dialogue"]}
found = set()
stop = (lambda: len(found) >= target_count) if target_count is not None else None
def on_state(node, aff, fl, it):
if by_node.get(node):
found.add(by_node[node])
states, budget_hit = bfs_packed(spec, on_state, stop=stop)
return found, states, budget_hit
def regressions(baseline: set, current: set) -> list:
"""回归 = 基线可达但当前不可达的结局;按名排序。"""
return sorted(baseline - current)
def main() -> int:
spec = load_spec()
names = [e["name"] for e in spec["endings"]]
base, base_states, base_hit = reachable_endings(spec)
baseline = set(base)
clone = json.loads(json.dumps(spec)) # 负例:掐断 e_truth 唯一入边 c_dq_2b
for n in clone["dialogue"]:
for c in (n.get("choices") or []):
if c["id"] == "c_dq_2b":
c["when"] = ["flag", "__never_set__", 1]
static_unreach = set(static_unreachable_endings(clone))
found4, _, _ = reachable_endings(clone, target_count=4)
current = set(found4) - static_unreach
regressed = regressions(baseline, current)
os.makedirs(OUT, exist_ok=True)
report = {"baseline_reachable": sorted(baseline), "baseline_states": base_states,
"baseline_budget_hit": base_hit,
"negative_case": "c_dq_2b.when -> ['flag','__never_set__',1](未声明 flag,结构死条件)",
"static_unreachable": sorted(static_unreach),
"current_reachable": sorted(current), "regressions": regressed}
with open(os.path.join(OUT, "nm_03_3_reachability_regression.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"基线可达结局 {len(baseline)} / {len(names)}:{' · '.join(sorted(baseline))}")
print(f"基线 BFS 状态 {base_states}(找到全部即早退,超预算={base_hit})")
print("负例 掐断 e_truth 唯一入边 c_dq_2b(条件改引用未声明 flag)")
print(f"静态口径不可达 {sorted(static_unreach)}")
print(f"改动后可达 {len(current)} / {len(names)}:{' · '.join(sorted(current))}")
print(f"回归结局 {regressed if regressed else '(无)'}")
if "真相" not in current and "真相" in baseline:
print("WARN 结局「真相」由可达变为不可达(软告警,不硬失败)")
print("wrote out/nm_03_3_reachability_regression.json")
print(f"OK nm_03_3_reachability_regression:基线 {len(baseline)} 可达,改动后 {len(current)} 可达,回归 {len(regressed)} 个")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_03_3_reachability_regression.py:
基线可达结局 5 / 5:沉没 · 灯守 · 真相 · 离港 · 缄默
基线 BFS 状态 9895423(找到全部即早退,超预算=False)
负例 掐断 e_truth 唯一入边 c_dq_2b(条件改引用未声明 flag)
静态口径不可达 ['真相']
改动后可达 4 / 5:沉没 · 灯守 · 离港 · 缄默
回归结局 ['真相']
WARN 结局「真相」由可达变为不可达(软告警,不硬失败)
wrote out/nm_03_3_reachability_regression.json
OK nm_03_3_reachability_regression:基线 5 可达,改动后 4 可达,回归 1 个
判读:两个口径为什么结论一致
第一,基线是 5 / 5,改动后是 4 / 5,回归 1 个。 基线 BFS 枚举了全部 9895423 个可达状态、找到 5 个结局后早退;掐断 c_dq_2b 后只剩 4 个,真相成为回归结局。「回归」这个数(1)比「可达结局数」(5→4)更有信息量——它直接指出了是哪一个变了。
第二,静态口径与 BFS 口径在这里结论一致。 c_dq_2b 是 e_truth 的唯一入边,掐断它,静态死条件分析就判出真相不可达——不必再跑一次 20 秒的全量 BFS。静态口径便宜,但只在「入边全断」时精确:如果一条边只是变难(条件更苛刻)而非恒假,静态口径判不出来,必须回退到 BFS 或采样。便宜口径与精确口径的边界,要写清楚。
第三,__never_set__ 是一个「未声明的 flag」,不是「声明了但没置位」。 这是两种不同的死法:引用未声明的名字是结构死条件(规格里根本没有这个 flag,条件永远求值为假);引用已声明但从未置位的 flag 是语义死条件(结构上合法,但要靠可达性分析才能判出恒假)。本例演示的是前者——它更便宜,也更该在规格校验阶段就被拦下。
B 层:阈值门的扰动扫描
上一个是布尔改动(边断没断)。更常见的是阈值改动:灯守结局 e_lit 的条件是 found_yan_journal AND yan >= 40——一个阈值门。下面这段 B 层脚本 sim/nm_03_3_threshold_perturb.py 把阈值 T 从 0 扫到 101(跨过好感度域上界 100),问一个精确的问题:e_lit 在哪个 T 上由可达变为不可达? 答案由一个 BFS 导出的 Y*(已置位 found_yan_journal 的状态里 yan 的最大值)一次定死,扫 T 只是常数时间。B 层只产出软告警,恒 exit 0。
#!/usr/bin/env python3
# file: sim/nm_03_3_threshold_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_03_3_threshold_perturb.py
"""卷三 3.3 结局阈值的扰动扫描(B 层):阈值一动,结局会不会变得不可达,**软告警不硬失败**。
灯守结局 e_lit 的条件是 `found_yan_journal AND yan >= 40`——一个**阈值门**。精确口径:一次
BFS 收集所有到达父节点 d_dq_2 的状态,取「已置位 found_yan_journal 的状态里 yan 的最大值」
Y*,则 e_lit 可达 ⇔ T <= Y*。采样口径:每个 T 用 2000 条均匀随机路径估 e_lit 占比,看它随
T 衰减的相变——它比精确口径**更早**归零,正是「可达 ≠ 易达」。口径(proxy):可达性是布尔
结构量、占比是路径频率,只做相对比较。**B 层只产出软告警,一律 exit 0**。公共内核
sim/nm_03_common.py。产出 out/nm_03_3_threshold_perturb.json。
"""
from __future__ import annotations
import json
import os
import sys
from nm_03_common import OUT, Rng, SEED, bfs_packed, load_spec, walk
ENDING_NAME = "灯守"
N_PER = 2000
THRESHOLDS = [0, 20, 40, 60, 80, 90, 100, 101]
def set_threshold(spec: dict, old: int, new: int) -> dict:
"""把规格里所有 ['aff','yan','>=',old] 的阈值改成 new(结局 when 与对应选项 when)。"""
def walk_cond(c):
if not c:
return
if c[0] == "aff" and c[1] == "yan" and c[2] == ">=" and c[3] == old:
c[3] = new
elif c[0] in ("and", "or"):
walk_cond(c[1]); walk_cond(c[2])
elif c[0] == "not":
walk_cond(c[1])
for e in spec["endings"]:
walk_cond(e.get("when"))
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
walk_cond(c.get("when"))
return spec
def main() -> int:
spec = load_spec()
end_node = next(n["id"] for n in spec["dialogue"] if n.get("ending") == ENDING_NAME)
parent = next(n["id"] for n in spec["dialogue"]
for c in (n.get("choices") or []) if c["goto"] == end_node)
old_thr = next(e["when"][2][3] for e in spec["endings"] if e["name"] == ENDING_NAME)
VARS, FLAGS = sorted(spec["state"]["vars"]), sorted(spec["state"]["flags"])
yi, ji = VARS.index("yan"), FLAGS.index("found_yan_journal")
pairs = set()
def on_state(node, aff, fl, it):
if node == parent:
pairs.add((aff[yi], (fl >> ji) & 1))
states, budget_hit = bfs_packed(spec, on_state)
yans = sorted(y for y, j in pairs if j)
y_star = yans[-1] if yans else -1
y_p50 = yans[len(yans) // 2] if yans else -1
y_p90 = yans[len(yans) * 9 // 10] if yans else -1
rows = []
for t in THRESHOLDS:
clone = set_threshold(json.loads(json.dumps(spec)), old_thr, t)
rng, hit = Rng(SEED), 0
for _ in range(N_PER):
name, _ = walk(clone, rng)
if name == ENDING_NAME:
hit += 1
rows.append({"threshold": t, "reachable": t <= y_star, "sample_share_permille": hit * 1000 // N_PER})
os.makedirs(OUT, exist_ok=True)
report = {"ending": ENDING_NAME, "parent_node": parent, "old_threshold": old_thr,
"parent_states": len(pairs), "states_enumerated": states, "budget_hit": budget_hit,
"y_star": y_star, "y_p50": y_p50, "y_p90": y_p90,
"n_per": N_PER, "seed": SEED, "rows": rows}
with open(os.path.join(OUT, "nm_03_3_threshold_perturb.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"结局 {ENDING_NAME} 父节点 {parent},原阈值 yan >= {old_thr}")
print(f"父节点可达状态 {len(pairs)} 个(BFS 全量 {states} 状态,超预算={budget_hit})")
print(f"已置位 found_yan_journal 的状态里 yan 最大值 Y* = {y_star}(中位 {y_p50},P90 {y_p90})")
print(f"{'阈值T':>6}{'可达':>6}{'采样占比‰':>12}")
for r in rows:
print(f"{r['threshold']:>6}{'是' if r['reachable'] else '否':>6}{r['sample_share_permille']:>12}")
warns = 0
for r in rows:
if not r["reachable"]:
print(f"WARN 阈值 T={r['threshold']} 下结局「{ENDING_NAME}」不可达(T > Y*={y_star})")
warns += 1
elif r["sample_share_permille"] == 0:
print(f"WARN 阈值 T={r['threshold']} 下 {N_PER} 条采样未走到「{ENDING_NAME}」(可达但极稀有)")
warns += 1
print(f"wrote out/nm_03_3_threshold_perturb.json rows={len(rows)}")
print(f"OK nm_03_3_threshold_perturb:Y*={y_star},{len(rows)} 档阈值已扫描,告警 {warns} 条已报告(B 层不硬失败)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_03_3_threshold_perturb.py:
结局 灯守 父节点 d_dq_2,原阈值 yan >= 40
父节点可达状态 37 个(BFS 全量 9895423 状态,超预算=False)
已置位 found_yan_journal 的状态里 yan 最大值 Y* = 100(中位 55,P90 95)
阈值T 可达 采样占比‰
0 是 90
20 是 85
40 是 58
60 是 21
80 是 3
90 是 0
100 是 0
101 否 0
WARN 阈值 T=90 下 2000 条采样未走到「灯守」(可达但极稀有)
WARN 阈值 T=100 下 2000 条采样未走到「灯守」(可达但极稀有)
WARN 阈值 T=101 下结局「灯守」不可达(T > Y*=100)
wrote out/nm_03_3_threshold_perturb.json rows=8
OK nm_03_3_threshold_perturb:Y*=100,8 档阈值已扫描,告警 3 条已报告(B 层不硬失败)
判读:可达的临界点是 Y*,易达的临界点更早
第一,可达的临界点精确落在 T = 101。 到达父节点 d_dq_2 且已置位 found_yan_journal 的状态有 37 个,其中 yan 的最大值是 Y* = 100(好感度域上界)。所以 e_lit 在 T <= 100 时可达、T = 101 时不可达——这个结论由一次 BFS 定死,与扫不扫 T 无关。「可达性」是布尔量,临界点必须精确。
第二,易达的临界点早得多。 采样占比随 T 一路衰减:T=0 时 90‰、T=40 时 58‰、T=80 时 3‰、T=90 起 2000 条采样一次都走不到。可达(T≤100)和易达(T≤80 左右)差了 20 个好感度点。 这正是「可达 ≠ 易达」——一个结局在结构上可能可达,但随机玩法几乎撞不到它。可达性回答「能不能」,采样占比回答「容不容易」,两个问题不能混。
第三,3 条告警全是软告警,脚本仍 exit 0。 T=90、T=100 是「可达但极稀有」(占比 0),T=101 是「不可达」——两者都只记 WARN。B 层不替设计做决定:把阈值定在哪,是设计选择;脚本只报告「这个选择会让结局变得多稀有 / 不可达」。
可达性与可达性回归都是代理指标:它们度量「某个结局在结构上能否被走到」,不声称可达的结局就是好结局,也不声称某个
Y*值对应某种体验。Y*只是「当前这张图里,通往该结局的状态中门槛变量的最大值」——规格一改,它同步改变。
自检表
| 检查项 | 合格标准 |
|---|---|
| 回归定义精确 | 「基线可达 + 改动后不可达」两个条件同时满足才算回归 |
| 两口径分清 | 静态死条件(便宜,入边全断时精确)与 BFS(精确,通用) |
| 临界点可定位 | 可达临界点 = Y*(精确);易达临界点由采样占比给出 |
| 软告警口径 | B 层不可达 / 极稀有只记 WARN,恒 exit 0;断言写「告警条数已报告」 |
第 3 章到此把「结局分布与分支平衡」量完:3.1 量结局的分布(熵),3.2 量分支的分布(偏斜),3.3 量改动前后的差异(可达性回归)。但「走到哪个结局」只是路径的一个坐标——每个选择能不能被玩家看见它的后果,是下一章要量的。
阅读导航:上一节:3.2 分支的平衡与偏斜 · 下一节:4.1 后果可见性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。