9.1 条件表达式:旗标、数值、持有

条件是嵌套数组写成的表达式树:三种叶子(旗标 / 好感度比较 / 物品持有)与三种组合(and / or / not)构成完整文法。本节给出文法表,并用 node --test 逐条验证六种形式的正例与负例。实测全规格 58 个条件节点,实际用到 5 种形式——or 声明了却一次没用。

9.1 条件表达式:旗标、数值、持有

Godot 任务条件图编写 与 Godot 任务图资源编辑器 讲的是在 Godot 里怎么把条件连起来、怎么在编辑器里点出来——那是引擎专属的实现与可视化 UI。本节问的是另一个层面的问题:把条件从引擎里拿出来、只当成一串可求值的表达式,它是什么形状?

答案是一棵表达式树:叶子只读三个东西(旗标、好感度、持有),内点只有三个运算符(and / or / not)。形状一旦定死,「这个条件能不能成立」「它是不是永远不成立」就都成了可判定的性质,与用什么引擎、有没有编辑器无关。

三种叶子:条件只能读三个东西

一个条件要判断的事,拆到底只有三类。它们对应状态里的三个命名空间,读法各不相同:

叶子写法读的是什么值的类型例子含义
["flag", 名, 值]一个 flag整数,实际只有 0 / 1["flag","knows_yan_missing",1]这条旗标置位了
["aff", 名, 运算符, 整数]一个好感度变量整数 0–100["aff","yan",">=",60]砚的好感度到 60 了
["item", 名, 值]一件物品整数,实际只有 0 / 1["item","lantern",1]提灯在手上

三行合起来是一条读取范围的声明:条件永远读不到第四个东西。它读不到「玩家上一步点了什么」,读不到「现在是第几幕」,也读不到「这条线走过几次」。需要这类信息时,规格的做法是先在某个选项的效果里把它写成 flag,再由条件去读那个 flag——读取范围窄,可判定性才守得住。

aff 是唯一带运算符的叶子,四个运算符是 >= / < / == / !=。为什么给 < 而不给 <=?因为整数域上 v < n 与 v >= n 正好互补,两个就够表达四种关系;多给一个 <= 只会让同一个意思有两种写法,写出来的规格也就有了两种形状。

三种组合:内点只有三个运算符

组合写法元数求值语义例子
["and", 条件, 条件]二元两侧都真才真["and", ["flag","found_yan_journal",1], ["aff","yan",">=",40]]
["or", 条件, 条件]二元任一侧真即真["or", ["flag","hui_trusts",1], ["item","rope_knot",1]]
["not", 条件]一元取反["not", ["flag","dawn_choice_made",1]]

三种叶子 + 三种组合,就是这份文法的全部。没有优先级问题(每个组合都把括号写成数组本身),没有隐式类型转换(值都是整数),也没有副作用(求值只读状态)。null 是第四个「形式」,它表示无条件成立——不是「条件失败」,是「这里根本没有条件」。这个区分很重要:null 与恒真的条件在求值上等价,但在阅读规格时,前者说「这里不需要条件」,后者说「这里写了一个碰巧总是真的条件」。

完整文法表

规格里的 conditions 段把上面这些写成了机器可读的声明:

grammar: nested-array          # 条件用嵌套数组表示,数组首位是算子名
ops:     >= / < / == / !=      # 好感度叶子支持的四个比较
truthy:  1                     # flag 与 item 的「真」写作 1
forms:
  flag  ["flag", "<flag_id>", 1]
  aff   ["aff", "<character_id>", "<op>", "<int>"]
  item  ["item", "<item_id>", 1]
  and   ["and", "<cond>", "<cond>"]
  or    ["or", "<cond>", "<cond>"]
  not   ["not", "<cond>"]

这张表是声明,不是使用情况。声明了六种形式不等于六种都用上了——下面 P 层的第一件事就是把两者分开数。

P 层:六种形式,每种都配一个负例

一个只会返回 true 的求值器等于没有求值器。所以下面的测试对每种形式都给正例与负例,并且额外检查两条代数性质:>= 与 < 在整数上互斥、== 与 != 在整数上互补。这两条性质用 0–100 的全区间逐个整数验,比抽查几个点更能说明问题。

// file: core/test/na_09_1_cond_expr.test.ts
// tier: P
// verify: node --test 'core/test/na_09_1_cond_expr.test.ts'
// 卷一 9.1:条件表达式的六种形式 —— 三种叶子(flag / aff / item)+ 三种组合(and / or / not)。
// 每种形式都配正例与负例:只能返回 true 的求值器等于没有求值器,负例才是它有用的证据。
// 另统计这六种形式在本规格里各出现多少次(文法声明 ≠ 规格实际用到)。

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import { condOk, initState } from "../src/inkwell/engine.ts";
import type { Cond, Spec, State } from "../src/inkwell/engine.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;

/** 受控状态:只覆盖本节关心的字段,其余取初始值。 */
function mk(over: Partial<State>): State {
  return { ...initState(spec), ...over };
}

// ---------------------------------------------------------------- 叶子一:flag
test("flag 叶子:置位为真,未置位为假", () => {
  const c: Cond = ["flag", "met_hui", 1];
  assert.equal(condOk(c, mk({ flags: { met_hui: 1 } })), true);
  assert.equal(condOk(c, mk({ flags: { met_hui: 0 } })), false);
});

test("flag 叶子:未声明的 flag 落到 0,不抛异常", () => {
  const c: Cond = ["flag", "met_hui", 1];
  assert.equal(condOk(c, mk({ flags: {} })), false);
  assert.equal(condOk(["not", c], mk({ flags: {} })), true);
});

// ---------------------------------------------------------------- 叶子二:aff
test("aff 叶子:>= 的边界是「等于也真」", () => {
  const c: Cond = ["aff", "yan", ">=", 30];
  assert.equal(condOk(c, mk({ aff: { yan: 29 } })), false);
  assert.equal(condOk(c, mk({ aff: { yan: 30 } })), true);
  assert.equal(condOk(c, mk({ aff: { yan: 31 } })), true);
});

test("aff 叶子:< 是 >= 的补集(同一整数上互斥)", () => {
  const ge: Cond = ["aff", "yan", ">=", 30];
  const lt: Cond = ["aff", "yan", "<", 30];
  for (let v = 0; v <= 100; v += 1) {
    const s = mk({ aff: { yan: v } });
    assert.equal(condOk(ge, s) !== condOk(lt, s), true, `v=${v} 上 >= 与 < 应互斥`);
  }
});

test("aff 叶子:== 与 != 在整数上互为补集", () => {
  const eq: Cond = ["aff", "hui", "==", 55];
  const ne: Cond = ["aff", "hui", "!=", 55];
  assert.equal(condOk(eq, mk({ aff: { hui: 55 } })), true);
  assert.equal(condOk(ne, mk({ aff: { hui: 55 } })), false);
  assert.equal(condOk(eq, mk({ aff: { hui: 54 } })), false);
  assert.equal(condOk(ne, mk({ aff: { hui: 54 } })), true);
});

// ---------------------------------------------------------------- 叶子三:item
test("item 叶子:持有为真,不持有为假", () => {
  const c: Cond = ["item", "lantern", 1];
  assert.equal(condOk(c, mk({ items: { lantern: 1 } })), true);
  assert.equal(condOk(c, mk({ items: { lantern: 0 } })), false);
  assert.equal(condOk(c, mk({ items: {} })), false);
});

// ---------------------------------------------------------------- 组合:and / or / not
test("and 是合取:两真才真,任一假即假", () => {
  const c: Cond = ["and", ["flag", "found_yan_journal", 1], ["aff", "yan", ">=", 40]];
  assert.equal(condOk(c, mk({ flags: { found_yan_journal: 1 }, aff: { yan: 40 } })), true);
  assert.equal(condOk(c, mk({ flags: { found_yan_journal: 1 }, aff: { yan: 39 } })), false);
  assert.equal(condOk(c, mk({ flags: { found_yan_journal: 0 }, aff: { yan: 99 } })), false);
});

test("or 是析取:任一真即真,两假才假", () => {
  const c: Cond = ["or", ["flag", "hui_trusts", 1], ["item", "rope_knot", 1]];
  assert.equal(condOk(c, mk({ flags: { hui_trusts: 1 }, items: { rope_knot: 0 } })), true);
  assert.equal(condOk(c, mk({ flags: { hui_trusts: 0 }, items: { rope_knot: 1 } })), true);
  assert.equal(condOk(c, mk({ flags: { hui_trusts: 0 }, items: { rope_knot: 0 } })), false);
});

test("not 是取反:单目,嵌套一层后回到原值", () => {
  const inner: Cond = ["flag", "dawn_choice_made", 1];
  assert.equal(condOk(["not", inner], mk({ flags: { dawn_choice_made: 0 } })), true);
  assert.equal(condOk(["not", inner], mk({ flags: { dawn_choice_made: 1 } })), false);
  assert.equal(condOk(["not", ["not", inner]], mk({ flags: { dawn_choice_made: 1 } })), true);
});

test("null 条件恒真:无条件是「无条件成立」而不是「条件失败」", () => {
  assert.equal(condOk(null, mk({})), true);
  assert.equal(condOk(undefined, mk({})), true);
});

// ---------------------------------------------------------------- 文法覆盖统计
function kinds(c: Cond | null | undefined, acc: string[]): void {
  if (!c) return;
  acc.push(c[0] as string);
  if (c[0] === "and" || c[0] === "or") {
    kinds(c[1] as Cond, acc);
    kinds(c[2] as Cond, acc);
  } else if (c[0] === "not") {
    kinds(c[1] as Cond, acc);
  }
}

type WideSpec = Spec & {
  conditions: { forms: Record<string, unknown> };
  scene_graph: { edges: { when: Cond | null }[] };
  world: { locations: { unlock_when: Cond | null }[] };
};

/** 把规格里全部条件位点(对话行 / 选项 / 边 / 地点 / 结局)摊平成一串算子名。 */
function allSites(): string[] {
  const w = spec as WideSpec;
  const acc: string[] = [];
  for (const n of w.dialogue) {
    kinds(n.line_when, acc);
    for (const c of n.choices ?? []) kinds(c.when, acc);
  }
  for (const e of w.scene_graph.edges) kinds(e.when, acc);
  for (const l of w.world.locations) kinds(l.unlock_when, acc);
  for (const e of w.endings) kinds(e.when, acc);
  return acc;
}

test("文法声明 6 种形式,规格实际用到 5 种", () => {
  const acc = allSites();
  const counts: Record<string, number> = {};
  for (const k of acc) counts[k] = (counts[k] ?? 0) + 1;
  const declared = Object.keys((spec as WideSpec).conditions.forms);
  assert.deepEqual(declared.sort(), ["aff", "and", "flag", "item", "not", "or"]);
  const used = Object.keys(counts).sort();
  assert.deepEqual(used, ["aff", "and", "flag", "item", "not"]);
  assert.equal(counts["or"], undefined);
  const leaves = counts["flag"] + counts["aff"] + counts["item"];
  const combiners = counts["and"] + counts["not"];
  assert.equal(acc.length, leaves + combiners);
  console.log(`全规格条件位点 ${acc.length} 个:叶子 ${leaves}(flag ${counts["flag"]} / aff ${counts["aff"]} / item ${counts["item"]})`
    + `,组合 ${combiners}(and ${counts["and"]} / or 0 / not ${counts["not"]})`);
});

本机实跑 node --test 'core/test/na_09_1_cond_expr.test.ts':

全规格条件位点 58 个:叶子 54(flag 22 / aff 15 / item 17),组合 4(and 2 / or 0 / not 2)
✔ flag 叶子:置位为真,未置位为假 (1.157958ms)
✔ flag 叶子:未声明的 flag 落到 0,不抛异常 (0.124792ms)
✔ aff 叶子:>= 的边界是「等于也真」 (0.088625ms)
✔ aff 叶子:< 是 >= 的补集(同一整数上互斥) (0.567416ms)
✔ aff 叶子:== 与 != 在整数上互为补集 (0.121583ms)
✔ item 叶子:持有为真,不持有为假 (0.205708ms)
✔ and 是合取:两真才真,任一假即假 (0.088125ms)
✔ or 是析取:任一真即真,两假才假 (0.222292ms)
✔ not 是取反:单目,嵌套一层后回到原值 (0.187291ms)
✔ null 条件恒真:无条件是「无条件成立」而不是「条件失败」 (0.133958ms)
✔ 文法声明 6 种形式,规格实际用到 5 种 (1.669875ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1179.051333

11 条全过,fail 0。

判读:声明 6 种,用到 5 种

输出第一行值得单独读一遍。把规格里全部条件位点(对话行、选项、场景边、地点解锁、结局)的条件树摊平,得到 58 个节点:叶子 54 个(flag 22、aff 15、item 17),组合 4 个(and 2、not 2)。

对照文法表会发现两件事:

观察数字读法
or 声明了但一次没用声明 6 种,实际 5 种不是缺陷,是当前案例的写法偏好——凡是「或」的意思,这里都拆成了两条独立选项
组合只占 4 / 58叶子 54,组合 4条件整体偏「浅」:绝大多数条件是单个叶子,深树很少

第一条要说清:「声明了没用」和「声明了用不了」是两件事。or 的求值路径由测试覆盖(or 那条用例就是直接构造一个 or 条件来跑的),所以它不是死代码;它只是在本案例的规格文本里还没被用上。如果哪天规格里出现一个真正需要析取的条件,求值器已经准备好了。

第二条是这份规格的形状:58 个节点里只有 4 个是组合,说明这里的条件几乎都是一次判断。这既让条件容易读,也让后面的闭包检验便宜——树越浅,逐位点判定可满足性的成本越低。

自检表

检查项合格标准
读取范围封闭叶子只有 flag / aff / item 三种,读不到路径、幕次、访问次数
运算符正交>= 与 < 互补、== 与 != 互补,同一意思只有一种写法
正负例成对六种形式每种都有能返回 false 的用例
声明与使用分开文法表是声明;实际用到几种要单独数出来
空条件有定义null 表示「无条件成立」,与「条件失败」明确区分

条件规定了「什么情况下可以走这条路」。但条件读的那些名字——旗标、好感度、物品——是谁写的、能写成什么,还没有规矩。下一节把三类状态与四个效果算子的写入域定下来。

阅读导航:上一节:8.3 假选择与真后果的判定 · 下一节:9.2 状态变量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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