11.1 对话节点、选项与跳转
客户端任务与对话分支 讲的是客户端怎么把任务与对话组织起来、怎么把选项画出来、怎么在本地记下进度——那是客户端实现侧的问题。本节问的是上一层:把一段对话摊成数据结构,它由什么构成?
答案是两类记录:节点(一次发言,含说话人与台词)与选项(节点上的出口,含文字、可选条件、效果与跳转)。节点靠选项连起来,选项靠 goto 指到下一个节点——于是整段对话成了一张有向图。图一旦定死,「选项指向的节点在不在」「这个节点有几个出口」就不再需要人去数。
节点层的八个字段
| 字段 | 出现 | 说明 |
|---|---|---|
id | 48 / 48 | 节点标识,全图唯一 |
scene | 48 / 48 | 所属场景,与场景图的节点对齐 |
speaker | 48 / 48 | 说话的角色 |
line | 48 / 48 | 台词(渲染产物,见 11.2) |
line_when | 2 / 48 | 台词的渲染条件;为空即无条件 |
line_else | 1 / 48 | 条件不成立时的备选台词 |
choices | 48 / 48 | 出口数组 |
ending | 5 / 48 | 结局标识,只出现在结局节点上 |
两点值得留意。其一,choices 是 48 / 48——每个节点都有这个字段,只是 5 个结局节点上是空数组。「空数组」与「缺字段」是两回事:前者说「这里确定没有出口」,后者说「忘了写」。遍历器与渲染器只认前者,遇到后者只能报错。其二,line_when 只有 2 个节点有,line_else 更是只有 1 个——条件台词在这份规格里是稀例,11.2 会专门处理这 2 个节点。
选项层的五个字段
| 字段 | 出现 | 说明 |
|---|---|---|
id | 115 / 115 | 选项标识,用于回放与日志 |
text | 115 / 115 | 选项文字 |
when | 34 / 115 | 可选条件;为空即无条件可选 |
effects | 115 / 115 | 效果算子对象 |
goto | 115 / 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 台词的条件渲染 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。