11.1 对话节点、选项与跳转

对话节点是字段化的记录:节点层八个字段、选项层五个字段,其中 goto 是选项的独立字段而非效果算子。本节给出两张字段表,用 S 层脚本校验 115 条跳转全部命中已声明节点并输出节点度数表,再用 P 层测试断言未知选项被拒绝、推进是纯函数。

11.1 对话节点、选项与跳转

客户端任务与对话分支 讲的是客户端怎么把任务与对话组织起来、怎么把选项画出来、怎么在本地记下进度——那是客户端实现侧的问题。本节问的是上一层:把一段对话摊成数据结构,它由什么构成?

答案是两类记录:节点(一次发言,含说话人与台词)与选项(节点上的出口,含文字、可选条件、效果与跳转)。节点靠选项连起来,选项靠 goto 指到下一个节点——于是整段对话成了一张有向图。图一旦定死,「选项指向的节点在不在」「这个节点有几个出口」就不再需要人去数。

节点层的八个字段

字段出现说明
id48 / 48节点标识,全图唯一
scene48 / 48所属场景,与场景图的节点对齐
speaker48 / 48说话的角色
line48 / 48台词(渲染产物,见 11.2)
line_when2 / 48台词的渲染条件;为空即无条件
line_else1 / 48条件不成立时的备选台词
choices48 / 48出口数组
ending5 / 48结局标识,只出现在结局节点上

两点值得留意。其一,choices 是 48 / 48——每个节点都有这个字段,只是 5 个结局节点上是空数组。「空数组」与「缺字段」是两回事:前者说「这里确定没有出口」,后者说「忘了写」。遍历器与渲染器只认前者,遇到后者只能报错。其二,line_when 只有 2 个节点有,line_else 更是只有 1 个——条件台词在这份规格里是稀例,11.2 会专门处理这 2 个节点。

选项层的五个字段

字段出现说明
id115 / 115选项标识,用于回放与日志
text115 / 115选项文字
when34 / 115可选条件;为空即无条件可选
effects115 / 115效果算子对象
goto115 / 115跳转目标(节点 id)

when 是 34 / 115,也就是说近七成选项是无条件可选的。这个比例决定了对话图的基本手感:大多数分叉是「你想怎么答就怎么答」,只有三成分叉受旗标或好感度约束。effects 与 goto 则是 115 / 115 全有——每个选项都要既说清「改什么状态」,又说清「跳到哪」。

goto 与 effects 的分工:跳转不是效果

作用写什么谁消费
goto决定下一个节点一个节点 id遍历器 / 状态机的转移
effects改写状态set / add / grant / revoke状态推进

把 goto 当成效果算子(写成 "effects": {"goto": "d_pier_2"})是最容易犯的一类错。原因在于两者改的是不同的东西:效果算子只改状态、不改「故事走到哪」;跳转只改位置、不改状态。一旦把跳转塞进效果,状态与路径就不再正交——穷举时无法区分「走到这个节点」与「状态变成了这样」,回放时也无法从状态单独还原路径。本规格实测 effects 段只出现三个键:add(66 次)、set(52 次)、grant(15 次);没有一个选项把 goto 写进 effects。

S 层:跳转完整性与节点度数表

#!/usr/bin/env python3
# file: sim/na_11_1_dialogue_node_goto.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_11_1_dialogue_node_goto.py
"""卷一 11.1 对话节点、选项与跳转(S 层):把 48 个对话节点与 115 条选项的
跳转目标当成一张有向图的边来校验,并输出每个节点的出 / 入度数表。

三件事:
  1) 字段盘点:节点层字段(id/scene/speaker/line/line_when/line_else/choices/ending)
     与选项层字段(id/text/when/effects/goto)各有几个、各自出现在多少个节点 / 选项上;
  2) 跳转完整性:**每一条 goto 的目标都必须是已声明的节点 id**(115/115);
  3) 分工校验:`goto` 是 choice 的独立字段,**不是效果算子**——`effects` 的键只允许
     set / add / grant / revoke,且任何 effects 里都不出现 goto。

产出 out/na_11_1_dialogue_goto.csv(节点度数表)。整数不变量:好感度初值 ∈ [0,100]。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "na_11_1_dialogue_goto.csv")

NODE_FIELDS = ["id", "scene", "speaker", "line", "line_when", "line_else", "choices", "ending"]
CHOICE_FIELDS = ["id", "text", "when", "effects", "goto"]
EFFECT_OPS = ("set", "add", "grant", "revoke")


def load_spec():
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def main() -> int:
    spec = load_spec()
    dlg = spec["dialogue"]
    ids = {n["id"] for n in dlg}
    choices = [c for n in dlg for c in (n.get("choices") or [])]
    gotos = [c["goto"] for c in choices]

    # 1) 字段盘点(每个字段出现多少个非空值)
    print(f"spec={spec['meta']['title']}  节点 {len(dlg)}  选项 {len(choices)}")
    print("节点层字段:" + "  ".join(f"{f}={sum(1 for n in dlg if n.get(f) is not None)}" for f in NODE_FIELDS))
    print("选项层字段:" + "  ".join(f"{f}={sum(1 for c in choices if c.get(f) is not None)}" for f in CHOICE_FIELDS))

    # 2) 跳转完整性
    bad = sorted({g for g in gotos if g not in ids})
    print(f"跳转目标校验:{len(gotos) - len([g for g in gotos if g not in ids])} / {len(gotos)} 条 goto 指向已声明节点"
          f";未命中 {len(bad)} 个 {bad}")
    if bad:
        print("FAIL 存在指向未声明节点的 goto")
        return 1

    # 3) goto 不是效果算子
    eff_keys = sorted({k for c in choices for k in (c.get("effects") or {})})
    print(f"effects 段出现过的键:{eff_keys}")
    illegal = [k for k in eff_keys if k not in EFFECT_OPS]
    if illegal:
        print(f"FAIL effects 出现非法算子 {illegal}")
        return 1
    goto_in_eff = [c["id"] for c in choices if "goto" in (c.get("effects") or {})]
    print(f"effects 里出现 goto 的选项:{len(goto_in_eff)} 个(goto 是 choice 的字段,不是算子)")
    if goto_in_eff:
        print("FAIL goto 混进了效果算子")
        return 1

    # 度数表
    in_deg = {n["id"]: 0 for n in dlg}
    for c in choices:
        in_deg[c["goto"]] += 1
    rows = []
    for n in dlg:
        ch = n.get("choices") or []
        kind = "ending" if n.get("ending") else "choice"
        rows.append({
            "id": n["id"], "scene": n["scene"], "speaker": n["speaker"], "kind": kind,
            "out_degree": len(ch), "in_degree": in_deg[n["id"]],
            "cond_choices": sum(1 for c in ch if c.get("when")),
        })
    out_hist: dict[int, int] = {}
    for r in rows:
        out_hist[r["out_degree"]] = out_hist.get(r["out_degree"], 0) + 1
    print("出度分布(节点数):" + "  ".join(f"{k}->{v}" for k, v in sorted(out_hist.items())))
    in_hist: dict[int, int] = {}
    for r in rows:
        in_hist[r["in_degree"]] = in_hist.get(r["in_degree"], 0) + 1
    print("入度分布(节点数):" + "  ".join(f"{k}->{v}" for k, v in sorted(in_hist.items())))
    widest = max(rows, key=lambda r: r["out_degree"])
    print(f"最宽节点 {widest['id']}:出度 {widest['out_degree']},其中带条件的 "
          f"{widest['cond_choices']} 条")
    sinks = [r["id"] for r in rows if r["out_degree"] == 0]
    print(f"出度为 0 的节点 {len(sinks)} 个:{sinks}")

    # 整数不变量:好感度初值 ∈ [0,100]
    for c in spec["characters"]:
        init = c["affinity"]["init"]
        assert 0 <= init <= 100, f"{c['id']} init {init} 越界"
    total_out = sum(r["out_degree"] for r in rows)
    print(f"出度之和 {total_out} == 选项数 {len(choices)}:{total_out == len(choices)}")
    assert total_out == len(choices), "出度之和应等于选项数"

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["id", "scene", "speaker", "kind",
                                           "out_degree", "in_degree", "cond_choices"])
        w.writeheader()
        w.writerows(rows)
    print(f"wrote out/na_11_1_dialogue_goto.csv rows={len(rows)}")
    print("OK na_11_1_dialogue_node_goto:115 条 goto 全部命中,goto 与效果算子分工清晰")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_11_1_dialogue_node_goto.py:

spec=雾港夜航  节点 48  选项 115
节点层字段:id=48  scene=48  speaker=48  line=48  line_when=2  line_else=1  choices=48  ending=5
选项层字段:id=115  text=115  when=34  effects=115  goto=115
跳转目标校验:115 / 115 条 goto 指向已声明节点;未命中 0 个 []
effects 段出现过的键:['add', 'grant', 'set']
effects 里出现 goto 的选项:0 个(goto 是 choice 的字段,不是算子)
出度分布(节点数):0->5  2->16  3->26  5->1
入度分布(节点数):0->1  1->13  2->4  3->26  4->4
最宽节点 d_dq_2:出度 5,其中带条件的 4 条
出度为 0 的节点 5 个:['e_lit', 'e_dark', 'e_truth', 'e_silence', 'e_loss']
出度之和 115 == 选项数 115:True
wrote out/na_11_1_dialogue_goto.csv rows=48
OK na_11_1_dialogue_node_goto:115 条 goto 全部命中,goto 与效果算子分工清晰

判读:出度分布说明对话是「窄而匀」的

输出里有三行是本节的重点。跳转完整性:115 条 goto 全部指向已声明节点,未命中 0 个。这不是自动成立的——只要有一个 goto 写成了不存在的 id,走到那条边的路径就会断,而断链在穷举时表现为「可达状态数莫名少一截」,很难定位;把它前置成一条静态校验最省事。

出度分布 0->5 2->16 3->26 5->1:5 个结局节点没有出口,16 个节点有两个出口,26 个节点有三个出口,只有 1 个节点有五个出口。三分之二的节点出口数是 2 或 3,这是一个「窄而匀」的形状:分叉密但不宽,玩家在任一个节点上的选择数基本在两三个之间。

最宽节点 d_dq_2 出度 5、其中 4 条带条件,是全图唯一一个宽度到 5 的节点,也是唯一需要认真考虑「选项列表会不会太长」的地方。入度分布里 3->26 说明大多数节点都有三条边汇入——对话图是多入口、少出口的,与场景图那种「一条主线带若干旁支」的形状不同。

P 层:推进的四个约定

数据结构定完了,接下来要问「拿一个选项去推进,会发生什么」。下面四条约定里,前三条是拒绝:不该推进的时候必须抛错,而不是静默返回原状态——静默会让错误一路漂到结局分布里。

// file: core/test/na_11_1_dialogue_goto.test.ts
// tier: P
// verify: node --test 'core/test/na_11_1_dialogue_goto.test.ts'
// 卷一 11.1:对话节点、选项与跳转 —— 推进的三个约定。
//   (a) 未知选项被拒绝(不是静默走空);
//   (b) 条件不满足的选项被拒绝(前端不该把它渲染成可点);
//   (c) 推进是纯函数:返回新状态,入参不被改写;
//   (d) goto 是 choice 的字段,不是效果算子 —— 它不改状态,只决定下一个节点。

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 { applyChoice, applyEffects, initState, nodeById } from "../src/inkwell/engine.ts";
import type { Effects, 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;

test("未知选项被拒绝:选项 id 不存在时抛错,而不是静默返回原状态", () => {
  const s = initState(spec);
  assert.throws(() => applyChoice(spec, s, "c_does_not_exist"), /unknown choice/);
  assert.throws(() => applyChoice(spec, s, ""), /unknown choice/);
});

test("条件不满足的选项被拒绝(点名 c_tavern_2c 的 hui>=55 门限)", () => {
  const s: State = { ...initState(spec), node: "d_tavern_2" };
  const low: State = { ...s, aff: { ...s.aff, hui: 54 } };
  assert.throws(() => applyChoice(spec, low, "c_tavern_2c"), /unavailable/);
  const ok: State = { ...s, aff: { ...s.aff, hui: 55 } };
  assert.equal(applyChoice(spec, ok, "c_tavern_2c").node, "d_tavern_3");
});

test("推进是纯函数:返回新对象,入参状态逐字段不变", () => {
  const s = initState(spec);
  const before = JSON.stringify(s);
  const next = applyChoice(spec, s, "c_pier_1a");
  assert.notEqual(next, s, "应返回新对象");
  assert.equal(JSON.stringify(s), before, "入参状态不应被改写");
  assert.equal(next.node, "d_pier_2");
  assert.equal(next.flags.knows_yan_missing, 1);
  assert.notEqual(next.path, s.path, "路径应是新数组");
  assert.equal(s.path.length, 1, "入参路径不应被追加");
});

test("goto 是 choice 的字段不是效果算子:effects 里出现 goto 不改变节点", () => {
  const s: State = { ...initState(spec), node: "d_pier_1" };
  const bogus = { set: { met_hai: 1 }, goto: "e_truth" } as unknown as Effects;
  const out = applyEffects(bogus, s);
  assert.equal(out.node, "d_pier_1", "goto 不该出现在效果里,节点不变");
  assert.equal(out.flags.met_hai, 1, "声明过的 set 仍然生效");

  for (const node of spec.dialogue) {
    for (const c of node.choices ?? []) {
      assert.equal(Object.hasOwn(c.effects, "goto"), false, `${c.id} 的 effects 混进了 goto`);
    }
  }
});

test("每条 goto 都指向已声明节点,且出度之和等于选项数", () => {
  let edges = 0;
  for (const node of spec.dialogue) {
    for (const c of node.choices ?? []) {
      assert.doesNotThrow(() => nodeById(spec, c.goto), `${c.id} 的 goto ${c.goto} 不存在`);
      edges += 1;
    }
  }
  assert.equal(edges, 115, `选项边应为 115,实为 ${edges}`);

  const wide = [...spec.dialogue].sort((a, b) => (b.choices?.length ?? 0) - (a.choices?.length ?? 0))[0];
  assert.equal(wide.id, "d_dq_2");
  assert.equal(wide.choices?.length, 5);
  console.log(`选项边 ${edges} 条;最宽节点 ${wide.id} 出度 ${wide.choices?.length}`);
});

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

选项边 115 条;最宽节点 d_dq_2 出度 5
✔ 未知选项被拒绝:选项 id 不存在时抛错,而不是静默返回原状态 (0.889333ms)
✔ 条件不满足的选项被拒绝(点名 c_tavern_2c 的 hui>=55 门限) (0.234209ms)
✔ 推进是纯函数:返回新对象,入参状态逐字段不变 (0.142209ms)
✔ goto 是 choice 的字段不是效果算子:effects 里出现 goto 不改变节点 (0.153292ms)
✔ 每条 goto 都指向已声明节点,且出度之和等于选项数 (0.675125ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 770.537209

5 条全过,fail 0。其中第三条(纯函数)值得单独说:applyChoice 返回一个新状态对象,入参状态在调用前后逐字段不变,路径也是新数组。这条约定是后面「确定性回放」的前提——如果推进偷偷改写了入参,那么同一个状态对象被两条路径共用时,先走的那条会污染后走的那条,回放就不再可复现。

自检表

检查项合格标准
跳转有目标每条 goto 都能在节点表里查到
字段与空数组分开choices 全有,结局节点上是空数组而非缺字段
跳转与效果分工effects 的键只有四个算子,不含 goto
推进是纯函数返回新对象,入参状态不变
拒绝而非静默未知选项、条件不满足的选项都抛错

节点与选项把「故事能往哪走」定死了。但节点上那条 line 并不是永远照原样念出来的——它可能带一个条件。下一节处理这 2 个条件台词节点。

阅读导航:上一节:10.3 好感度曲线的形状 · 下一节:11.2 台词的条件渲染 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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