6.2 汇流的合法性

汇聚本身不是缺陷——115 条选项去重后只剩 56 条唯一跳转。真正的非法汇流是汇聚之后状态不可区分:两条不同入边到达同一节点后,下游全部条件的求值完全一致,信息被丢弃。本节给出可判定口径 D(M),实测 34 个汇聚节点里 22 个冗余、3 个跨源冗余,并用合成规格负例证明检测器会开火。

6.2 汇流的合法性

站内的独立游戏的叙事设计与分支对话 给的是分支汇聚的设计经验:哪里该合、哪里该分,配的是清单与示意图,代码不可跑。本节把「该不该合」变成一条能跑出计数的判据:什么样的汇聚算非法——两条边合到一起之后,如果下游再也分不出你是从哪条边进来的,这条信息就白丢了。上一节查的是「走进去出不来」,这一节查的是「走进来之后,两条不同的来路被抹平」。

汇流本身不是问题

先破除一个误解:汇聚不等于缺陷。本规格 115 条选项,按「源节点 → 目标」去重后只剩 56 条唯一跳转;也就是说大量选项其实是同一对节点之间的重复边,最后都收进 47 个不同的目标节点。分支叙事本来就会合流——不然节点数会指数爆炸。合流是压缩,是常态。

所以判据不能是「有没有汇聚」,只能是「汇聚有没有丢信息」。一个节点有多条入边,如果下游还能靠状态把「你是从哪条边来的」区分开,这次汇聚就是合法的;如果区分不开,就是冗余汇流——两条边白合了。

什么算非法汇流:可判定口径

「信息有没有丢」听着主观,但可以写成离散判据。对每个汇聚节点 M:

  1. 取 M 及其下游闭包里的全部条件——包括选项的 when、台词的 line_when、以及可达结局的 when——组成判据集 D(M)。这是「下游可能拿来区分来路」的全部依据。
  2. 对 M 的每条入边 e,遍历所有经 e 到达 M 的状态,把每个状态在 D(M) 上求值成一个位向量(D(M) 里第 j 个条件为真则第 j 位为 1),收集成向量集合。
  3. 两条入边等价,当且仅当它们的向量集合相等。等价即冗余。

为什么「集合相等」是对的判据?因为下游能看到的只有 D(M) 的求值结果。如果两条入边产生的所有可能求值向量一模一样,那么下游无论怎么写条件,都无法把这两条来路分开——来路信息在合流点就被彻底丢弃了。反之,只要存在一个向量只在一条入边出现,下游就有办法区分。

这条判据只依赖条件求值,不依赖任何主观判断,所以能穷举。代价是状态空间大——但和第 6.1 节一样,投影到条件可读变量后只有 3042869 个可达状态,几秒跑完。

S 层:为每条入边收集求值向量集合

脚本 sim/ne_06_2_merge_legality.py 做三件事:① 扫出所有入边 ≥2 的汇聚节点;② 对每个汇聚节点算下游闭包条件集 D(M);③ 在投影状态上 BFS,为每条入边收集到达向量集合,再按集合相等分等价类。

#!/usr/bin/env python3
# file: sim/ne_06_2_merge_legality.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_06_2_merge_legality.py
"""卷二 6.2 汇流的合法性(S 层):哪些汇聚把信息丢了。

汇聚(一个节点有多条入边)本身不是问题——本规格 115 条选项去重后只有 56 条唯一跳转
(按「源节点 -> 目标」去重),汇聚是常态。**冗余汇流**指:两条不同入边到达同一节点时,
其后继全部条件的求值结果完全相同,于是「从哪条边进来」这条信息被丢弃,后续条件无法区分。

可判定口径(写死在代码里):对每个汇聚节点 M,取 M 及其下游闭包里的**全部条件**(选项
when + 台词 line_when + 可达结局的 when)组成判据集 D(M);对每条入边 e,收集所有经 e
到达 M 的状态在 D(M) 上的**求值向量集合**;两入边等价 <=> 集合相等,等价即冗余。

用「投影到条件可读变量」的完整 BFS 精确枚举到达状态。产出 out/ne_06_2_merge_legality.csv。
"""
from __future__ import annotations

import csv
import json
import os
import sys
from collections import defaultdict

HERE = os.path.dirname(os.path.abspath(__file__))
AFF_LO, AFF_HI = 0, 100


def read_vars(spec):
    rf, ri, ra = set(), set(), set()

    def walk(c):
        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:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    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)}
    ending_of = {n["id"]: n["ending"] for n in nodes if n.get("ending")}
    end_when = {e["name"]: e.get("when") 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

    in_edges = defaultdict(list)
    for n in nodes:
        for c in (n.get("choices") or []):
            in_edges[c["goto"]].append((n["id"], c["id"]))
    merges = {m: e for m, e in in_edges.items() if len(e) >= 2}
    n_jumps = len({(n["id"], c["goto"]) for n in nodes for c in (n.get("choices") or [])})

    def downstream(m):
        seen, stack, conds = set(), [m], []
        while stack:
            x = stack.pop()
            if x in seen:
                continue
            seen.add(x)
            for c in (by[x].get("choices") or []):
                if c.get("when"):
                    conds.append(c["when"])
                stack.append(c["goto"])
            if by[x].get("line_when"):
                conds.append(by[x]["line_when"])
            if x in ending_of and end_when.get(ending_of[x]):
                conds.append(end_when[ending_of[x]])
        return conds

    D = {m: downstream(m) for m in merges}

    aff0 = tuple(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in RA)
    seen = {(start, aff0, 0, 0)}
    frontier = [(start, aff0, 0, 0)]
    arr = defaultdict(set)
    while frontier:
        nxt = []
        for node, aff, fl, it in frontier:
            if by[node].get("ending"):
                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)
                if c["goto"] in merges:
                    vec = 0
                    for j, d in enumerate(D[c["goto"]]):
                        if cond(d, a2, f2, i2):
                            vec |= 1 << j
                    arr[(c["goto"], (node, c["id"]))].add(vec)
                k = (c["goto"], a2, f2, i2)
                if k not in seen:
                    seen.add(k)
                    nxt.append(k)
        frontier = nxt

    rows, pairs, cross_red = [], [], []
    for m, ins in sorted(merges.items()):
        sets = {e: frozenset(arr[(m, e)]) for e in ins}
        classes = {}
        for e in ins:
            classes.setdefault(sets[e], []).append(e)
        cr = False
        for i in range(len(ins)):
            for j in range(i + 1, len(ins)):
                if sets[ins[i]] == sets[ins[j]]:
                    pairs.append((m, ins[i], ins[j]))
                    if ins[i][0] != ins[j][0]:
                        cr = True
        if cr:
            cross_red.append(m)
        rows.append({"node": m, "in_degree": len(ins), "sources": len({s for s, _ in ins}),
                     "classes": len(classes), "cross_source": int(len({s for s, _ in ins}) >= 2),
                     "redundant": int(len(classes) < len(ins))})

    out = os.path.join(HERE, "../out")
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "ne_06_2_merge_legality.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)

    n_red = sum(r["redundant"] for r in rows)
    print(f"选项 115 条 / 唯一跳转(源->目标去重){n_jumps} 条")
    print(f"汇聚节点 {len(merges)} 个(跨源 {sum(r['cross_source'] for r in rows)} / 同源扇入 "
          f"{sum(1 - r['cross_source'] for r in rows)}),投影可达状态 {len(seen)}")
    print(f"冗余汇流节点 {n_red} 个 / 等价入边对 {len(pairs)} 对;合法(各入边可区分){len(merges) - n_red} 个")
    print(f"其中跨源冗余汇流 {len(cross_red)} 个:{cross_red}")
    print("冗余入边对(同一汇聚节点、到达求值向量集合相同):")
    for m, e1, e2 in pairs:
        print(f"    {m}  {e1[0]}/{e1[1]}  ==  {e2[0]}/{e2[1]}")
    print("跨源但可区分(合法汇聚,信息保留):")
    for r in rows:
        if r["cross_source"] and not r["redundant"]:
            print(f"    {r['node']}  入边 {r['in_degree']} 条来自 {r['sources']} 源,分 {r['classes']} 类")
    print(f"wrote out/ne_06_2_merge_legality.csv rows={len(rows)}")
    print("OK ne_06_2_merge_legality:汇流扫描完成")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/ne_06_2_merge_legality.py:

选项 115 条 / 唯一跳转(源->目标去重)56 条
汇聚节点 34 个(跨源 8 / 同源扇入 26),投影可达状态 3042869
冗余汇流节点 22 个 / 等价入边对 23 对;合法(各入边可区分)12 个
其中跨源冗余汇流 3 个:['d_bw_1', 'd_dq_1', 'd_mr_1']
冗余入边对(同一汇聚节点、到达求值向量集合相同):
    d_bw_1  d_hub_b2/c_hub_b2b  ==  d_lt_3/c_lt_3b
    d_bw_2  d_bw_1/c_bw_1a  ==  d_bw_1/c_bw_1b
    d_bw_3  d_bw_2/c_bw_2a  ==  d_bw_2/c_bw_2b
    d_ch_3  d_ch_2/c_ch_2a  ==  d_ch_2/c_ch_2b
    d_chapel_2  d_chapel_1/c_chapel_1b  ==  d_chapel_1/c_chapel_1c
    d_chapel_3  d_chapel_2/c_chapel_2a  ==  d_chapel_2/c_chapel_2b
    d_db_2  d_db_1/c_db_1a  ==  d_db_1/c_db_1b
    d_db_3  d_db_2/c_db_2a  ==  d_db_2/c_db_2b
    d_dq_1  d_lt_3/c_lt_3a  ==  d_bw_3/c_bw_3a
    d_dq_1  d_fg_3/c_fg_3a  ==  d_fg_3/c_fg_3b
    d_fg_2  d_fg_1/c_fg_1a  ==  d_fg_1/c_fg_1b
    d_fg_3  d_fg_2/c_fg_2a  ==  d_fg_2/c_fg_2b
    d_ho_2  d_ho_1/c_ho_1a  ==  d_ho_1/c_ho_1b
    d_ho_3  d_ho_2/c_ho_2a  ==  d_ho_2/c_ho_2b
    d_hub_b2  d_db_3/c_db_3a  ==  d_db_3/c_db_3b
    d_lb_2  d_lb_1/c_lb_1a  ==  d_lb_1/c_lb_1b
    d_lt_2  d_lt_1/c_lt_1b  ==  d_lt_1/c_lt_1c
    d_lt_3  d_lt_2/c_lt_2a  ==  d_lt_2/c_lt_2b
    d_mr_1  d_hub_a2/c_hub_a2b  ==  d_lb_3/c_lb_3b
    d_mr_3  d_mr_2/c_mr_2a  ==  d_mr_2/c_mr_2b
    d_tavern_2  d_tavern_1/c_tavern_1b  ==  d_tavern_1/c_tavern_1c
    d_tc_2  d_tc_1/c_tc_1a  ==  d_tc_1/c_tc_1b
    d_wp_2  d_wp_1/c_wp_1a  ==  d_wp_1/c_wp_1c
跨源但可区分(合法汇聚,信息保留):
    d_fg_1  入边 2 条来自 2 源,分 2 类
    d_ho_1  入边 3 条来自 2 源,分 3 类
    d_hub_a1  入边 4 条来自 2 源,分 4 类
    d_hub_a2  入边 4 条来自 2 源,分 4 类
wrote out/ne_06_2_merge_legality.csv rows=34
OK ne_06_2_merge_legality:汇流扫描完成

结果怎么读

34 个汇聚节点里,22 个冗余、12 个合法。 冗余的绝大多数是同源扇入——同一个节点的两个选项都指向同一目标(比如 d_bw_1/c_bw_1a == d_bw_1/c_bw_1b),这两条边本来就没打算传递不同信息,被判定冗余是预期之内的:它们只是同一段对话的两个措辞,效果一样,合流当然分不开。

真正值得盯的是跨源冗余:两条边来自不同节点,却在下游完全不可区分。本规格有 3 个这样的节点——d_bw_1、d_dq_1、d_mr_1。以 d_bw_1 为例,它的一条入边来自 d_hub_b2、另一条来自 d_lt_3,两条来路在 D(d_bw_1) 上的求值向量集合完全相同,意味着「你是从码头那侧来的、还是从灯塔那侧来的」在 d_bw_1 之后再也影响不了任何条件。这就是信息丢失——设计上如果本想让下游对来路有反应,这里就是 bug。

对照着看合法跨源汇聚:d_hub_a1 有 4 条入边来自 2 个源,却分成 4 类——每条边的到达状态在下游都能被区分,信息完整保留。这就是「合流但不丢信息」的正面样本。

P 层:合成规格负例

「34 个汇聚、22 个冗余」这句话要可信,检测器必须能在构造出来的例子上开火。P 层用一个两节点合成规格做正反两例:a 有两个选项都跳到 m,m 有一个带 when 的选项跳到结局。

  • 负例一:a 的两个选项效果相同(都空)→ 到达 m 的状态集合相同 → 判冗余(classes 1 类、redundant true)。
  • 负例二:给其中一个选项加 set: {f: 1} → 两条入边到达 m 的 f 不同 → 判合法(classes 2 类、redundant false)。

同一个合成规格,只改一个选项的 effects,判定就从冗余翻成合法——说明检测器读的确实是「到达状态的求值差异」,不是别的。本机实跑 node --test 'core/test/ne_06_2_merge_legality.test.ts':

汇聚 34,冗余 22,等价入边对 23
✔ 真实规格:34 个汇聚节点,22 个冗余,跨源冗余 3 个 (5888.018459ms)
✔ 负例一 · 两入边效果相同:合成规格的汇聚被判定为冗余 (0.255791ms)
✔ 负例二 · 两入边效果不同:同一合成规格的汇聚不再冗余 (0.735667ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6178.891292

判读

第一,「冗余」不等于「要改」。 22 个冗余里绝大多数是同源扇入(措辞不同的同一跳转),它们冗余是正常的。判据的价值在于把跨源冗余单独拎出来——只有那 3 个才是「两条不同来路被抹平」的真问题。

第二,判据是保守的。 它只问「下游条件能不能区分」,不预测玩家体验。一个跨源冗余节点可能永远没人注意,也可能正是设计想要的效果。它给的是一份可复核的清单,不是判决。

第三,口径可以收紧或放松。 D(M) 取的是「下游闭包的全部条件」。如果把 line_when 排除,只留选项 when,冗余数会变——因为有些区分只体现在台词层。口径必须写死在代码里、随结论一起报告,否则「22 个」这个数字无从复现。

自检表

检查项合格标准
先破除误解明确汇聚是常态(115 条选项 → 56 条唯一跳转),判据只针对「丢信息」
口径可判定D(M) = 下游闭包全部条件;等价 = 到达求值向量集合相等
精确非抽样在全部 3042869 个投影可达状态上收集向量,不靠采样
跨源单列冗余拆成同源扇入与跨源冗余,只后者是信息丢失
负例必给合成规格改一处 effects 即翻转判定,证明检测器开火

汇流查完,还剩最后一问:所有结局都够得着吗?下一节给 5 个结局各找一条真实到达路径,并把「可达」与「易达」分开报。

阅读导航:上一节:6.1 死路检测 · 下一节:6.3 结局的可达性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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