8.3 假选择与真后果的判定

一个选项是假选择,当且仅当它不改变任何状态,且与其他选项在终局可达集上不可区分。判据写死在脚本里,不按结果调参。本节用 S 层脚本对《雾港夜航》的 115 个选项逐个判定:空效果选项 16 个,其中 12 个仍判真后果,真正判为假选择的只有 4 个。

8.3 假选择与真后果的判定

Phaser 分支叙事与对话系统 那一侧会告诉你「怎么把选项渲染出来」,但渲染得再漂亮也回答不了一个更基础的问题:这个选项到底有没有用。第 8.1 节数出 43 个带选项的节点里有 34 个是扇出(多个选项指向同一去向),这一节把「有没有用」变成一个可跑的判定。判据先写死,再让它跑,跑出什么就报什么。

从「没效果」到「不可区分」:判据的三层

最朴素的判据是「选项没有效果就算假选择」。这个判据太松:一个选项可以完全不写 effects(不改旗标、不改好感度、不给物品),但它的 goto 通向一个别的节点,而那个节点后续能拿到的东西不同。它的效果写在下游,不写在选项上。

所以真正的判据要跨过三层:

层判据说明
L1 无效果effects 为空(无 set / add / grant / revoke)只是必要条件,不是充分条件
L2 不可区分在每一个可达状态下,都存在同节点另一个在该状态下可选的选项,其 goto 的结局可达集与它相同结局可达集 = 从该处出发、honor 全部 when 后能到达的结局集合
L3 判定假选择 ⇔ L1 ∧ L2(对所有可达状态成立)任一可达状态下能与同节点选项区分,即判真后果

L2 的「结局可达集」是这套判据的落点。它不问「后面看到的文本一样吗」,只问「后面能到达的结局一样吗」——因为结局是唯一被结构承认的收敛点。两个去向即使中间风景完全不同,只要可达结局集合相同,在这套判据下就不可区分。

为什么必须带上条件与状态

如果忽略 when,只按静态图算「从某节点能不能到达某结局」,会得到一个没用的答案:这张图里除结局节点外的每一个节点都能到达全部 5 个结局,于是任何两个选项的结局可达集都是全集 {5 个},判定退化成「所有选项都是假选择」。

真实差异全部藏在条件里。结局的 when 读的是旗标、好感度与物品;玩家手里有没有某件物品、某个好感度够不够,决定了哪个结局是活的。而这些状态是沿途一个选项一个选项攒出来的。所以判定必须在每个具体状态上做,而不是在图上做——同一个选项在不同状态下可能一个可达 e_lit、另一个不可达。

S 层:对 115 个选项逐个判定

下面这段是 S 层脚本 sim/na_08_3_fake_choice.py。它做三步:先用 BFS 枚举所有可达的 (节点, 状态) 对(状态压成整数去重),并在 8 个「空效果选项」所在节点上把状态留下来;再对每个 (节点, 状态) 记忆化地算出结局可达集(一个 5 位掩码);最后对 16 个空效果选项,逐个在它自己的每一个可达状态上检查是否存在结局可达集相同的兄弟选项。全部状态都找得到兄弟即判假选择。脚本另附一段对照:统计被写却从不被读的旗标与物品,用来界定这套判据的边界。

#!/usr/bin/env python3
# file: sim/na_08_3_fake_choice.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_08_3_fake_choice.py
"""卷一 8.3 假选择与真后果(S 层):对 115 个选项逐个判定它有没有后果。

判据(写死在代码里,不按结果调参):选项 c 是「假选择」 <=> 在**每一个**可达状态 s 下
(s 是 BFS 枚举到的、节点 n 可达的状态):
  1) c.effects 为空——不 set flag、不 add 好感度、不 grant / revoke 物品;且
  2) 存在同节点另一个在该状态下可选的选项 c',使从 c.goto 与从 c'.goto 出发、honor 每条
     when 与每个结局的 when 之后可达的结局集合完全相同。
任一可达状态下 c 与所有同节点选项都能区分,即判「真后果」。

产出 out/narr08_fake_choice.csv(115 行)与控制台清单;选项数不符即非零退出。
"""
from __future__ import annotations

import csv
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LO, HI = 0, 100


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8"))
    nodes = spec["dialogue"]
    by = {n["id"]: n for n in nodes}
    start = spec["meta"]["start"]
    vars_ = sorted(spec["state"]["vars"])
    flags = sorted(spec["state"]["flags"])
    items = sorted(spec["state"]["items"])
    VI = {v: i for i, v in enumerate(vars_)}
    FI = {f: i for i, f in enumerate(flags)}
    II = {it: i for i, it in enumerate(items)}
    NV, NF, NI = len(vars_), len(flags), len(items)
    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"]}
    EIDX = {e["name"]: i for i, e in enumerate(spec["endings"])}
    NIDX = {n["id"]: i for i, n in enumerate(nodes)}

    def eval_cond(c, st) -> bool:
        if c is None:
            return True
        k = c[0]
        if k == "flag":
            return st[NV + FI[c[1]]] == c[2]
        if k == "aff":
            v, op, n = st[VI[c[1]]], c[2], c[3]
            return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
        if k == "item":
            return st[NV + NF + II[c[1]]] == c[2]
        if k == "and":
            return eval_cond(c[1], st) and eval_cond(c[2], st)
        if k == "or":
            return eval_cond(c[1], st) or eval_cond(c[2], st)
        return not eval_cond(c[1], st)

    def apply_effects(eff, st) -> tuple:
        s = list(st)
        for k in (eff.get("set") or {}):
            s[NV + FI[k]] = eff["set"][k]
        for k in (eff.get("add") or {}):
            v = s[VI[k]] + eff["add"][k]
            s[VI[k]] = LO if v < LO else HI if v > HI else v
        for k in (eff.get("grant") or {}):
            s[NV + NF + II[k]] = eff["grant"][k]
        for k in (eff.get("revoke") or {}):
            s[NV + NF + II[k]] = 0
        return tuple(s)

    def pack(st) -> int:
        code = 0
        for i in range(NV):
            code = (code << 7) | st[i]
        fb = ib = 0
        for i in range(NF):
            fb |= st[NV + i] << i
        for i in range(NI):
            ib |= st[NV + NF + i] << i
        return (code << (NF + NI)) | (fb << NI) | ib

    aff0 = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
            for v in vars_}
    init = tuple([aff0[v] for v in vars_] + [0] * NF + [0] * NI)
    empty = [(n["id"], c) for n in nodes for c in (n.get("choices") or [])
             if not any((c.get("effects") or {}).get(k)
                        for k in ("set", "add", "grant", "revoke"))]
    src_nodes = sorted({nid for nid, _ in empty})

    # ---- BFS:枚举所有可达 (节点, 状态),只留下源节点的状态 ----
    seen = {(NIDX[start] << 55) | pack(init)}
    frontier = [(start, init)]
    states_at = {nid: [] for nid in src_nodes}
    while frontier:
        nxt = []
        for node, st in frontier:
            if node in states_at:
                states_at[node].append(st)
            for c in (by[node].get("choices") or []):
                if eval_cond(c.get("when"), st):
                    st2 = apply_effects(c.get("effects") or {}, st)
                    k = (NIDX[c["goto"]] << 55) | pack(st2)
                    if k not in seen:
                        seen.add(k)
                        nxt.append((c["goto"], st2))
        frontier = nxt
    n_states = len(seen)
    del seen

    # ---- 结局可达集 mask_of(节点, 状态),记忆化 ----
    memo: dict = {}

    def mask_of(node: str, st) -> int:
        key = (NIDX[node] << 55) | pack(st)
        if key in memo:
            return memo[key]
        if node in ending_of:
            m = (1 << EIDX[ending_of[node]]) if eval_cond(end_when[ending_of[node]], st) else 0
        else:
            m = 0
            for c in by[node]["choices"]:
                if eval_cond(c.get("when"), st):
                    m |= mask_of(c["goto"], apply_effects(c.get("effects") or {}, st))
        memo[key] = m
        return m

    # ---- 逐个选项判定 ----
    verdict: dict = {}
    for nid, c in empty:
        sibs = [o for o in by[nid]["choices"] if o["id"] != c["id"]]
        tot = hit = 0
        for st in states_at[nid]:
            if not eval_cond(c.get("when"), st):
                continue
            tot += 1
            mine = mask_of(c["goto"], st)
            if any(eval_cond(o.get("when"), st) and mask_of(o["goto"], st) == mine
                   for o in sibs):
                hit += 1
        verdict[c["id"]] = "fake" if (tot and hit == tot) else "real"

    # ---- 对照:被写却从不被读的 flag / 物品(判据把「有状态差量」直接算作真后果) ----
    def walk(c, f: set, i: set) -> None:
        if not c:
            return
        if c[0] in ("and", "or"):
            walk(c[1], f, i)
            walk(c[2], f, i)
        elif c[0] == "not":
            walk(c[1], f, i)
        elif c[0] == "flag":
            f.add(c[1])
        elif c[0] == "item":
            i.add(c[1])

    rf, ri = set(), set()
    for n in nodes:
        walk(n.get("line_when"), rf, ri)
        for c in (n.get("choices") or []):
            walk(c.get("when"), rf, ri)
    for e in spec["endings"]:
        walk(e.get("when"), rf, ri)
    for e in spec["scene_graph"]["edges"]:
        walk(e.get("when"), rf, ri)
    for loc in spec["world"]["locations"]:
        walk(loc.get("unlock_when"), rf, ri)
    wr_f = {k for n in nodes for c in (n.get("choices") or [])
            for k in ((c.get("effects") or {}).get("set") or {})}
    wr_i = {k for n in nodes for c in (n.get("choices") or [])
            for k in list(((c.get("effects") or {}).get("grant") or {}))
            + list(((c.get("effects") or {}).get("revoke") or {}))}

    rows = [{"node": n["id"], "choice": c["id"], "goto": c["goto"],
             "empty_effects": int(not any((c.get("effects") or {}).get(k)
                                          for k in ("set", "add", "grant", "revoke"))),
             "verdict": verdict.get(c["id"], "real")}
            for n in nodes for c in (n.get("choices") or [])]
    n_fake = sum(1 for r in rows if r["verdict"] == "fake")

    print(f"nodes={len(nodes)}  choices={len(rows)}  空效果选项 {len(empty)}  "
          f"有状态差量选项 {len(rows) - len(empty)}")
    print(f"假选择 {n_fake}  真后果 {len(rows) - n_fake}")
    print("假选择清单(空效果 且 所有可达状态下与某同节点选项的结局可达集相同):")
    for r in rows:
        if r["verdict"] == "fake":
            print(f"    {r['node']}  {r['choice']}  -> {r['goto']}")
    print("空效果但判为真后果(在某可达状态下能改变结局可达集):")
    for r in rows:
        if r["verdict"] == "real" and r["empty_effects"]:
            print(f"    {r['node']}  {r['choice']}  -> {r['goto']}")
    print(f"记忆化条目 {len(memo)}  可达节点-状态对 {n_states}")
    print(f"写而不读的 flag({len(wr_f - rf)}):{sorted(wr_f - rf)}")
    print(f"写而不读的物品({len(wr_i - ri)}):{sorted(wr_i - ri)}")

    out_path = os.path.join(ROOT, "out")
    os.makedirs(out_path, exist_ok=True)
    with open(os.path.join(out_path, "narr08_fake_choice.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_fake_choice.csv rows={len(rows)}")

    if len(rows) != spec["meta"]["scale"]["choices"]:
        print(f"FAIL 选项数 {len(rows)} != scale {spec['meta']['scale']['choices']}")
        return 1
    print("OK na_08_3_fake_choice:115 个选项逐个判定完成")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/na_08_3_fake_choice.py(129 秒,/usr/bin/time -l 报最大常驻内存约 3.1 GB):

nodes=48  choices=115  空效果选项 16  有状态差量选项 99
假选择 4  真后果 111
假选择清单(空效果 且 所有可达状态下与某同节点选项的结局可达集相同):
    d_lb_1  c_lb_1b  -> d_lb_2
    d_bw_3  c_bw_3a  -> d_dq_1
    d_bw_3  c_bw_3b  -> d_fg_1
    d_fg_3  c_fg_3a  -> d_dq_1
空效果但判为真后果(在某可达状态下能改变结局可达集):
    d_hub_a1  c_hub_a1a  -> d_ch_1
    d_hub_a1  c_hub_a1b  -> d_wp_1
    d_lb_3  c_lb_3a  -> d_tc_1
    d_lb_3  c_lb_3b  -> d_mr_1
    d_hub_b2  c_hub_b2a  -> d_lt_1
    d_hub_b2  c_hub_b2b  -> d_bw_1
    d_dq_1  c_dq_1c  -> d_dq_2
    d_dq_2  c_dq_2a  -> e_lit
    d_dq_2  c_dq_2b  -> e_truth
    d_dq_2  c_dq_2c  -> e_silence
    d_dq_2  c_dq_2d  -> e_loss
    d_dq_2  c_dq_2e  -> e_dark
记忆化条目 9895360  可达节点-状态对 9895423
写而不读的 flag(7):['has_lantern', 'hui_trusts', 'met_hai', 'met_lin', 'met_mu', 'promised_hai', 'shen_trusts']
写而不读的物品(1):['fog_glass']
wrote out/narr08_fake_choice.csv rows=115
OK na_08_3_fake_choice:115 个选项逐个判定完成

判读:4 个假选择、12 个空效果但真后果

16 个空效果选项里只有 4 个是假选择。 这是本节最重要的一个数字:如果按最朴素的「没写 effects 就算假」判据,会报出 16 个假选择;加上 L2 之后只剩 4 个。四分之三的「无效果选项」其实有后果——它们的后果不写在选项上,写在去向里。

4 个假选择集中在一小片区域:

节点选项去向读法
d_lb_1c_lb_1bd_lb_2与同节点的兄弟选项在所有可达状态下结局集相同
d_bw_3c_bw_3ad_dq_1d_bw_3 的两条分支彼此结局等价
d_bw_3c_bw_3bd_fg_1同上,另一条分支
d_fg_3c_fg_3ad_dq_1与 d_bw_3 区域同属一小块「风景不同、去向等价」的图

d_bw_3 是一个分叉点(第 8.1 节列出过它),它的两条去向 d_dq_1 与 d_fg_1 都是汇聚点。判定结果显示:这两条分支在每一个可达状态下可达的结局集合完全相同。也就是说这一处分岔改变了玩家看到的场景,但没有改变玩家能到达的结局。这不是 bug——它是「氛围分岔」:设计上要的就是走不同的路看不同的景。但判据必须把它标出来,因为它和「假选择」在图上长得一模一样,区别只在设计意图,而意图不进图。

12 个空效果选项有真后果,它们分两类。 一类是 7 个「枢纽跳转」:d_hub_a1、d_hub_b2、d_lb_3 各自的两个选项分别指向两条并行的支线(d_ch_1/d_wp_1、d_lt_1/d_bw_1、d_tc_1/d_mr_1),d_dq_1 的 c_dq_1c 指向 d_dq_2。这些选项本身不写效果,但两条支线沿途能拿到的物品与旗标不同,于是可达结局集不同。另一类是 5 个终局选择:d_dq_2 的五个选项分别直达 e_lit、e_truth、e_silence、e_loss、e_dark——它们的效果为空,但去向就是结局本身,五个结局的 when 各不相同,因此五个选项两两可区分。

这次判定还顺带独立复核了一个数。 脚本自己实现的 BFS 枚举出 9895423 个可达 (节点, 状态) 对,与 na_00 口径一致。记忆化条目 9895360 比它少 63——memo 只记 mask_of 真正走过的 (节点, 状态) 对,seen 记的是全部可达对,两者不是一个量;差集恰好是「可达、但这次判定没有查询到」的那部分。把两个数并排印出来,是为了让「可达」与「被判定用到」这两个集合的差别留在输出里。

诚实报告:这套判据放过了什么

判据的 L1 是「effects 为空」,也就是说:只要选项写了效果,无论那个效果有没有被任何条件读到,它都被算作真后果。 这不是笔误,是判据的边界,必须报出来。脚本末尾那段对照就是在量这个边界:全站有 7 个旗标与 1 件物品被写却从不被读——has_lantern、hui_trusts、met_hai、met_lin、met_mu、promised_hai、shen_trusts 与物品 fog_glass。

这意味着存在这样一类选项:它写了效果(比如 set: {"met_hai": 1}),因此被判真后果;但 met_hai 没有任何 when 读它,所以这个效果不可能改变任何结局的可达集。如果判据再严一档(要求「效果必须被读到」),这类选项会从「真后果」挪到「假选择」。

所以「111 个真后果」这个数的准确读法是:111 个选项至少改变了状态,而不是「111 个选项都影响结局」。前者是脚本判定的结果,后者需要更严的判据——本节把边界量清楚,不越界下结论。

另外两条边界也要说明:判据只问结局可达集,不问中间内容——两个去向的文本、场景、对话完全不同但结局集相同时,判为不可区分;判据也不做概率加权——某个结局可达但需要极苛刻的条件,与随手可达,在集合口径下一视同仁。这两条都是「可判定性换来的代价」:能被脚本判的东西必须是离散、确定的量,凡是要靠「读起来像不像有意义」判断的,脚本一律不判。

自检表

检查项合格标准
判据前置判据写进脚本注释,先定后跑,不按结果调参
状态感知在每个可达状态上判定,不用静态图可达性替代
结局集口径用条件过滤后的结局可达集,不是「图上能到哪些结局」
反例必报空效果却判真后果的选项必须逐条列出,不能只报计数
边界显式判据放过什么(有状态差量即算真后果)必须单独成段说明

三节连起来看:8.1 给图拍了骨架照,8.2 给骨架量了宽与深,8.3 把「每个选项到底有没有后果」变成了一个能跑出 4 与 111 的判定。这三件事都不依赖任何引擎——它们只依赖 story_spec.json 这一份数据。下一章回到语言层:when 里的条件表达式本身怎么设计、怎么判、怎么避免写出永远为真或永远为假的死条件。

阅读导航:上一节:8.2 宽度与深度 · 下一节:9.1 条件表达式:旗标、数值、持有 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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