8.2 阈值门与门限扰动

好感度的分段在规格里就是一串 [aff, 名, >=, 整数] 阈值门,长在选项 when、节点 line_when、结局 when 三处。本节把 15 条门限全抽出来,再逐条扰动 ±5,在同一采样口径下重算覆盖率与可达结局集合,找出 4 条临界门限;并如实说明为什么放宽 5 点不起作用而收紧 5 点才起作用——基线覆盖率已饱和在 1000‰。B 层只打印 WARN,退出码 0。

8.2 阈值门与门限扰动

互动叙事中的好感度设计 那篇讲的是经验:分段该设在哪、玩家多久能感觉到一次变化。本节讲机件:这些分段在规格里就是一串 ["aff", 名, ">=", 整数] 的阈值门,本节把它们全抽出来,再逐条扰动 ±5,看哪几扇门真正承重。

上一节把好感度的值域定住了。这一节回答另一个问题:这些值在哪儿被用到。规格里的好感度不直接改剧情,它只做一件事——决定某条选项、某行台词、某个结局能不能出现。

门限长在三处

["aff", 名, ">=", 整数] 这个合取项可以长在三个地方:选项的 when(这条选项能不能选)、节点的 line_when(这一行台词要不要显示)、结局的 when(这个结局够不够格)。它还可能嵌在 and / or / not 里,所以抽取不能只看顶层。

下面这个 S 层脚本只做一件事:把三处的门限全抽平,附上它长在哪、什么算子、门限多少。返回的每条门限还带一个指向原始条件列表的引用,扰动层可以就地改门限值再重采样。

#!/usr/bin/env python3
# file: sim/ne_08_2_threshold_gate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_08_2_threshold_gate.py
"""卷二 8.2 阈值门(S 层):把 ["aff", 名, ">=", 整数] 抽成一张门限表。

阈值门可能长在三处:选项的 when、节点的 line_when、结局的 when。本模块把全部门限
(含嵌在 and/or/not 里的)抽出来,列出位置、变量、算子与门限值,并断言门限值全部落在
声明域 [0,100] 内。collect_gates 返回的每条门限带一个指向原始条件列表的引用 cond,
8.2 的 B 层可就地改 cond[3] 再重采样,从而量出门限的临界性。
只读 config/story_spec.json,产出 out/ne_08_2_threshold_gate.json。整数口径。
"""
from __future__ import annotations
import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")


def load_spec() -> dict:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def walk_aff(c):
    """抽出条件里全部 aff 合取项(含嵌套)。"""
    if not c:
        return
    if c[0] == "aff":
        yield c
    elif c[0] == "not":
        yield from walk_aff(c[1])
    elif c[0] in ("and", "or"):
        yield from walk_aff(c[1])
        yield from walk_aff(c[2])


def collect_gates(spec: dict) -> list:
    """门限表。每条门限带 cond 引用,扰动层可就地改 cond[3]。"""
    gates: list = []

    def add(kind: str, node: str, choice: str, cond) -> None:
        for sub in walk_aff(cond):
            gates.append({"kind": kind, "node": node, "choice": choice, "cond": sub,
                          "var": sub[1], "op": sub[2], "n": sub[3]})

    for n in spec["dialogue"]:
        add("line_when", n["id"], "-", n.get("line_when"))
        for c in (n.get("choices") or []):
            add("choice", n["id"], c["id"], c.get("when"))
    for e in spec["endings"]:
        add("ending", e["id"], e["id"], e.get("when"))
    return gates


def site(g: dict) -> str:
    return f"{g['node']}/{g['choice']}" if g["choice"] != "-" else f"{g['node']}[{g['kind']}]"


def main() -> int:
    spec = load_spec()
    gates = collect_gates(spec)
    kinds, per_var = {}, {}
    for g in gates:
        kinds[g["kind"]] = kinds.get(g["kind"], 0) + 1
        per_var[g["var"]] = per_var.get(g["var"], 0) + 1
    thr = sorted({g["n"] for g in gates})
    print(f"spec={spec['meta']['title']}  阈值门 {len(gates)} 条("
          + " / ".join(f"{k} {v}" for k, v in sorted(kinds.items())) + ")")
    print(f"门限变量 {sorted(per_var)}(每条变量上的门限数 {dict(sorted(per_var.items()))})")
    print(f"门限值集合 {thr}({len(thr)} 个档位)")
    print("门限表(按门限值升序):")
    print("  位置                             类别        变量  算子  门限")
    for g in sorted(gates, key=lambda x: (x["n"], x["node"], x["choice"])):
        print(f"  {site(g):<32} {g['kind']:<10} {g['var']:<4} {g['op']:<4} {g['n']:>3}")
    bad = [site(g) for g in gates if not (AFF_LO <= g["n"] <= AFF_HI)]
    print(f"I1 全部门限值 ∈ [0,100]:{not bad}")
    if bad:
        print(f"FAIL 门限越界 {bad}")
        return 1
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ne_08_2_threshold_gate.json"), "w", encoding="utf-8") as fh:
        json.dump({"gates": [{k: v for k, v in g.items() if k != "cond"} for g in gates],
                   "kinds": kinds, "per_var": per_var, "thresholds": thr}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/ne_08_2_threshold_gate.json")
    print("OK ne_08_2_threshold_gate:阈值门已抽平,门限值全部 ∈ [0,100]")
    return 0


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

本机实跑得到 15 条门限:按位置分是 choice 13 / ending 1 / line_when 1,按变量分是 yan 7 / hui 3 / shen 3 / mu 2,门限值只用了 6 个档位 [25, 30, 40, 55, 60, 70],I1 全部门限值 ∈ [0,100] 通过。yan 一个人占了 7 条——主角自己的好感度是被引用最多的。

扰动 ±5:谁在承重

门限值的大小不都一样重要。要分清「承重墙」和「装饰墙」,最直接的办法是扰动:把每条门限单独 ±5,在同一采样口径下重算「可达结局集合」和「分支覆盖率」,看谁一动结果就变。覆盖率 = 采样走过的对话边 / 全部对话边(千分位整数),它是采样口径的代理指标,只用于相对比较,不是验收。

B 层不硬失败:任何偏离只打印 WARN,退出码仍是 0。门限机件来自上一个脚本,采样器在本模块内。

#!/usr/bin/env python3
# file: sim/ne_08_2_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ne_08_2_perturb.py
"""卷二 8.2 门限扰动扫描(B 层,软告警不硬失败)。

把每条阈值门的门限值单独上下微调 ±5(在**内存副本**上改,不写回真源),在同一采样
口径下重算「可达结局集合」与「分支覆盖率」,看哪些门限一调就改变结果——那些就是
**临界门限**。覆盖率 = 采样走过的对话边 / 全部对话边(千分位整数),是采样口径的
**代理指标**,只用于相对比较。任何偏离只打印 WARN,exit 0(B 层口径,不是验收)。
门限机件来自 ne_08_2_threshold_gate;采样器在本模块内。整数口径,禁浮点。
产出 out/ne_08_2_perturb.json。
"""
from __future__ import annotations
import copy
import json
import os
import sys

import ne_08_2_threshold_gate as tg

AFF_LO, AFF_HI = 0, 100
SAMPLE_N, SEED, STEPS = 20000, 20261016, 200
DELTAS = (-5, 5)
WARN_PERMILLE = 1
JSON_PATH = os.path.join(tg.OUT, "ne_08_2_perturb.json")


def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
    return lo if v < lo else hi if v > hi else v


def cond_ok(c, s: dict) -> bool:
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


def init_state(spec: dict) -> dict:
    aff = {v: clamp(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v))
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in spec["state"]["flags"]},
            "items": {i: 0 for i in spec["state"]["items"]}}


def apply_effects(eff: dict, s: dict) -> dict:
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k in sorted(eff.get("set") or {}):
        flags[k] = eff["set"][k]
    for k in sorted(eff.get("add") or {}):
        aff[k] = clamp(aff.get(k, 0) + eff["add"][k])
    for k in sorted(eff.get("grant") or {}):
        items[k] = eff["grant"][k]
    for k in sorted(eff.get("revoke") or {}):
        items[k] = 0
    return {"node": s["node"], "aff": aff, "flags": flags, "items": items}


def sample(spec: dict, n: int, seed: int) -> dict:
    """有界随机采样:覆盖边数、覆盖率(千分位整数)与结局分布。MINSTD 同种子。"""
    by_id = {x["id"]: x for x in spec["dialogue"]}
    total = sum(len(x.get("choices") or []) for x in spec["dialogue"])
    rng, seen, ends = seed, set(), {}
    for _ in range(n):
        s = init_state(spec)
        for _step in range(STEPS):
            cur = by_id[s["node"]]
            if cur.get("ending"):
                ends[cur["ending"]] = ends.get(cur["ending"], 0) + 1
                break
            opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
            if not opts:
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            seen.add((s["node"], c["id"]))
            s = apply_effects(c.get("effects") or {}, s)
            s["node"] = c["goto"]
    return {"edges": len(seen), "total_edges": total, "permille": len(seen) * 1000 // max(1, total),
            "endings": dict(sorted(ends.items())), "ending_set": sorted(ends)}


def main() -> int:
    spec = copy.deepcopy(tg.load_spec())
    gates = tg.collect_gates(spec)
    base = sample(spec, SAMPLE_N, SEED)
    print(f"spec={spec['meta']['title']}  阈值门 {len(gates)} 条  采样 {SAMPLE_N} 条路径 seed={SEED}")
    print(f"基线:覆盖 {base['edges']}/{base['total_edges']} 边 = {base['permille']}‰;"
          f"可达结局 {base['ending_set']}({len(base['ending_set'])} 个)")
    print(f"结局分布 {base['endings']}")
    print("\n门限扰动扫描(每条门限单独 ±5,其余不变):")
    print("  位置                             门限     基线‰    -5‰    +5‰   结局集变化")
    rows, critical = [], []
    for g in gates:
        n0 = g["cond"][3]
        res = {}
        for d in DELTAS:
            g["cond"][3] = n0 + d
            res[d] = sample(spec, SAMPLE_N, SEED)
        g["cond"][3] = n0
        dm, dp = res[-5]["permille"] - base["permille"], res[5]["permille"] - base["permille"]
        ech = res[-5]["ending_set"] != base["ending_set"] or res[5]["ending_set"] != base["ending_set"]
        s = tg.site(g)
        print(f"  {s:<32} {g['var']}>={n0:<3} {base['permille']:>7} {res[-5]['permille']:>7} "
              f"{res[5]['permille']:>7}   {'变' if ech else '不变'}")
        rows.append({"site": s, "var": g["var"], "kind": g["kind"], "n": n0, "base_permille": base["permille"],
                     "minus_permille": res[-5]["permille"], "plus_permille": res[5]["permille"],
                     "d_minus": dm, "d_plus": dp, "ending_set_changed": ech})
        if abs(dm) >= WARN_PERMILLE or abs(dp) >= WARN_PERMILLE or ech:
            critical.append({"site": s, "var": g["var"], "n": n0, "d_minus": dm, "d_plus": dp,
                             "ending_set_changed": ech})
    print(f"\n临界门限(±5 即改变覆盖率≥{WARN_PERMILLE}‰ 或改变可达结局集合):{len(critical)} 条")
    for c in sorted(critical, key=lambda x: (-abs(x["d_minus"]), -abs(x["d_plus"]), x["site"])):
        print(f"  {c['site']:<32} {c['var']}>={c['n']:<3} Δ-5 {c['d_minus']:+d}‰  Δ+5 {c['d_plus']:+d}‰"
              f"  结局集{'变' if c['ending_set_changed'] else '不变'}")
    warns = [f"临界门限 {c['site']}({c['var']}>={c['n']}):Δ-5 {c['d_minus']:+d}‰ Δ+5 {c['d_plus']:+d}‰"
             for c in critical]
    print("\n" + ("\n".join(f"WARN {w}" for w in warns) if warns
                  else "无软告警:±5 的门限扰动未改变覆盖率或结局集合"))
    os.makedirs(tg.OUT, exist_ok=True)
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({"sample_n": SAMPLE_N, "seed": SEED, "deltas": list(DELTAS),
                   "baseline": base, "rows": rows, "critical": critical, "warnings": warns},
                  fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print(f"wrote out/ne_08_2_perturb.json({len(gates)} 条门限 × {len(DELTAS)} 档)")
    print(f"OK ne_08_2_perturb:扰动已跑完,{len(critical)} 条临界门限,软告警口径,exit 0")
    return 0


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

本机实跑(20000 条采样路径,种子 20261016):

spec=雾港夜航  阈值门 15 条  采样 20000 条路径 seed=20261016
基线:覆盖 115/115 边 = 1000‰;可达结局 ['沉没', '灯守', '真相', '离港', '缄默'](5 个)
结局分布 {'沉没': 5507, '灯守': 1018, '真相': 178, '离港': 10074, '缄默': 3223}

门限扰动扫描(每条门限单独 ±5,其余不变):
  位置                             门限     基线‰    -5‰    +5‰   结局集变化
  d_tavern_2/c_tavern_2c           hui>=55     1000    1000     991   不变
  d_tavern_3/c_tavern_3b           hui>=60     1000    1000    1000   不变
  d_chapel_2/c_chapel_2c           shen>=55     1000    1000     991   不变
  d_chapel_3/c_chapel_3b           shen>=60     1000    1000    1000   不变
  d_ch_2/c_ch_2c                   mu>=55     1000    1000     982   不变
  d_wp_2/c_wp_2c                   yan>=25     1000    1000     991   不变
  d_wp_3/c_wp_3b                   yan>=30     1000    1000    1000   不变
  d_lb_2/c_lb_2c                   yan>=30     1000    1000    1000   不变
  d_tc_2/c_tc_2c                   yan>=40     1000    1000    1000   不变
  d_ho_3/c_ho_3b                   mu>=70     1000    1000    1000   不变
  d_db_2/c_db_2c                   shen>=60     1000    1000    1000   不变
  d_lt_2[line_when]                yan>=30     1000    1000    1000   不变
  d_bw_2/c_bw_2c                   hui>=60     1000    1000    1000   不变
  d_dq_2/c_dq_2a                   yan>=40     1000    1000    1000   不变
  e_lit/e_lit                      yan>=40     1000    1000    1000   不变

临界门限(±5 即改变覆盖率≥1‰ 或改变可达结局集合):4 条
  d_ch_2/c_ch_2c                   mu>=55  Δ-5 +0‰  Δ+5 -18‰  结局集不变
  d_chapel_2/c_chapel_2c           shen>=55  Δ-5 +0‰  Δ+5 -9‰  结局集不变
  d_tavern_2/c_tavern_2c           hui>=55  Δ-5 +0‰  Δ+5 -9‰  结局集不变
  d_wp_2/c_wp_2c                   yan>=25  Δ-5 +0‰  Δ+5 -9‰  结局集不变

WARN 临界门限 d_tavern_2/c_tavern_2c(hui>=55):Δ-5 +0‰ Δ+5 -9‰
WARN 临界门限 d_chapel_2/c_chapel_2c(shen>=55):Δ-5 +0‰ Δ+5 -9‰
WARN 临界门限 d_ch_2/c_ch_2c(mu>=55):Δ-5 +0‰ Δ+5 -18‰
WARN 临界门限 d_wp_2/c_wp_2c(yan>=25):Δ-5 +0‰ Δ+5 -9‰
wrote out/ne_08_2_perturb.json(15 条门限 × 2 档)
OK ne_08_2_perturb:扰动已跑完,4 条临界门限,软告警口径,exit 0

读法要点:放宽(−5)几乎不起作用,收紧(+5)才起作用。原因是基线覆盖率已经饱和在 1000‰——20000 条采样路径走遍了全部 115 条边。门限放宽只会让本来可选的东西更可选,覆盖不到新边;门限收紧才会把一些路径挡回去。

四条临界门限里,d_ch_2/c_ch_2c(mu >= 55)最敏感:收紧 5 点,覆盖率掉 18‰,另外三条各掉 9‰。它们的共同点是门限恰好压在采样能到的下沿上——8.3 会证明这三条正是「缺口为 0」的门限。

自检表

检查项合格标准
门限抽全选项 / line_when / 结局三处都抽,含嵌在 and / or / not 里的
门限合法15 条门限值全部落在声明域 [0,100] 内
扰动口径明确覆盖率是采样代理指标,只作相对比较,不当验收
软告警不硬失败B 层只打印 WARN,退出码 0
饱和效应诚实基线 1000‰ 使放宽无效,如实说明而不隐藏

临界门限找到了。但「谁承重」和「谁可达」是两回事:一条门限可能很敏感,却永远跨不过去——那它就是死门。下一节把钳位、初值、累积口径、门限可达性四条不变量一次核到底。

阅读导航:上一节:8.1 好感度的状态机 · 下一节:8.3 好感度的不变量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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