互动叙事中的好感度设计 那篇讲的是经验:分段该设在哪、玩家多久能感觉到一次变化。本章往下沉一层,把这些分段当成一串可数的阈值门,本节量它们的触达分布——哪些路径触发了哪道门、一条路径平均触达几道、有多少条路径一道门都没碰到。经验回答「门该设在哪」,本节回答「门设在这里,有多少路真的走得过去」。
8.2 阈值触达的分布
上一节把好感度的曲线画出来了。这一节回答另一个问题:这些数值在哪儿被用到。规格里的好感度不直接改剧情,它只做一件事——决定某条选项能不能出现。这类门在真源里写成一串 ["aff", 名, ">=", 整数],可以长在选项的 when 里,也可以长在结局的 when 里(还可能嵌在 and / or / not 里,所以抽取不能只看顶层)。
触达的定义要写死:一条路径对某道门 (变量, 门限) 触达,当且仅当该变量沿这条路径取到的最大值 >= 门限。这个定义只看「到没到过」,不看「到达时在哪一步」——因为门的作用就是「变量够不够格」,与时机无关。
S 层:抽出 14 道门,数触达
脚本先抽门,再采样 5000 条路径,对每条路径算出 4 个变量沿路径的最大值,于是可以做三件度量:每道门的触达率(千分位)、每条路径触达的门数分布、从未触达任何门的路径数。
#!/usr/bin/env python3
# file: sim/nm_08_2_threshold_hits.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_08_2_threshold_hits.py
"""卷三 8.2 阈值触达的分布(S 层)。
好感度的分段门在规格里是一串 ["aff", 名, ">=", 整数]。本脚本把它们从选项 when 与
结局 when 里抽出来,定义一条路径对某道门的**触达**为「该变量沿路径取到的最大值
>= 门限」。于是可以做三件度量:
① 每道门的**触达率**(多少条采样路径触达了它,千分位);
② 每条路径**触达的门数**分布(0…G 的直方图);
③ **从未触达任何门**的路径数——它们往往说明门设得太高(这是设计信号,不是错误)。
全部是结构量的 proxy,只做相对比较。走路机件复用 sim/nm_08_1_affinity_curve.py(只读)。
产出 out/nm_08_2_threshold_hits.json。
"""
from __future__ import annotations
import json
import os
import sys
import nm_08_1_affinity_curve as ac
AFF_LO, AFF_HI = 0, 100
SAMPLE_N, SEED, STEP_CAP = 5000, 20261016, 64
OUT = ac.OUT
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:
"""阈值门表:每条门 (kind, site, var, n),只收 >= 且变量在 state.vars 内。"""
gates = []
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
for sub in walk_aff(c.get("when")):
if sub[1] in spec["state"]["vars"] and sub[2] == ">=":
gates.append({"kind": "choice", "site": f"{n['id']}/{c['id']}",
"var": sub[1], "n": sub[3]})
for e in spec["endings"]:
for sub in walk_aff(e.get("when")):
if sub[1] in spec["state"]["vars"] and sub[2] == ">=":
gates.append({"kind": "ending", "site": e["id"], "var": sub[1], "n": sub[3]})
return gates
def sample_maxima(spec: dict, n: int, seed: int) -> list:
"""采样 n 条路径,返回每条路径 4 个变量沿路径的最大值字典。"""
by = {x["id"]: x for x in spec["dialogue"]}
VARS = sorted(spec["state"]["vars"])
rng = seed
out = []
for _ in range(n):
s = ac.init_state(spec)
mx = dict(s["aff"])
for _step in range(STEP_CAP):
cur = by[s["node"]]
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if ac.cond_ok(c.get("when"), s)]
if not opts:
break
rng = (rng * 48271) % 2147483647
s = ac.step(spec, s, opts[rng % len(opts)])
for v in VARS:
if s["aff"][v] > mx[v]:
mx[v] = s["aff"][v]
out.append(mx)
return out
def main() -> int:
spec = ac.load_spec()
VARS = sorted(spec["state"]["vars"])
gates = collect_gates(spec)
maxima = sample_maxima(spec, SAMPLE_N, SEED)
print(f"阈值门 {len(gates)} 条(选项 {sum(1 for g in gates if g['kind'] == 'choice')}"
f" / 结局 {sum(1 for g in gates if g['kind'] == 'ending')}) 采样 {SAMPLE_N} 条 seed={SEED}")
print("每道门的触达率(该变量沿路径最大值 >= 门限,千分位):")
print(" 位置 变量 门限 触达路径数 触达率‰")
hit_rows = []
for g in sorted(gates, key=lambda x: (x["var"], x["n"], x["site"])):
cnt = sum(1 for mx in maxima if mx[g["var"]] >= g["n"])
permille = cnt * 1000 // SAMPLE_N
hit_rows.append({**g, "hit_paths": cnt, "hit_permille": permille})
print(f" {g['site']:<32} {g['var']:<5}{g['n']:>4}{cnt:>11}{permille:>9}")
counts = [sum(1 for g in gates if mx[g["var"]] >= g["n"]) for mx in maxima]
hist = {k: 0 for k in range(0, len(gates) + 1)}
for c in counts:
hist[c] += 1
never = hist[0]
print(f"\n触达门数分布(每条路径触达了 0…{len(gates)} 道门中的几道):")
print(" 门数 " + " ".join(f"{k:>6}" for k in sorted(hist)))
print(" 路径数 " + " ".join(f"{hist[k]:>6}" for k in sorted(hist)))
print(f"从未触达任何门的路径 {never} / {SAMPLE_N} = {never * 1000 // SAMPLE_N}‰")
print(f"触达门数均值 {sum(counts) * 1000 // len(counts)} 毫(均值 × 1000)")
bad = [g["site"] for g in gates if not (AFF_LO <= g["n"] <= AFF_HI)]
print(f"I1 全部门限值 ∈ [0,100]:{not bad}")
assert not bad, "门限越界"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_08_2_threshold_hits.json"), "w", encoding="utf-8") as fh:
json.dump({"gates": len(gates), "sample_n": SAMPLE_N, "seed": SEED,
"gate_hits": hit_rows, "hit_count_histogram": {str(k): v for k, v in hist.items()},
"never_hit_paths": never, "never_hit_permille": never * 1000 // SAMPLE_N,
"mean_gates_hit_milli": sum(counts) * 1000 // len(counts)},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_08_2_threshold_hits.json")
print(f"OK nm_08_2_threshold_hits:{len(gates)} 道门触达分布已量出,{never} 条路径从未触达任何门")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_08_2_threshold_hits.py:
阈值门 14 条(选项 13 / 结局 1) 采样 5000 条 seed=20261016
每道门的触达率(该变量沿路径最大值 >= 门限,千分位):
位置 变量 门限 触达路径数 触达率‰
d_tavern_2/c_tavern_2c hui 55 2729 545
d_bw_2/c_bw_2c hui 60 1747 349
d_tavern_3/c_tavern_3b hui 60 1747 349
d_ch_2/c_ch_2c mu 55 2947 589
d_ho_3/c_ho_3b mu 70 533 106
d_chapel_2/c_chapel_2c shen 55 3810 762
d_chapel_3/c_chapel_3b shen 60 3194 638
d_db_2/c_db_2c shen 60 3194 638
d_wp_2/c_wp_2c yan 25 4291 858
d_lb_2/c_lb_2c yan 30 3515 703
d_wp_3/c_wp_3b yan 30 3515 703
d_dq_2/c_dq_2a yan 40 2246 449
d_tc_2/c_tc_2c yan 40 2246 449
e_lit yan 40 2246 449
触达门数分布(每条路径触达了 0…14 道门中的几道):
门数 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路径数 18 64 93 220 430 421 458 756 455 657 746 267 198 179 38
从未触达任何门的路径 18 / 5000 = 3‰
触达门数均值 7592 毫(均值 × 1000)
I1 全部门限值 ∈ [0,100]:True
wrote out/nm_08_2_threshold_hits.json
OK nm_08_2_threshold_hits:14 道门触达分布已量出,18 条路径从未触达任何门
读法有三层。第一,14 道门的触达率差得很开:最高的 d_wp_2/c_wp_2c(yan >= 25)有 858‰ 的路径触达,最低的 d_ho_3/c_ho_3b(mu >= 70)只有 106‰。门限越高越难触达——yan >= 25 是一道矮门,mu >= 70 是一道高门。第二,触达门数呈双峰:0 道门只有 18 条、1 道 64 条,而 7 道门有 756 条、10 道门有 746 条——多数路径能一口气触达 7–10 道门,均值 7.592 道。第三,有 18 条路径(3‰)从未触达任何门——这 18 条路一道好感度门都没碰到,往往说明它们绕开了所有引用好感度的节点(例如早早在低好感度处转向了不设门的支线),这是设计信号,不是错误。
B 层:门限 ±10,看分布怎么变
B 层把每道门限单独上下挪 ±10,在内存副本上改(不写回真源),重走路径后重算「这道门自己的触达率」与「从未触达任何门的路径数」。为什么必须重走而不是只在旧的 max 上比较?因为门限改变会改变可选项集合——门一放宽,原本不可选的选项变得可选,路径的走向就变了,max 也随之变。
#!/usr/bin/env python3
# file: sim/nm_08_2_hit_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_08_2_hit_perturb.py
"""卷三 8.2 阈值触达分布的门限扰动(B 层,软告警不硬失败)。
把每道门限单独上下挪 ±10(在**内存副本**上改,不写回真源),在同一采样口径下重走
路径、重算「这道门自己的触达率」与「从未触达任何门的路径数」,看谁一动分布就变。
关键:门限改变会改变**可选项集合**,所以必须**重走**路径,不能只在旧 max 上比较。
输出只给汇总与最敏感三道,完整 14 行见 out/nm_08_2_hit_perturb.json。**全部 exit 0**
——任何偏离只打印 WARN,断言写「告警条数已报告」,不写「必须无告警」。
门限机件与采样器复用 sim/nm_08_2_threshold_hits.py(只读)。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import nm_08_2_threshold_hits as th
AFF_LO, AFF_HI = 0, 100
SAMPLE_N, SEED = 5000, 20261016
DELTAS = (-10, 10)
WARN_PERMILLE = 1
def find_and_set(spec: dict, kind: str, site: str, var: str, n0: int, new_n: int) -> bool:
"""在内存副本里把某道门的门限改掉,返回是否找到。"""
if kind == "choice":
nid, cid = site.split("/")
conds = [c.get("when") for n in spec["dialogue"] if n["id"] == nid
for c in (n.get("choices") or []) if c["id"] == cid]
else:
conds = [e.get("when") for e in spec["endings"] if e["id"] == site]
for cond in conds:
for sub in th.walk_aff(cond):
if sub[1] == var and sub[2] == ">=" and sub[3] == n0:
sub[3] = new_n
return True
return False
def hit_count(spec: dict, var: str, n: int) -> tuple:
"""在给定 spec 下采样,返回 (本门触达路径数, 从未触达任何门的路径数)。"""
gates = th.collect_gates(spec)
maxima = th.sample_maxima(spec, SAMPLE_N, SEED)
hit = sum(1 for mx in maxima if mx[var] >= n)
never = sum(1 for mx in maxima if not any(mx[g["var"]] >= g["n"] for g in gates))
return hit, never
def main() -> int:
base = th.ac.load_spec()
gates = th.collect_gates(base)
print(f"阈值门 {len(gates)} 条 采样 {SAMPLE_N} 条 seed={SEED} 软告警阈值 {WARN_PERMILLE}‰")
rows, warns = [], []
for g in gates:
n0 = g["n"]
b_hit, b_never = hit_count(base, g["var"], n0)
b_pm = b_hit * 1000 // SAMPLE_N
res = {}
for d in DELTAS:
sp = copy.deepcopy(base)
nn = max(AFF_LO, min(AFF_HI, n0 + d))
find_and_set(sp, g["kind"], g["site"], g["var"], n0, nn)
h, nv = hit_count(sp, g["var"], nn)
res[d] = (h * 1000 // SAMPLE_N, nv)
changed = res[-10][1] != b_never or res[10][1] != b_never
rows.append({"site": g["site"], "var": g["var"], "n": n0, "base_permille": b_pm,
"minus10_permille": res[-10][0], "plus10_permille": res[10][0],
"base_never": b_never, "minus10_never": res[-10][1], "plus10_never": res[10][1],
"never_changed": changed,
"sensitivity": abs(res[-10][0] - b_pm) + abs(res[10][0] - b_pm)})
if rows[-1]["sensitivity"] >= WARN_PERMILLE or changed:
warns.append(f"门 {g['site']}({g['var']}>={n0}):Δ-10 {res[-10][0] - b_pm:+d}‰ "
f"Δ+10 {res[10][0] - b_pm:+d}‰ 未触达门路径 {b_never}→{res[-10][1]}/{res[10][1]}")
base_never = rows[0]["base_never"]
print(f"基线:从未触达任何门 {base_never} / {SAMPLE_N} = {base_never * 1000 // SAMPLE_N}‰")
print(f"±10 扰动后受影响的门 {len(warns)} / {len(gates)}"
f"(改变本门触达率 ≥{WARN_PERMILLE}‰ 或改变未触达门路径数)")
print("最敏感三道(按 |Δ-10| + |Δ+10| 排序,本门触达‰ 基线→-10/+10):")
for r in sorted(rows, key=lambda x: -x["sensitivity"])[:3]:
print(f" {r['site']:<30} {r['var']}>={r['n']:<3} {r['base_permille']:>4}→"
f"{r['minus10_permille']}/{r['plus10_permille']} 未触达门 {r['base_never']}→"
f"{r['minus10_never']}/{r['plus10_never']}")
print(f"wrote out/nm_08_2_hit_perturb.json({len(gates)} 道门 × {len(DELTAS)} 档,{len(warns)} 条 WARN)")
os.makedirs(th.OUT, exist_ok=True)
with open(os.path.join(th.OUT, "nm_08_2_hit_perturb.json"), "w", encoding="utf-8") as fh:
json.dump({"sample_n": SAMPLE_N, "seed": SEED, "deltas": list(DELTAS),
"rows": rows, "warnings": warns}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"OK nm_08_2_hit_perturb:扰动已跑完,{len(warns)} 条软告警已报告(B 层不硬失败,exit 0)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_08_2_hit_perturb.py:
阈值门 14 条 采样 5000 条 seed=20261016 软告警阈值 1‰
基线:从未触达任何门 18 / 5000 = 3‰
±10 扰动后受影响的门 14 / 14(改变本门触达率 ≥1‰ 或改变未触达门路径数)
最敏感三道(按 |Δ-10| + |Δ+10| 排序,本门触达‰ 基线→-10/+10):
d_tavern_3/c_tavern_3b hui>=60 349→1000/83 未触达门 18→0/18
d_bw_2/c_bw_2c hui>=60 349→1000/95 未触达门 18→0/18
d_ch_2/c_ch_2c mu>=55 589→1000/111 未触达门 18→0/42
wrote out/nm_08_2_hit_perturb.json(14 道门 × 2 档,14 条 WARN)
OK nm_08_2_hit_perturb:扰动已跑完,14 条软告警已报告(B 层不硬失败,exit 0)
读法要点有三条。第一,放宽(−10)几乎总把触达率推到 1000‰:门一矮,几乎所有路径都够得着——最敏感的三道门在 −10 后全部从三五百‰ 跳到 1000‰。第二,收紧(+10)把触达率压下去:同一批门 +10 后掉到 83‰ / 95‰ / 111‰,比基线低得多。第三,最关键的信号是「从未触达任何门」的路径数:基线是 18 条,把某些门放宽 10 点后变成 0 条——那 18 条「绕开所有门」的路被矮门接住了;把 mu >= 55 那道门收紧 10 点后升到 42 条——更多路径被挡在门外。这 18 → 0 与 18 → 42 的变化,正是「门设得高不高」的可算证据。
B 层只产出软告警:14 道门全部在 ±10 下改变了分布,脚本逐条打印 WARN,但退出码仍是 0——门限扰动不是错误,是设计参数的空间。断言写的是「告警条数已报告」,不是「必须无告警」。所有触达率都是结构量的 proxy,只做相对比较;「触达率高」不等于「关系写得好」,只是「有更多路走到了这道门前」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 门抽全 | 选项 when 与结局 when 都抽,含嵌在 and / or / not 里的 |
| 触达定义 | 「变量沿路径最大值 >= 门限」,与时机无关 |
| 分布完整 | 每道门触达率 + 触达门数直方图 + 未触达路径数三者都报 |
| 扰动重走 | 门限改变后重走路径,不在旧 max 上比较 |
| 软告警不硬失败 | B 层只打印 WARN,退出码 0 |
门触达数完了,但「触达了几道门」和「曲线长什么形状」是两回事:一条路可能触达 10 道门,曲线却是来回震荡的。下一节把每条曲线按整数规则分类成一个形状,统计各形状占比。
阅读导航:上一节:8.1 好感度随路径的变化 · 下一节:8.3 关系曲线的形状分类 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。