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 好感度的不变量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。