8.1 分支拓扑:分叉、汇聚、并行

分支拓扑把对话图读成三种原语:分叉是一出多、汇聚是多入一、并行是两条互不可达的去向。本节用 S 层脚本对 48 个节点统计出度与入度,找出全部 9 个分叉点与 8 个汇聚点,再用支配点算法分离出 4 个「所有路径必经」的强制汇流点,最后落出一张 48 行的拓扑表。

8.1 分支拓扑:分叉、汇聚、并行

Phaser 分支叙事与对话系统 那一侧关心的是「对话树怎么在引擎里跑起来」——数据结构、节点推进、选项渲染。本节在它下面一层:不问引擎怎么执行,只问这张图长什么形状。前七章把叙事的构成、节拍、条件与顺序约束都落成了可校验的数据;到了第八章,问题换了一个角度——不看单个节点写了什么,看节点与节点之间的连线排布。这一节处理最基础的一层:拓扑原语。

三种原语:分叉、汇聚、并行

把 dialogue 段的 48 个节点读成有向图:节点是对话节点,边是选项(choice 从当前节点指向它的 goto)。在这个视角下,叙事里所有「线」的走向都可以拆成三种原语:

原语图论口径叙事含义判据
分叉 fork出度 ≥ 2 且目标不唯一一次真正的岔路:不同选项通向不同节点distinct(goto) >= 2
汇聚 merge入度 ≥ 2(前驱去重)多条线收拢到同一个节点,故事重新合流len(preds) >= 2
并行 parallel同一分叉点分出的两条去向互不可达两条线各自推进,彼此不干扰b ∉ reach(a) 且 a ∉ reach(b)

三者互不替代。分叉管「有没有岔路」,汇聚管「岔路有没有收回来」,并行管「岔开的线之间会不会互相踩到」。一个只有分叉、没有汇聚的结构是纯树:每一层的选择都会永久分裂世界,节点总数随深度指数膨胀。一个只有分叉、没有并行的结构是纠缠图:两条看似分开的线在几跳之后又能互相跳到,玩家会觉得「我刚做的选择好像不算数」。

分叉不是扇出:一个必须先分开的口径

出度 ≥ 2 并不等于分叉。同一个节点上挂两个选项、但两个选项的 goto 指向同一个节点,这在图上是「出度 2、去向 1」——本节把它单列一类,叫扇出 fanout。

口径出度去向数是否改变后续
分叉 fork≥ 2≥ 2是:走哪条选项,后面看的节点不同
扇出 fanout≥ 21否:去向相同,差别只在选项自身的效果

这个区分在《雾港夜航》里格外重要:全站 43 个带选项的节点中,只有 9 个是分叉,另外 34 个是扇出。也就是说多数选择不改变你接下来看到什么,只改变你带着什么状态走下去。这不是设计缺陷,而是两种不同的手段:分叉管「剧情走向」,扇出管「状态记账」。但它意味着——只数出度会严重高估这张图的分支程度。8.3 会把这件事算到底。

强制汇流:用支配点找出「必经之路」

汇聚还有强弱之分。一个节点入度 ≥ 2 只说明有人汇进来;如果从起点到任一结局的每条路径都必须经过它,那它是强制汇流点——玩家无论怎么选都会撞上它。

这就是编译原理里的支配点(dominator):m 支配 n,当且仅当从起点到 n 的每条路径都经过 m。在有向无环图上用迭代法求不动点即可:先把每个节点的支配集初始化为全部可达节点,起点支配集为 {起点},然后反复用 dom[n] = ⋂ dom[p] ∪ {n}(p 取 n 的全部前驱)更新,直到不再变化。把 5 个结局节点的支配集取交集,得到的就是「所有结局都必经」的节点集合。

为什么用交集而不是逐个判断:结局是终点,没有后继,所以「到某个结局的路径」等价于「到该结局节点本身」。取 5 个结局的支配集交集,正好是「到每个结局都必须经过」的节点——这正是强制汇流的定义。

S 层:清点 48 个节点

下面这段是 S 层脚本 sim/na_08_1_topology.py。它读 config/story_spec.json,建出边表与邻接表,算出度、去重入度、支配集,然后打印三类原语的清单与并行去向对,并写出 48 行拓扑表。节点数与边数对不上 meta.scale 就非零退出——拓扑清点必须先确认自己读的是完整图。

#!/usr/bin/env python3
# file: sim/na_08_1_topology.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_08_1_topology.py
"""卷一 8.1 分支拓扑(S 层):把 48 个对话节点读成有向图,量三种拓扑原语。

  分叉 fork    出度 >= 2 且目标不唯一(一出多,真的分出不同去向)
  扇出 fanout  出度 >= 2 但全部指向同一目标(多个选项共用一个去向)
  汇聚 merge   入度 >= 2(多入一,由不同前驱汇到一起)
  强制汇流     入度 >= 2 且是支配点:从起点到任一结局的路径都必经它
  并行         同一分叉点分出的两条去向互不可达(两条线彼此不干扰)

产出 out/narr08_topology.csv(48 行)与控制台摘要;节点/边计数不符即非零退出。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SPEC = os.path.join(ROOT, "config/story_spec.json")


def reachable(succ: dict, start: str) -> set:
    seen, stack = set(), [start]
    while stack:
        x = stack.pop()
        if x not in seen:
            seen.add(x)
            stack.extend(succ[x])
    return seen


def dominators(succ: dict, preds: dict, start: str) -> dict:
    """n 被 m 支配 <=> 从起点到 n 的每条路径都经过 m。DAG 上迭代求不动点。"""
    r = reachable(succ, start)
    dom = {n: set(r) for n in r}
    dom[start] = {start}
    changed = True
    while changed:
        changed = False
        for n in sorted(r):
            ps = [p for p in preds[n] if p in r]
            if n == start or not ps:
                continue
            new = set.intersection(*(dom[p] for p in ps)) | {n}
            if new != dom[n]:
                dom[n] = new
                changed = True
    return dom


def nearest_join(succ: dict, a: str, b: str):
    """a、b 分出的两条线「最近重逢」的节点与跳数:BFS 序遇到的第一个共同后继。"""
    both = reachable(succ, a) & reachable(succ, b)
    seen, queue = {a}, [(a, 0)]
    while queue:
        x, d = queue.pop(0)
        if x in both:
            return x, d
        for y in succ[x]:
            if y not in seen:
                seen.add(y)
                queue.append((y, d + 1))
    return None, None


def main() -> int:
    spec = json.load(open(SPEC, encoding="utf-8"))
    nodes = spec["dialogue"]
    by = {n["id"]: n for n in nodes}
    start = spec["meta"]["start"]
    scale = spec["meta"]["scale"]

    succ = {n["id"]: [] for n in nodes}
    preds = {n["id"]: set() for n in nodes}
    edges = []
    for n in nodes:
        for c in (n.get("choices") or []):
            succ[n["id"]].append(c["goto"])
            preds[c["goto"]].add(n["id"])
            edges.append((n["id"], c["id"], c["goto"]))
    preds = {k: sorted(v) for k, v in preds.items()}

    out_deg = {n["id"]: len(n.get("choices") or []) for n in nodes}
    dst = {n["id"]: len({c["goto"] for c in (n.get("choices") or [])}) for n in nodes}
    in_deg = {n["id"]: len(preds[n["id"]]) for n in nodes}
    leaves = sorted(n["id"] for n in nodes if out_deg[n["id"]] == 0)
    end_dom = set.intersection(*(dominators(succ, preds, start)[e] for e in leaves))

    forks = sorted(n for n in out_deg if dst[n] >= 2)
    fanouts = sorted(n for n in out_deg if out_deg[n] >= 2 and dst[n] == 1)
    merges = sorted(n for n in in_deg if in_deg[n] >= 2)
    forced = sorted(n for n in merges if n in end_dom)

    parallel = []
    for f in forks:
        tg = sorted({c["goto"] for c in by[f]["choices"]})
        for i in range(len(tg)):
            for j in range(i + 1, len(tg)):
                a, b = tg[i], tg[j]
                if b not in reachable(succ, a) and a not in reachable(succ, b):
                    parallel.append((f, a, b))
    mid = [p for p in parallel if p[1] not in leaves and p[2] not in leaves]

    bad = []
    if len(nodes) != scale["dialogue_nodes"]:
        bad.append(f"节点数 {len(nodes)} != scale {scale['dialogue_nodes']}")
    if len(edges) != scale["choices"]:
        bad.append(f"边数 {len(edges)} != scale {scale['choices']}")

    print(f"nodes={len(nodes)}  edges={len(edges)}  start={start}")
    print(f"分叉 fork(出度>=2 且目标不唯一)    {len(forks)}  {forks}")
    print(f"扇出 fanout(出度>=2 但目标唯一)    {len(fanouts)}")
    print(f"汇聚 merge(入度>=2)                {len(merges)}  {merges}")
    print(f"强制汇流(入度>=2 且所有路径必经)   {len(forced)}  {forced}")
    print(f"结局节点 leaf(出度=0)              {len(leaves)}  {leaves}")
    print(f"并行去向对 {len(parallel)}(终局对 {len(parallel) - len(mid)}、中途并行 {len(mid)})")
    for f, a, b in mid:
        j, d = nearest_join(succ, a, b)
        print(f"    {f} -> {a} ∥ {b}  最近重逢 {j}({d} 跳)")

    rows = [{"id": n["id"], "scene": n["scene"], "speaker": n.get("speaker", "-"),
             "in_deg": in_deg[n["id"]], "out_deg": out_deg[n["id"]], "distinct": dst[n["id"]],
             "role": ("ending" if n.get("ending") else "forced-merge" if n["id"] in forced
                      else "fork" if n["id"] in forks else "merge" if n["id"] in merges
                      else "fanout" if n["id"] in fanouts else "chain")} for n in nodes]
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "narr08_topology.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)
    print(f"wrote out/narr08_topology.csv rows={len(rows)}")

    for e in bad:
        print(f"FAIL {e}")
    if bad:
        print(f"== na_08_1_topology:FAIL {len(bad)}")
        return 1
    print("OK na_08_1_topology:拓扑原语清点一致")
    return 0


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

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

nodes=48  edges=115  start=d_pier_1
分叉 fork(出度>=2 且目标不唯一)    9  ['d_bw_3', 'd_dq_2', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2', 'd_lb_3', 'd_lt_3', 'd_mr_3', 'd_pier_2']
扇出 fanout(出度>=2 但目标唯一)    34
汇聚 merge(入度>=2)                8  ['d_bw_1', 'd_dq_1', 'd_fg_1', 'd_ho_1', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2', 'd_mr_1']
强制汇流(入度>=2 且所有路径必经)   4  ['d_dq_1', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2']
结局节点 leaf(出度=0)              5  ['e_dark', 'e_lit', 'e_loss', 'e_silence', 'e_truth']
并行去向对 14(终局对 10、中途并行 4)
    d_hub_a1 -> d_ch_1 ∥ d_wp_1  最近重逢 d_hub_a2(3 跳)
    d_lb_3 -> d_mr_1 ∥ d_tc_1  最近重逢 d_ho_1(3 跳)
    d_mr_3 -> d_db_1 ∥ d_ho_1  最近重逢 d_hub_b2(3 跳)
    d_pier_2 -> d_chapel_1 ∥ d_tavern_1  最近重逢 d_hub_a1(3 跳)
wrote out/narr08_topology.csv rows=48
OK na_08_1_topology:拓扑原语清点一致

判读:48 个节点读出了什么

把三行数字摊开看,这张图的结构比「48 个节点、115 个选项」这个总量描述要具体得多。

分叉只有 9 个,而且集中。 9 个分叉点是 d_pier_2、d_lb_3、d_hub_a1、d_hub_a2、d_hub_b2、d_mr_3、d_lt_3、d_bw_3、d_dq_2。其中 4 个带 hub_ 或 dq_ 前缀——它们是结构上的枢纽,其余是幕内的小分岔。48 个节点里只有 9 个真正分岔,意味着这张图的骨架是一条主干 + 若干短支,而不是一棵分形树。

汇聚 8 个,强制汇流 4 个。 8 个汇聚点是 d_dq_1、d_hub_a1、d_hub_a2、d_hub_b2、d_bw_1、d_fg_1、d_ho_1、d_mr_1。注意 d_hub_a1、d_hub_a2、d_hub_b2、d_dq_1 四个同时出现在分叉表与汇聚表里——它们既收线又放线,是枢纽节点;另外四个(d_bw_1、d_fg_1、d_ho_1、d_mr_1)各挂 3 个选项、但 3 个选项指向同一个去向,按本节前面区分扇出时的口径属于扇出,是纯粹的收束点。

四个强制汇流点是 d_dq_1、d_hub_a1、d_hub_a2、d_hub_b2——它们同时满足「入度 ≥ 2」与「到任一结局的路径都必经」。这是本节最有信息量的一行:故事有四个玩家无论如何都躲不开的节点。按所属场景读,d_hub_a1 与 d_hub_b2 挂在 pier、d_hub_a2 挂在 watch_post,三个都在第一夜的场景里;d_dq_1 挂在 dawn_quay,是第三夜黎明码头的那道。四个节点把整张图切成了四段:每一个强制汇流点都是一个「无论如何都会到达」的里程碑,玩家在不同支线上的经历都会在这四个点上被重新对齐。

并行 14 对,其中中途并行只有 4 对。 14 对里有 10 对是「分叉点直接分出两个结局」,那不是并行,那是互斥结局(e_lit 与 e_truth 当然互不可达)。真正值得看的是中途并行的 4 对,脚本对每对都算了「最近重逢」的节点与跳数:

分叉点两条去向最近重逢读法
d_hub_a1d_ch_1 ∥ d_wp_1d_hub_a2(3 跳)账房线与守夜线,两条都在第一夜
d_lb_3d_mr_1 ∥ d_tc_1d_ho_1(3 跳)第二夜的市场线与潮汐线
d_mr_3d_db_1 ∥ d_ho_1d_hub_b2(3 跳)第二夜的沉钟线与港务线
d_pier_2d_chapel_1 ∥ d_tavern_1d_hub_a1(3 跳)第一夜的礼拜堂线与酒馆线

四对并行的重逢跳数都是 3,且四个重逢点里有三个是强制汇流点(d_hub_a1、d_hub_a2、d_hub_b2),只有 d_ho_1 是普通汇聚点。这说明这张图的并行是局部的:两条线分开走 3 跳就重新相遇。这正是「局部并行 + 全局收束」的形状——玩家在短区间里有真实的空间感,但叙事主线不会永久分家。

反过来说,没有一处「两条线永远不相遇」的中途并行。如果存在,那就是一张可以各自独立成篇的子故事图,而不是一个统一的故事。

自检表

检查项合格标准
口径分离分叉(去向 ≥ 2)与扇出(去向 = 1)分开统计,不混成一个数
入度去重入度按前驱去重统计,不按边数——同一前驱的两条边只算一次
强制汇流用支配点交集判定,而不是「入度大就算」
并行判定双向不可达才算并行,单向可达只算「先后」
计数守卫节点数/边数与 meta.scale 不一致即非零退出

拓扑清点给了一张骨架图,但它只回答「线怎么连」。每条线有多长、每个岔口有多宽是另一组量:宽度决定玩家每次要读几个选项,深度决定一个周目要走多少步。下一节把这两把尺子架上。

阅读导航:上一节:7.3 顺序约束与解锁 · 下一节:8.2 宽度与深度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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