5.3 弧光的可达性
独立游戏的叙事设计与分支对话 谈「玩家不一定会走到你想让他走到的地方」——那是散文式的可达性经验,把「走不到」说成一种担忧。本节把这份担忧变成一个可以量出来的数:沿真实路径跑一遍,看每个角色的好感度到底有没有越过弧光声明的门限。前两节证明了阶段表自洽、门限够得到;这一节要回答最要紧的那个问题——玩家走得到吗。
关键问题:真的可达吗
上一节用「乐观上界」筛了一遍,但乐观上界有一个致命的乐观:它假设玩家把每个正向选项都选了一遍。真实玩家在岔路上只选一条,而且往往选不到给某个角色加好感的那些。于是「够得到」和「走得到」之间,隔着整个选择空间。
这一节的问法很直接:对每个角色、每个门限,有没有哪怕一条真实路径让它越过去?如果有,是多大比例的路径?如果没有,是哪一段机制断了?前一个问题是「可达性」,后一个问题是「可达的密度」——两者都要答。
采样:两万条路径各走一遍
采样策略:从 meta.start 出发,每一步在当前可用选项里用自实现的 MINSTD 伪随机挑一条(state = state * 48271 % 2147483647),一直走到结局节点。种子固定为 20261016,与 na_00 / na_02 同源——同种子同路径,这是本套跨脚本对照的地基。
每条路径记录四个变量角色的好感度峰值,而不是终值。这个选择很关键:一个角色可能在第二夜冲到 90,第三夜因为两个负向选项掉回 60。若只看终值,就会漏掉「他确实到过 trust 档」这个事实。弧光的门限是过程量——问的是「有没有到过」,不是「最后停在哪」。
两万条路径跑完,每个角色得到一张「门限 → 有多少条路径到过」的表。这是一个上界性质的采样:它只能证明「可达」(找到反例即可),不能证明「不可达」——采样没走到,不等于没有路径走到。所以判「不可达」时,我们还有 5.2 的乐观上界做旁证。
S 层:可达性采样
下面这个脚本做采样与统计。它直接复用 na_00_spec_check 的条件求值与效果算子,保证与规范校验器同源——采样里用到的 eval_cond / apply_effects 与全站校验用的是同一份实现,不会出现「校验器说能走、采样器说不能走」的分裂。
#!/usr/bin/env python3
# file: sim/na_05_3_arc_reachability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_05_3_arc_reachability.py
"""弧光可达性:沿 20000 条采样路径,看每个角色的好感度是否真的越过 stages 的门限。
乐观上界(5.2)只说明「够得到」;本节用真实路径实测。条件求值与效果算子直接复用
na_00_spec_check,保证与规范校验器同源。产出 out/arc_reachability.json。
"""
from __future__ import annotations
import json
import os
import sys
from na_00_spec_check import apply_effects, eval_cond, load_spec # 同源求值器
AFF_LO, AFF_HI = 0, 100
SAMPLE_N = 20000
SEED = 20261016
GATE_ORDER = ["cold", "warm", "trust"]
def main() -> int:
spec = load_spec()
nodes = {n["id"]: n for n in spec["dialogue"]}
chars = spec["characters"]
vars_ = sorted(spec["state"]["vars"])
init = {c["id"]: c["affinity"]["init"] for c in chars}
rng = SEED
max_aff = dict(init)
hit = {c["id"]: {t: 0 for t in c["affinity"]["thresholds"]} for c in chars}
dead = 0
for _ in range(SAMPLE_N):
st = {"aff": dict(init),
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
node = spec["meta"]["start"]
peak = dict(init)
for _step in range(200):
cur = nodes[node]
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
if not opts:
dead += 1
break
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
st = apply_effects(c.get("effects") or {}, st)
for v in vars_:
if st["aff"][v] > peak[v]:
peak[v] = st["aff"][v]
node = c["goto"]
else:
dead += 1
for cid in peak:
max_aff[cid] = max(max_aff[cid], peak[cid])
for t in hit[cid]:
if peak[cid] >= t:
hit[cid][t] += 1
for cid, v in max_aff.items():
assert AFF_LO <= v <= AFF_HI, f"{cid} 最大好感度越界 {v}"
rows = []
for c in chars:
cid = c["id"]
cells = [{"gate": GATE_ORDER[i], "threshold": t, "reachable": hit[cid][t] > 0,
"paths": hit[cid][t], "permille": (hit[cid][t] * 1000) // SAMPLE_N}
for i, t in enumerate(c["affinity"]["thresholds"])]
rows.append({"id": cid, "name": c["name"], "is_var": cid in vars_,
"init": c["affinity"]["init"], "max_aff": max_aff[cid], "cells": cells})
unreachable = [f"{r['id']}·{cell['gate']}={cell['threshold']}"
for r in rows for cell in r["cells"] if not cell["reachable"]]
reach = sum(1 for r in rows for cell in r["cells"] if cell["reachable"])
total = sum(len(r["cells"]) for r in rows)
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/arc_reachability.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"sample_n": SAMPLE_N, "seed": SEED, "dead_paths": dead, "rows": rows,
"cells_reachable": reach, "cells_total": total, "unreachable": unreachable},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"采样 {SAMPLE_N} 条路径,seed={SEED},卡死 {dead} 条")
print("角色 初值 峰值 | 门限30 | 门限60 | 门限85")
for r in rows:
cells = [f"{cell['gate']}={cell['threshold']} {'可达' if cell['reachable'] else '不可达'}({cell['permille']}‰)"
for cell in r["cells"]]
print(f"{r['id']} {r['init']} {r['max_aff']} | " + " | ".join(cells))
print(f"门限可达 {reach}/{total};不可达清单:{unreachable}")
if dead:
print(f"WARN 有 {dead} 条路径卡死")
print("OK na_05_3_arc_reachability:20000 条采样路径跑完,整数不变量通过")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_05_3_arc_reachability.py:
采样 20000 条路径,seed=20261016,卡死 0 条
角色 初值 峰值 | 门限30 | 门限60 | 门限85
yan 20 100 | cold=30 可达(708‰) | warm=60 可达(86‰) | trust=85 可达(4‰)
hui 50 97 | cold=30 可达(1000‰) | warm=60 可达(341‰) | trust=85 可达(17‰)
shen 50 100 | cold=30 可达(1000‰) | warm=60 可达(636‰) | trust=85 可达(112‰)
mu 50 100 | cold=30 可达(1000‰) | warm=60 可达(321‰) | trust=85 可达(37‰)
lin 40 40 | cold=30 可达(1000‰) | warm=60 不可达(0‰) | trust=85 不可达(0‰)
hai 60 60 | cold=30 可达(1000‰) | warm=60 可达(1000‰) | trust=85 不可达(0‰)
门限可达 15/18;不可达清单:['lin·warm=60', 'lin·trust=85', 'hai·trust=85']
OK na_05_3_arc_reachability:20000 条采样路径跑完,整数不变量通过
从输出读到的三件事
第一,15/18 可达,3 条不可达,全部落在林与孩身上。lin·warm、lin·trust、hai·trust 三条在两条路径里都是 0‰。这和 5.2 的乐观上界完全吻合——乐观上界说「连最好情况都够不到」,采样说「两万条里一条都没到」,两个独立口径指向同一批门限,这就是「不可达」可信的证据。
第二,「卡死 0 条」本身就是一条不变量。两万条路径没有一条走进「无可用选项且非结局」的死胡同——这说明场景图与选项条件没有制造死路。这条读数属于结构校验,但它必须和可达性一起报:若有一批路径卡死,上面的峰值统计就会被污染(没走到终局的角色峰值不可比)。
第三,「可达」内部的落差极大。同样是「可达」,cold 门限在五个角色上是 1000‰(每条路径都过),而砚的 trust 只有 4‰、徽的 trust 只有 17‰。这两者都写着「可达」两个字,含义却完全不同——一个是「人人都会看到」,一个是「两万条里只有八百条能看到」。
可达 ≠ 常见
把 trust 门限单独拎出来排一排,落差看得更清楚:
| 角色 | trust=85 可达路径占比 | 峰值 |
|---|---|---|
| 沈 shen | 112‰ | 100 |
| 沐 mu | 37‰ | 100 |
| 徽 hui | 17‰ | 97 |
| 砚 yan | 4‰ | 100 |
| 林 lin | 0‰ | 40 |
| 孩 hai | 0‰ | 60 |
读这张表要小心一件事:可达是布尔量,密度是比例量。写「可达」只回答「有没有」,不回答「多不多」。上表里沈的 trust 是砚的 28 倍,但两个都写「可达」——如果只报布尔量,这个 28 倍的差会被完全抹掉。所以一张诚实的可达性表,必须同时给布尔量与占比。
峰值一列还解释了一件事:徽的峰值是 97 而不是 100。这意味着徽在全部两万条路径里,好感度从未被顶到上限——不是机制不允许,而是给徽加好感的选项分布得比较散,没有哪条路径能一次攒够。这是「峰值」这一列能抓到、而「门限可达」抓不到的信息。
一条路径长什么样
统计表是两万条路径的汇总,容易被当成抽象数字。把第一条采样路径原样打出来,可达性就有了具体的形状。下表是 seed=20261016 的第一条路径,逐拍记录四个变量角色的好感度:
step node choice aff(yan,hui,shen,mu)
0 d_pier_1 — 20, 50, 50, 50
1 d_pier_2 c_pier_1b 20, 52, 50, 50
2 d_chapel_1 c_pier_2b 20, 52, 50, 50
3 d_chapel_2 c_chapel_1a 20, 52, 55, 50
4 d_chapel_3 c_chapel_2a 20, 52, 60, 50
5 d_hub_a1 c_chapel_3b 20, 52, 65, 50
6 d_wp_1 c_hub_a1b 20, 52, 65, 50
7 d_wp_2 c_wp_1b 25, 52, 65, 50
8 d_wp_3 c_wp_2a 30, 52, 65, 50
9 d_hub_a2 c_wp_3a 30, 52, 65, 50
10 d_mr_1 c_hub_a2b 30, 52, 65, 50
11 d_mr_2 c_mr_1a 35, 52, 65, 50
12 d_mr_3 c_mr_2b 35, 52, 65, 50
13 d_db_1 c_mr_3b 35, 52, 65, 50
14 d_db_2 c_db_1c 35, 52, 80, 50
15 d_db_3 c_db_2a 35, 52, 90, 50
16 d_hub_b2 c_db_3a 35, 52, 90, 50
17 d_bw_1 c_hub_b2b 35, 52, 90, 50
18 d_bw_2 c_bw_1c 35, 37, 90, 50
19 d_bw_3 c_bw_2b 35, 27, 90, 50
20 d_dq_1 c_bw_3a 35, 27, 90, 50
21 d_dq_2 c_dq_1a 35, 27, 90, 50
22 e_dark c_dq_2e 35, 27, 90, 50
ending = 离港
读这条路径能看到三件事:
- 同一个门限,不同角色的命运不同。这条路径上,沈在第 15 拍冲到 90,越过了 trust(85);而砚只到 35,刚过 cold(30)就停住了——他的 warm(60)在这条路径上没被碰到。
- 好感度会掉。徽在第 18、19 拍因为两个负向选项,从 52 一路掉到 27。这条路径上徽的峰值只有 52,离 warm(60)还差 8——他从头到尾没到过 warm。终值 27 更低,两种口径在这条路径上给出了一致结论;但在别的路径上可能不一致,这正是本节坚持用峰值的原因。
- 弧光的门限与结局的分岔是两套机制。这条路径走到「离港」结局(
e_dark),一个与沈无关的收束;可沈的好感度却在这条路径上被顶到了 trust。也就是说,一条路径可以「沈的弧走满了,结局却是离港」——弧光的可达性与结局的分布不是同一个问题,卷三会分别度量。
这条路径还顺带印证了采样表的一个读数:砚的 cold 在 708‰ 的路径上被越过,可这条路径刚好是那 708‰ 之一(第 8 拍到了 30),而它的 warm 属于没被越过的 914‰。单条路径看不出比例,但它把「比例是怎么来的」演示了一遍——两万条各自走一遍,每条记一个峰值,最后按门限数一遍。
三张表合起来
三节下来,同一个对象被三个口径问了三遍:
| 问题 | 工具 | 18 条里的结果 |
|---|---|---|
| 阶段表结构自洽吗 | 5.1 结构校验 | 18/18 合法 |
| 门限在声明节拍前够得到吗 | 5.2 乐观上界 | 14/18 对齐(4 条错位) |
| 门限在真实路径上被走到吗 | 5.3 采样 | 15/18 可达(3 条不可达) |
两张表差 1 条,差的就是「排期问题」与「机制缺失」的分野。5.2 的 4 条错位里,砚的阶段①只是声明太早——它的 cold 门限其实可达(708‰),只是声明在 b01、机制上最早 b05 才够得到;另外 3 条(林 ×2、孩 ×1)才是真的不可达。于是 5.3 报「3 条不可达」,与 5.2 的 3 条「不可达」逐条对应,而那条「落后」在 5.3 里不出现——因为 5.3 只问「到没到过」,不问「到得早不早」。
这正是三个口径要分开的原因:结构校验答「写对了吗」,乐观上界答「够得到吗」,采样答「走得到吗」。三者缺一,就会把「声明太早」和「机制缺失」混成同一类问题。
自检表
| 检查项 | 合格标准 |
|---|---|
| 峰值而非终值 | 门限是过程量,统计「有没有到过」而非「最后停在哪」 |
| 种子固定 | 用 20261016,与 na_00 / na_02 同源,路径可复现 |
| 布尔与占比并报 | 「可达」必须同时给布尔量与千分比,否则密度差被抹掉 |
| 同源求值 | 条件与效果复用 na_00 的实现,避免校验器与采样器分裂 |
| 不可达有旁证 | 采样判「不可达」时用乐观上界交叉验证 |
弧光的三个阶段、三个转折节拍、三个门限,到这里被问完了。但弧光只是角色这一层;把六个角色的弧光叠起来,得到的是整部作品的结构——三幕怎么分、节拍怎么排、比例怎么校验。下一章从单个角色升到整体结构,先回答一个最老的问题:三幕、五幕、起承转合,到底该选哪一种。
阅读导航:上一节:5.2 弧光与行为的对齐 · 下一节:6.1 三幕、五幕与起承转合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。