4.1 选择序列与路径
Godot 对话历史与后果日志 关心的是怎么把玩家做过的选择存下来、怎么在 UI 里回看——它记录的是「点过什么」。本章换一个方向:同样一串选择,喂回引擎之后必须走出完全相同的状态,而且这个「相同」要能被断言。第 4 章三节依次回答:一串选择对应什么(本节)、同种子为什么必然同回放(4.2)、跨语言怎么证明逐位一致(4.3)。
两种表示:选择序列与路径
一次通关有两份可以记录的东西,它们长度差 1、含义不同:
| 表示 | 内容 | 谁产生的 | 例子片段 |
|---|---|---|---|
| 选择序列 | 玩家按下的一串 choice_id | 玩家输入 | c_pier_1b > c_pier_2b > … |
| 路径 | 引擎依次停靠的一串 node_id | 引擎推进 | d_pier_1 > d_pier_2 > … |
选择序列是输入,路径是输出。路径首节点是起点 d_pier_1,之后每个节点都是前一步选择的 goto 目标,所以路径长度恒等于选择步数加一。这一节要把这个「加一」关系做成可断言的。
S 层:双表示对照与 DAG 证据
脚本从规格里取一条完整路径(用固定种子采样),把它的选择序列与节点路径并排打印,再把 48 个对话节点建成有向图、跑一次 Kahn 拓扑排序。它同时产出 out/ne_04_1_path.csv——逐步的 (step, choice_id, from_node, to_node) 四列。
#!/usr/bin/env python3
# file: sim/ne_04_1_path.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_04_1_path.py
"""卷二 4.1 选择序列与路径(S 层):一条路径有两种表示——玩家按下的是「选择序列」
(一串 choice_id),引擎走出来的却是「路径」(一串 node_id)。本节给出双表示对照、
115->56 的汇聚观察,以及对话图是 DAG 的拓扑证据。产出 out/ne_04_1_path.csv。
PRNG 用自实现 Lehmer MINSTD,与 core/src/inkwell/engine.ts 同算法同种子。
"""
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, "ne_04_1_path.csv")
SEED = 20261016
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def cond_ok(c, s: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if k == "and":
return cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
if k == "not":
return not cond_ok(c[1], s)
raise ValueError(f"unknown cond op: {k}")
def init_state(spec: dict) -> dict:
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
"path": [spec["meta"]["start"]]}
def node_by_id(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def apply_choice(spec: dict, s: dict, cid: str) -> dict:
n = node_by_id(spec, s["node"])
c = next(x for x in (n.get("choices") or []) if x["id"] == cid)
if not cond_ok(c.get("when"), s):
raise ValueError(f"choice unavailable: {cid}")
aff, flags, items, eff = dict(s["aff"]), dict(s["flags"]), dict(s["items"]), c.get("effects") or {}
for k in eff.get("set", {}):
flags[k] = eff["set"][k]
for k in eff.get("add", {}):
aff[k] = max(0, min(100, aff.get(k, 0) + eff["add"][k]))
for k in eff.get("grant", {}):
items[k] = eff["grant"][k]
for k in eff.get("revoke", {}):
items[k] = 0
return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
"path": [*s["path"], c["goto"]]}
def sample_one(spec: dict, seed: int) -> list[str]:
"""Lehmer MINSTD 采样一条完整路径,返回选择序列(choice_id 列表)。"""
rng, s, trail = seed, init_state(spec), []
for _ in range(200):
if node_by_id(spec, s["node"]).get("ending"):
break
opts = [c for c in (node_by_id(spec, s["node"]).get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
trail.append(c["id"])
s = apply_choice(spec, s, c["id"])
return trail
def trace(spec: dict, choices: list[str]) -> list[dict]:
"""把选择序列展开成逐步的 (选择, 起点, 终点) 三元组。"""
s, rows = init_state(spec), []
for step, cid in enumerate(choices, 1):
frm = s["node"]
s = apply_choice(spec, s, cid)
rows.append({"step": step, "choice_id": cid, "from_node": frm, "to_node": s["node"]})
return rows
def topo_sort(adj: dict) -> list[str]:
"""Kahn 拓扑排序;返回序列长度 < 节点数即说明图有环。"""
indeg = {k: 0 for k in adj}
for u in adj:
for v in adj[u]:
indeg[v] += 1
ready = sorted(k for k in adj if indeg[k] == 0)
order: list[str] = []
while ready:
u = ready.pop(0)
order.append(u)
for v in adj[u]:
indeg[v] -= 1
if indeg[v] == 0:
ready.append(v)
ready.sort()
return order
def main() -> int:
spec = load_spec()
adj = {n["id"]: sorted({c["goto"] for c in (n.get("choices") or [])}) for n in spec["dialogue"]}
edges = {(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
n_nodes = len(adj)
n_choices = sum(len(n.get("choices") or []) for n in spec["dialogue"])
choices = sample_one(spec, SEED)
rows = trace(spec, choices)
nodes = [spec["meta"]["start"], *(r["to_node"] for r in rows)]
print(f"spec={spec['meta']['title']} 起点 {spec['meta']['start']} seed={SEED}")
print(f"1) 样例路径:选择序列 {len(choices)} 步 -> 节点路径 {len(nodes)} 个节点")
print(f" 选择序列(choice_id):{' > '.join(choices)}")
print(f" 节点路径(node_id) :{' > '.join(nodes)}")
print(f" 终点 {nodes[-1]} 是否结局:{bool(node_by_id(spec, nodes[-1]).get('ending'))}")
print(f"2) 规模:{n_nodes} 个对话节点,{n_choices} 条选项,去重后唯一跳转 {len(edges)} 条")
print(f" 选项数 / 唯一跳转 = {n_choices} / {len(edges)}(多条不同选项汇到同一后继节点)")
order = topo_sort(adj)
is_dag = len(order) == n_nodes
print(f"3) Kahn 拓扑排序:排入 {len(order)} / {n_nodes} 个节点;图是 DAG:{is_dag}")
print(f" 拓扑序前 8:{order[:8]}")
print(f" 拓扑序末 5:{order[-5:]}")
known = {n["id"] for n in spec["dialogue"]}
assert all(x in known for x in nodes) and is_dag, "路径含未知节点或图有环"
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["step", "choice_id", "from_node", "to_node"], lineterminator="\n")
w.writeheader()
w.writerows(rows)
print(f"wrote out/ne_04_1_path.csv rows={len(rows)}")
print("OK ne_04_1_path:双表示对照已输出,115->56 汇聚确认,对话图为 DAG")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_04_1_path.py:
spec=雾港夜航 起点 d_pier_1 seed=20261016
1) 样例路径:选择序列 22 步 -> 节点路径 23 个节点
选择序列(choice_id):c_pier_1b > c_pier_2b > c_chapel_1a > c_chapel_2a > c_chapel_3b > c_hub_a1b > c_wp_1b > c_wp_2a > c_wp_3a > c_hub_a2b > c_mr_1a > c_mr_2b > c_mr_3b > c_db_1c > c_db_2a > c_db_3a > c_hub_b2b > c_bw_1c > c_bw_2b > c_bw_3a > c_dq_1a > c_dq_2e
节点路径(node_id) :d_pier_1 > d_pier_2 > d_chapel_1 > d_chapel_2 > d_chapel_3 > d_hub_a1 > d_wp_1 > d_wp_2 > d_wp_3 > d_hub_a2 > d_mr_1 > d_mr_2 > d_mr_3 > d_db_1 > d_db_2 > d_db_3 > d_hub_b2 > d_bw_1 > d_bw_2 > d_bw_3 > d_dq_1 > d_dq_2 > e_dark
终点 e_dark 是否结局:True
2) 规模:48 个对话节点,115 条选项,去重后唯一跳转 56 条
选项数 / 唯一跳转 = 115 / 56(多条不同选项汇到同一后继节点)
3) Kahn 拓扑排序:排入 48 / 48 个节点;图是 DAG:True
拓扑序前 8:['d_pier_1', 'd_pier_2', 'd_chapel_1', 'd_chapel_2', 'd_chapel_3', 'd_tavern_1', 'd_tavern_2', 'd_tavern_3']
拓扑序末 5:['e_dark', 'e_lit', 'e_loss', 'e_silence', 'e_truth']
wrote out/ne_04_1_path.csv rows=22
OK ne_04_1_path:双表示对照已输出,115->56 汇聚确认,对话图为 DAG
out/ne_04_1_path.csv 的头几行把双表示钉死在同一张表上:每一步的 choice_id 与它跨过的 from_node -> to_node 边并排,读起来就是「按了哪个键、从哪走到哪」:
step,choice_id,from_node,to_node
1,c_pier_1b,d_pier_1,d_pier_2
...
22,c_dq_2e,d_dq_2,e_dark
判读:115 与 56 的落差
第一,22 步选择走出 23 个节点。 这条样例路径从 d_pier_1 出发,经过 22 次选择,停在结局 e_dark。路径比选择序列多出的那一个节点就是起点——它不需要任何选择就能到达。这个「加一」是双表示唯一的数量关系,也是 P 层最容易写错的断言。
第二,115 条选项去重后只有 56 条唯一跳转。 这是本节最值得读的数字:选项数几乎是唯一跳转数的两倍,说明多条不同的选项经常汇到同一个后继节点。两个选项写的是两句不同的话,goto 却指向同一个节点——这就是卷一 8.1 讲的汇聚。「选项数」和「路径形状数」因此不是一回事:玩家能按的键比图上的边多得多。
第三,拓扑排序吃掉了全部 48 个节点,图是无环 DAG。 Kahn 算法只能排出有向无环图的拓扑序;一旦有环,环上节点的入度永远降不到 0,序列就会短于节点数。这里排入 48/48,说明对话图没有任何环,后续所有基于「层号」「深度」的说法才良定义。
| 观察 | 数字 | 读法 |
|---|---|---|
| 选择步数 / 路径节点数 | 22 / 23 | 路径 = 选择 + 1(起点不需选择) |
| 选项数 / 唯一跳转 | 115 / 56 | 汇聚是常态,选项数不是路径形状数 |
| Kahn 排入节点 | 48 / 48 | 图是 DAG,无环 |
P 层:把双表示写成门禁
同一件事在 TypeScript 内核里再走一遍,并把它写成 node --test 门禁:断言路径长度 = 选择步数 + 1、115 -> 56、拓扑序覆盖全部节点,最后读 Python 侧落下的 CSV 逐行核对——两条独立实现必须给出同一条路径。
// file: core/test/ne_04_1_path.test.ts
// tier: P
// verify: node --test 'core/test/ne_04_1_path.test.ts'
// 卷二 4.1(P 层门禁):双表示一致、115->56 汇聚、对话图是 DAG、与 Python 侧同序。
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 { nodeById } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { pathOf, trace, adjacency, uniqueGotos, topoSort, firstSequence } from "../src/inkwell/ne_04_1_path.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;
const SEED = 20261016;
test("选择序列与节点路径是同一趟推进的两种表示", () => {
const choices = firstSequence(spec, SEED);
const path = pathOf(spec, choices);
assert.equal(path[0], spec.meta.start, "路径首节点应为起点");
assert.equal(path.length, choices.length + 1, "路径节点数 = 选择步数 + 1");
const steps = trace(spec, choices);
for (let i = 0; i < steps.length; i += 1) {
assert.equal(steps[i].from, path[i], `第 ${i} 步起点错`);
assert.equal(steps[i].to, path[i + 1], `第 ${i} 步终点错`);
}
assert.ok(nodeById(spec, path[path.length - 1]).ending, "终点应为结局节点");
for (const n of path) assert.ok(spec.dialogue.some((d) => d.id === n), `未知节点 ${n}`);
});
test("115 条选项去重后只有 56 条唯一跳转", () => {
const nChoices = spec.dialogue.reduce((a, n) => a + (n.choices ?? []).length, 0);
assert.equal(spec.dialogue.length, 48);
assert.equal(nChoices, 115);
assert.equal(uniqueGotos(spec.dialogue), 56);
assert.ok(nChoices > uniqueGotos(spec.dialogue), "选项数应大于唯一跳转数(存在汇聚)");
});
test("对话图是 DAG:Kahn 拓扑排序吃掉全部节点", () => {
const adj = adjacency(spec.dialogue);
const order = topoSort(adj);
assert.equal(order.length, spec.dialogue.length, "拓扑序长度应等于节点数(无环)");
const pos: Record<string, number> = {};
order.forEach((k, i) => (pos[k] = i));
for (const u of Object.keys(adj)) for (const v of adj[u]) assert.ok(pos[u] < pos[v], `边 ${u}->${v} 违反拓扑序`);
});
test("与 Python 侧产物逐条同序(同种子)", () => {
const rows = readFileSync(join(ROOT, "out/ne_04_1_path.csv"), "utf8")
.trim().split("\n").slice(1)
.map((l) => {
const [step, choice_id, from_node, to_node] = l.split(",");
return { step: Number(step), choice_id, from_node, to_node };
});
const path = pathOf(spec, rows.map((r) => r.choice_id));
for (const r of rows) {
assert.equal(path[r.step - 1], r.from_node, `第 ${r.step} 步起点不一致`);
assert.equal(path[r.step], r.to_node, `第 ${r.step} 步终点不一致`);
}
});
本机实跑 node --test 'core/test/ne_04_1_path.test.ts':
✔ 选择序列与节点路径是同一趟推进的两种表示 (1.144042ms)
✔ 115 条选项去重后只有 56 条唯一跳转 (0.197291ms)
✔ 对话图是 DAG:Kahn 拓扑排序吃掉全部节点 (0.293416ms)
✔ 与 Python 侧产物逐条同序(同种子) (0.690625ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
第 4 条测试是本节的收口:它读 Python 侧落下的 CSV,用内核重放同一串 choice_id,逐行核对起点与终点。两条独立实现给出同一条路径,这是「双表示」不只是记法、而是可执行约定的证据。
自检表
| 检查项 | 合格标准 |
|---|---|
| 双表示长度关系 | 路径节点数 == 选择步数 + 1 |
| 汇聚已量化 | 选项数 > 唯一跳转数,且差值与实跑一致 |
| 无环有证据 | Kahn 拓扑序长度 == 节点数 |
| 跨语言同序 | 内核重放的选择序列与 Python 侧产物逐行一致 |
选择序列与路径的关系钉死之后,下一个问题是:同一串选择序列,重放两次会不会得到同一个状态? 如果会,靠的是什么?下一节把随机性钉死在一个可播种的整数 PRNG 上。
阅读导航:上一节:3.3 状态快照与差量 · 下一节:4.2 同种子同回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。