2.1 条件表达式的求值器
独立游戏的叙事设计与分支对话 讲的是分支条件的经验:哪些选择该卡条件、玩家读不读得懂一句门槛、好感度阈值设多高才算有意义。那是散文式的设计判断,配的是清单与示意图。本节问的是另一层:把条件从设计判断里拿出来、只当成一串可求值的表达式,它是什么形状,怎么被一台机器递归地读成真假。这是卷二「把选择还原成可执行的系统」的第一块砖。
答案是表达式树。叶子只读三个东西,内点只有三个运算符;形状一旦定死,「这个条件成立吗」就成了一个纯函数——给定状态返回真假,与用什么引擎、有没有编辑器、台词怎么渲染都无关。卷一 9.1 条件表达式:旗标、数值、持有 已经把这份文法定死;本节把它落到执行侧:写出求值器,并对全规格的条件做一次用量清点。
文法:叶子三种、组合三种
一个条件拆到底,只读状态里的三个命名空间:
| 叶子写法 | 读什么 | 值域 | 例子 |
|---|---|---|---|
["flag", 名, 0|1] | 一个 flag | 布尔,写成 0 / 1 | ["flag","knows_yan_missing",1] |
["item", 名, 0|1] | 一件物品 | 布尔,写成 0 / 1 | ["item","lantern",1] |
["aff", 名, 算子, 整数] | 一个好感度变量 | 整数 0–100 | ["aff","yan",">=",60] |
内点只有三种组合,都是把子条件写成数组本身(所以没有优先级问题):
| 组合 | 元数 | 求值语义 |
|---|---|---|
["and", c1, c2] | 二元 | 两侧都真才真 |
["or", c1, c2] | 二元 | 任一侧真即真 |
["not", c] | 一元 | 取反 |
null 是第四个「形式」,表示无条件成立——不是「条件失败」,是「这里根本没有条件」。求值器遇到 null 直接返回真。读取范围就这么窄:条件读不到「上一步点了什么」「现在是第几幕」「这条路走过几次」;需要这些时,规格的做法是先在某个选项的效果里把它写成 flag,再由条件去读那个 flag。
求值器:递归地把树读成真假
求值器就是一段递归下降:遇到叶子读一个命名空间的值并与目标值比,遇到内点先递归子条件再按语义合成。三条纪律要写进代码:
- 未声明的名字落到默认值。状态里没有
lin,flags.get("lin", 0)取 0,不抛异常——这让「引用了域外名字」变成可判定的恒假(2.3 展开)。 - 未声明的算子必须抛错,而不是静默地当成某个默认分支。声明了
>=<==!=四个,别的算子一律视为规格错误。 - 求值是纯函数:只读状态、不写状态、无副作用。
S 层:把全规格的条件求值一遍并分类
S 层脚本摊平全部条件位点——对话行 line_when、选项 when、场景边 when、场景节点 enter_when、地点 unlock_when、结局 when——递归访问每棵条件树,按形态与比较算子计数,再按「声明未用 = 报告;用了未声明 = FAIL」的口径对账:
#!/usr/bin/env python3
# file: sim/ne_02_1_eval.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_02_1_eval.py
"""卷二 2.1 条件求值(S 层):把 story_spec.json 里全部条件递归求值一遍,统计各形态用量。
条件文法 = 嵌套数组(可求值、可判定可满足性):
["flag", 名, 0|1] / ["item", 名, 0|1] / ["aff", 名, 算子, 整数]
["and", c1, c2] / ["or", c1, c2] / ["not", c]
本脚本摊平全部条件位点(对话行 line_when / 选项 when / 场景边 when / 场景节点 enter_when /
地点 unlock_when / 结局 when),递归统计各形态与各比较算子的用量,并按口径对账:
「声明未用 = 报告;用了未声明 = FAIL」。复用 sim/na_00_spec_check.py 的 load_spec。
产出 out/ne_02_1_eval.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as base # noqa: E402 复用 load_spec
OUT = os.path.join(os.path.dirname(HERE), "out/ne_02_1_eval.json")
def sites(spec: dict) -> list:
"""摊平全部条件位点:(出处类别, 出处, 条件)。"""
out = []
for n in spec["dialogue"]:
if n.get("line_when"):
out.append(("line_when", f"{n['id']}.line_when", n["line_when"]))
for c in (n.get("choices") or []):
if c.get("when"):
out.append(("choice", f"{n['id']}/{c['id']}", c["when"]))
for e in spec["scene_graph"]["edges"]:
if e.get("when"):
out.append(("edge", f"{e['from']}->{e['to']}", e["when"]))
for nd in spec["scene_graph"]["nodes"]:
if nd.get("enter_when"):
out.append(("enter_when", f"node {nd['id']}", nd["enter_when"]))
for loc in spec["world"]["locations"]:
if loc.get("unlock_when"):
out.append(("unlock_when", f"location {loc['id']}", loc["unlock_when"]))
for e in spec["endings"]:
if e.get("when"):
out.append(("ending", f"ending {e['id']}", e["when"]))
return out
def walk(c, forms: dict, ops: dict) -> None:
"""递归访问条件树:每个节点按形态计数,aff 叶子另按比较算子计数。"""
if c is None:
return
k = c[0]
forms[k] = forms.get(k, 0) + 1
if k in ("and", "or"):
walk(c[1], forms, ops)
walk(c[2], forms, ops)
elif k == "not":
walk(c[1], forms, ops)
elif k == "aff":
ops[c[2]] = ops.get(c[2], 0) + 1
def main() -> int:
spec = base.load_spec()
declared_forms = sorted(spec["conditions"]["forms"].keys())
declared_ops = sorted(spec["conditions"]["ops"])
all_sites = sites(spec)
forms, ops, by_src = {}, {}, {}
for src, _, c in all_sites:
by_src[src] = by_src.get(src, 0) + 1
walk(c, forms, ops)
used_forms = sorted(forms.keys())
undeclared = [k for k in used_forms if k not in declared_forms]
unused_forms = [k for k in declared_forms if k not in forms]
unused_ops = [k for k in declared_ops if k not in ops]
nodes = sum(forms.values())
print(f"条件位点 {len(all_sites)} 个 / 条件树节点 {nodes} 个")
print("位点分布:" + " / ".join(f"{k} {v}" for k, v in sorted(by_src.items())))
print(f"形态用量(声明 {len(declared_forms)} 种 / 使用 {len(used_forms)} 种):")
for f in declared_forms:
n = forms.get(f, 0)
print(f" {f:<5} {n:>3} {'✓ 声明' if n else '✗ 声明未用'}")
print(f"比较算子(声明 {len(declared_ops)} 种 / 使用 {len(ops)} 种):")
for o in declared_ops:
print(f" {o:<3} {ops.get(o, 0):>3}")
print(f"声明未用(报告,非 FAIL):{unused_forms + unused_ops or '无'}")
print(f"用了未声明(FAIL):{undeclared or '无'}")
report = {"condition_sites": len(all_sites), "tree_nodes": nodes,
"by_source": by_src, "forms": forms, "ops": ops,
"declared_forms": declared_forms, "declared_ops": declared_ops,
"unused_forms": unused_forms, "unused_ops": unused_ops,
"undeclared": undeclared}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
if undeclared:
print(f"FAIL 用了未声明的形态:{undeclared}")
return 1
print("OK ne_02_1_eval:全部形态均在声明域内,声明未用仅作报告")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_02_1_eval.py:
条件位点 57 个 / 条件树节点 63 个
位点分布:choice 34 / edge 5 / ending 4 / enter_when 5 / line_when 2 / unlock_when 7
形态用量(声明 6 种 / 使用 5 种):
aff 15 ✓ 声明
and 2 ✓ 声明
flag 25 ✓ 声明
item 19 ✓ 声明
not 2 ✓ 声明
or 0 ✗ 声明未用
比较算子(声明 4 种 / 使用 1 种):
!= 0
< 0
== 0
>= 15
声明未用(报告,非 FAIL):['or', '!=', '<', '==']
用了未声明(FAIL):无
OK ne_02_1_eval:全部形态均在声明域内,声明未用仅作报告
P 层:求值器与内核对齐
P 层是同一台求值器的 TypeScript 版:语义与 engine.ts 的 condOk 一致,但把「算子是否已声明」写成一条硬约束。它只依赖内核的 Cond / State 类型,不改公共内核:
// file: core/src/inkwell/ne_02_1_eval.ts
// tier: P
// verify: node --test 'core/test/ne_02_1_eval.test.ts'
// 卷二 2.1:条件表达式求值器。与 engine.ts 的 condOk 同语义,但把「算子是否已声明」
// 变成一条硬约束:用了未声明的比较算子直接抛错(FAIL),声明未用只作报告。
// 这是卷一 9.1 的文法在卷二执行侧的落点——同文法、同语义,多了执行期的对账。
import type { Cond, Op, Spec, State } from "./engine.ts";
/** 文法声明的四个比较算子。求值器只认这四个。 */
export const DECLARED_OPS: readonly Op[] = [">=", "<", "==", "!="];
/** 求值器:把「用了未声明的算子」当硬错误抛出,而不是静默地当成某个默认分支。 */
export function evalCond(c: Cond | null | undefined, s: State): boolean {
if (!c) return true;
const kind = c[0];
if (kind === "flag") return (s.flags[c[1] as string] ?? 0) === (c[2] as number);
if (kind === "item") return (s.items[c[1] as string] ?? 0) === (c[2] as number);
if (kind === "aff") {
const v = s.aff[c[1] as string] ?? 0;
const op = c[2] as Op;
const n = c[3] as number;
if (!DECLARED_OPS.includes(op)) throw new Error(`undeclared op: ${String(op)}`);
if (op === ">=") return v >= n;
if (op === "<") return v < n;
if (op === "==") return v === n;
return v !== n;
}
if (kind === "and") return evalCond(c[1] as Cond, s) && evalCond(c[2] as Cond, s);
if (kind === "or") return evalCond(c[1] as Cond, s) || evalCond(c[2] as Cond, s);
if (kind === "not") return !evalCond(c[1] as Cond, s);
throw new Error(`undeclared form: ${String(kind)}`);
}
/** 递归摊平一棵条件树:形态计数写 forms,aff 叶子的比较算子计数写 ops。 */
export function walk(
c: Cond | null | undefined,
forms: Record<string, number>,
ops: Record<string, number>,
): void {
if (!c) return;
const k = c[0] as string;
forms[k] = (forms[k] ?? 0) + 1;
if (k === "and" || k === "or") {
walk(c[1] as Cond, forms, ops);
walk(c[2] as Cond, forms, ops);
} else if (k === "not") {
walk(c[1] as Cond, forms, ops);
} else if (k === "aff") {
const o = c[2] as string;
ops[o] = (ops[o] ?? 0) + 1;
}
}
/** engine.ts 的 Spec 不含场景图与地点;这里定义局部扩展类型,不动公共内核。 */
export type WideSpec = Spec & {
scene_graph: {
nodes: { id: string; enter_when: Cond | null }[];
edges: { from: string; to: string; when: Cond | null }[];
};
world: { locations: { id: string; unlock_when: Cond | null }[] };
};
/** 摊平全部条件位点:[类别, 出处, 条件]。 */
export function sites(spec: WideSpec): [string, string, Cond][] {
const out: [string, string, Cond][] = [];
for (const n of spec.dialogue) {
if (n.line_when) out.push(["line_when", `${n.id}.line_when`, n.line_when]);
for (const c of n.choices ?? []) {
if (c.when) out.push(["choice", `${n.id}/${c.id}`, c.when]);
}
}
for (const e of spec.scene_graph.edges) {
if (e.when) out.push(["edge", `${e.from}->${e.to}`, e.when]);
}
for (const nd of spec.scene_graph.nodes) {
if (nd.enter_when) out.push(["enter_when", `node ${nd.id}`, nd.enter_when]);
}
for (const loc of spec.world.locations) {
if (loc.unlock_when) out.push(["unlock_when", `location ${loc.id}`, loc.unlock_when]);
}
for (const e of spec.endings) {
if (e.when) out.push(["ending", `ending ${e.id}`, e.when]);
}
return out;
}
测试 core/test/ne_02_1_eval.test.ts 让 evalCond 与内核的 condOk 在「全部位点 × 一小组代表状态」上逐点对齐,再复算形态用量。本机实跑 node --test 'core/test/ne_02_1_eval.test.ts':
求值器对照:57 位点 × 5 状态 = 285 次一致
条件树节点 63 个:flag 25 / aff 15 / item 19 / and 2 / not 2 / or 0
比较算子:>= 15 / < 0 / == 0 / != 0
✔ evalCond 与内核 condOk 在全部位点 × 代表状态上一致 (1.34875ms)
✔ 形态用量:声明 6 种、实际用到 5 种,or 声明未用 (0.718208ms)
✔ 比较算子:声明 4 个,实际只用到 >= 一种(15 次) (0.164583ms)
✔ 用了未声明的算子:求值器直接抛错(不是静默取默认分支) (0.295ms)
✔ or 的求值路径仍然被覆盖(声明未用不等于用不了) (0.115917ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.339042
5 条全过、fail 0。求值器与内核在 285 个点上同值——这说明两处消费共享同一份条件语义,而不是各写各的。
判读:声明域 > 使用域
把两份输出并排读,得到本卷反复出现的那个事实:声明的东西比用到的东西多。
| 维度 | 声明 | 使用 | 差集 |
|---|---|---|---|
| 条件形态 | 6 种(flag item aff and or not) | 5 种 | or 声明未用(0 次) |
| 比较算子 | 4 个(>= < == !=) | 1 个 | < == != 声明未用(各 0 次) |
| 条件位点 | —— | 57 个 | 树节点 63 个(叶子 59 / 内点 4) |
两条读法:
第一,声明未用不是缺陷,是当前案例的写法偏好。 这份规格里凡是「或」的意思都拆成了两条独立选项,所以 or 一次没用;好感度门槛全部用「够不够高」表达,所以只用到 >=。这不是文法的问题,也不是死代码——or 的求值路径由测试直接构造覆盖(or 那条用例就是现造一个 or 来跑的)。所以口径必须是:声明未用 = 报告(列出来给人看,不判失败),用了未声明 = FAIL(规格里出现文法没声明的东西,求值器立刻抛错)。
第二,条件整体偏「浅」。 63 个树节点里只有 4 个是内点(and 2 + not 2),其余 59 个都是单叶子。绝大多数条件是一次判断。这既让条件好读,也让后面几节的成本低:树越浅,逐位点判可满足性、逐位点求值的开销越小。第 2.2 节会把这个「浅」变成可数的步数,第 2.3 节把它变成可判定的可满足性。
自检表
| 检查项 | 合格标准 |
|---|---|
| 求值语义与内核一致 | 同一位点同一状态,两处求值结果相同(285 点全等) |
| 未声明算子硬失败 | 出现文法外的算子直接抛错,不静默取默认分支 |
| 域外名字有定义 | 引用状态里没有的名字落到默认 0,不抛异常 |
| 声明与使用分开数 | 声明未用只报告;用了未声明才 FAIL |
| 求值是纯函数 | 只读状态、不写状态、无副作用 |
条件读的三个命名空间——旗标、好感度、物品——是谁写的、能写成什么,是下一章「状态推进与效果」的事。但在那之前,本节这个求值器还有两个执行侧的性质要讲清楚:它怎么短路(省不省步数)、以及一个条件在状态域里到底有没有解。下一节先算步数。
阅读导航:上一节:1.3 引擎与规格的同源 · 下一节:2.2 短路与复杂度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。