13.1 结构校验:孤儿节点与断链
《游戏设计》卷一《玩法机制与可执行规格》
也有一套校验,但它校验的是数值与规则:资源守恒、支配策略、死锁可解性——对象是一局战斗的状态轨迹。本节换一个对象:校验结构与条件。同一份 story_spec.json,机制层问「这份规则自洽吗」,叙事层问「这张故事图走得通吗」。两套共享「整数不变量 + 确定性」的工程纪律,但判据完全不同——本节只做结构这一层,条件可满足性归 13.2,声明域差集归 13.3。
三类结构缺陷
结构校验不看条件能不能满足,只看图的形状。三类缺陷各有精确定义,不依赖任何一条具体路径:
| 缺陷 | 对象 | 判据 | 为什么是缺陷 |
|---|---|---|---|
| 孤儿 | 节点 | 没有任何选项 goto 到它,且它不是 meta.start | 从入口走不到,也无人指向,是一段死代码 |
| 断链 | 选项 | goto 指向一个不存在的节点 id | 玩家一旦选中,引擎就抛「未知节点」 |
| 死路 | 节点 | 选项数为 0,且没有 ending 标记 | 走进去出不来,也不是结局 |
三类的共同点是纯结构:判定一个孤儿不需要知道任何状态,判定一条断链不需要求值任何条件。正因为不依赖状态,这类缺陷可以在一张图上一次扫完,成本与状态空间规模无关——13.2 那种需要枚举状态的检查,正是结构校验要避免的东西。
S 层:逐类检测
脚本复用 sim/na_00_spec_check.py 的 load_spec,把对话图与场景图各扫一遍。对话图三类全查;场景图没有「结局标记」这一说,所以只查孤儿与断链,另把无出边的终点单独列出来(本案例的终点是 dawn_quay,正是黎明码头)。
#!/usr/bin/env python3
# file: sim/na_13_1_structure_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_13_1_structure_check.py
"""卷一 13.1 结构校验(S 层):孤儿节点 / 断链 / 死路。
复用 sim/na_00_spec_check.py 的 load_spec。只做「结构」这一层:孤儿(无人指向且
非起点)、断链(goto 指向不存在的节点)、死路(无选项且无结局标记)。对话图与
场景图各扫一遍。产出 out/narr13_structure.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/narr13_structure.json")
def scan_dialogue(spec: dict) -> dict:
nodes, start = spec["dialogue"], spec["meta"]["start"]
ids = {n["id"] for n in nodes}
incoming = {i: 0 for i in ids}
broken = []
for n in nodes:
for c in (n.get("choices") or []):
if c["goto"] in ids:
incoming[c["goto"]] += 1
else:
broken.append(f"{n['id']}/{c['id']}->{c['goto']}")
return {
"nodes": len(ids),
"choices": sum(len(n.get("choices") or []) for n in nodes),
"start": start,
"orphans": sorted(i for i in ids if incoming[i] == 0 and i != start),
"broken": sorted(broken),
"dead_ends": sorted(n["id"] for n in nodes
if not n.get("choices") and not n.get("ending")),
}
def scan_scene_graph(spec: dict) -> dict:
g = spec["scene_graph"]
ids = {n["id"] for n in g["nodes"]}
incoming = {i: 0 for i in ids}
broken = []
for e in g["edges"]:
if e["from"] in ids and e["to"] in ids:
incoming[e["to"]] += 1
else:
broken.append(f"{e['from']}->{e['to']}")
entry = sorted(n["id"] for n in g["nodes"] if n["act"] == 1 and incoming[n["id"]] == 0)
return {
"nodes": len(ids),
"edges": len(g["edges"]),
"entry": entry,
"orphans": sorted(i for i in ids if incoming[i] == 0 and i not in entry),
"broken": sorted(broken),
"terminals": sorted(i for i in ids if not any(e["from"] == i for e in g["edges"])),
}
def main() -> int:
spec = base.load_spec()
d, s = scan_dialogue(spec), scan_scene_graph(spec)
defects = (len(d["orphans"]) + len(d["broken"]) + len(d["dead_ends"])
+ len(s["orphans"]) + len(s["broken"]))
report = {"ok": defects == 0, "defects": defects, "dialogue": d, "scene_graph": s}
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")
print(f"对话图:节点 {d['nodes']},选项 {d['choices']},起点 {d['start']}")
print(f" 孤儿 {len(d['orphans'])}:{d['orphans']}")
print(f" 断链 {len(d['broken'])}:{d['broken']}")
print(f" 死路 {len(d['dead_ends'])}:{d['dead_ends']}")
print(f"场景图:节点 {s['nodes']},边 {s['edges']},入口 {s['entry']}")
print(f" 孤儿 {len(s['orphans'])}:{s['orphans']}")
print(f" 断链 {len(s['broken'])}:{s['broken']}")
print(f" 终点(无出边){len(s['terminals'])}:{s['terminals']}")
print(f"结构缺陷合计 {defects}")
if defects:
return 1
print("OK na_13_1_structure_check:孤儿 0 / 断链 0 / 死路 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_13_1_structure_check.py:
对话图:节点 48,选项 115,起点 d_pier_1
孤儿 0:[]
断链 0:[]
死路 0:[]
场景图:节点 14,边 16,入口 ['pier']
孤儿 0:[]
断链 0:[]
终点(无出边)1:['dawn_quay']
结构缺陷合计 0
OK na_13_1_structure_check:孤儿 0 / 断链 0 / 死路 0
P 层:负例证明检测器会开火
一个只会返回空列表的检测器,跑在真实规格上也「全绿」——它什么都没查。要证明检测器真的在查,必须给它人为注入的负例:每个负例只破坏一类结构,断言检测器对恰好那一类开火、对其余两类保持沉默。深拷贝一份规格再动手,真实规格不被污染。
// file: core/test/na_13_1_structure.test.ts
// tier: P
// verify: node --test 'core/test/na_13_1_structure.test.ts'
// 卷一 13.1:结构检测器 —— 孤儿 / 断链 / 死路。先对真实规格断言三类缺陷全为 0,
// 再人为注入三个负例,断言检测器对每一个都真的开火。
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";
type Choice = { id: string; goto: string };
type DNode = { id: string; choices?: Choice[]; ending?: string };
type Graph = { meta: { start: string }; dialogue: DNode[] };
/** 结构检测器:只看图论形状,不看条件是否可满足。 */
function scan(g: Graph) {
const ids = new Set(g.dialogue.map((n) => n.id));
const incoming = new Map<string, number>();
for (const id of ids) incoming.set(id, 0);
const broken: string[] = [];
for (const n of g.dialogue) {
for (const c of n.choices ?? []) {
if (!ids.has(c.goto)) broken.push(`${n.id}/${c.id}->${c.goto}`);
else incoming.set(c.goto, (incoming.get(c.goto) ?? 0) + 1);
}
}
return {
orphans: [...ids].filter((i) => (incoming.get(i) ?? 0) === 0 && i !== g.meta.start).sort(),
broken: broken.sort(),
deadEnds: g.dialogue
.filter((n) => (n.choices ?? []).length === 0 && !n.ending)
.map((n) => n.id)
.sort(),
};
}
const HERE = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(HERE, "../../config/story_spec.json"), "utf8")) as Graph;
const clone = (): Graph => JSON.parse(JSON.stringify(spec)) as Graph;
test("真实规格:孤儿 / 断链 / 死路 全为 0", () => {
const d = scan(spec);
assert.deepEqual([d.orphans, d.broken, d.deadEnds], [[], [], []]);
console.log(`真实规格:节点 ${spec.dialogue.length},孤儿 ${d.orphans.length} / `
+ `断链 ${d.broken.length} / 死路 ${d.deadEnds.length}`);
});
test("负例一 · 孤儿:注入一个无人指向的节点", () => {
const g = clone();
g.dialogue.push({ id: "d_orphan_probe", choices: [] });
assert.deepEqual(scan(g).orphans, ["d_orphan_probe"]);
});
test("负例二 · 断链:把一个 goto 改到不存在的节点", () => {
const g = clone();
const first = g.dialogue.find((n) => (n.choices ?? []).length > 0) as DNode;
(first.choices as Choice[])[0].goto = "d_nowhere";
assert.equal(scan(g).broken.length, 1);
assert.equal(scan(g).broken[0].endsWith("->d_nowhere"), true);
});
test("负例三 · 死路:清空一个非结局节点的选项", () => {
const g = clone();
const mid = g.dialogue.find((n) => !n.ending && (n.choices ?? []).length > 0) as DNode;
mid.choices = [];
assert.equal(scan(g).deadEnds.includes(mid.id), true);
});
test("起点被排除在孤儿之外", () => {
const g = clone();
g.dialogue.push({ id: "d_orphan_probe", choices: [] });
assert.equal(scan(g).orphans.includes("d_orphan_probe"), true);
const promoted: Graph = { ...g, meta: { start: "d_orphan_probe" } };
assert.equal(scan(promoted).orphans.includes("d_orphan_probe"), false);
});
本机实跑 node --test 'core/test/na_13_1_structure.test.ts':
真实规格:节点 48,孤儿 0 / 断链 0 / 死路 0
✔ 真实规格:孤儿 / 断链 / 死路 全为 0 (2.255833ms)
✔ 负例一 · 孤儿:注入一个无人指向的节点 (0.422083ms)
✔ 负例二 · 断链:把一个 goto 改到不存在的节点 (0.362875ms)
✔ 负例三 · 死路:清空一个非结局节点的选项 (0.302167ms)
✔ 起点被排除在孤儿之外 (0.397708ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1311.869084
判读:三条读数
第一,真实规格三类缺陷全为 0。 48 个对话节点、115 条选项、14 个场景节点、16 条场景边,没有孤儿、没有断链、没有死路。这不是巧合:na_00_spec_check.py 的 BFS 穷举已经独立给出「不可达节点 0 / 不可达选项 0 / 卡死路径 0」,本节的结构检测是它在图论层面的对偶——一个查「从起点能不能走到」,一个查「图上有没有天生就接不上的边」。两者都过,说明这张图在结构上是闭合的。
第二,负例必须与真实规格同构。 三个负例都是在一份 JSON.parse(JSON.stringify(...)) 的深拷贝上做的手术:加一个无人指向的节点、把一条 goto 改到 d_nowhere、清空一个非结局节点的选项。断言的是「检测器对这一个负例开火」,而不是「检测器报错」。这种写法把一个空检测器(永远返回空)和一个全报检测器(永远报错)都排除掉了——前者过不了负例测试,后者过不了真实规格测试。
第三,起点是唯一的豁免。 起点 d_pier_1 没有任何入边——这是它的定义,不是缺陷。检测器把 i !== meta.start 写进判据,最后一个测试专门验证这条豁免:把孤儿提升为起点后,它就不再是孤儿。场景图同理,pier 是第一幕里唯一无人指向的节点,被当作入口而非孤儿。
自检表
| 检查项 | 合格标准 |
|---|---|
| 缺陷定义不含状态 | 三类判据都只引用图的形状,不引用任何条件 |
| 对话图与场景图分查 | 场景图无结局标记,只查孤儿与断链,终点单列 |
| 起点豁免 | meta.start 不计入孤儿 |
| 负例与真实规格隔离 | 负例在深拷贝上注入,不污染真实规格 |
| 检测器双向可证 | 空检测器与全报检测器都会被负例测试证否 |
结构是「接得上」的必要条件,却不是充分条件:一条边两端都存在,不代表走过它时条件一定成立。下一节处理的是条件这一层——一个引用的变量,在到达那个节点之前根本不可能取到所需的值。
阅读导航:上一节:12.3 文本表与单页预览 · 下一节:13.2 语义校验:可满足性与死条件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。