站内的独立游戏的叙事设计与分支对话 讲的是**「有意义的选择」这条设计经验**:哪些选项该有分量、哪些只是氛围,配的是清单与示意图,代码不可跑。本节把「假选择」变成一条可判定、可计数的判据:两个不同选项在同一状态下推进出的状态逐字段相等,它们就是一对假选择。上一节量「后果离得多远」,这一节判「有没有后果」——而且是按状态判,不是按选项的文本判。
4.2 假选择的检测
先把口径讲死:这是一个代理指标(proxy)。「假选择」在这里是一句关于状态的陈述——两条边推进出同一个状态,不是「玩家觉得没意义」的主观判断。玩家是否觉得某次选择白选,需真实业务环境、命令行不可复现,本套不做。判据给的是一份可复核的清单,只用于规格内部的相对比较。
判据:推进状态逐字段相等
同一节点 n 的两个不同选项 c ≠ c',在一个可达状态 s 下都可选时,若
step(c, s) == step(c', s) # 节点 + 好感度 + flag + 物品,逐字段相等
则称 (c, c') 在 s 下是一对假选择。分两类:
| 类 | 条件 | 性质 |
|---|---|---|
| 静态假 | c.goto == c'.goto 且 c.effects 与 c'.effects 逐字段相同 | 在所有状态下都假——两个措辞不同、结果一字不差的选项 |
| 状态假 | 效果不同,但因状态已满足(flag 已是 1、好感度已顶到上限)而推进结果仍相等 | 只在部分状态下假——静态判据会漏掉它 |
为什么按「推进状态」判、不按「效果文本」判? 因为效果是不是「有差」依赖当前状态:set: {f: 1} 在 f 已经是 1 时是空操作。只看 effects 有没有差异,会把这种「在这个状态下恰好无差」的选项误判成真选择——下面 d_fg_3 就是活例。
S 层:精确枚举全部可达状态
脚本 sim/nm_04_2_fake_choice_detection.py 用完整 BFS 枚举全部可达 (节点, 状态) 对,在弹出每个状态时逐对判定,不采样;落 out/nm_04_2_fake_choice.csv:
#!/usr/bin/env python3
# file: sim/nm_04_2_fake_choice_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_04_2_fake_choice_detection.py
"""卷三 4.2 假选择的检测(S 层):同一状态下两个选项推进出的状态是否完全相同。
可判定口径(写死在代码里,不按结果调参):
同一节点 n 的两个**不同**选项 c != c',在一个可达状态 s 下**都可选**时,
若 step(c, s) == step(c', s)(推进后的 节点 + 好感度 + flag + 物品 **逐字段相等**),
则称 (c, c') 在 s 下是一对**假选择**。
两类:
· 静态假:c.goto == c'.goto 且 c.effects 与 c'.effects **逐字段相同**——所有状态下都假;
· 状态假:效果不同,但因状态已满足(如 flag 已是 1、好感度已顶到上限)而推进结果仍相等。
用完整 BFS **精确枚举**全部可达 (节点, 状态) 对,在弹出时逐对判定,不做采样。
与卷一 8.3 的分工:8.3 是**选项级**、用**结局可达集投影**判「有没有后果」;本节是
**状态级**、用**推进状态逐字段相等**判「两条边是不是走了同一个结果」。
产出 out/nm_04_2_fake_choice.csv。选项数不符即非零退出。
"""
from __future__ import annotations
import csv
import json
import os
import sys
from collections import defaultdict
from itertools import combinations
HERE = os.path.dirname(os.path.abspath(__file__))
LO, HI = 0, 100
def main() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
start = spec["meta"]["start"]
vars_ = sorted(spec["state"]["vars"])
flags = sorted(spec["state"]["flags"])
items = sorted(spec["state"]["items"])
VI = {v: i for i, v in enumerate(vars_)}
FI = {f: i for i, f in enumerate(flags)}
II = {it: i for i, it in enumerate(items)}
NV, NF, NI = len(vars_), len(flags), len(items)
NIDX = {n["id"]: i for i, n in enumerate(nodes)}
def pack(st) -> int:
"""把 (好感度 + flag 位 + 物品位) 打成一个整数,用于状态去重。"""
code = 0
for v in st[:NV]:
code = (code << 7) | v
fb = ib = 0
for i in range(NF):
fb |= st[NV + i] << i
for i in range(NI):
ib |= st[NV + NF + i] << i
return (code << (NF + NI)) | (fb << NI) | ib
def eval_cond(c, st) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st[NV + FI[c[1]]] == c[2]
if k == "aff":
v, op, n = st[VI[c[1]]], c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "item":
return st[NV + NF + II[c[1]]] == c[2]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
return not eval_cond(c[1], st)
def apply_effects(eff, st) -> tuple:
s = list(st)
for k in (eff.get("set") or {}):
s[NV + FI[k]] = eff["set"][k]
for k in (eff.get("add") or {}):
v = s[VI[k]] + eff["add"][k]
s[VI[k]] = LO if v < LO else HI if v > HI else v
for k in (eff.get("grant") or {}):
s[NV + NF + II[k]] = eff["grant"][k]
for k in (eff.get("revoke") or {}):
s[NV + NF + II[k]] = 0
return tuple(s)
def eff_key(c):
return json.dumps(c.get("effects") or {}, sort_keys=True, separators=(",", ":"))
pair_static, all_pairs = {}, []
for n in nodes:
chs = n.get("choices") or []
for c, c2 in combinations(chs, 2):
key = (n["id"], c["id"], c2["id"])
all_pairs.append(key)
pair_static[key] = int(c["goto"] == c2["goto"] and eff_key(c) == eff_key(c2))
aff0 = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in vars_}
init = tuple([aff0[v] for v in vars_] + [0] * NF + [0] * NI)
# 完整 BFS,弹出时逐对判定(状态打包成整数去重,不存每节点状态列表)
seen = {(NIDX[start] << 55) | pack(init)}
frontier = [(start, init)]
pair_states = defaultdict(int) # 该对在多少个状态下推进结果相等
pair_avail = defaultdict(int) # 该对在多少个状态下都可用
while frontier:
nxt = []
for node, st in frontier:
chs = by[node].get("choices") or []
avail = [c for c in chs if eval_cond(c.get("when"), st)]
for c, c2 in combinations(avail, 2):
key = (node, c["id"], c2["id"])
pair_avail[key] += 1
if c["goto"] == c2["goto"] and \
apply_effects(c.get("effects") or {}, st) == \
apply_effects(c2.get("effects") or {}, st):
pair_states[key] += 1
for c in chs:
if eval_cond(c.get("when"), st):
st2 = apply_effects(c.get("effects") or {}, st)
k = (NIDX[c["goto"]] << 55) | pack(st2)
if k not in seen:
seen.add(k)
nxt.append((c["goto"], st2))
frontier = nxt
n_states = len(seen)
rows = [{"node": nd, "choice_a": a, "choice_b": b,
"static_fake": pair_static[(nd, a, b)],
"avail_states": pair_avail[(nd, a, b)],
"equal_states": pair_states[(nd, a, b)],
"state_level_fake": int(pair_states[(nd, a, b)] > 0)}
for nd, a, b in all_pairs]
n_static = sum(r["static_fake"] for r in rows)
n_state_level = sum(r["state_level_fake"] for r in rows)
n_not_static = sum(1 for r in rows if not r["static_fake"] and r["state_level_fake"])
total_equal = sum(r["equal_states"] for r in rows)
total_avail = sum(r["avail_states"] for r in rows)
out_dir = os.path.join(HERE, "../out")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "nm_04_2_fake_choice.csv"), "w",
encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
print(f"选项 115 条,同节点可比较的选项对 {len(rows)} 对,可达 (节点,状态) 对 {n_states}")
print(f"静态假选择对 {n_static} 对(同跳转且效果逐字段相同,所有状态下都假)")
print(f"状态级假选择对 {n_state_level} 对(至少一个可达状态下推进结果逐字段相等)")
print(f"其中非静态、仅因状态已满足而假的对 {n_not_static} 对")
print(f"可用 (对,状态) 计数 {total_avail},其中推进结果相等 {total_equal} "
f"({total_equal * 1000 // total_avail if total_avail else 0} 千分)")
print("静态假选择对清单:")
for r in rows:
if r["static_fake"]:
print(f" {r['node']} {r['choice_a']} == {r['choice_b']}")
print("状态级假选择对清单(equal_states / avail_states):")
for r in rows:
if r["state_level_fake"]:
tag = "静态" if r["static_fake"] else "非静态"
print(f" {r['node']} {r['choice_a']} == {r['choice_b']} "
f"{tag} {r['equal_states']}/{r['avail_states']}")
print(f"wrote out/nm_04_2_fake_choice.csv rows={len(rows)}")
if not all_pairs:
print("FAIL 没有可比较的选项对")
return 1
print("OK nm_04_2_fake_choice_detection:假选择对已精确检出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_04_2_fake_choice_detection.py:
选项 115 条,同节点可比较的选项对 104 对,可达 (节点,状态) 对 9895423
静态假选择对 3 对(同跳转且效果逐字段相同,所有状态下都假)
状态级假选择对 10 对(至少一个可达状态下推进结果逐字段相等)
其中非静态、仅因状态已满足而假的对 7 对
可用 (对,状态) 计数 8058100,其中推进结果相等 1054370 (130 千分)
静态假选择对清单:
d_wp_1 c_wp_1a == c_wp_1c
d_tc_1 c_tc_1a == c_tc_1b
d_mr_2 c_mr_2a == c_mr_2b
状态级假选择对清单(equal_states / avail_states):
d_wp_1 c_wp_1a == c_wp_1c 静态 25/25
d_lb_1 c_lb_1a == c_lb_1b 非静态 353/353
d_lb_1 c_lb_1a == c_lb_1c 非静态 28/50
d_lb_1 c_lb_1b == c_lb_1c 非静态 28/50
d_tc_1 c_tc_1a == c_tc_1b 静态 847/847
d_mr_2 c_mr_2a == c_mr_2b 静态 2046/2046
d_ho_3 c_ho_3a == c_ho_3b 非静态 178/5202
d_fg_2 c_fg_2a == c_fg_2b 非静态 8475/709402
d_fg_3 c_fg_3a == c_fg_3b 非静态 1036900/1036900
d_dq_1 c_dq_1a == c_dq_1b 非静态 5490/15006
wrote out/nm_04_2_fake_choice.csv rows=104
OK nm_04_2_fake_choice_detection:假选择对已精确检出
判读:静态判据会漏掉的那一对
115 个选项构成 104 对同节点可比较的选项对,可达 (节点,状态) 对 9895423 个(与卷二穷举口径一致)。结果分三层:
- 静态假 3 对:
d_wp_1(c_wp_1a == c_wp_1c)、d_tc_1(c_tc_1a == c_tc_1b)、d_mr_2(c_mr_2a == c_mr_2b)——都是同一节点的两个选项效果逐字段相同、跳转相同,只是台词措辞不同。任何静态检查都能抓到。 - 状态级假 10 对,其中 7 对非静态——效果文本不同,却在至少一个可达状态下推进结果相等。
- 可用
(对,状态)计数 8058100,其中推进结果相等 1054370(130 千分)。
最值得看的是 d_fg_3:c_fg_3a 空效果、c_fg_3b 写 set: {saw_lighthouse_light: 1}。效果文本不同,但它在全部 1036900 个可用状态下推进结果都相等——因为走到 d_fg_3 时 saw_lighthouse_light 恒为 1,那个 set 永远是个空操作。只看 effects 的静态判据会把它判成真选择;状态级判据正确地把它判成「恒假」。 这正是本节把口径定在「推进状态」而非「效果文本」的理由。
对照着看 d_ho_3:c_ho_3a(无条件下 grant salt_letter)与 c_ho_3b(when mu >= 70 下 grant 同物并 set / add)只在 178 / 5202 个状态下相等——多数状态下它们确实分得开,是局部假而非恒假。「在多少个状态下假」本身就是个量,比一个布尔标签更有信息。
本节与卷一 8.3 假选择与真后果 的分工:8.3 是选项级、用结局可达集投影判「这个选项有没有后果」;本节是状态级、用推进状态逐字段相等判「两条边是不是走了同一个结果」。粒度不同,计数也不同,两者互补。
P 层:合成规格负例
「10 对假选择」这句话要可信,检测器必须能在构造出来的例子上开火。P 层用一个四节点合成规格(s0 → a → m → end)做三例,只改 effects:
- 静态假:
a的两个选项效果都是{}→ 判静态假; - 非假:把
c2改成set: {g: 1}→ 状态未满足,两者分得开 → 判非假; - 状态假:
s0先把g置 1,c1空效果、c2再set g=1→ 效果不同(非静态)但推进结果相等 → 判状态假。
本机实跑 node --test 'core/test/nm_04_2_fake_choice.test.ts':
✔ 静态假 · 两选项效果逐字段相同:判为静态假选择对 (0.859542ms)
✔ 非假 · 两选项效果不同且状态未满足:该对不再假 (0.168042ms)
✔ 状态假 · 效果不同但状态已满足:非静态却仍判假 (0.438083ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.35025
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径可判定 | 假 = step(c,s) == step(c',s) 逐字段相等;不靠文本、不靠语义 |
| 精确非抽样 | 在全部 9895423 个可达 (节点,状态) 对上判定 |
| 分层报告 | 静态假与状态假分开计,恒假与局部假分开计 |
| 负例必给 | 三个合成规格证明三种判定都会开火(P 层 3 条断言全绿) |
| proxy 口径 | 「假」是关于状态的陈述,不声称玩家觉得没意义 |
判据有了、计数也有了,还剩一个问题:这条判据在一条路径上意味着什么——玩家走一路,要隔多少节点才碰到一次「有分量」的选择?下一节把密度与节奏量出来。
阅读导航:上一节:4.1 后果可见性 · 下一节:4.3 选择密度与节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。