8.3 好感度的不变量
互动叙事中的好感度设计 那篇讲的是经验:好感度该在什么节点给、给多少才不显得突兀。本节讲不变量:不管怎么给,有四条底线必须永远成立,本节把每一条都写成可枚举、可断言的残差。
「不变量」这个词听着抽象,落地其实很朴素:某些数必须恒为 0。钳位越界的变量数、初值越出声明区间的角色数、累积口径对不上的变量数、结构性不可达的门限数——这四条只要有一条不为 0,规格就有洞。本节把这四条一次核到底,并把它们写成一条公式。
四条残差的口径
①钳位——全部可达状态、以及每次转移之后,4 个变量都 ∈ [0,100];②初值——每个声明角色的 affinity.init 落在自身 affinity.range 内;③累积口径——每条转移、每个变量,新值必须等于 clamp(旧值 + 该选项对该变量的 add),且 set 不得写好感度;④门限可达性——每条 ["aff", 名, ">=", 整数] 都真的可能被跨过。
前三者复用 8.1 的 enumerate_states:同一趟 BFS 里挂两个回调,一个逐状态查钳位与门限,一个逐转移查累积口径。第四者需要知道每条门限「长在哪个节点」以及「该节点上实测最高能到多少」,两者一减就是缺口。
把四条不变量核一遍
#!/usr/bin/env python3
# file: sim/ne_08_3_invariant.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_08_3_invariant.py
"""卷二 8.3 好感度的不变量(S 层):四条必须恒为 0 的残差。
复用 8.1 的完整 BFS 机件 enumerate_states,逐状态、逐转移核对:
① 钳位——全部可达状态、以及每次转移后,4 个变量都 ∈ [0,100];
② 初值——每个声明角色的 affinity.init 落在自身 affinity.range 内;
③ 累积口径——每条转移、每个变量:新值必须等于 clamp(旧值 + 该选项对该变量的 add);
set 不得写好感度;
④ 阈值门可达性——每条 ["aff", 名, ">=", 整数] 门限都真的可能被跨过。
另如实报告:lin / hai 声明了 affinity 却不在 state.vars,故它们的门限**结构性不可达**;
但实测本规格 0 个条件引用它们。判据写死:**声明未用=报告;用了未声明=FAIL**。
只读 config/story_spec.json,产出 out/ne_08_3_invariant.json。整数口径。
"""
from __future__ import annotations
import json
import os
import sys
import ne_08_1_state_machine as sm
AFF_LO, AFF_HI = 0, 100
def collect_gates(spec: dict) -> list:
"""抽平全部 aff 门限(含嵌在 and/or/not 里的),返回 [{kind,node,choice,var,op,n}]。"""
gates: list = []
def walk(c):
if not c:
return
if c[0] == "aff":
yield c
elif c[0] == "not":
yield from walk(c[1])
elif c[0] in ("and", "or"):
yield from walk(c[1])
yield from walk(c[2])
def add(kind, node, choice, cond):
for sub in walk(cond):
gates.append({"kind": kind, "node": node, "choice": choice,
"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 = sm.load_spec()
VARS, AI, nodes, NI, choices, encode, decode = sm.context(spec)
gates = collect_gates(spec)
assert all(g["op"] == ">=" for g in gates), "本节门限按 >= 口径统计"
gnode: dict = {}
for gi, g in enumerate(gates):
if g["var"] in AI:
gnode.setdefault(NI[g["node"]], []).append(gi)
gsat = [False] * len(gates)
gmax = [AFF_LO] * len(gates)
st = {"clamp": 0, "tclamp": 0, "cum": 0}
def on_state(ni, aff, fb, ib):
if any(v < AFF_LO or v > AFF_HI for v in aff):
st["clamp"] += 1
for gi in gnode.get(ni, ()):
v = aff[AI[gates[gi]["var"]]]
if v > gmax[gi]:
gmax[gi] = v
if v >= gates[gi]["n"]:
gsat[gi] = True
def on_transition(ni, aff, fb, ib, ci, sa, sf, si):
if any(v < AFF_LO or v > AFF_HI for v in sa):
st["tclamp"] += 1
add_by = dict(choices[ni][ci][2])
for i in range(len(VARS)):
if sa[i] != sm.clamp(aff[i] + add_by.get(i, 0)):
st["cum"] += 1
r = sm.enumerate_states(spec, on_state=on_state, on_transition=on_transition)
init_bad = [c["id"] for c in spec["characters"]
if not (c["affinity"]["range"][0] <= c["affinity"]["init"] <= c["affinity"]["range"][1])]
set_var = sum(1 for n in nodes for c in (n.get("choices") or [])
for k in ((c.get("effects") or {}).get("set") or {}) if k in AI)
used_und: set = set()
def scan(c):
if not c:
return
if c[0] == "aff":
if c[1] not in AI:
used_und.add(c[1])
elif c[0] in ("and", "or"):
scan(c[1])
scan(c[2])
elif c[0] == "not":
scan(c[1])
for n in nodes:
scan(n.get("line_when"))
for c in (n.get("choices") or []):
scan(c.get("when"))
for k in ((c.get("effects") or {}).get("add") or {}):
if k not in AI:
used_und.add(k)
for e in spec["endings"]:
scan(e.get("when"))
declared_unused = sorted(c["id"] for c in spec["characters"] if c["id"] not in AI)
dead = [site(gates[gi]) for gi in range(len(gates)) if not gsat[gi]]
resid = st["clamp"] + st["tclamp"] + len(init_bad) + st["cum"] + len(dead)
print(f"spec={spec['meta']['title']} 可达状态 {r['states']}(超限={r['budget_hit']}) 转移 {r['transitions']}")
print(f"① 钳位:状态越界 {st['clamp']} 转移后越界 {st['tclamp']} (全部 ∈ [0,100])")
print(f"② 初值:{len(spec['characters'])} 个声明角色,越出自身声明区间 {len(init_bad)}")
print(f"③ 累积口径:转移中「新值 != clamp(旧值 + add)」的变量数 {st['cum']};set 写好感度的键 {set_var}")
print(f"④ 阈值门可达性:{len(gates)} 条门限,结构性不可达 {len(dead)} 条")
print(" 门限表(出现节点上的实测最高值):")
print(" 位置 变量 门限 实测最高 缺口")
for gi, g in enumerate(gates):
print(f" {site(g):<32} {g['var']:<4} {g['n']:>3} {gmax[gi]:>6} {g['n'] - gmax[gi]:>3}")
print(f"声明域 > 状态域:{declared_unused}(有 affinity 声明,不在 state.vars;声明未用=报告)")
print(f"引用未声明好感度变量的条件/效果 {len(used_und)} 处 {sorted(used_und)}(用了未声明=FAIL)")
print(f"I-ALL 残差 = 钳位 {st['clamp'] + st['tclamp']} + 初值 {len(init_bad)} "
f"+ 累积 {st['cum']} + 死门 {len(dead)} = {resid}")
if r["budget_hit"] or used_und or resid:
print("FAIL 残差非零或引用了未声明变量")
return 1
os.makedirs(sm.OUT, exist_ok=True)
with open(os.path.join(sm.OUT, "ne_08_3_invariant.json"), "w", encoding="utf-8") as fh:
json.dump({"states": r["states"], "transitions": r["transitions"],
"clamp_violations": st["clamp"] + st["tclamp"], "init_violations": len(init_bad),
"cumulative_mismatches": st["cum"], "set_var_keys": set_var, "gates": len(gates),
"dead_gates": dead, "declared_unused": declared_unused,
"used_undeclared": sorted(used_und), "residual": resid},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/ne_08_3_invariant.json")
print("OK ne_08_3_invariant:四条残差恒为 0,声明未用已报告,无用了未声明")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑:
spec=雾港夜航 可达状态 9895423(超限=False) 转移 12163825
① 钳位:状态越界 0 转移后越界 0 (全部 ∈ [0,100])
② 初值:6 个声明角色,越出自身声明区间 0
③ 累积口径:转移中「新值 != clamp(旧值 + add)」的变量数 0;set 写好感度的键 0
④ 阈值门可达性:15 条门限,结构性不可达 0 条
门限表(出现节点上的实测最高值):
位置 变量 门限 实测最高 缺口
d_tavern_2/c_tavern_2c hui 55 57 -2
d_tavern_3/c_tavern_3b hui 60 67 -7
d_chapel_2/c_chapel_2c shen 55 55 0
d_chapel_3/c_chapel_3b shen 60 65 -5
d_ch_2/c_ch_2c mu 55 55 0
d_wp_2/c_wp_2c yan 25 25 0
d_wp_3/c_wp_3b yan 30 35 -5
d_lb_2/c_lb_2c yan 30 35 -5
d_tc_2/c_tc_2c yan 40 45 -5
d_ho_3/c_ho_3b mu 70 100 -30
d_db_2/c_db_2c shen 60 85 -25
d_lt_2[line_when] yan 30 75 -45
d_bw_2/c_bw_2c hui 60 82 -22
d_dq_2/c_dq_2a yan 40 100 -60
e_lit/e_lit yan 40 100 -60
声明域 > 状态域:['hai', 'lin'](有 affinity 声明,不在 state.vars;声明未用=报告)
引用未声明好感度变量的条件/效果 0 处 [](用了未声明=FAIL)
I-ALL 残差 = 钳位 0 + 初值 0 + 累积 0 + 死门 0 = 0
wrote out/ne_08_3_invariant.json
OK ne_08_3_invariant:四条残差恒为 0,声明未用已报告,无用了未声明
四条残差全是 0,I-ALL 合计 0。最值得注意的是第四条的缺口列:三条门限的实测最高值恰好等于门限本身(缺口 0)——d_chapel_2/c_chapel_2c(shen >= 55)、d_ch_2/c_ch_2c(mu >= 55)、d_wp_2/c_wp_2c(yan >= 25)。门限只到「刚好够得着」,再高一点就永远跨不过去。
这正好与 8.2 的扰动结果对上:那三条加上 d_tavern_2 就是四条临界门限。缺口为 0 是「刚好可达」,扰动敏感是「一碰就变」——同一批门限从两个角度被同时指认。
声明未用 = 报告,用了未声明 = FAIL
lin(林)与 hai(孩)有 affinity 声明却不在 state.vars 里。判据写死:声明未用=报告;用了未声明=FAIL。前者只提示,后者直接红——因为「用了未声明」意味着条件引用的变量根本不存在于状态里,是必然的运行时洞。本规格实测「引用未声明好感度变量 0 处」。
P 层:把四条不变量写成断言
P 层把四条不变量写成 5 条断言,数据来自 samplePaths(spec, 2000, seed) 的确定性采样——同种子同结果,所以下面这些数每次跑都一样。
// file: core/test/ne_08_3_invariant.test.ts
// tier: P
// verify: node --test 'core/test/ne_08_3_invariant.test.ts'
// 8.3 好感度的不变量:钳位 / 初值 / 累积口径 / 门限可达性 / 声明未用(四条残差恒为 0)。
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 { samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { declaredUnused, usedUndeclaredVars, initOutOfRange, scanPaths, gateReachability } from "../src/inkwell/ne_08_3_invariant.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(HERE, "../../config/story_spec.json"), "utf8")) as Spec;
const SEED = 20261016;
const PATHS = samplePaths(spec, 2000, SEED);
test("① 钳位:采样路径每一步的好感度都是 [0,100] 内整数", () => {
const r = scanPaths(spec, PATHS);
assert.ok(r.states > 0, "扫描到的状态数应大于 0");
assert.equal(r.clampViolations, 0, "不得有越界的好感度");
console.log(`扫描状态 ${r.states} 个,钳位越界 ${r.clampViolations}`);
});
test("② 初值:全部声明角色的 init 落在自身 affinity.range 内", () => {
assert.deepEqual(initOutOfRange(spec), []);
});
test("③ 累积口径:新值恒等于 clamp(旧值 + add),无一处不一致", () => {
const r = scanPaths(spec, PATHS);
assert.equal(r.cumulativeMismatches, 0);
});
test("④ 阈值门可达性:每条门限都真的可能被跨过(采样最高到达 >= 门限)", () => {
const probes = gateReachability(spec, PATHS);
assert.equal(probes.length, 15, `门限应为 15 条,实为 ${probes.length}`);
const dead = probes.filter((p) => !p.visited || p.maxSeen < p.n);
assert.deepEqual(dead, [], "不得有结构性不可达的门限");
console.log(`门限 ${probes.length} 条全部可达;缺口为 0 的 ${probes.filter((p) => p.maxSeen === p.n).map((p) => `${p.site}(${p.v}>=${p.n})`).join(" ")}`);
});
test("声明未用=报告,用了未声明=FAIL:lin/hai 有声明但无引用", () => {
assert.deepEqual(declaredUnused(spec), ["hai", "lin"]);
assert.deepEqual(usedUndeclaredVars(spec), [], "不得引用未声明的好感度变量");
});
本机实跑:
扫描状态 52840 个,钳位越界 0
门限 15 条全部可达;缺口为 0 的 d_chapel_2/c_chapel_2c(shen>=55) d_ch_2/c_ch_2c(mu>=55) d_wp_2/c_wp_2c(yan>=25)
✔ ① 钳位:采样路径每一步的好感度都是 [0,100] 内整数 (44.496ms)
✔ ② 初值:全部声明角色的 init 落在自身 affinity.range 内 (0.45375ms)
✔ ③ 累积口径:新值恒等于 clamp(旧值 + add),无一处不一致 (38.917541ms)
✔ ④ 阈值门可达性:每条门限都真的可能被跨过(采样最高到达 >= 门限) (29.115875ms)
✔ 声明未用=报告,用了未声明=FAIL:lin/hai 有声明但无引用 (0.30675ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
五条全过。最后一条把「声明未用」与「用了未声明」拆成两个断言:前者断言结果恰是 ['hai', 'lin'](报告),后者断言结果为空(FAIL 线)。把报告项写进断言的好处是——它不会因为「反正只是警告」而被悄悄改掉。
自检表
| 检查项 | 合格标准 |
|---|---|
| 钳位残差 | 全部可达状态与每次转移后,4 个变量都在 [0,100] 内,越界 0 |
| 初值残差 | 每个声明角色的 init 落在自身 range 内,越出 0 |
| 累积口径残差 | 新值恒等于 clamp(旧值 + add),set 不写好感度 |
| 门限可达性残差 | 15 条门限无一条结构性不可达;缺口为 0 的三条被点名 |
| 声明边界诚实 | 声明未用只报告、用了未声明判 FAIL,两者分开断言 |
四条不变量定住了,规格到这一步可以放心改:任何一次改 add 的数值或门限值,重跑这三个脚本就能知道有没有踩线。但这些都是跑在命令行上的。下一节把整个规格内联进单个 HTML 文件——不依赖 Node、不依赖 Python,双击就能走完全部分支。
阅读导航:上一节:8.2 阈值门与门限扰动 · 下一节:9.1 规格内联进单文件 HTML 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。