4.1 后果可见性

后果可见性量的是:一个选项写下的状态差量,要隔几步才被下游的条件读到。本节把 115 个选项逐个算成整数距离——34 个立即可见、37 个延迟、28 个写进状态却永不被读到,另有 16 个根本没有差量;平均 2.42 步、最远 12 步。全程结构 BFS,不出现浮点,也不预测玩家感受。

站内的独立游戏的叙事设计与分支对话 谈的是玩家选择体验——什么样的选项算「有意义」,配的是清单与示意图,代码不可跑。本节把「一次选择离它被玩家察觉有多远」变成一条可判定的整数距离:一个选项写下的状态差量,要隔几个节点才被下游的条件读到。间隔越长,玩家越难把后来的变化和自己刚才那一下联系起来——这是本章三节的入口,4.2 判「有没有后果」,本节判「后果离得多远」。

4.1 后果可见性

先把口径讲死:本节算出来的「后果可见性」是一个代理指标(proxy)。它量的是状态差量到第一个读点的结构距离——一个纯整数、可复现的结构量,不是玩家实际有没有感觉到。玩家是否留意到某个 flag 变了、是否把结局的差异归因到自己三夜前的那次选择,都需真实业务环境、命令行不可复现,本套不做、也不假装能做。这个距离只用于同一份规格内部的相对比较:比两个选项谁的后果更「即时」、比规格演化前后可见性怎么变。

定义:差量、读点、距离

三样东西,全部写死在代码里:

记号含义来源
write(c)选项 c 的效果写到的可观测量集合set 的 flag、add 的好感度变量、grant / revoke 的物品
read(n)节点 n 处会被求值的可观测量集合n 的每个选项的 when、n 的 line_when、n 若为结局节点则含该结局的 when
d(c)可见性距离:从 c.goto(记为第 0 步)沿 goto 边结构 BFS,第一个满足 read(n) ∩ write(c) ≠ ∅ 的节点所在步数结构量,与状态无关

d(c) = 0 表示「落点自己就依赖这个差量」——玩家一下脚,下一屏的条件就变了;d(c) = 5 表示要走过 5 个节点,差量才第一次被某条 when 读到。若整个下游都读不到 write(c),记 d(c) = ∞:这条差量写进了状态,却永远没人看。

为什么距离可以只按结构算、不必按状态算?因为 write(c) 是选项自带的——effects 写在规格里,与当前状态无关;read(n) 也是节点自带的。两者相交与否只取决于「哪个节点读了哪些变量」,是一条纯图上的可达性问题,不需要展开状态空间。代价是它忽略 when 的可满足性:某条读点也许在状态上不可达,距离会被低估。这一点在判读里显式标出。

S 层:115 个选项逐个算距离

脚本 sim/nm_04_1_consequence_visibility.py 读 config/story_spec.json,对 115 个选项各算一次 write / d / 分类,落 out/nm_04_1_visibility.csv 与 .json,并用纯 xml.etree 出直方图 out/nm_04_1_visibility.svg:

#!/usr/bin/env python3
# file: sim/nm_04_1_consequence_visibility.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_04_1_consequence_visibility.py
"""卷三 4.1 后果可见性(S 层):一个选项产生的状态差量,要隔几步才被下游「读到」。

可判定口径(写死在代码里,不按结果调参):
  1) write(c) = 选项 c 的效果写到的可观测量集合:set 写的 flag、add 写的好感度变量、
     grant / revoke 写的物品。三者都是**整数**量,不涉及文本。
  2) read(n) = 节点 n 处会被求值的可观测量集合:n 的每个选项的 when、n 的 line_when、
     以及 n 若是结局节点则含该结局的 when。
  3) 可见性距离 d(c) = 从 c.goto 出发(c.goto 记为第 0 步),沿 goto 边做**结构 BFS**
     (忽略 when 的可满足性),第一个满足 read(n) ∩ write(c) != 空 的节点所在的步数。
     若整个下游都读不到 write(c),则 d(c) = ∞(「写入即遗忘」)。

这是一个 **proxy**:它度量的是「差量离它的第一个读点有多远」这个**结构量**,只用于
不同选项之间的**相对比较**,不预测玩家的主观感受,也不声称「间隔短 = 体验好」。

产出 out/nm_04_1_visibility.csv(115 行)、out/nm_04_1_visibility.json、
纯 xml.etree 直方图 out/nm_04_1_visibility.svg。选项数不符即非零退出。
"""
from __future__ import annotations

import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import Counter, deque

HERE = os.path.dirname(os.path.abspath(__file__))
SVG = "{http://www.w3.org/2000/svg}"


def read_vars(spec):
    """节点 id -> 该节点处会被求值的可观测量集合(flag / 好感度 / 物品)。"""
    by = {n["id"]: n for n in spec["dialogue"]}
    out: dict[str, set] = {}

    def walk(c, acc):
        if not c:
            return
        k = c[0]
        if k in ("and", "or"):
            walk(c[1], acc)
            walk(c[2], acc)
        elif k == "not":
            walk(c[1], acc)
        elif k == "flag":
            acc.add(("flag", c[1]))
        elif k == "aff":
            acc.add(("aff", c[1]))
        elif k == "item":
            acc.add(("item", c[1]))

    end_when = {e["name"]: e.get("when") for e in spec["endings"]}
    for n in spec["dialogue"]:
        acc: set = set()
        walk(n.get("line_when"), acc)
        for c in (n.get("choices") or []):
            walk(c.get("when"), acc)
        if n.get("ending") and end_when.get(n["ending"]):
            walk(end_when[n["ending"]], acc)
        out[n["id"]] = acc
    return by, out


def write_vars(c):
    eff = c.get("effects") or {}
    acc = set()
    for k in (eff.get("set") or {}):
        acc.add(("flag", k))
    for k in (eff.get("add") or {}):
        acc.add(("aff", k))
    for k in list((eff.get("grant") or {})) + list((eff.get("revoke") or {})):
        acc.add(("item", k))
    return acc


def visibility_distance(by, reads, c):
    """从 c.goto 出发结构 BFS,返回第一个读点的步数;读不到返回 None。"""
    w = write_vars(c)
    if not w:
        return None, None
    start = c["goto"]
    seen = {start}
    q = deque([(start, 0)])
    while q:
        nid, d = q.popleft()
        if reads.get(nid, set()) & w:
            return d, nid
        for ch in (by[nid].get("choices") or []):
            if ch["goto"] not in seen:
                seen.add(ch["goto"])
                q.append((ch["goto"], d + 1))
    return None, None


def bucket(d):
    if d is None:
        return "never"
    if d == 0:
        return "d0"
    if d <= 2:
        return "d1_2"
    return "d3plus"


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    by, reads = read_vars(spec)

    rows = []
    for n in spec["dialogue"]:
        for c in (n.get("choices") or []):
            w = write_vars(c)
            d, at = visibility_distance(by, reads, c)
            rows.append({
                "node": n["id"], "choice": c["id"], "goto": c["goto"],
                "writes": len(w),
                "writes_flags": sum(1 for t, _ in w if t == "flag"),
                "writes_aff": sum(1 for t, _ in w if t == "aff"),
                "writes_items": sum(1 for t, _ in w if t == "item"),
                "visible_at": at or "",
                "distance": "" if d is None else d,
                "bucket": bucket(d) if w else "no_delta",
            })

    n = len(rows)
    no_delta = [r for r in rows if r["bucket"] == "no_delta"]
    with_delta = [r for r in rows if r["bucket"] != "no_delta"]
    bk = Counter(r["bucket"] for r in with_delta)
    dist = [r["distance"] for r in with_delta if r["distance"] != ""]
    mean_milli = (sum(dist) * 1000 // len(dist)) if dist else 0
    mx = max(dist) if dist else 0

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

    summary = {
        "choices": n,
        "no_delta": len(no_delta),
        "with_delta": len(with_delta),
        "immediate_d0": bk["d0"],
        "lag_d1_2": bk["d1_2"],
        "lag_d3plus": bk["d3plus"],
        "never_read": bk["never"],
        "mean_distance_milli": mean_milli,
        "max_distance": mx,
        "writes_flags_total": sum(r["writes_flags"] for r in rows),
        "writes_aff_total": sum(r["writes_aff"] for r in rows),
        "writes_items_total": sum(r["writes_items"] for r in rows),
    }
    with open(os.path.join(out_dir, "nm_04_1_visibility.json"), "w", encoding="utf-8") as fh:
        json.dump(summary, fh, ensure_ascii=False, indent=1)

    # 纯 xml.etree 直方图:可见性距离 0..max 与 never
    order = [str(i) for i in range(mx + 1)] + ["never"]
    counts = [sum(1 for r in with_delta if r["bucket"] != "never" and r["distance"] == i)
              for i in range(mx + 1)]
    counts.append(bk["never"])
    W, H, PAD, BW = 520, 260, 40, 44
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
                             "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W),
                                "height": str(H), "fill": "#ffffff"})
    cxm = max(counts) if counts else 1
    for i, cnt in enumerate(counts):
        h = 0 if cxm == 0 else (H - 2 * PAD) * cnt // cxm
        x = PAD + i * BW
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - h),
                                    "width": str(BW - 8), "height": str(h),
                                    "fill": "#3b6ea5"})
        ET.SubElement(svg, "text", {"x": str(x + (BW - 8) // 2), "y": str(H - PAD + 16),
                                    "font-size": "12", "text-anchor": "middle"})
        svg[-1].text = order[i]
        ET.SubElement(svg, "text", {"x": str(x + (BW - 8) // 2), "y": str(H - PAD - h - 4),
                                    "font-size": "11", "text-anchor": "middle"})
        svg[-1].text = str(cnt)
    ET.SubElement(svg, "text", {"x": str(PAD), "y": "22", "font-size": "13"})
    svg[-1].text = "visibility distance histogram (choices with delta)"
    svg_path = os.path.join(out_dir, "nm_04_1_visibility.svg")
    ET.ElementTree(svg).write(svg_path, encoding="utf-8", xml_declaration=True)
    back = ET.fromstring(open(svg_path, encoding="utf-8").read())
    bars = sum(1 for e in back.iter() if e.tag.rsplit("}", 1)[-1] == "rect") - 1
    assert bars == len(order), f"SVG 回读 rect 数 {bars} != {len(order)}"

    print(f"选项 {n} 条:无差量(空效果){len(no_delta)} 条 / 有差量 {len(with_delta)} 条")
    print(f"有差量选项的可见性距离:立即(d=0) {bk['d0']} · 延迟 1-2 步 {bk['d1_2']} · "
          f"延迟 >=3 步 {bk['d3plus']} · 永不被读到 {bk['never']}")
    print(f"平均可见性距离 {mean_milli} 毫(即 {mean_milli / 1000:.2f} 步)  最大 {mx} 步")
    print(f"写出的可观测量:flag {summary['writes_flags_total']} 次 / "
          f"好感度 {summary['writes_aff_total']} 次 / 物品 {summary['writes_items_total']} 次")
    fin = [r for r in with_delta if r["distance"] != ""]
    if fin:
        far = max(fin, key=lambda r: int(r["distance"]))
        print(f"最远的一条:{far['node']}  {far['choice']}  -> {far['goto']}  "
              f"d={far['distance']}  读点 {far['visible_at']}")
    print(f"写而不读(d=∞)的选项 {bk['never']} 条,前 5 条:"
          f"{[r['choice'] for r in with_delta if r['distance'] == ''][:5]}")
    print(f"wrote out/nm_04_1_visibility.csv rows={len(rows)} / .json / .svg (bars={bars})")

    if len(rows) != spec["meta"]["scale"]["choices"]:
        print(f"FAIL 选项数 {len(rows)} != scale {spec['meta']['scale']['choices']}")
        return 1
    print("OK nm_04_1_consequence_visibility:115 个选项的后果可见性距离已量出")
    return 0


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

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

选项 115 条:无差量(空效果)16 条 / 有差量 99 条
有差量选项的可见性距离:立即(d=0) 34 · 延迟 1-2 步 14 · 延迟 >=3 步 23 · 永不被读到 28
平均可见性距离 2422 毫(即 2.42 步)  最大 12 步
写出的可观测量:flag 56 次 / 好感度 66 次 / 物品 16 次
最远的一条:d_ch_3  c_ch_3a  -> d_hub_a2  d=12  读点 d_dq_2
写而不读(d=∞)的选项 28 条,前 5 条:['c_pier_2a', 'c_tavern_1b', 'c_ch_1a', 'c_wp_1a', 'c_wp_1c']
wrote out/nm_04_1_visibility.csv rows=115 / .json / .svg (bars=14)
OK nm_04_1_consequence_visibility:115 个选项的后果可见性距离已量出

判读:三档差量与一档「写而不读」

115 个选项里,16 个根本没有差量(effects 为空,既不 set flag、不 add 好感度、也不 grant / revoke 物品)——它们连「写」这一步都没有,谈不上可见性,单列一档。剩下 99 个有差量的选项分三档:

档数量读法
立即(d=0)34落点自己的条件就读了这个差量——后果当场可见
延迟 1–2 步14隔一两个节点被读到
延迟 ≥3 步23要走过一段路才被读到
永不被读到(∞)28写进了状态,但下游没有任何条件读它

有差量选项的平均可见性距离 2422 毫(2.42 步),最远 12 步(d_ch_3/c_ch_3a,一路走到 d_dq_2 才被读到)。写出的可观测量分布是 flag 56 次 / 好感度 66 次 / 物品 16 次——注意好感度写得最多,但它的读点集中在少数阈值门上(第 8 章专门量阈值触达)。

最该盯的是「永不被读到」那 28 条。 它们和 4.2 的假选择是同一族问题的两个面:假选择是「两条边走了同一个结果」,写而不读是「一条边写了结果但没人读」。两者的共同后果都是玩家的操作没有落在任何后续条件上。但口径要诚实:「永不被读到」不等于「这条选项没用」——它可能只是给玩家一句即时反馈、或改了一个只在结局 when 里才被读的量(本节口径里结局 when 是算作读点的)。判据给的是一份可复核的清单,不是判决。

还要提醒一个反直觉的点:距离短不等于设计好。 34 个「立即可见」的选项里,很多是「选完立刻设一个 flag,落点马上用它分岔」——这是清晰,不是深度;而延迟 12 步那条也许恰恰是「伏笔」。本节的数只回答「多远」,不回答「多好」。

自检表

检查项合格标准
真源唯一只读 config/story_spec.json,无第二份
口径可判定write / read 只依赖规格,与状态无关;距离 = 结构 BFS 步数
整数不变量距离是整数;无差量选项单列,不混进距离分布
精确非抽样覆盖全部 115 个选项,不抽样
proxy 口径结构距离只做相对比较,不声称玩家能感觉到

可见性算完,还剩一问:那 28 条「写而不读」和 16 条「无差量」,在状态推进的层面会不会更严重——两条不同的选项推进出完全相同的状态?下一节给这件事一个可判定、可计数的检测。

阅读导航:上一节:3.3 结局可达性的回归 · 下一节:4.2 假选择的检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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