交互案例 · 状态树审计
《玻璃花园》看起来像一部常规的分支视觉小说:22 个场景、44 个选择,其中 9 个 带条件,7 个结局。静态图检查没有发现悬空跳转,也没有孤立场景。状态空间搜索 发现 7 个结局全部可达,但有一个条件选择不可达。唯一授予“黑金双色梳”的场景是 scene_2_05N;需要这把梳子的选择却位于 scene_2_03A,而图中没有从授予场景 回到那里的路径。故事仍然可以通关,这个分支只是永远不可见——这是最昂贵的一类 内容 bug,因为没有玩家能报告它。
源材料包含 scripts_src/ 下的 Ren'Py 风格脚本(label、jump、menu),以及 src/data/story.js 里的运行时故事图。运行时不是 Ren'Py 本身,而是 React + Zustand,但形状相同:场景表、带可选条件的选择,以及修改持久状态的 effects。
对图有影响的状态:
| 状态 | 在图中的作用 |
|---|---|
san_value | 控制隐藏选项是否出现;clamp 在 [0, 100] |
player_belief | unaware / amelia_lover / natalie_father,解锁身份线 |
identity_flag | 由信念 + 梳子派生,玩家不能直接选择 |
has_comb | 打开真结局通道;只在 scene_2_05N 被设置 |
inventory | 保存 天平宝剑、破损的钢斧、烂苹果金属罐、黑金双色梳 |
flags | 记录路线选择;用于诊断和未来条件 |
玩家看不到这些值的大部分。他们看到的是一段文字、一个看似不可选的选项,或者 干脆没有选项。这就是信息差:图知道某个分支为什么消失,玩家只知道它不在。
我对故事数据跑了两类检查:
next 目标都存在;忽略条件时,没有场景从 scene_1_01 不可达。onEnter,在真实 条件下探索所有可达状态。这与 store 的 selectChoice 顺序一致。| 指标 | 结果 |
|---|---|
| 场景 | 22 |
| 选择 | 44 |
| 条件选择 | 9 |
| 结局 | 7 |
悬空 next 目标 | 0 |
| 孤立场景 | 0 |
| 探索到的唯一状态 | 168 |
| 探索到的转移 | 179 |
| 可达结局 | 7/7 |
| 可达非重启选择 | 36/37 |
| 不可达条件选择 | 1 |
唯一不可达的是 scene_2_03A:use_comb_confrontation:
// story.js — 唯一授予 has_comb 的地方
scene_2_05N: {
onEnter: {
set: { san_value: 50, truth_key: true, has_comb: true },
addItems: ['黑金双色梳'],
},
// ...
}
// story.js — 需要它的选择
scene_2_03A: {
choices: [
{
id: 'use_comb_confrontation',
condition: (state) => state.has_comb,
next: 'scene_3_00_TE',
},
],
}scene_2_03A 从 scene_2_02A(宝剑线)进入。scene_2_05N 从祭坛、安全区出口 或金发画像支线进入;它的出边只通向 scene_3_00_TE 或 scene_hiddenB → scene_2_04A1。没有任何一条边回到 scene_2_03A。条件本身是 合法 JavaScript,目标也存在,但选择依然不可达。
真正有意思的条件不是显式的“有没有道具”,而是它的真假由更早的选择决定,往往 在玩家看到后果之前就已经写好。
| 门 | 条件 | 门前可达值 | 状态 |
|---|---|---|---|
| 金发画像 | san_value < 100 | 1_02A:只有 95 | 潜伏——在唯一路径上恒为真 |
| 相信阿米莉亚 | san_value > 60 | 2_03A:93–95 | 潜伏——在当前图中恒为真 |
| 隐藏结局 | san_value <= 40 && inventory.includes('天平宝剑') | 2_04A1:30–90 | 生效——取决于之前的 SAN 损失 |
| 真结局梳子 | has_comb | 2_03A:永不为真;2_04A1:梳子路线为真 | 在 2_03A 是死分支,在 2_04A1 生效 |
SAN 门很有启发性。san_value < 100 和 san_value > 60 看起来像动态难度检查, 但在它们所在的门前,可达值只有 95 和 93–95。它们是潜伏条件:代码正确,却在当前 图里从不改变可见路线。隐藏结局则是真正生效的:镜子扣 55,从 100 出发剩 45; 如果之前损失过 5 点,就剩 40,选项打开。梳子门正好相反:它在 2_04A1 为真, 在同样被求值的 2_03A 却永远为假。
这就是状态树测试的价值。单独测试每个条件只能证明谓词能工作,不能证明它会在一个 “能为真”的状态里被求值——也不能证明它能为假。死掉的梳子选择正是这个缺口: state.has_comb 可以为真,只是永远不在那个场景。
MISSING_SCENE 兜底页。(scene, san, inventory, has_comb, belief, flags) 上做 BFS,抓“结构合法但语义已死”的条件。onEnter,与 store 一致。顺序 反过来会改变目标场景里哪些门可见。[0, 100];背包去重;flags 合并而不是覆盖。| 检查项 | 结果 |
|---|---|
| 非重启目标解析 | 37/37 |
| 悬空目标 | 0 |
| 孤立场景 | 0 |
| 可达结局 | 7/7 |
| 可达非重启选择 | 36/37 |
| 不可达条件选择 | 1(scene_2_03A:use_comb_confrontation) |
| 无结局的死路 | 0 |
| 探索到的唯一状态 | 168 |
| 探索到的转移 | 179 |
修复很小,但修法是一个设计决策:
scene_2_05N 回到 scene_2_03A 的路径;或scene_2_04A1,那里梳子确实可用。有条件通过。 可玩的故事是连贯的:7 个结局全部可达,没有场景死路,没有断链。 只有一个条件选择永远看不到。这不是叙事的发布阻断项,但它是真实的内容缺陷,也是 对未来分支的警告:一张图可以结构合法,却包含任何状态都打不开的选项。
可复用的规则是:在状态空间里测试可达性,而不只是在场景图里。 一个能编译的 condition,不等于一个能触发的分支。