10.2 阈值门
Godot 对话选择历史与后果日志 记的是玩家已经做过的选择;本节管的是玩家还不能做的选择。当某个选项要求「好感度 ≥ 25」时,那 25 就是一个阈值门——它把一条对话边锁上,直到状态满足为止。日志是事后的,门限是事前的,两者合起来才解释得清「选择为什么可选」。
阈值门的形状是固定的:["aff", 名字, ">=", 整数]。本节做两件事:把全规格的阈值门抽成一张门限表(每条门限在采样路径上被判定几次、满足几次,即跨越率),并断言「门限未达时选项不可选」。
门限长在三处
阈值门不只长在选项上。全规格扫下来是 15 条,分三类:
| 位置 | 字段 | 条数 | 判定时机 | 不满足时的行为 |
|---|---|---|---|---|
| 选项 | choices[].when | 13 | 玩家面对该节点时 | 该选项不出现在可选列表里 |
| 节点 | dialogue[].line_when | 1 | 要渲染这一行时 | 该行整段不渲染 |
| 结局 | endings[].when | 1 | 路径走到终点时 | 该结局不可达 |
三处的判定时机不同,所以同一条 ["aff", "yan", ">=", 25] 长在不同位置,语义完全不一样。
门限表:跨越率、最高到达与缺口
门限的判定次数不是设计出来的,是采样跑出来的。脚本用 MINSTD(同种子)跑 20000 条路径,每条门限记两个数:visits(被判定几次)与 sat(满足几次),跨越率取千分位整数 sat*1000//visits。跑出来的门限值集合是 [25, 30, 40, 55, 60, 70]——六个档位,全部落在 [0,100] 内;跨越率从 54‰ 到 1000‰,基线死门 0 条。
表里还有一列「缺口」,是 门限 - 该门限被判定时变量的最高到达值。d_wp_2/c_wp_2c 的缺口是 0——20000 条采样里这条门限上的变量最多只到过 25,正好压在线上。它是最脆弱的一条:任何一个让 yan 少加 1 的改动都会把它变成死门。缺口为 0 的门限应当被单独盯住,它们是阈值设计里最先失效的地方。
判定次数本身也是一条信息:d_dq_2/c_dq_2a 判定了 20000 次(等于路径总数,因为它是结局门限,每条路径走到终点都判一次),而 e_lit/e_lit 只判了 1018 次(只有走到这个结局的路径才会遇到它)。它告诉你这条门限挡在路的哪一段。
跨越率是采样口径的代理指标,只用于相对比较:比较两条门限谁更常被满足、比较扰动前后同一条门限的变化。它不是「这条门限设计得好不好」的绝对判断——采样策略换一种,数字就会变。
S 层:门限表脚本
下面这段是 S 层脚本 sim/na_10_2_threshold_gate.py。它把三处的 aff 条件(含嵌在 and/or/not 里的)抽平,跑采样统计,断言 I2(门限阈值全部在 [0,100] 内)。它同时是 10.3 复用的门限机件(collect_gates / run_gates)。
#!/usr/bin/env python3
# file: sim/na_10_2_threshold_gate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_10_2_threshold_gate.py
"""卷一 10.2 阈值门(S 层):把 ["aff", 名, ">=", 整数] 抽成一张门限表。
阈值门可能长在三处:选项的 when、对话节点的 line_when、结局的 when。本节把全部门限抽出来,
在 20000 条采样路径上统计「判定过几次、其中满足几次」,每条门限拿到一个**跨越率**;跨越率
为 0 即**死门**。跨越率是采样口径的**代理指标**,只用于相对比较。整数口径:千分位整数
(sat*1000//visits),禁浮点。产出 out/na_10_2_threshold_gate.csv。
本模块同时是 10.3 B 层复用的门限机件(collect_gates / run_gates)。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
SAMPLE_N = 20000
SEED = 20261016
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
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 load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
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 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 init_state(spec: dict, delta: int = 0) -> dict:
"""初始值来自 characters[].affinity.init;delta 是整体扰动(10.3 B 层用)。"""
aff = {v: clamp(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) + delta)
for v in sorted(spec["state"]["vars"])}
flags = {f: 0 for f in spec["state"]["flags"]}
items = {i: 0 for i in spec["state"]["items"]}
return {"node": spec["meta"]["start"], "aff": aff, "flags": flags, "items": 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 collect_gates(spec: dict) -> tuple:
"""门限表 + 每个节点上要在访问时判定的门限下标。"""
gates: list = []
at: dict = {}
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], "visits": 0, "sat": 0, "max_seen": AFF_LO})
at.setdefault(node, []).append(len(gates) - 1)
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"))
name2node = {n["ending"]: n["id"] for n in spec["dialogue"] if n.get("ending")}
for e in spec["endings"]:
add("ending", name2node.get(e["name"], "-"), e["id"], e.get("when"))
return gates, at
def run_gates(spec: dict, gates: list, at: dict, n: int, seed: int, delta: int = 0) -> dict:
"""有界随机采样,统计每条门限的判定 / 满足次数。MINSTD 同种子。"""
by_id = {x["id"]: x for x in spec["dialogue"]}
for g in gates:
g["visits"] = g["sat"] = 0
g["max_seen"] = AFF_LO
rng = seed
for _ in range(n):
s = init_state(spec, delta)
for _step in range(200):
for gid in at.get(s["node"], ()):
g = gates[gid]
g["visits"] += 1
if s["aff"].get(g["var"], 0) > g["max_seen"]:
g["max_seen"] = s["aff"][g["var"]]
if cond_ok(g["cond"], s):
g["sat"] += 1
cur = by_id[s["node"]]
if cur.get("ending"):
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)]
s = apply_effects(c.get("effects") or {}, s)
s["node"] = c["goto"]
for g in gates:
g["rate_permille"] = g["sat"] * 1000 // max(1, g["visits"])
g["gap"] = g["n"] - g["max_seen"]
g["dead"] = 1 if g["sat"] == 0 else 0
tv = sum(g["visits"] for g in gates)
ts = sum(g["sat"] for g in gates)
return {"visits": tv, "satisfied": ts, "rate_permille": ts * 1000 // max(1, tv),
"dead": sum(g["dead"] for g in gates)}
def main() -> int:
spec = load_spec()
gates, at = collect_gates(spec)
agg = run_gates(spec, gates, at, SAMPLE_N, SEED)
kinds: dict = {}
for g in gates:
kinds[g["kind"]] = kinds.get(g["kind"], 0) + 1
print(f"spec={spec['meta']['title']} 门限 {len(gates)} 条({kinds}) 采样 {SAMPLE_N} 条路径 seed={SEED}")
print(f"门限变量:{sorted({g['var'] for g in gates})} 门限值:{sorted({g['n'] for g in gates})}")
print("门限表(按跨越率升序):")
print(" 门限 判定次数 满足次数 跨越率 最高到达 缺口")
for g in sorted(gates, key=lambda x: (x["rate_permille"], x["node"], x["choice"])):
site = f"{g['node']}/{g['choice']}" if g["choice"] != "-" else f"{g['node']}[{g['kind']}]"
print(f" {site:<26} {g['visits']:>8} {g['sat']:>9} {g['rate_permille']:>4}‰ "
f"{g['max_seen']:>6} {g['gap']:>3}")
dead = [g["node"] + "/" + g["choice"] for g in gates if g["dead"]]
print(f"死门(跨越率 0):{len(dead)} 条" + (f" {dead}" if dead else "")
+ f" 全体门限跨越率 {agg['rate_permille']}‰({agg['satisfied']}/{agg['visits']})")
bad = [g for g in gates if not (AFF_LO <= g["n"] <= AFF_HI)]
print(f"I2 门限阈值全部 ∈ [0,100]:{not bad}")
if bad:
return 1
print("OK na_10_2_threshold_gate:门限表已出,跨越率与死门均已统计,I2 成立")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_10_2_threshold_gate.py:
spec=雾港夜航 门限 15 条({'choice': 13, 'line_when': 1, 'ending': 1}) 采样 20000 条路径 seed=20261016
门限变量:['hui', 'mu', 'shen', 'yan'] 门限值:[25, 30, 40, 55, 60, 70]
门限表(按跨越率升序):
门限 判定次数 满足次数 跨越率 最高到达 缺口
d_tc_2/c_tc_2c 4985 273 54‰ 45 -5
d_ho_3/c_ho_3b 12499 1419 113‰ 100 -30
d_lb_2/c_lb_2c 10050 1142 113‰ 35 -5
d_bw_2/c_bw_2c 14604 2038 139‰ 82 -22
d_wp_3/c_wp_3b 9942 2235 224‰ 35 -5
d_tavern_3/c_tavern_3b 10038 2779 276‰ 67 -7
d_wp_2/c_wp_2c 9942 3326 334‰ 25 0
d_db_2/c_db_2c 7501 2943 392‰ 85 -25
d_chapel_2/c_chapel_2c 9962 4151 416‰ 55 0
d_ch_2/c_ch_2c 10058 4222 419‰ 55 0
d_dq_2/c_dq_2a 20000 8398 419‰ 100 -60
d_tavern_2/c_tavern_2c 10038 4214 419‰ 57 -2
d_lt_2[line_when] 10011 5868 586‰ 75 -45
d_chapel_3/c_chapel_3b 9962 6233 625‰ 65 -5
e_lit/e_lit 1018 1018 1000‰ 100 -60
死门(跨越率 0):0 条 全体门限跨越率 333‰(50259/150610)
I2 门限阈值全部 ∈ [0,100]:True
OK na_10_2_threshold_gate:门限表已出,跨越率与死门均已统计,I2 成立
P 层:门限未达即不可选
S 层给的是频率,P 层给的是判定。core/test/na_10_2_threshold_gate.test.ts 断言三件事:门限未达时 applyChoice 抛 unavailable;恰好达到时(>= 是闭区间)可选;穷举全部顶层门限,n-1 拒绝、n 通过。
// file: core/test/na_10_2_threshold_gate.test.ts
// tier: P
// verify: node --test 'core/test/na_10_2_threshold_gate.test.ts'
// 10.2 阈值门:门限未达时选项不可选;门限恰好达到时可选的边界行为。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { condOk, applyChoice } from "../src/inkwell/engine.ts";
import type { Spec, State, Cond } from "../src/inkwell/engine.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const readJson = (p: string) => JSON.parse(readFileSync(p, "utf8"));
const spec = readJson(join(ROOT, "config/story_spec.json")) as Spec;
function stateAt(node: string, aff: Record<string, number>): State {
const full: Record<string, number> = {};
for (const v of spec.state.vars) full[v] = aff[v] ?? 0;
const flags: Record<string, number> = {};
for (const f of spec.state.flags) flags[f] = 0;
const items: Record<string, number> = {};
for (const i of spec.state.items) items[i] = 0;
return { node, aff: full, flags, items, path: [node] };
}
/** 「顶层就是 ["aff", 名, ">=", 整数]」的选项门限。 */
function topLevelGates(): { node: string; choice: string; v: string; n: number }[] {
const out: { node: string; choice: string; v: string; n: number }[] = [];
for (const node of spec.dialogue) {
for (const c of node.choices ?? []) {
const w = c.when as Cond | null;
if (w && w[0] === "aff" && w[2] === ">=") {
out.push({ node: node.id, choice: c.id, v: w[1] as string, n: w[3] as number });
}
}
}
return out;
}
test("门限未达时选项不可选(点名两处真实门限)", () => {
const low = stateAt("d_wp_2", { yan: 24 });
assert.equal(condOk(["aff", "yan", ">=", 25], low), false);
assert.throws(() => applyChoice(spec, low, "c_wp_2c"), /unavailable/);
const low2 = stateAt("d_tavern_2", { hui: 54 });
assert.equal(condOk(["aff", "hui", ">=", 55], low2), false);
assert.throws(() => applyChoice(spec, low2, "c_tavern_2c"), /unavailable/);
});
test("门限恰好达到时可选(边界闭区间)", () => {
const at = stateAt("d_wp_2", { yan: 25 });
assert.equal(condOk(["aff", "yan", ">=", 25], at), true);
assert.equal(applyChoice(spec, at, "c_wp_2c").node, "d_wp_3");
});
test("穷举全部顶层门限:n-1 拒绝、n 通过,且阈值都是 [0,100] 内整数", () => {
const gates = topLevelGates();
// 13 条选项门限里,12 条的 when 顶层就是 aff;d_dq_2/c_dq_2a 的 aff 嵌在 and 里(由 S 层覆盖)。
assert.equal(gates.length, 12, `顶层门限应为 12 条,实为 ${gates.length}`);
for (const g of gates) {
assert.ok(Number.isInteger(g.n) && g.n >= 0 && g.n <= 100, `${g.v} 的门限 ${g.n} 越界`);
const below = stateAt(g.node, { [g.v]: g.n - 1 });
const ok = stateAt(g.node, { [g.v]: g.n });
assert.equal(condOk(["aff", g.v, ">=", g.n], below), false, `${g.node}/${g.choice} 在 ${g.n - 1} 时不应可选`);
assert.equal(condOk(["aff", g.v, ">=", g.n], ok), true, `${g.node}/${g.choice} 在 ${g.n} 时应可选`);
assert.throws(() => applyChoice(spec, below, g.choice), /unavailable/);
assert.doesNotThrow(() => applyChoice(spec, ok, g.choice));
}
console.log(`顶层门限 ${gates.length} 条:${gates.map((g) => `${g.v}>=${g.n}`).join(" ")}`);
});
本机实跑 node --test 'core/test/na_10_2_threshold_gate.test.ts'(摘录):
顶层门限 12 条:hui>=55 hui>=60 shen>=55 shen>=60 mu>=55 yan>=25 yan>=30 yan>=30 yan>=40 mu>=70 shen>=60 hui>=60
✔ 门限未达时选项不可选(点名两处真实门限) (0.802166ms)
✔ 门限恰好达到时可选(边界闭区间) (0.168ms)
✔ 穷举全部顶层门限:n-1 拒绝、n 通过,且阈值都是 [0,100] 内整数 (0.858042ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
穷举测试只找到 12 条顶层门限,而 S 层说有 13 条选项门限。差的这一条是 d_dq_2/c_dq_2a:它的 aff 条件嵌在一个 and 里,不是 when 的顶层。这不是漏掉,而是两种口径的分工——P 层穷举「顶层就是 aff」的门限(能直接构造单变量状态来测边界),嵌套形态的门限由 S 层抽平后覆盖。测试里把这件事写成注释和断言,就是为了让「12」这个数字可解释。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三处都扫 | 选项 when、节点 line_when、结局 when 一处不漏 |
| 嵌套抽平 | and/or/not 里的 aff 也要抽出来,不能只认顶层 |
| 阈值合法 | 全部门限值都在 [0,100] 内(I2) |
| 死门显式 | 跨越率 0 的门限必须被单独列出,不能混在表里 |
| 口径诚实 | 跨越率声明为代理指标,只做相对比较 |
门限表说的是「一条门限被跨过多少次」。但它没说另一件更要紧的事:好感度在一条路径上是怎么走过去的——是稳步抬升,还是来回震荡?下一节把每个变量的好感度沿路径画成曲线,把「形状」也变成一个可分类的量。
阅读导航:上一节:10.1 好感度的初始化与增减 · 下一节:10.3 好感度曲线的形状 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。