4.2 假选择的检测

两个不同选项在同一状态下推进出的状态逐字段相等,就是一对假选择。本节用完整 BFS 精确枚举 9895423 个可达 (节点,状态) 对,检出 3 对静态假与 10 对状态级假;其中 d_fg_3 的一对在全部 1036900 个可用状态下都相等,静态判据会漏掉它。再用三个合成规格证明检测器会开火。

站内的独立游戏的叙事设计与分支对话 讲的是**「有意义的选择」这条设计经验**:哪些选项该有分量、哪些只是氛围,配的是清单与示意图,代码不可跑。本节把「假选择」变成一条可判定、可计数的判据:两个不同选项在同一状态下推进出的状态逐字段相等,它们就是一对假选择。上一节量「后果离得多远」,这一节判「有没有后果」——而且是按状态判,不是按选项的文本判。

4.2 假选择的检测

先把口径讲死:这是一个代理指标(proxy)。「假选择」在这里是一句关于状态的陈述——两条边推进出同一个状态,不是「玩家觉得没意义」的主观判断。玩家是否觉得某次选择白选,需真实业务环境、命令行不可复现,本套不做。判据给的是一份可复核的清单,只用于规格内部的相对比较。

判据:推进状态逐字段相等

同一节点 n 的两个不同选项 c ≠ c',在一个可达状态 s 下都可选时,若

step(c, s) == step(c', s)      # 节点 + 好感度 + flag + 物品,逐字段相等

则称 (c, c') 在 s 下是一对假选择。分两类:

类条件性质
静态假c.goto == c'.goto 且 c.effects 与 c'.effects 逐字段相同在所有状态下都假——两个措辞不同、结果一字不差的选项
状态假效果不同,但因状态已满足(flag 已是 1、好感度已顶到上限)而推进结果仍相等只在部分状态下假——静态判据会漏掉它

为什么按「推进状态」判、不按「效果文本」判? 因为效果是不是「有差」依赖当前状态:set: {f: 1} 在 f 已经是 1 时是空操作。只看 effects 有没有差异,会把这种「在这个状态下恰好无差」的选项误判成真选择——下面 d_fg_3 就是活例。

S 层:精确枚举全部可达状态

脚本 sim/nm_04_2_fake_choice_detection.py 用完整 BFS 枚举全部可达 (节点, 状态) 对,在弹出每个状态时逐对判定,不采样;落 out/nm_04_2_fake_choice.csv:

#!/usr/bin/env python3
# file: sim/nm_04_2_fake_choice_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_04_2_fake_choice_detection.py
"""卷三 4.2 假选择的检测(S 层):同一状态下两个选项推进出的状态是否完全相同。

可判定口径(写死在代码里,不按结果调参):
  同一节点 n 的两个**不同**选项 c != c',在一个可达状态 s 下**都可选**时,
  若 step(c, s) == step(c', s)(推进后的 节点 + 好感度 + flag + 物品 **逐字段相等**),
  则称 (c, c') 在 s 下是一对**假选择**。

  两类:
    · 静态假:c.goto == c'.goto 且 c.effects 与 c'.effects **逐字段相同**——所有状态下都假;
    · 状态假:效果不同,但因状态已满足(如 flag 已是 1、好感度已顶到上限)而推进结果仍相等。

用完整 BFS **精确枚举**全部可达 (节点, 状态) 对,在弹出时逐对判定,不做采样。
与卷一 8.3 的分工:8.3 是**选项级**、用**结局可达集投影**判「有没有后果」;本节是
**状态级**、用**推进状态逐字段相等**判「两条边是不是走了同一个结果」。

产出 out/nm_04_2_fake_choice.csv。选项数不符即非零退出。
"""
from __future__ import annotations

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

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


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"]
    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)
    NIDX = {n["id"]: i for i, n in enumerate(nodes)}

    def pack(st) -> int:
        """把 (好感度 + flag 位 + 物品位) 打成一个整数,用于状态去重。"""
        code = 0
        for v in st[:NV]:
            code = (code << 7) | v
        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

    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 eff_key(c):
        return json.dumps(c.get("effects") or {}, sort_keys=True, separators=(",", ":"))

    pair_static, all_pairs = {}, []
    for n in nodes:
        chs = n.get("choices") or []
        for c, c2 in combinations(chs, 2):
            key = (n["id"], c["id"], c2["id"])
            all_pairs.append(key)
            pair_static[key] = int(c["goto"] == c2["goto"] and eff_key(c) == eff_key(c2))

    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)

    # 完整 BFS,弹出时逐对判定(状态打包成整数去重,不存每节点状态列表)
    seen = {(NIDX[start] << 55) | pack(init)}
    frontier = [(start, init)]
    pair_states = defaultdict(int)       # 该对在多少个状态下推进结果相等
    pair_avail = defaultdict(int)        # 该对在多少个状态下都可用
    while frontier:
        nxt = []
        for node, st in frontier:
            chs = by[node].get("choices") or []
            avail = [c for c in chs if eval_cond(c.get("when"), st)]
            for c, c2 in combinations(avail, 2):
                key = (node, c["id"], c2["id"])
                pair_avail[key] += 1
                if c["goto"] == c2["goto"] and \
                   apply_effects(c.get("effects") or {}, st) == \
                   apply_effects(c2.get("effects") or {}, st):
                    pair_states[key] += 1
            for c in chs:
                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)

    rows = [{"node": nd, "choice_a": a, "choice_b": b,
             "static_fake": pair_static[(nd, a, b)],
             "avail_states": pair_avail[(nd, a, b)],
             "equal_states": pair_states[(nd, a, b)],
             "state_level_fake": int(pair_states[(nd, a, b)] > 0)}
            for nd, a, b in all_pairs]

    n_static = sum(r["static_fake"] for r in rows)
    n_state_level = sum(r["state_level_fake"] for r in rows)
    n_not_static = sum(1 for r in rows if not r["static_fake"] and r["state_level_fake"])
    total_equal = sum(r["equal_states"] for r in rows)
    total_avail = sum(r["avail_states"] for r in rows)

    out_dir = os.path.join(HERE, "../out")
    os.makedirs(out_dir, exist_ok=True)
    with open(os.path.join(out_dir, "nm_04_2_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"选项 115 条,同节点可比较的选项对 {len(rows)} 对,可达 (节点,状态) 对 {n_states}")
    print(f"静态假选择对 {n_static} 对(同跳转且效果逐字段相同,所有状态下都假)")
    print(f"状态级假选择对 {n_state_level} 对(至少一个可达状态下推进结果逐字段相等)")
    print(f"其中非静态、仅因状态已满足而假的对 {n_not_static} 对")
    print(f"可用 (对,状态) 计数 {total_avail},其中推进结果相等 {total_equal} "
          f"({total_equal * 1000 // total_avail if total_avail else 0} 千分)")
    print("静态假选择对清单:")
    for r in rows:
        if r["static_fake"]:
            print(f"    {r['node']}  {r['choice_a']} == {r['choice_b']}")
    print("状态级假选择对清单(equal_states / avail_states):")
    for r in rows:
        if r["state_level_fake"]:
            tag = "静态" if r["static_fake"] else "非静态"
            print(f"    {r['node']}  {r['choice_a']} == {r['choice_b']}  "
                  f"{tag}  {r['equal_states']}/{r['avail_states']}")
    print(f"wrote out/nm_04_2_fake_choice.csv rows={len(rows)}")

    if not all_pairs:
        print("FAIL 没有可比较的选项对")
        return 1
    print("OK nm_04_2_fake_choice_detection:假选择对已精确检出")
    return 0


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

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

选项 115 条,同节点可比较的选项对 104 对,可达 (节点,状态) 对 9895423
静态假选择对 3 对(同跳转且效果逐字段相同,所有状态下都假)
状态级假选择对 10 对(至少一个可达状态下推进结果逐字段相等)
其中非静态、仅因状态已满足而假的对 7 对
可用 (对,状态) 计数 8058100,其中推进结果相等 1054370 (130 千分)
静态假选择对清单:
    d_wp_1  c_wp_1a == c_wp_1c
    d_tc_1  c_tc_1a == c_tc_1b
    d_mr_2  c_mr_2a == c_mr_2b
状态级假选择对清单(equal_states / avail_states):
    d_wp_1  c_wp_1a == c_wp_1c  静态  25/25
    d_lb_1  c_lb_1a == c_lb_1b  非静态  353/353
    d_lb_1  c_lb_1a == c_lb_1c  非静态  28/50
    d_lb_1  c_lb_1b == c_lb_1c  非静态  28/50
    d_tc_1  c_tc_1a == c_tc_1b  静态  847/847
    d_mr_2  c_mr_2a == c_mr_2b  静态  2046/2046
    d_ho_3  c_ho_3a == c_ho_3b  非静态  178/5202
    d_fg_2  c_fg_2a == c_fg_2b  非静态  8475/709402
    d_fg_3  c_fg_3a == c_fg_3b  非静态  1036900/1036900
    d_dq_1  c_dq_1a == c_dq_1b  非静态  5490/15006
wrote out/nm_04_2_fake_choice.csv rows=104
OK nm_04_2_fake_choice_detection:假选择对已精确检出

判读:静态判据会漏掉的那一对

115 个选项构成 104 对同节点可比较的选项对,可达 (节点,状态) 对 9895423 个(与卷二穷举口径一致)。结果分三层:

  • 静态假 3 对:d_wp_1(c_wp_1a == c_wp_1c)、d_tc_1(c_tc_1a == c_tc_1b)、d_mr_2(c_mr_2a == c_mr_2b)——都是同一节点的两个选项效果逐字段相同、跳转相同,只是台词措辞不同。任何静态检查都能抓到。
  • 状态级假 10 对,其中 7 对非静态——效果文本不同,却在至少一个可达状态下推进结果相等。
  • 可用 (对,状态) 计数 8058100,其中推进结果相等 1054370(130 千分)。

最值得看的是 d_fg_3:c_fg_3a 空效果、c_fg_3b 写 set: {saw_lighthouse_light: 1}。效果文本不同,但它在全部 1036900 个可用状态下推进结果都相等——因为走到 d_fg_3 时 saw_lighthouse_light 恒为 1,那个 set 永远是个空操作。只看 effects 的静态判据会把它判成真选择;状态级判据正确地把它判成「恒假」。 这正是本节把口径定在「推进状态」而非「效果文本」的理由。

对照着看 d_ho_3:c_ho_3a(无条件下 grant salt_letter)与 c_ho_3b(when mu >= 70 下 grant 同物并 set / add)只在 178 / 5202 个状态下相等——多数状态下它们确实分得开,是局部假而非恒假。「在多少个状态下假」本身就是个量,比一个布尔标签更有信息。

本节与卷一 8.3 假选择与真后果 的分工:8.3 是选项级、用结局可达集投影判「这个选项有没有后果」;本节是状态级、用推进状态逐字段相等判「两条边是不是走了同一个结果」。粒度不同,计数也不同,两者互补。

P 层:合成规格负例

「10 对假选择」这句话要可信,检测器必须能在构造出来的例子上开火。P 层用一个四节点合成规格(s0 → a → m → end)做三例,只改 effects:

  • 静态假:a 的两个选项效果都是 {} → 判静态假;
  • 非假:把 c2 改成 set: {g: 1} → 状态未满足,两者分得开 → 判非假;
  • 状态假:s0 先把 g 置 1,c1 空效果、c2 再 set g=1 → 效果不同(非静态)但推进结果相等 → 判状态假。

本机实跑 node --test 'core/test/nm_04_2_fake_choice.test.ts':

✔ 静态假 · 两选项效果逐字段相同:判为静态假选择对 (0.859542ms)
✔ 非假 · 两选项效果不同且状态未满足:该对不再假 (0.168042ms)
✔ 状态假 · 效果不同但状态已满足:非静态却仍判假 (0.438083ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.35025

自检表

检查项合格标准
口径可判定假 = step(c,s) == step(c',s) 逐字段相等;不靠文本、不靠语义
精确非抽样在全部 9895423 个可达 (节点,状态) 对上判定
分层报告静态假与状态假分开计,恒假与局部假分开计
负例必给三个合成规格证明三种判定都会开火(P 层 3 条断言全绿)
proxy 口径「假」是关于状态的陈述,不声称玩家觉得没意义

判据有了、计数也有了,还剩一个问题:这条判据在一条路径上意味着什么——玩家走一路,要隔多少节点才碰到一次「有分量」的选择?下一节把密度与节奏量出来。

阅读导航:上一节:4.1 后果可见性 · 下一节:4.3 选择密度与节奏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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