16.2 一份规格的全链路回放
站内 《游戏引擎实现原理》
从零造了一个引擎,量的是渲染与帧;这一节把镜头拉回规则,量的是同一条 digest 能不能穿过四种宿主。16.1 检查表里最硬的一项是卷三的「同一份规格换品类还成不成立」——这一节把它展开成全链路回放:从 config/play_spec.json 出发,在 S 层 Python、P 层 TypeScript、W 层浏览器与第 13 章的六个品类变体上各取一次 digest,看它们是不是同一条字符串。
链上四个节点给出同一条 digest
四个节点是同一份规格在四处宿主上的投影:S 层现算、P 层构建期算、W 层浏览器无头算、变体 deckbuilder 由第 13 章闸门冻结。回放脚本把四条 digest 摆在一张表上逐字比对:
| 链上节点 | 宿主 | digest 来源 | 结果 |
|---|---|---|---|
| S Python | Python 仿真内核 | sim/gd_proto_16_replay.py 现算 | 逐字相同 |
| P TypeScript | TS 规格驱动内核 | out/gd_proto_16_build.json 的 baseDigest | 逐字相同 |
| W 浏览器 | 单文件 HTML 里的内核 | out/proto16_digests.json 的 baseDigest | 逐字相同 |
| 变体 deckbuilder | 第 13 章闸门冻结 | out/gd_proto_13_gate.json 的 determinism.digest | 逐字相同 |
同一条 digest 是 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}——12 个键按字母序、值全是整数或布尔,跨三种语言逐位一致。这不是巧合:整数运算 + 同算法同种子的 Lehmer PRNG + 同分隔符,是跨语言逐位一致的唯一保证。
W 层:六个内核内联进一个页面
W 节点是这个回放器本体:六个内核(本体 + 五个 core/genre/*.ts)与共享底座擦类型后内联进一个 HTML,点一个品类就跑对应内核。六个变体的结果摘要(dealt/turn/won)是卡牌构筑 50/4/true、塔防 772/6/true、三消 166/24/false、平台跳跃 30000/120/true、自走棋 149/12/true、放置 324619/800/false。擦类型复用 web/pack.mjs 的 bundle();四个品类内核各带一个逐字相同的顶层 idiv,模块作用域是严格模式,重复声明即 SyntaxError,所以顶层同名函数必须折叠——沿用第 13 章的 dedupeFunctions。
// file: web/build_proto16.mjs
// tier: W
// verify: node web/build_proto16.mjs
//
// 卷三 16.2 全链路回放(W 层):六个内核(本体 combat.ts + 五个 core/genre/*.ts)与共享底座按手工拓扑
// 序擦类型后,连同六份规格、第 13 章闸门 digest、第 14 章距离摘要内联进一个 HTML(out/proto16.html)。
// 暴露 __ashdeck:run(genre?)(默认 deckbuilder,与 S/P 层逐字相同)/ perturbed()(本体 energyMax 改 2
// 后的 digest,B 层 W 侧证据)/ genres() / gateDigests() / snapshot() / canvasDrawn。构建期在 Node 侧把
// 六变体与扰动各跑一遍落 out/gd_proto_16_build.json。擦类型复用 pack.mjs 的 bundle();顶层同名函数折叠
// 沿用第 13 章的 dedupeFunctions(四个品类内核各带一个逐字相同的 idiv,严格模式下重复声明即 SyntaxError)。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";
import { tdRun, tdDigest } from "../core/genre/towerdefense.ts";
import { m3Run, m3Digest } from "../core/genre/match3.ts";
import { pfRun, pfDigest } from "../core/genre/platformer.ts";
import { abRun, abDigest } from "../core/genre/autobattler.ts";
import { idleRun, idleDigest } from "../core/genre/idle.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
const MODULES = [ // 手工拓扑序:先声明后使用
"core/src/ashdeck/types.ts", "core/src/ashdeck/rng.ts", "core/src/ashdeck/cards.ts",
"core/src/ashdeck/combat.ts", "core/src/ashdeck/digest.ts", "core/genre/towerdefense.ts",
"core/genre/match3.ts", "core/genre/platformer.ts", "core/genre/autobattler.ts", "core/genre/idle.ts",
];
/** 只把逐字相同的顶层函数折叠成一份;同名但内容不同则抛错,绝不静默取后者。 */
function dedupeFunctions(src) {
const lines = src.split("\n");
const out = [];
const seen = new Map();
let i = 0;
while (i < lines.length) {
const m = /^function ([A-Za-z_$][\w$]*)\(/.exec(lines[i]);
if (m === null) { out.push(lines[i]); i += 1; continue; }
let depth = 0, opened = false, j = i;
const block = [];
for (; j < lines.length; j += 1) {
block.push(lines[j]);
for (const ch of lines[j]) {
if (ch === "{") { depth += 1; opened = true; } else if (ch === "}") depth -= 1;
}
if (opened && depth === 0) break;
}
const text = block.join("\n");
const prev = seen.get(m[1]);
if (prev === undefined) { seen.set(m[1], text); out.push(text); }
else if (prev !== text) throw new Error("同名函数内容不同,拒绝折叠: " + m[1]);
i = j + 1;
}
return out.join("\n");
}
const registry = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_13_variants.json"), "utf8"));
const gate = JSON.parse(readFileSync(join(ROOT, "out/gd_proto_13_gate.json"), "utf8"));
const matrix = JSON.parse(readFileSync(join(ROOT, "out/gd_proto_14_matrix.json"), "utf8"));
const specs = {}, gateDigests = {};
for (const v of registry.variants) specs[v.genre] = JSON.parse(readFileSync(join(ROOT, v.spec), "utf8"));
for (const v of gate.variants) gateDigests[v.genre] = v.determinism.digest;
const core = dedupeFunctions(bundle(ROOT, MODULES));
const script = core + `
const SPECS = ` + inlineConfig(specs) + `;
const GENRES = ` + inlineConfig(registry.variants.map((v) => ({ genre: v.genre, label: v.label }))) + `;
const GATE_DIGESTS = ` + inlineConfig(gateDigests) + `;
const GATE = ` + inlineConfig({ passed: gate.passed, total: gate.variants.length, allPass: gate.allPass }) + `;
const RUN = { deckbuilder: runSpec, "tower-defense": tdRun, match3: m3Run, platformer: pfRun, autobattler: abRun, idle: idleRun };
const DIG = { deckbuilder: digestSnap, "tower-defense": tdDigest, match3: m3Digest, platformer: pfDigest, autobattler: abDigest, idle: idleDigest };
const SNAPS = {};
for (const g of GENRES) SNAPS[g.genre] = RUN[g.genre](JSON.parse(JSON.stringify(SPECS[g.genre])));
const cv = document.getElementById("cv"), ctx = cv.getContext("2d");
// 六行一图:每行一个品类,横条是单局步数(played),当前选中那行高亮。
function draw(genre) {
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.fillStyle = "#e2e8f0"; ctx.font = "13px system-ui, sans-serif";
ctx.fillText(genre + " · 步 " + SNAPS[genre].played + (SNAPS[genre].won ? " 胜" : " 未胜"), 16, 28);
document.getElementById("out").textContent = DIG[genre](SNAPS[genre]);
}
globalThis.__ashdeck = {
run(genre) { const k = typeof genre === "string" && RUN[genre] ? genre : "deckbuilder"; draw(k); return DIG[k](SNAPS[k]); },
perturbed() { const p = JSON.parse(JSON.stringify(SPECS.deckbuilder)); p.params.energyMax = 2; draw("deckbuilder"); return digestSnap(runSpec(p)); },
genres() { return GENRES.map((g) => g.genre); },
gateDigests() { return GATE_DIGESTS; },
snapshot() { return SNAPS; },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真。
requestAnimationFrame(() => requestAnimationFrame(() => { globalThis.__ashdeck.canvasDrawn = true; }));
`;
const body = `<h1>《灰烬牌库》· 一份规格的全链路回放(第 16 章)</h1>
<div class="sub">规格 → S 层 Python → P 层 TS → W 层浏览器 → 品类实例化(6 变体)→ 闸门(6/6)→ 距离矩阵</div>
<canvas id="cv" width="720" height="380"></canvas>
<div class="row">${registry.variants.map((v) => `<button data-g="${v.genre}">${v.label}</button>`).join("\n")}</div>
<div class="card"><div class="k">当前变体 digest(默认 deckbuilder,应与 S / P 层逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">第 13 章闸门:六变体</div><div class="big" id="gate">—</div></div>
<script>document.querySelectorAll("button[data-g]").forEach(function (b) {
b.addEventListener("click", function () { globalThis.__ashdeck.run(b.getAttribute("data-g")); });
});</script>`;
const html = page({ title: "《灰烬牌库》· 第 16.2 节一份规格的全链路回放",
subtitle: "六个内核 · 一份 schema · 一条链上的同一个 digest", body, script });
const bytes = writePage(join(ROOT, "out", "proto16.html"), html);
const RAW = { deckbuilder: runSpec, "tower-defense": tdRun, match3: m3Run, platformer: pfRun, autobattler: abRun, idle: idleRun };
const HASH = { deckbuilder: digestSnap, "tower-defense": tdDigest, match3: m3Digest, platformer: pfDigest, autobattler: abDigest, idle: idleDigest };
const nodeDigests = {};
for (const v of registry.variants) nodeDigests[v.genre] = HASH[v.genre](RAW[v.genre](specs[v.genre]));
const pertSpec = JSON.parse(JSON.stringify(specs.deckbuilder));
pertSpec.params.energyMax = 2;
const perturbDigest = digestSnap(runSpec(pertSpec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "gd_proto_16_build.json"),
JSON.stringify({ spec: "gd_proto_16_replay", modules: MODULES.length, kernelChars: core.length,
page: "out/proto16.html", pageBytes: bytes, genres: registry.variants.map((v) => v.genre),
baseDigest: nodeDigests.deckbuilder, perturbDigest, genreDigests: nodeDigests,
gateDigest: gateDigests.deckbuilder, gateAllPass: gate.allPass, gatePassed: gate.passed,
minNonZeroL1: matrix.checks.minNonZero }, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built out/proto16.html " + bytes + " B base=" + nodeDigests.deckbuilder.slice(0, 40) + "…");
单文件零外部引用,无头断言给到 canvas 273600 px 已绘制、截图 58793 B。
S 层:四个节点摆在同一张表上
回放脚本读三份产物(P 层构建结果、W 层浏览器结果、第 13 章闸门)并现算 S 层那一条,然后断言四条 digest 的集合只有一个元素:
#!/usr/bin/env python3
# file: sim/gd_proto_16_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_16_replay.py
"""16.2 一份规格的全链路回放(S 层):在同一条链上取 digest,证明逐字一致。
链:config/play_spec.json → S Python → P TS 内核 → W 浏览器原型 → 品类实例化(6 变体)→ 闸门(6/6)
→ 距离矩阵。四个基线节点必须给出同一条 digest:S(本脚本现算)、P(构建期 Node 算)、W(浏览器无头
算)、变体 deckbuilder(第 13 章冻结)。再核闸门 6/6 与距离矩阵最小非零 L1=1743。全整数、无浮点、无 random。
"""
from __future__ import annotations
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")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def load(name):
with open(os.path.join(OUT, name), encoding="utf-8") as f:
return json.load(f)
def main() -> int:
spec = ashlib.load_spec()
build = load("gd_proto_16_build.json") # P 侧
wdig = load("proto16_digests.json") # W 侧
gate = load("gd_proto_13_gate.json") # 第 13 章闸门
matrix = load("gd_proto_14_matrix.json") # 第 14 章距离矩阵
deck = next(v for v in gate["variants"] if v["genre"] == "deckbuilder")
chain = [
{"node": "S Python", "source": "sim/gd_proto_16_replay.py", "digest": ashlib.digest(ashlib.run_spec(spec))},
{"node": "P TypeScript", "source": "out/gd_proto_16_build.json", "digest": build["baseDigest"]},
{"node": "W 浏览器", "source": "out/proto16_digests.json", "digest": wdig["baseDigest"]},
{"node": "变体 deckbuilder(第13章)", "source": "out/gd_proto_13_gate.json", "digest": deck["determinism"]["digest"]},
]
same = len({c["digest"] for c in chain}) == 1
gate_ok = gate["allPass"] is True and gate["passed"] == len(gate["variants"])
min_l1 = matrix["checks"]["minNonZero"]
matrix_ok = matrix["checks"]["symmetric"] is True and min_l1 == 1743
bad = [k for k, v in {"链上一致": same, "闸门 6/6": gate_ok, "最小非零 L1=1743": matrix_ok}.items() if not v]
print("=== 16.2 一份规格的全链路回放:链上 digest 对照 ===")
for c in chain:
print(f" {c['node']:26s} {c['digest']}")
print(f"链上一致(4 节点逐字相同)={same}")
print(f"第 13 章闸门 allPass={gate['allPass']} passed={gate['passed']}/{len(gate['variants'])} failed={gate['failed']}")
print(f"第 14 章距离矩阵 symmetric={matrix['checks']['symmetric']} 最小非零L1={min_l1} 最远对={matrix['farthest'][0]['pair']}")
report = {"id": "gd_proto_16_replay", "spec": spec["id"], "strategyLength": len(spec["strategy"]),
"chain": chain, "chainDigest": chain[0]["digest"], "chainAllSame": same,
"gate": {"allPass": gate["allPass"], "passed": gate["passed"], "total": len(gate["variants"]),
"failed": gate["failed"], "criteria": gate["criteria"]},
"matrix": {"minNonZeroL1": min_l1, "symmetric": matrix["checks"]["symmetric"],
"closest": matrix["closest"][0], "farthest": matrix["farthest"][0]},
"variantDigests": {v["genre"]: v["determinism"]["digest"] for v in gate["variants"]},
"checks": {"chainAllSame": same, "gate6of6": gate_ok, "matrixMinL1": matrix_ok}, "fail": len(bad)}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_16_replay.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"fail={len(bad)} {'(全链路一致)' if not bad else bad}")
print("wrote out/gd_proto_16_replay.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
实跑输出:链上一致(4 节点逐字相同)=True、第 13 章闸门 allPass=True passed=6/6 failed=[]、第 14 章距离矩阵 symmetric=True 最小非零L1=1743 最远对=['三消', '放置']、fail=0 (全链路一致)。
B 层:改一个规则参数,链上是否同步改变
只一致不变,还不能说明链是活的——也可能四处各写各的、恰好都算了个常数。B 层只改一个规则参数(params.energyMax 3→2),要求链上三处宿主同步改变且仍逐字一致。本层按纪律是软告警不硬失败:任何一处没变只打印告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_proto_16_replay_b.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_proto_16_replay_b.py
"""16.2 一份规格的全链路回放(B 层):改动一个规则参数,链上各节点是否**同步改变**。
只一致不变,说明规格是死的,或四处各写各的、只是恰好都算了个常数。B 层只改**一个规则参数**
(params.energyMax 3→2),要求链上三处宿主(S Python / P TypeScript / W 浏览器)**同步改变且仍逐字
一致**:每处扰动后 digest ≠ 它的基线(参数被真的消费);三处扰动后仍两两逐字相同。本层按纪律是
**软告警不硬失败**:任何一处没变只打印告警,进程始终 exit 0。落 out/gd_proto_16_replay_b.json。
"""
from __future__ import annotations
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")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
OVERRIDE = {"energyMax": 2}
def load(name):
with open(os.path.join(OUT, name), encoding="utf-8") as f:
return json.load(f)
def main() -> int:
spec = ashlib.load_spec()
build = load("gd_proto_16_build.json") # P 侧
wdig = load("proto16_digests.json") # W 侧
pert = json.loads(json.dumps(spec))
pert["params"].update(OVERRIDE)
hosts = [
{"host": "S Python", "base": ashlib.digest(ashlib.run_spec(spec)), "perturbed": ashlib.digest(ashlib.run_spec(pert))},
{"host": "P TypeScript", "base": build["baseDigest"], "perturbed": build["perturbDigest"]},
{"host": "W 浏览器", "base": wdig["baseDigest"], "perturbed": wdig["browserPerturb"]},
]
warnings = []
for h in hosts:
h["changed"] = h["perturbed"] != h["base"]
if not h["changed"]:
warnings.append(h["host"] + " 扰动后 digest 未改变——该参数未被消费")
base_same = len({h["base"] for h in hosts}) == 1
pert_same = len({h["perturbed"] for h in hosts}) == 1
if not base_same:
warnings.append("基线三处宿主 digest 不一致")
if not pert_same:
warnings.append("扰动后三处宿主 digest 不一致——链已漂移")
print("=== 16.2 链上各节点对 params.energyMax 3→2 的响应(B 层,软告警)===")
for h in hosts:
print(f" {h['host']:14s} base={h['base']}")
print(f" {'':14s} pert={h['perturbed']} changed={h['changed']}")
print(f"基线一致={base_same} 扰动一致={pert_same} 全部改变={all(h['changed'] for h in hosts)}")
print(f"warnings={len(warnings)}")
for w in warnings:
print(f" [WARN] {w}")
print("exit=0(软告警不硬失败)")
report = {"id": "gd_proto_16_replay_b", "override": OVERRIDE, "hosts": hosts,
"baseSame": base_same, "perturbedSame": pert_same, "allChanged": all(h["changed"] for h in hosts),
"warnings": warnings, "note": "软告警不硬失败:某处未变只说明该参数未被这条链路消费,不判定为错。"}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_16_replay_b.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("wrote out/gd_proto_16_replay_b.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
扰动后 digest 变成 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}——energy 1→0、playerHp 48→46、turn 4→5 三处同步变。实跑输出 基线一致=True 扰动一致=True 全部改变=True、warnings=0、exit=0(软告警不硬失败):参数被真的消费,链是活的。这一节把「换品类还成不成立」证到了字节级。
阅读导航:上一节:16.1 三卷装配检查表 · 下一节:16.3 交付与后续 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。