6.3 结局的可达性

5 个结局(灯守 / 离港 / 真相 / 缄默 / 沉没)全部可达,但可达不等于易达。本节为每个结局给出一条真实可回放的到达路径,再用 20000 条均匀随机采样算分布:离港 10074、沉没 5507、缄默 3223、灯守 1018、真相仅 178。并厘清 endings 的 id 与 dialogue 的 ending 两个口径。

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 条均匀随机路径落在五个结局上:

结局采样数占比
离港1007450.4%
沉没550727.5%
缄默322316.1%
灯守10185.1%
真相1780.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 分支宽度的度量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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