15.2 从规格到校验的流水线
《游戏设计》卷一《玩法机制与可执行规格》 的流水线终点是守恒残差归零:机制仿真跑完,断言整数不变量没被破坏。本卷的流水线形状相同——真源进、产物出、最后一环核对——但终点换成了可达与覆盖:跑完之后要断言的是分支覆盖率满分、不可达节点为零、卡死路径为零。两套共享「命令可复现、数字来自实跑」的纪律,判据不通用。
五环流水线
一条改动要走到「可以提交」,必须依次穿过五环。每一环都有明确的输入、动作与唯一的失败信号:
| 环 | 输入 | 动作 | 产物 | 失败信号 |
|---|---|---|---|---|
| 1 结构 + 语义校验 | config/story_spec.json | 孤儿 / 断链 / 死条件 / 命名空间 / 可达性 BFS | out/spec_check.json | exit 非 0 |
| 2 规格解析与 SVG | 同一份规格 | 十段字段解析 + 纯 xml.etree 出图 | out/tension_curve.svg 等 | 回读断言失败 |
| 3 跨语言摘要(Python 侧) | 同一份规格 | 采样 500 条路径,逐条算状态摘要 | out/digest_py.json | 好感度越界 |
| 4 内核门禁(TS 侧) | 规格 + 第 3 环产物 | 用内核重放同 500 条路径,逐位比对 | 无(断言即产物) | node --test 有 fail |
| 5 产物核对 | 第 1、3 环产物 | 复核覆盖率 / 不可达 / 卡死 / 行数 | out/pipeline_arch.json | 任一判据不成立 |
顺序不能颠倒。第 4 环依赖第 3 环的产物,第 5 环依赖第 1 与第 3 环的产物;把它们合成一次执行,才能保证「报告里的数字」与「产物里的数字」出自同一趟运行。
逐条命令
五环各自一条命令,末条是串起来一次跑完:
# snippet: 五环流水线逐条命令(在 $HOME/.inkwellbook/inkwell 下执行)
/opt/homebrew/bin/python3 sim/na_00_spec_check.py # 1 结构 + 语义校验
/opt/homebrew/bin/python3 sim/na_01_spec_parse.py # 2 规格解析与 SVG
/opt/homebrew/bin/python3 sim/na_02_digest.py # 3 跨语言摘要(Python 侧)
node --test 'core/test/na_02_digest.test.ts' # 4 内核门禁(TS 侧)
/opt/homebrew/bin/python3 sim/na_15_2_pipeline.py # 1–5 一次跑完并核对产物
第 4 环必须写带引号的 glob。node --test <目录> 不会发现 .ts 文件,写目录等于零个测试通过——这是本套踩过两次的坑。第 3、4 环的顺序也不能换:内核门禁读的是第 3 环落下的 out/digest_py.json,文件不存在时它会直接报「先跑 Python 侧」。
S 层:把五环串成一次执行
下面这个驱动把四环当子进程跑一遍、记录退出码与耗时,再在进程内做第 5 环的产物核对,把整趟结果落成 out/pipeline_arch.json。它是流水线唯一的「一次执行」入口——五条命令手工跑一遍和跑它一遍,得到的产物应当相同。
#!/usr/bin/env python3
# file: sim/na_15_2_pipeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_15_2_pipeline.py
"""卷一 15.2(S 层):把五环流水线真跑一遍,落 out/pipeline_arch.json 供 P 层门禁断言。
1 结构校验 + 语义校验 sim/na_00_spec_check.py
2 规格解析与 SVG sim/na_01_spec_parse.py
3 跨语言摘要(Python 侧)sim/na_02_digest.py
4 内核门禁(TS 侧) node --test core/test/na_02_digest.test.ts
5 产物核对 读 out/spec_check.json 与 out/digest_py.json
任一环非零退出即整体非零退出。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = "/opt/homebrew/bin/python3"
STAGES = [
("结构校验 + 语义校验", [PY, "sim/na_00_spec_check.py"]),
("规格解析与 SVG", [PY, "sim/na_01_spec_parse.py"]),
("跨语言摘要(Python 侧)", [PY, "sim/na_02_digest.py"]),
("内核门禁(TS 侧)", ["node", "--test", "core/test/na_02_digest.test.ts"]),
]
def load(rel: str) -> dict | list:
with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
return json.load(fh)
def main() -> int:
stages: list[dict] = []
failed = False
total = len(STAGES) + 1 # 末环是进程内产物核对
for i, (name, cmd) in enumerate(STAGES, 1):
t0 = time.time()
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
dt = time.time() - t0
lines = [ln for ln in (p.stdout or "").splitlines() if ln.strip()]
stages.append({"n": i, "name": name, "cmd": " ".join(cmd), "exit": p.returncode,
"seconds": round(dt, 1), "last": lines[-1] if lines else ""})
print(f"[{i}/{total}] {name:<22} exit={p.returncode} {dt:5.1f}s {(lines[-1] if lines else '')[:64]}")
failed = failed or p.returncode != 0
sc, dp = load("out/spec_check.json"), load("out/digest_py.json")
checks = {
"coverage_permille": sc["coverage_permille"],
"visited_edges": sc["visited_edges"],
"all_edges": sc["all_edges"],
"unreachable_nodes": len(sc["unreachable_nodes"]),
"unreachable_choices": len(sc["unreachable_choices"]),
"dead_ends": sc["dead_ends"],
"sample_n": sc["sample_n"],
"digest_rows": len(dp),
"digest_first3": [r["digest"] for r in dp[:3]],
}
print(f"[{total}/{total}] 产物核对")
print(f" 分支覆盖率 {checks['coverage_permille']}‰({checks['visited_edges']}/{checks['all_edges']} 条对话边)")
print(f" 不可达节点 {checks['unreachable_nodes']}、不可达选项 {checks['unreachable_choices']}、卡死 {checks['dead_ends']}")
print(f" digest_py 行数 {checks['digest_rows']},前 3 条摘要 {checks['digest_first3']}")
ok = (not failed and checks["coverage_permille"] == 1000 and checks["unreachable_nodes"] == 0
and checks["unreachable_choices"] == 0 and checks["dead_ends"] == 0)
out = os.path.join(ROOT, "out/pipeline_arch.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"ok": ok, "stages": stages, "checks": checks}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
if not ok:
print("FAIL 流水线未全绿")
return 1
print("OK na_15_2_pipeline:5 个环节全部 exit 0,产物核对通过")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_15_2_pipeline.py:
[1/5] 结构校验 + 语义校验 exit=0 94.6s OK na_00_spec_check:规格自洽
[2/5] 规格解析与 SVG exit=0 0.3s OK na_01_spec_parse:九段字段全部解析,2 张 SVG 回读通过,整数不变量通过
[3/5] 跨语言摘要(Python 侧) exit=0 0.4s OK na_02_digest.py:500 条路径,不变量通过,写出 out/digest_py.json
[4/5] 内核门禁(TS 侧) exit=0 0.7s ℹ duration_ms 482.055834
[5/5] 产物核对
分支覆盖率 1000‰(115/115 条对话边)
不可达节点 0、不可达选项 0、卡死 0
digest_py 行数 500,前 3 条摘要 [3976226298, 3848604931, 1198029437]
OK na_15_2_pipeline:5 个环节全部 exit 0,产物核对通过
时间账值得看一眼:第 1 环 94.6 秒,其余三环合计 1.4 秒。整条流水线的成本几乎全在 BFS 可达性枚举上——这是「可穷举但非平凡」的代价,也是为什么本案例的规模是硬约束(放大一档,第 1 环从分钟级变成小时级)。这个秒数会随机器负载波动,但量级关系稳定。
P 层:把最后一环写成门禁
第 5 环的核对目前是驱动脚本里的几行断言。它有一个薄弱处:只要有人手工改过 out/spec_check.json,或者忘了重跑流水线,这份报告就会变成过期快照。下面的 node --test 门禁把「报告是不是过期」变成可判定的:它不信任报告,而是重新从原始产物派生一遍数字再比对。
// file: core/test/na_15_2_pipeline_gate.test.ts
// tier: P
// verify: node --test 'core/test/na_15_2_pipeline_gate.test.ts'
// 流水线最后一环的门禁:断言 out/pipeline_arch.json 不是一份过期报告——
// 把 manifest 里声称的数字,重新从原始产物派生一遍再比对。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { replay, digest, samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const readJson = (rel: string) => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));
test("流水线报告存在,且四个环节全部 exit 0", () => {
assert.ok(existsSync(join(ROOT, "out/pipeline_arch.json")), "先跑 /opt/homebrew/bin/python3 sim/na_15_2_pipeline.py");
const m = readJson("out/pipeline_arch.json") as { ok: boolean; stages: { n: number; exit: number }[] };
assert.equal(m.stages.length, 4);
for (const s of m.stages) assert.equal(s.exit, 0, `第 ${s.n} 环退出码非 0`);
assert.equal(m.ok, true);
});
test("报告不是过期快照:与原始产物逐项一致", () => {
const m = readJson("out/pipeline_arch.json") as { checks: Record<string, number> };
const sc = readJson("out/spec_check.json") as Record<string, unknown> & { unreachable_nodes: string[]; unreachable_choices: string[] };
assert.equal(m.checks.coverage_permille, sc.coverage_permille);
assert.equal(m.checks.visited_edges, sc.visited_edges);
assert.equal(m.checks.all_edges, sc.all_edges);
assert.equal(m.checks.unreachable_nodes, sc.unreachable_nodes.length);
assert.equal(m.checks.unreachable_choices, sc.unreachable_choices.length);
assert.equal(m.checks.dead_ends, sc.dead_ends);
assert.equal(m.checks.sample_n, sc.sample_n);
});
test("门禁判据:覆盖率满分、零不可达、零卡死", () => {
const sc = readJson("out/spec_check.json") as Record<string, unknown>;
assert.equal(sc.coverage_permille, 1000);
assert.equal(sc.visited_edges, sc.all_edges);
assert.deepEqual(sc.unreachable_nodes, []);
assert.deepEqual(sc.unreachable_choices, []);
assert.equal(sc.dead_ends, 0);
});
test("最后一环:内核重放前 3 条路径,摘要与 Python 侧产物一致", () => {
const m = readJson("out/pipeline_arch.json") as { checks: { digest_rows: number; digest_first3: number[] } };
const dp = readJson("out/digest_py.json") as { choices: string[]; digest: number }[];
assert.equal(m.checks.digest_rows, dp.length);
assert.deepEqual(m.checks.digest_first3, dp.slice(0, 3).map((r) => r.digest));
const spec = readJson("config/story_spec.json") as Spec;
for (let i = 0; i < 3; i += 1) assert.equal(digest(replay(spec, dp[i].choices)), dp[i].digest, `第 ${i} 条摘要不一致`);
// 采样器也要与 Python 侧同序:前 3 条选择序列一致。
const ts = samplePaths(spec, dp.length, 20261016);
for (let i = 0; i < 3; i += 1) assert.deepEqual(ts[i], dp[i].choices, `第 ${i} 条选择序列不一致`);
});
本机实跑 node --test 'core/test/na_15_2_pipeline_gate.test.ts':
✔ 流水线报告存在,且四个环节全部 exit 0 (2.477416ms)
✔ 报告不是过期快照:与原始产物逐项一致 (1.171417ms)
✔ 门禁判据:覆盖率满分、零不可达、零卡死 (0.889083ms)
✔ 最后一环:内核重放前 3 条路径,摘要与 Python 侧产物一致 (24.5255ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 401.844042
判读:三条读数
第一,最后一环是唯一能抓「忘了重跑」的地方。 前四环各自只保证自己那一步对;只有第 5 环同时读两份产物,才能发现「规格改了、报告没更新」。第 4 条测试更进一步:它不信报告,直接用内核重放前 3 条路径,拿摘要和 Python 侧产物比。摘要一致说明两条独立实现的推进结果相同——这是整数不变量之外,本套唯一有意义的跨语言判据。
第二,覆盖率满分不代表分支设计得好。 流水线跑出 115/115 条对话边全部可达、不可达节点为零。这个读数只说明这张图没有孤儿,不说明分支有趣、平衡或值得玩。覆盖率是结构量,不是质量量;把它当质量指标用,就是把「没有断头路」当成「剧情写得好」。
第三,耗时集中在第 1 环,因此流水线要按环取舍。 改一句台词只需要跑第 1 环;改一个 add 的增量必须跑到第 4 环(状态变了,摘要就变了);改一个 when 条件则必须五环全跑。判断该跑几环,靠的是 15.1 那张影响面表——影响面越宽,要跑的环越多。
自检表
| 检查项 | 合格标准 |
|---|---|
| 命令可复制 | 每条命令都能原样贴进终端跑通 |
| glob 带引号 | node --test 一律写全路径或带引号的 glob |
| 顺序有依赖 | 内核门禁读 Python 侧产物,先 3 后 4 |
| 最后一环不信任报告 | 从原始产物重新派生数字再比对 |
| 秒数不硬编码 | 时间账说明量级关系,不把秒数当验收阈值 |
流水线保证「规格改了、产物跟着变」。但规格的改动从哪来、谁批、什么时候允许合入,是流程问题,不是脚本问题。下一节处理这部分,并把本机跑不了的那一半明确标出来。
阅读导航:上一节:15.1 规格、仿真、内核、原型的目录分工 · 下一节:15.3 协作与版本管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。