2.1 条件表达式的求值器

条件在规格里是嵌套数组写成的表达式树,求值器就是把它递归地读成真假。本节给出六种形态的求值语义,用 Python 与 TypeScript 两条链把 story_spec.json 里 57 个条件位点全部求值一遍,并把声明域与使用域分开数:声明 6 种形态只用 5 种,声明 4 个比较算子只用 >= 一种。

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。

求值器:递归地把树读成真假

求值器就是一段递归下降:遇到叶子读一个命名空间的值并与目标值比,遇到内点先递归子条件再按语义合成。三条纪律要写进代码:

  1. 未声明的名字落到默认值。状态里没有 lin,flags.get("lin", 0) 取 0,不抛异常——这让「引用了域外名字」变成可判定的恒假(2.3 展开)。
  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 短路与复杂度 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长