16.2 三种消费的总联调
独立游戏的叙事设计与分支对话
用散文讲分支与好感度,代码不可跑;本节把同一份规格喂给三种可跑的消费,看它们会不会互相打架。三卷把同一份 config/story_spec.json 读了三种方式:穷举(卷一/卷二:把可达状态全枚举出来)、执行(卷二:沿一条选择序列推进,给出终局状态)、度量(本卷:采样一大批路径,量出覆盖率与分布)。三种读法各自都对,不等于放在一起还对。总联调就是让它们跑同一份规格,并断言结论不矛盾。
两条互不矛盾的包含关系
联调不靠「看起来差不多」,靠两条可以判真假的包含关系:
- A1(集合包含):BFS 穷举出的可达状态集 ⊇ 采样路径集。采样路径途经的每一个状态都必须落在穷举集合里——只要漏掉一个,就说明「穷举」漏了可达分支,或「采样」走进了不该到的状态,两者必有一错。
- A2(跨宿主逐位相等):同一条选择序列在 Python / TS / 浏览器三处推进出的状态摘要逐位相同(这里是
4162796975)。摘要只对整数状态做(节点 + 好感度 + 旗标 + 道具),不对文本做。
穷举 × 执行 × 度量(S 层)
一个脚本里把三种消费跑一遍:完整 BFS(预算 12000000)、确定性策略 first 走一条路径、采样 N=20000 条。产物 out/nm_16_2_fullchain.json 供 P / W 层读取。
#!/usr/bin/env python3
# file: sim/nm_16_2_fullchain.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_16_2_fullchain.py
"""卷三 16.2 三种消费的总联调(S 层):穷举 × 执行 × 度量跑同一份规格。
同一份 config/story_spec.json 被三种消费读:穷举(S)完整 BFS 枚举可达状态集(预算
12000000);执行(S)确定性策略 first 走一条路径给出整数摘要(锚点);度量(S)采样
N=20000 条给出覆盖率、结局分布与熵。关键断言是两条**互相不矛盾**的包含关系:
A1 BFS 穷举的可达集 ⊇ 采样路径集;
A2 同一条选择序列在 Python 侧推进出的整数摘要,与卷二记录的跨语言锚点逐位相同。
重逻辑在 sim/nm_16_common.py;全部整数,熵例外只进输出不进状态。产物供 P / W 层读取。
"""
from __future__ import annotations
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nm_16_common as cm # noqa: E402
ROOT = cm.ROOT
N, SEED, BUDGET = 20000, 20261016, 12000000
ANCHOR = 4162796975
def main() -> int:
t0 = time.time()
spec = cm.load_spec()
by_id = {n["id"]: n for n in spec["dialogue"]}
m = spec["metrics"]
dedup = cm.dedup_edges(spec)
_, _, _, pack = cm.make_packer(spec)
# 穷举(S)
seen, edges, un, exhausted = cm.bfs(spec, pack, BUDGET)
# 执行(S):确定性策略 first
choices, path, s, dg = cm.anchor(spec)
canon = cm.canonical(s, path)
# 度量(S):采样 N 条,含状态包含性检查
mm = cm.sample(spec, N, SEED, keys=True)
samp_keys, missing = mm["states"], mm["states"] - seen
containment, anchor_ok = not missing, dg == ANCHOR
print(f"穷举(S) 可达状态 {len(seen)}(预算 {BUDGET},耗尽 {exhausted})")
print(f" 可达边 {len(edges)}/{len(dedup)} 不可达节点 {len(un)}")
print(f"执行(S) first 策略 {len(choices)} 步 → {s['node']}({by_id[s['node']].get('ending', '')}),摘要 {dg}")
print(f" 规范串 {canon[:72]}…")
print(f"度量(S) 采样 {N} 条:覆盖率 {mm['coverage_permille']}‰({mm['edges']}/{len(dedup)})≥ {m['coverage_target_permille']}‰")
print(f" 结局分布 {mm['dist']}")
print(f" 结局熵 {mm['entropy_milli']} 毫比特 ≥ {m['ending_entropy_min_milli_bits']};整数不变量残差 {mm['invariant_residual']}")
print(f"联调 A1 BFS 可达集 ⊇ 采样路径集:采样状态 {len(samp_keys)} 个,缺失 {len(missing)}")
print(f"联调 A2 Python 锚点摘要 {dg} == 跨语言锚点 {ANCHOR}:{anchor_ok}")
assert exhausted, "BFS 未耗尽(疑似超预算)"
assert len(seen) <= BUDGET, "状态数超预算"
assert containment, f"采样路径集不被 BFS 可达集包含,缺失 {len(missing)}"
assert anchor_ok, "Python 锚点摘要与跨语言锚点不一致"
assert mm["invariant_residual"] == 0, "整数不变量残差非 0"
out = {"seed": SEED, "sample_n": N, "choices": choices, "node": s["node"],
"ending": by_id[s["node"]].get("ending", ""), "digest": dg, "canonical": canon,
"states": len(seen), "state_budget": BUDGET, "bfs_exhausted": exhausted,
"edges_covered": len(edges), "dedup_edges": len(dedup), "unreachable_nodes": un,
"sampled_states": len(samp_keys), "sampled_states_missing": len(missing),
"coverage_permille": mm["coverage_permille"], "ending_distribution": mm["dist"],
"ending_entropy_milli_bits": mm["entropy_milli"],
"invariant_residual": mm["invariant_residual"],
"anchor": ANCHOR, "elapsed_s": round(time.time() - t0, 1)}
with open(os.path.join(ROOT, "out/nm_16_2_fullchain.json"), "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"wrote out/nm_16_2_fullchain.json({out['elapsed_s']} 秒)")
print("OK nm_16_2_fullchain:穷举 × 执行 × 度量跑通,包含关系与锚点一致")
return 0
if __name__ == "__main__":
sys.exit(main())
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_2_fullchain.py
穷举(S) 可达状态 9895423(预算 12000000,耗尽 True)
可达边 56/56 不可达节点 0
执行(S) first 策略 25 步 → e_lit(灯守),摘要 4162796975
规范串 e_lit|hui=60,mu=65,shen=50,yan=65|cave_explored,found_yan_journal,knows_…
度量(S) 采样 20000 条:覆盖率 1000‰(56/56)≥ 850‰
结局分布 {'离港': 10074, '沉没': 5507, '缄默': 3223, '灯守': 1018, '真相': 178}
结局熵 1714 毫比特 ≥ 1500;整数不变量残差 0
联调 A1 BFS 可达集 ⊇ 采样路径集:采样状态 168478 个,缺失 0
联调 A2 Python 锚点摘要 4162796975 == 跨语言锚点 4162796975:True
wrote out/nm_16_2_fullchain.json(32.1 秒)
OK nm_16_2_fullchain:穷举 × 执行 × 度量跑通,包含关系与锚点一致
一次完整 BFS 枚举出 9895423 个可达状态(预算 12000000,耗尽 True 说明真的搜完了,不是被预算截断);采样 20000 条路径触达 168478 个不同状态,缺失 0——A1 成立。
同一序列在 TS 侧复算(P 层)
P 层读同一份产物,用 core/src/inkwell/nm_16_2_fullchain.ts(import 只读内核 engine.ts)重放那条锚点序列,并独立采样一遍度量:
// file: core/test/nm_16_2_fullchain.test.ts
// tier: P
// verify: node --test 'core/test/nm_16_2_fullchain.test.ts'
// 卷三 16.2:P 层读 S 层的联调产物,在 TS 内核上复算锚点摘要与采样度量,逐项与 Python 侧比对。
// 先跑 /opt/homebrew/bin/python3 sim/nm_16_2_fullchain.py 生成 out/nm_16_2_fullchain.json。
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 { replayAnchor, sampleMetrics } from "../src/inkwell/nm_16_2_fullchain.ts";
import type { Spec } 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;
const ART = join(ROOT, "out/nm_16_2_fullchain.json");
type Report = {
choices: string[]; node: string; ending: string; digest: number; anchor: number;
states: number; coverage_permille: number; edges_covered: number; dedup_edges: number;
ending_distribution: Record<string, number>; ending_entropy_milli_bits: number;
invariant_residual: number; seed: number; sample_n: number;
};
function report(): Report {
assert.ok(existsSync(ART), "先跑 /opt/homebrew/bin/python3 sim/nm_16_2_fullchain.py");
return JSON.parse(readFileSync(ART, "utf8")) as Report;
}
test("P 层重放锚点路径,摘要与 Python 及跨语言锚点逐位相同", () => {
const py = report();
const a = replayAnchor(spec, py.choices);
assert.equal(a.node, py.node, "终局节点不一致");
assert.equal(a.ending, py.ending, "结局不一致");
assert.equal(a.digest, py.digest, "摘要与 Python 不一致");
assert.equal(a.digest, py.anchor, "摘要与跨语言锚点不一致");
console.log(`P 层执行:${py.choices.length} 步 → ${a.node}(${a.ending}),摘要 ${a.digest}`);
});
test("P 层采样度量与 Python 侧一致:覆盖率 / 分布 / 熵", () => {
const py = report();
const ts = sampleMetrics(spec, py.sample_n, py.seed);
assert.equal(ts.edges, py.edges_covered, "覆盖边数不一致");
assert.equal(ts.dedupTotal, py.dedup_edges, "去重边总数不一致");
assert.equal(Math.floor((ts.edges * 1000) / ts.dedupTotal), py.coverage_permille, "覆盖率不一致");
assert.equal(ts.entropyMilliBits, py.ending_entropy_milli_bits, "结局熵不一致");
const tsDist = JSON.stringify(Object.keys(ts.dist).sort().map((k) => `${k}=${ts.dist[k]}`));
const pyDist = JSON.stringify(Object.keys(py.ending_distribution).sort().map((k) => `${k}=${py.ending_distribution[k]}`));
assert.equal(tsDist, pyDist, "结局分布不一致");
console.log(`P 层度量:覆盖率 ${py.coverage_permille}‰(${ts.edges}/${ts.dedupTotal}),熵 ${ts.entropyMilliBits} 毫比特`);
});
test("整数不变量残差为 0(好感度全程落在 [0,100])", () => {
const py = report();
const ts = sampleMetrics(spec, py.sample_n, py.seed);
assert.equal(ts.invariantResidual, 0, "好感度越界");
assert.equal(py.invariant_residual, 0, "Python 侧好感度越界");
console.log(`P 层不变量:采样 ${py.sample_n} 条,好感度越界 ${ts.invariantResidual}`);
});
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && node --test core/test/nm_16_2_fullchain.test.ts
P 层执行:25 步 → e_lit(灯守),摘要 4162796975
P 层度量:覆盖率 1000‰(56/56),熵 1714 毫比特
P 层不变量:采样 20000 条,好感度越界 0
✔ P 层重放锚点路径,摘要与 Python 及跨语言锚点逐位相同 (10.523958ms)
✔ P 层采样度量与 Python 侧一致:覆盖率 / 分布 / 熵 (376.004916ms)
✔ 整数不变量残差为 0(好感度全程落在 [0,100]) (372.574542ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
浏览器里真玩一遍(W 层)
W 层把规格与内核内联进单文件 HTML(web/build_nm16.mjs → web/dist/nm16.html,41918 字节),用 Playwright 无头注入同一条锚点序列点击,逐条断言三处摘要一致:
// file: web/verify_nm16.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_nm16.mjs
// 卷三 16.2:Playwright 无头打开 nm16.html,注入 out/nm_16_2_fullchain.json 的锚点选择序列真玩,
// 断言浏览器 / TS 内核 / Python 三处终局节点、结局、状态摘要逐位相同;跑两遍证明确定性;读回
// canvas 前景像素证明「已绘制」;截图并落成 out/nm_16_2_w_fullchain.json。NODE_PATH 对 ESM
// import 无效 → 必须 createRequire;规格侧复用同一份 engine.ts。
import { createRequire } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { replayAnchor } from "../core/src/inkwell/nm_16_2_fullchain.ts";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const OUT = join(ROOT, "out");
const SHOTS = join(OUT, "shots");
mkdirSync(SHOTS, { recursive: true });
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const chain = JSON.parse(readFileSync(join(OUT, "nm_16_2_fullchain.json"), "utf8"));
const url = pathToFileURL(join(HERE, "dist/nm16.html")).href;
let fail = 0;
const check = (name, got, want) => {
const ok = got === want;
if (!ok) fail += 1;
console.log(`${ok ? "OK " : "FAIL"} ${name}: ${got}${ok ? "" : " != " + want}`);
};
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 820, height: 1000 } });
// 冷启动预热页:首个 page 读 canvas 像素恒为空白,先画一次 8x8 再丢弃
const warm = await ctx.newPage();
await warm.setContent('<canvas id="w" width="8" height="8"></canvas>');
await warm.evaluate(() => {
const g = document.getElementById("w").getContext("2d");
g.fillStyle = "#38bdf8";
g.fillRect(0, 0, 8, 8);
});
await warm.close();
const page = await ctx.newPage();
async function play() {
await page.goto(url);
await page.evaluate(() => window.__nm16.reset());
for (const c of chain.choices) await page.click(`[data-choice-id="${c}"]`);
return page.evaluate(() => ({ node: window.__nm16.node, ending: window.__nm16.ending,
digest: window.__nm16.digest, path: window.__nm16.path }));
}
console.log(`== 16.2 三处消费总联调(注入锚点 ${chain.choices.length} 步)`);
const ts = replayAnchor(spec, chain.choices);
const got = await play();
check("TS 内核摘要 == Python 产物", ts.digest, chain.digest);
check("浏览器摘要 == Python 产物", got.digest, chain.digest);
check("浏览器摘要 == 跨语言锚点", got.digest, chain.anchor);
check("浏览器终局节点 == Python 产物", got.node, chain.node);
check("浏览器结局 == Python 产物", got.ending, chain.ending);
check("浏览器节点序列长度 == Python 产物", got.path.length, chain.choices.length + 1);
const got2 = await play();
check("两遍渲染摘要一致", got2.digest, got.digest);
const drawn = await page.evaluate(() => window.__nm16.drawn());
console.log(`${drawn >= 1 ? "OK " : "FAIL"} 已绘制像素 ${drawn} px(断言 >= 1)`);
if (drawn < 1) fail += 1;
const shot = await page.screenshot({ path: join(SHOTS, "nm16_fullchain.png"), fullPage: true });
console.log(`OK 截图 out/shots/nm16_fullchain.png ${shot.length} bytes`);
if (shot.length <= 0) { fail += 1; console.log("FAIL 截图为空"); }
const report = { source: "web/verify_nm16.mjs", choices: chain.choices.length,
python_digest: chain.digest, ts_digest: ts.digest, browser_digest: got.digest,
anchor: chain.anchor, node: got.node, ending: got.ending, path_len: got.path.length,
two_pass_deterministic: got2.digest === got.digest, drawn_px: drawn, screenshot_bytes: shot.length,
hosts_agree: ts.digest === chain.digest && got.digest === chain.digest };
writeFileSync(join(OUT, "nm_16_2_w_fullchain.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
console.log("wrote out/nm_16_2_w_fullchain.json");
await browser.close();
console.log(`== verify_nm16: 三处摘要 ${got.digest},失败 ${fail}`);
process.exit(fail ? 1 : 0);
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && node web/build_nm16.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_nm16.mjs
wrote web/dist/nm16.html 41918 bytes
== 16.2 三处消费总联调(注入锚点 25 步)
OK TS 内核摘要 == Python 产物: 4162796975
OK 浏览器摘要 == Python 产物: 4162796975
OK 浏览器摘要 == 跨语言锚点: 4162796975
OK 浏览器终局节点 == Python 产物: e_lit
OK 浏览器结局 == Python 产物: 灯守
OK 浏览器节点序列长度 == Python 产物: 26
OK 两遍渲染摘要一致: 4162796975
OK 已绘制像素 4402 px(断言 >= 1)
OK 截图 out/shots/nm16_fullchain.png 44455 bytes
wrote out/nm_16_2_w_fullchain.json
== verify_nm16: 三处摘要 4162796975,失败 0
三处一致说明了什么
三种消费跑同一份规格,没有互相矛盾:
| 断言 | 消费 | 实测 | 结论 |
|---|---|---|---|
| A1 可达集 ⊇ 采样集 | 穷举 vs 度量 | 9895423 态 / 采样 168478 态 / 缺失 0 | 包含成立 |
| A2 Python == TS | 执行 vs 执行 | 4162796975 == 4162796975 | 逐位相等 |
| A2 Python == 浏览器 | 执行 vs 执行 | 4162796975 == 4162796975 | 逐位相等 |
| 两遍渲染 | 浏览器 vs 浏览器 | 4162796975 == 4162796975 | 确定性 |
跨宿主逐位相等靠的是同一条纪律:整数状态、自实现 Lehmer MINSTD(rng = rng * 48271 % 2147483647)、digest 只对状态做。正文里说的「一致」永远指整数状态逐位一致,不是「文本逐字相等」。
覆盖率 1000‰、结局熵 1714 毫比特、包含关系 0 缺失——全是结构量代理指标(proxy),只做相对比较,它们不声称这份叙事更好玩;真人玩家测试需真实业务环境,命令行不可复现,标 X。联调只证明一件事:三卷的三种消费同源、确定、互不矛盾。
阅读导航:上一节:16.1 三卷的装配检查表 · 下一节:16.3 交付与后续 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。