5.3 弧光的可达性

乐观上界只说明「够得到」,不等于「走得到」。本节真的跑两万条采样路径,逐条记录六个角色的好感度峰值,看它是否越过 stages 对应的三个门限。结果是 15/18 可达、3 条不可达(全部落在林与孩身上);更耐读的是「可达」内部的落差——砚的 trust 只有 4‰ 的路径走到,沈的有 112‰。

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 可达路径占比峰值
沈 shen112‰100
沐 mu37‰100
徽 hui17‰97
砚 yan4‰100
林 lin0‰40
孩 hai0‰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 三幕、五幕与起承转合 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长