8.1 好感度的状态机
互动叙事中的好感度设计 那篇讲的是设计经验:好感度该分几段、怎么让玩家感知到它、怎么防止刷分。本节往下沉一层,讲机件:当好感度被写成 4 个整数变量之后,它到底是一台什么样的状态机、能走到哪些值、哪些值其实永远走不到。经验回答「设计得好不好」,本节回答「这台机器能走到哪」。
《雾港夜航》把 4 个角色的好感度声明成 state.vars 里的 4 个整数:yan(砚)、hui(徽)、shen(沈)、mu(沐)。声明域是每变量 [0,100],也就是 101 个取值。如果四个变量彼此独立,组合数该是 101^4 = 104060401。本节要验证的正是这个「如果」。
状态 = 位置 + 数值 + 布尔
这台机器的状态由四段拼成:当前停在哪一个对话节点、4 个好感度整数、18 个布尔 flag、9 个物品。转移是一条「可用」的选项——它的 when 在当前状态下求值为真,应用它的 effects,再跳到 goto。
四类写入里只有一类碰好感度:add 累加后钳到 [0,100];set 只写 flag,grant / revoke 只写物品。所以好感度是这台机器里唯一的数值维度,其余全是位置或布尔——这既是它好推理的原因,也是本节能把它整个枚举出来的原因。
四个变量与它们的初值
| 变量 | 角色 | 初值 | 声明区间 | 声明的分段门 |
|---|---|---|---|---|
yan | 砚 | 20 | [0,100] | 30 / 60 / 85 |
hui | 徽 | 50 | [0,100] | 30 / 60 / 85 |
shen | 沈 | 50 | [0,100] | 30 / 60 / 85 |
mu | 沐 | 50 | [0,100] | 30 / 60 / 85 |
注意:characters[] 里其实声明了 6 个角色——lin(林,初值 40)与 hai(孩,初值 60)也有 affinity 段,但它们不在 state.vars 里。这不是 bug,是「声明未用」:它们的 init 与 range 写了,却没有任何条件或效果引用它们。8.3 会把这条当报告项,不当失败。
值域远小于声明域:把状态空间 BFS 一遍
enumerate_states 从起点 d_pier_1 出发做完整 BFS。为了把 989 万个状态塞进内存,状态被打包成一个整数:节点占 6 位、4 个好感度各占 7 位(0–100 够用)、18 个 flag 占 18 位、9 个物品占 9 位。下面是脚本本体。
#!/usr/bin/env python3
# file: sim/ne_08_1_state_machine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_08_1_state_machine.py
"""卷二 8.1 好感度的状态机(S 层):把好感度当成一个受钳位约束的整数状态机。
状态 = (当前节点, 4 个好感度整数, 18 个 flag, 9 个物品);转移 = 一条可用选项:求值
when → 应用 effects(set 写 flag、add 累加好感度后钳到 [0,100]、grant/revoke 写物品)
→ 跳到 goto。好感度是这台机器里唯一的**数值**维度,其可达值域远小于声明域 0–100。
enumerate_states 是本章公用的完整 BFS 机件(8.3 直接复用):状态打包成单个整数
(节点 6 位 / 好感度 4×7 位 / flag 18 位 / item 9 位)以压低峰值内存,可选 hooks 在
遍历中逐状态 / 逐转移回调。只读 config/story_spec.json,产出 out/ne_08_1_state_machine.*。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
AFF_LO, AFF_HI = 0, 100
STATE_BUDGET = 12000000
SH_IB, SH_FB, SH_AFF = 9, 18, 28
SH_NODE = SH_IB + SH_FB + SH_AFF
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 _comp(c, AI, FI, II):
"""把条件编译成带下标的元组,避免每次求值都查名字。"""
if not c:
return None
k = c[0]
if k == "aff":
return ("a", AI[c[1]], c[2], c[3])
if k == "flag":
return ("f", FI[c[1]], c[2])
if k == "item":
return ("i", II[c[1]], c[2])
if k == "and":
return ("&", _comp(c[1], AI, FI, II), _comp(c[2], AI, FI, II))
if k == "or":
return ("|", _comp(c[1], AI, FI, II), _comp(c[2], AI, FI, II))
return ("!", _comp(c[1], AI, FI, II))
def _ev(c, aff, fb, ib) -> bool:
if c is None:
return True
t = c[0]
if t == "a":
v, op, n = aff[c[1]], c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if t == "f":
return ((fb >> c[1]) & 1) == c[2]
if t == "i":
return ((ib >> c[1]) & 1) == c[2]
if t == "&":
return _ev(c[1], aff, fb, ib) and _ev(c[2], aff, fb, ib)
if t == "|":
return _ev(c[1], aff, fb, ib) or _ev(c[2], aff, fb, ib)
return not _ev(c[1], aff, fb, ib)
def context(spec: dict) -> tuple:
"""编译 spec:返回 (VARS, AI, nodes, NI, choices, encode, decode)。"""
VARS = sorted(spec["state"]["vars"])
FLAGS = sorted(spec["state"]["flags"])
ITEMS = sorted(spec["state"]["items"])
AI = {v: i for i, v in enumerate(VARS)}
FI = {v: i for i, v in enumerate(FLAGS)}
II = {v: i for i, v in enumerate(ITEMS)}
nodes = spec["dialogue"]
NI = {n["id"]: i for i, n in enumerate(nodes)}
choices = []
for n in nodes:
cs = []
for c in (n.get("choices") or []):
e = c.get("effects") or {}
cs.append((_comp(c.get("when"), AI, FI, II),
[(FI[k], v) for k, v in (e.get("set") or {}).items()],
[(AI[k], v) for k, v in (e.get("add") or {}).items()],
[(II[k], v) for k, v in (e.get("grant") or {}).items()],
[II[k] for k in (e.get("revoke") or {})],
NI[c["goto"]]))
choices.append(cs)
def encode(ni: int, aff, fb: int, ib: int) -> int:
code = aff[0] | (aff[1] << 7) | (aff[2] << 14) | (aff[3] << 21)
return (ni << SH_NODE) | (code << SH_IB << SH_FB) | (fb << SH_IB) | ib
def decode(key: int) -> tuple:
ib = key & 511
fb = (key >> SH_IB) & ((1 << SH_FB) - 1)
code = (key >> (SH_IB + SH_FB)) & ((1 << SH_AFF) - 1)
aff = (code & 127, (code >> 7) & 127, (code >> 14) & 127, (code >> 21) & 127)
return key >> SH_NODE, aff, fb, ib
return VARS, AI, nodes, NI, choices, encode, decode
def enumerate_states(spec: dict, on_state=None, on_transition=None, budget: int = STATE_BUDGET) -> dict:
"""完整 BFS。on_state(ni, aff, fb, ib);on_transition(ni, aff, fb, ib, ci, sa, sf, si)。"""
VARS, AI, nodes, NI, choices, encode, decode = context(spec)
aff0 = tuple(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in VARS)
start = encode(NI[spec["meta"]["start"]], aff0, 0, 0)
seen = {start}
frontier = [start]
states = transitions = 0
hit = False
while frontier:
nxt: list = []
for key in frontier:
ni, aff, fb, ib = decode(key)
states += 1
if on_state:
on_state(ni, aff, fb, ib)
if nodes[ni].get("ending"):
continue
for ci, (cw, sl, al, gl, rl, gi) in enumerate(choices[ni]):
if not _ev(cw, aff, fb, ib):
continue
transitions += 1
a = list(aff)
for i, d in al:
a[i] = clamp(a[i] + d)
f2 = fb
for i, v in sl:
f2 = (f2 | (1 << i)) if v else (f2 & ~(1 << i))
i2 = ib
for i, v in gl:
i2 = (i2 | (1 << i)) if v else (i2 & ~(1 << i))
for i in rl:
i2 &= ~(1 << i)
if on_transition:
on_transition(ni, aff, fb, ib, ci, a, f2, i2)
nk = encode(gi, a, f2, i2)
if nk not in seen:
seen.add(nk)
nxt.append(nk)
frontier = nxt
if states > budget:
hit = True
break
return {"states": states, "transitions": transitions, "budget_hit": hit,
"VARS": VARS, "nodes": nodes, "choices": choices}
def main() -> int:
t0 = time.time()
spec = load_spec()
VARS, AI, nodes, NI, choices, encode, decode = context(spec)
aff_vals = [set() for _ in VARS]
aff_tuples: set = set()
def on_state(ni, aff, fb, ib):
for i in range(len(VARS)):
aff_vals[i].add(aff[i])
aff_tuples.add(aff[0] | (aff[1] << 7) | (aff[2] << 14) | (aff[3] << 21))
r = enumerate_states(spec, on_state=on_state)
add_cnt = {v: 0 for v in VARS}
add_total = 0
for n in nodes:
for c in (n.get("choices") or []):
for k in ((c.get("effects") or {}).get("add") or {}):
add_total += 1
if k in add_cnt:
add_cnt[k] += 1
declared = {c["id"]: c["affinity"] for c in spec["characters"]}
distinct = [len(s) for s in aff_vals]
prod = 1
for d in distinct:
prod *= d
reach_aff = len(aff_tuples)
space = 101 ** len(VARS)
print(f"spec={spec['meta']['title']} 变量 {VARS} 起点 {spec['meta']['start']}")
print(f"可达状态 {r['states']}(完整 BFS,预算 {STATE_BUDGET},超限={r['budget_hit']}) 转移 {r['transitions']}")
print("好感度状态机(声明域每变量 0–100 共 101 个取值):")
print(f" {'变量':<6}{'init':>5}{'add 次数':>9}{'取到不同值':>10} 实测区间")
rows = []
for i, v in enumerate(VARS):
s = aff_vals[i]
lo, hi = min(s), max(s)
print(f" {v:<6}{declared[v]['init']:>5}{add_cnt[v]:>9}{len(s):>10} [{lo}, {hi}]")
rows.append({"var": v, "init": declared[v]["init"], "add_count": add_cnt[v],
"distinct": len(s), "min": lo, "max": hi})
print(f"add 写入合计 {add_total} 次(" + " ".join(f"{v}×{add_cnt[v]}" for v in VARS) + ")")
print(f"各变量值域大小之积 {'×'.join(str(d) for d in distinct)} = {prod}")
print(f"实测可达好感度组合数 {reach_aff}(值域之积 {prod},相等={reach_aff == prod})")
print(f"声明空间 101^{len(VARS)} = {space};可达组合占 {reach_aff * 1000000 // space} 每百万")
bad = [VARS[i] for i, s in enumerate(aff_vals) if min(s) < AFF_LO or max(s) > AFF_HI]
print(f"I1 全部可达好感度 ∈ [0,100]:{not bad}")
assert r["states"] > 0 and not r["budget_hit"] and not bad
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ne_08_1_state_machine.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["var", "init", "add_count", "distinct", "min", "max"])
w.writeheader()
w.writerows(rows)
with open(os.path.join(OUT, "ne_08_1_state_machine.json"), "w", encoding="utf-8") as fh:
json.dump({"states": r["states"], "transitions": r["transitions"], "add_total": add_total,
"vars": rows, "product": prod, "reachable_affinity_tuples": reach_aff,
"cartesian_product": reach_aff == prod, "declared_space": space,
"elapsed_s": round(time.time() - t0, 1)}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/ne_08_1_state_machine.csv / .json")
print("OK ne_08_1_state_machine:状态机已枚举,好感度值域远小于声明域")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑(约 22 秒):
spec=雾港夜航 变量 ['hui', 'mu', 'shen', 'yan'] 起点 d_pier_1
可达状态 9895423(完整 BFS,预算 12000000,超限=False) 转移 12163825
好感度状态机(声明域每变量 0–100 共 101 个取值):
变量 init add 次数 取到不同值 实测区间
hui 50 13 35 [10, 97]
mu 50 12 16 [25, 100]
shen 50 18 12 [45, 100]
yan 20 23 21 [0, 100]
add 写入合计 66 次(hui×13 mu×12 shen×18 yan×23)
各变量值域大小之积 35×16×12×21 = 141120
实测可达好感度组合数 87190(值域之积 141120,相等=False)
声明空间 101^4 = 104060401;可达组合占 837 每百万
I1 全部可达好感度 ∈ [0,100]:True
wrote out/ne_08_1_state_machine.csv / .json
OK ne_08_1_state_machine:状态机已枚举,好感度值域远小于声明域
结论有两层。第一层是值域远小于声明域:yan 只取到 21 个不同的值、hui 35 个、shen 12 个、mu 16 个,没有任何变量取满 101 个。
第二层更重要——四个变量并不独立。把各自的值域大小相乘得 35×16×12×21 = 141120,但真正可达的好感度四元组只有 87190 个。141120 是「每个变量各自能取的值」的笛卡尔积上界,87190 才是「四个变量同时成立」的实际计数。差距来自耦合:yan 的每一次 add 都发生在某个具体节点上,而到达那个节点本身就已经约束了另外三个变量的范围。所以第 5 章给出的 141120 只能当上界读,不能当可达组合数读。
P 层:初值、用量、累加与钳位
S 层枚举的是「能走到哪」,P 层把同一批选项钉在类型层面,断言四件事:初值确实来自 characters[].affinity.init;add 一共 66 次且分得清每条变量的用量;add 是累积的(同一变量两次写入会叠加);累加之后确实被钳回 [0,100]。
add 合计 66:yan×23 hui×13 shen×18 mu×12
✔ 初值来自 characters[].affinity.init:yan=20 hui=50 shen=50 mu=50 (0.987792ms)
✔ add 用量:共 66 次,yan×23 shen×18 hui×13 mu×12 (0.571209ms)
✔ add 是累积的:同一变量两次写入叠加(50 → +5 → +10) (0.092708ms)
✔ 累加后钳到 [0,100]:人为放大百万级仍被钳回 (0.065959ms)
✔ 转移是纯函数:不改入参、同输入同输出;set 不碰好感度 (0.164416ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
五条全过。最后一条是负例:把 add 的增量放大到 1000000,断言结果仍是 100;改成 -1000000 则断言仍是 0。它证明的不是实现正确,而是钳位确实在执行——哪次重构漏掉钳位,它会立刻红。
自检表
| 检查项 | 合格标准 |
|---|---|
| 状态打包无损 | 节点 / 好感度 / flag / item 四段拼成一个整数,BFS 去重不误判 |
| 值域实测 | 4 个变量的可达值域分别为 21 / 35 / 12 / 16,均小于 101 |
| 组合数诚实 | 可达四元组 87190 ≠ 值域之积 141120,如实报告耦合 |
| 钳位有负例 | 增量放大到百万级仍被钳回 [0,100] |
| 声明未用不判 FAIL | lin / hai 有 affinity 声明但无引用,只报告 |
值域、初值、钳位都定住了。但好感度不是拿来看的,是拿来开门的:某条选项要 yan >= 30 才能选。下一节把这些门限全抽出来,再逐条扰动 ±5,看哪几扇门是真正承重的。
阅读导航:上一节:7.3 组合爆炸的控制 · 下一节:8.2 阈值门与门限扰动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。