《游戏设计》卷一《玩法机制与可执行规格》 证明的是同一份玩法规格在仿真 / 内核 / 原型 / 基准四种消费下数值一致——它的真源是数值与规则,消费对象是一局战斗的状态轨迹。本章证明的是另一件事:同一份叙事规格在 Python / TypeScript / 浏览器三个宿主下推进出的状态摘要逐位相同,并明确划出「什么可以不同、什么必须相同」的容差边界。本节先立摘要本身。
13.1 跨语言摘要与 digest
一个状态里有节点、好感度、flag、物品、路径。要把它们压成一个可比对的短值,本套用 FNV-1a 32 位摘要,输入是状态的 canonical 规范串——节点、好感度、置位 flag、持有物品、路径五段用竖线拼成。
关键纪律:摘要只对整数状态做,不对台词文本做。 文本涉及编码、换行与 Unicode 归一化,跨语言把它当判据是不可靠的;而节点名、整数好感度、布尔 flag、路径是一串确定的 ASCII,可以稳定地哈希。所以本节从头到尾只对状态做摘要,正文里也只对整数状态说「逐位一致」。
摘要的输入:canonical 串的四段
| 段 | 内容 | 例 |
|---|---|---|
| 1 | 终局节点 | e_dark |
| 2 | 4 个好感度(键名排序,k=v) | hui=27,mu=50,shen=90,yan=35 |
| 3 | 置位 flag(排序) | dawn_choice_made,knows_bell_legend,… |
| 4 | 持有物品(排序) | journal,ledger_page,… |
| 5 | 路径(> 连接) | d_pier_1>…>e_dark |
两侧的实现必须共享同一种 32 位溢出语义:Python 用 & 0xFFFFFFFF 截断,TypeScript 用 Math.imul。若用普通浮点乘法,乘积约 7.2×10¹⁶ 会超过 2⁵³、尾数被静默截断,两侧立刻分道扬镳——13.2 会把这一点实测出来。
S 层:Python 侧算 500 条摘要并与卷一比对
脚本采样 500 条路径(seed=20261016),逐条算 canonical 与摘要,落 out/ne_13_1_py.json;再读卷一 sim/na_02_digest.py 落下的 out/digest_py.json 逐条比对——同算法、同种子、同一份规格,跨卷必须逐位相同。
#!/usr/bin/env python3
# file: sim/ne_13_1_cross_digest.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_13_1_cross_digest.py
"""卷二 13.1 跨语言摘要与 digest(S 层,Python 侧)。
摘要 = FNV-1a 32 位,只对 canonical 规范串(状态)做,不对台词文本做。
采样 N=500 条路径(seed=20261016),逐条算 canonical 与 digest,落 out/ne_13_1_py.json。
并与卷一 sim/na_02_digest.py 落下的 out/digest_py.json 逐条比对——同算法、同种子、
同一份规格,跨卷必须逐位相同,这就是「卷二未另立真源」的证据。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ne_11_common import OUT, apply_effects, canonical, cond_ok, fnv1a, init_state, load_spec # noqa: E402
PY_JSON = os.path.join(OUT, "ne_13_1_py.json")
SUM_JSON = os.path.join(OUT, "ne_13_1_summary.json")
V1_JSON = os.path.join(OUT, "digest_py.json")
SEED, N = 20261016, 500
def node(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def sample_paths(spec: dict, n: int, seed: int) -> list[list[str]]:
"""Lehmer MINSTD:state = state * 48271 % 2147483647。"""
rng, out = seed, []
for _ in range(n):
s, trail = init_state(spec), []
for _step in range(200):
cur = node(spec, s["node"])
if cur.get("ending"):
break
opts = [c for c in (cur.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_effects(c.get("effects") or {}, s, c["goto"])
out.append(trail)
return out
def rows_of(spec: dict, paths: list[list[str]]) -> list[dict]:
rows = []
for i, p in enumerate(paths):
s = init_state(spec)
for cid in p:
c = next(x for x in (node(spec, s["node"]).get("choices") or []) if x["id"] == cid)
s = apply_effects(c.get("effects") or {}, s, c["goto"])
rows.append({"i": i, "choices": p, "canonical": canonical(s),
"digest": fnv1a(canonical(s).encode("utf-8")),
"node": s["node"], "ending": node(spec, s["node"]).get("ending", "")})
return rows
def main() -> int:
spec = load_spec()
rows = rows_of(spec, sample_paths(spec, N, SEED))
with open(V1_JSON, encoding="utf-8") as fh:
v1 = json.load(fh)
same_v1 = sum(1 for a, b in zip(rows, v1)
if a["digest"] == b["digest"] and a["canonical"] == b["canonical"])
uniq = len({r["digest"] for r in rows})
print(f"spec={spec['meta']['title']} digest=FNV-1a 32 位 只对 canonical 状态串做,不对文本做")
print(f"采样 n={N} seed={SEED};本侧(ne_13_1)算出 {len(rows)} 条 canonical 与 digest")
print(f"与卷一 na_02_digest.py 的 out/digest_py.json 逐位相同:{same_v1} / {N} 条")
print(f"摘要去重后 {uniq} 个不同值 / {len(rows)} 条路径")
print("前 3 条摘要(本侧 = 卷一):")
for a, b in zip(rows[:3], v1[:3]):
print(f" #{a['i']} digest={a['digest']:>10} (卷一 {b['digest']:>10}) node={a['node']:<6} ending={a['ending']}")
assert same_v1 == N, "与卷一摘要不一致,卷二另立了真源"
os.makedirs(OUT, exist_ok=True)
with open(PY_JSON, "w", encoding="utf-8") as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
fh.write("\n")
with open(SUM_JSON, "w", encoding="utf-8") as fh:
json.dump({"n": N, "seed": SEED, "same_as_volume1": same_v1, "unique_digest": uniq,
"first3_digest": [r["digest"] for r in rows[:3]]}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote out/ne_13_1_py.json rows={len(rows)}")
print("OK ne_13_1_cross_digest:500/500 与卷一逐位相同,摘要非弱指纹")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_13_1_cross_digest.py:
spec=雾港夜航 digest=FNV-1a 32 位 只对 canonical 状态串做,不对文本做
采样 n=500 seed=20261016;本侧(ne_13_1)算出 500 条 canonical 与 digest
与卷一 na_02_digest.py 的 out/digest_py.json 逐位相同:500 / 500 条
摘要去重后 499 个不同值 / 500 条路径
前 3 条摘要(本侧 = 卷一):
#0 digest=3976226298 (卷一 3976226298) node=e_dark ending=离港
#1 digest=3848604931 (卷一 3848604931) node=e_dark ending=离港
#2 digest=1198029437 (卷一 1198029437) node=e_dark ending=离港
wrote out/ne_13_1_py.json rows=500
OK ne_13_1_cross_digest:500/500 与卷一逐位相同,摘要非弱指纹
P 层:TS 侧重放并逐位比对
门禁读 out/ne_13_1_py.json,用 TS 内核 replay() 重放同 500 条选择序列,逐条比对 canonical 串与摘要;再读卷一产物核对前 3 条。这就是「同一批 N 条路径逐位相同的条数」两侧对齐的地方。
// file: core/test/ne_13_1_cross_digest.test.ts
// tier: P
// verify: node --test 'core/test/ne_13_1_cross_digest.test.ts'
// 卷二 13.1:TS 侧重放 500 条路径,与 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 { canonical, digest, replay } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { asciiOnly, bitwiseIdentical, firstThree } from "../src/inkwell/ne_13_1_cross_digest.ts";
import type { Row } from "../src/inkwell/ne_13_1_cross_digest.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 rows = JSON.parse(readFileSync(join(ROOT, "out/ne_13_1_py.json"), "utf8")) as Row[];
const v1 = JSON.parse(readFileSync(join(ROOT, "out/digest_py.json"), "utf8")) as Row[];
test("TS 侧重放 500 条:与 Python 侧 canonical 与 digest 逐位相同 500 / 500", () => {
assert.equal(rows.length, 500);
assert.equal(bitwiseIdentical(spec, rows), 500);
console.log(`跨语言逐位相同 ${bitwiseIdentical(spec, rows)} / ${rows.length} 条`);
});
test("前 3 条摘要两侧一致,且与卷一 digest_py.json 逐位相同", () => {
const want = [3976226298, 3848604931, 1198029437];
assert.deepEqual(firstThree(rows), want);
assert.deepEqual(firstThree(v1), want);
for (let i = 0; i < 3; i += 1) {
const s = replay(spec, rows[i].choices);
assert.equal(canonical(s), rows[i].canonical);
assert.equal(digest(s), v1[i].digest);
}
console.log(`前 3 条摘要 ${JSON.stringify(want)}:卷二 = 卷一`);
});
test("摘要输入是纯 ASCII 状态串,不含任何台词文本", () => {
assert.equal(asciiOnly(rows), true);
assert.match(rows[0].canonical, /^[\x20-\x7e]*$/);
console.log(`500 条 canonical 全为 ASCII:${asciiOnly(rows)}`);
});
本机实跑 node --test 'core/test/ne_13_1_cross_digest.test.ts':
跨语言逐位相同 500 / 500 条
前 3 条摘要 [3976226298,3848604931,1198029437]:卷二 = 卷一
500 条 canonical 全为 ASCII:true
✔ TS 侧重放 500 条:与 Python 侧 canonical 与 digest 逐位相同 500 / 500 (27.644959ms)
✔ 前 3 条摘要两侧一致,且与卷一 digest_py.json 逐位相同 (0.58775ms)
✔ 摘要输入是纯 ASCII 状态串,不含任何台词文本 (0.541709ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
判读:500/500 与 499 个不同值
第一,两侧 500 条全部逐位相同。 Python 侧算出 500 条 canonical 与摘要,TS 侧用另一份独立实现重放同一批选择序列,bitwiseIdentical 返回 500——500 条路径一条不差。
第二,与卷一逐位相同。 同一份规格、同一个种子(20261016)、同一算法,卷一 out/digest_py.json 与卷二 out/ne_13_1_py.json 的前 3 条摘要都是 3976226298 / 3848604931 / 1198029437,500 条全部相等。这证明卷二没有另立真源——它消费的是同一份规格。
第三,摘要有分辨力。 500 条路径去重后得到 499 个不同值——只有两条路径终局状态相同。若摘要退化成一个常数,逐位一致就毫无意义。
| 观察 | 数字 | 读法 |
|---|---|---|
| 两侧逐位相同 | 500 / 500 | 两条独立实现状态摘要一致 |
| 与卷一逐位相同 | 500 / 500 | 卷二未另立真源 |
| 摘要去重值 | 499 / 500 | 摘要有分辨力,非弱指纹 |
| canonical 非 ASCII 字符 | 0 | 摘要不含任何台词文本 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 摘要只对状态 | 输入是 canonical 串,不含任何台词文本 |
| 键序确定 | 两侧都按键名排序,k=v 逗号连接 |
| 溢出语义一致 | Python & 0xFFFFFFFF 对应 TS Math.imul |
| 逐位比对 | 500 条路径的 canonical 与 digest 全等 |
| 跨卷自洽 | 与卷一 digest_py.json 同种子采样逐位相同 |
摘要立住了,接下来的问题是:同一个摘要值,能不能在三种不同的宿主上分别算出来。下一节把 Python / Node / 浏览器三条链摆到同一条确定性策略路径上。
阅读导航:上一节:12.3 文本的一致性校验 · 下一节:13.2 同源不同宿主 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。