站内那篇独立游戏的叙事设计与分支对话
(约 1076 行)谈的是玩家选择体验:怎么让一次选择被记住、怎么让后果可信,配的是散文与清单,代码不可跑。本章把「记住」这件事变成一个可断言的性质——一次通关不是被记在日志里,而是被编码成一串 choice_id,只要把这串 id 再喂给引擎一遍,就能重建出逐位相同的状态。这是本卷所有度量的前提:如果同一条路径两次走出的状态不一样,那么覆盖率、结局分布、张力曲线全部失去意义。
上一节定死了编码,这一节定死复现。
回放是什么
回放(replay)是把一串选择序列从头喂给引擎,让它重新推进出终局状态。它成立的前提只有一个:引擎的转移是纯函数——同样的输入必然得到同样的输出,没有随机、没有时钟、没有 I/O。
只要这个前提成立,回放就有三个直接后果:
- 可复现:同一个玩家两次做出同样的选择,到达同一个状态。
- 可跨语言:Python 侧算出的摘要,TS 侧用同一串序列能算回同一个整数。
- 可调试:线上出了一条异常路径,把它编码成
choice_id序列,本地就能重建现场。
本节要证的,就是这三条里最硬的那条:回放无隐藏状态。
三条证据
「回放无隐藏状态」不是一句口号,它拆成三个可测的断言:
| 断言 | 测法 | 为什么必要 |
|---|---|---|
| 同序列两次回放相同 | 同一串 id 推进两次,比终局摘要 | 排除「推进本身带随机」 |
| 前缀从零重放一致 | 每个前缀独立重放,比全程记录的该步摘要 | 排除「中途状态被外部记忆污染」 |
| 采样序列批量一致 | 500 条序列各回放两次,逐条比 | 排除「只在个别路径上成立」 |
第一条排除随机,第二条排除隐藏状态(比如某个全局变量在推进中被悄悄改写),第三条排除样本偏差。三条都过,才敢说「状态完全由选择序列决定」。
S 层:Python 侧的回放复现
sim/nm_01_3_replay.py 走确定性路径,回放两次比终局摘要;再对每一个前缀从零重放,与全程记录的逐步摘要比对;最后对 500 条采样序列各回放两次。它还会读卷二产物 out/ne_04_3_py.json(同种子同采样的 500 条摘要)做跨卷对照。推进与摘要复用卷一 sim/na_02_digest.py(只读 import)。
#!/usr/bin/env python3
# file: sim/nm_01_3_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_01_3_replay.py
"""卷三 1.3 路径的回放与复现(S 层,Python 侧):证明「同一选择序列两次推进,
走出的状态逐位一致」。
四条证据:
1) 确定性序列回放两次,终局 digest 相同(且与卷二 ne_01_1 的 4162796975 一致)
2) 前缀重放:每个前缀从零重放出的 digest == 全程记录的该步 digest(无隐藏状态)
3) 500 条采样序列各自回放两次,逐条逐位一致
4) 与卷二产物 out/ne_04_3_py.json 对照(存在时):同种子同摘要
落 out/nm_01_3_replay.json。推进与摘要复用卷一 sim/na_02_digest.py(只读 import)。
摘要只对状态做,不对文本做;全部整数。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from na_02_digest import cond_ok, init_state, node_by_id, apply_choice, digest, sample_paths # noqa: E402
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "nm_01_3_replay.json")
AFF_LO, AFF_HI = 0, 100
SEED, N = 20261016, 500
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def replay(spec: dict, choices: list[str]) -> dict:
s = init_state(spec)
for cid in choices:
s = apply_choice(spec, s, cid)
return s
def walk(spec: dict) -> list[str]:
s, trail = init_state(spec), []
for _ in range(64):
cur = node_by_id(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
trail.append(opts[0]["id"])
s = apply_choice(spec, s, opts[0]["id"])
return trail
def main() -> int:
spec = load_spec()
det = walk(spec)
# 1) 确定性序列两次回放
d1, d2 = digest(replay(spec, det)), digest(replay(spec, det))
# 2) 前缀重放:全程记录每步 digest,再对每个前缀从零重放
s, full = init_state(spec), [digest(init_state(spec))]
for cid in det:
s = apply_choice(spec, s, cid)
full.append(digest(s))
prefix_ok = all(digest(replay(spec, det[:k])) == full[k] for k in range(len(det) + 1))
# 3) 500 条采样序列各自回放两次
seqs = sample_paths(spec, N, SEED)
dg = [digest(replay(spec, p)) for p in seqs]
dg2 = [digest(replay(spec, p)) for p in seqs]
same_n = sum(1 for a, b in zip(dg, dg2) if a == b)
# 4) 与卷二产物对照(存在时)
peer = os.path.join(OUT, "ne_04_3_py.json")
peer_match, peer_note = 0, "缺 out/ne_04_3_py.json,跳过"
if os.path.isfile(peer):
with open(peer, encoding="utf-8") as fh:
old = json.load(fh)
peer_match = sum(1 for a, b in zip(dg, [r["digest"] for r in old]) if a == b)
peer_note = f"卷二 ne_04_3_py.json {len(old)} 条"
finals = [replay(spec, p) for p in seqs]
bad = sum(1 for st in finals for v in st["aff"].values() if not (AFF_LO <= v <= AFF_HI))
print(f"spec={spec['meta']['title']} 策略=首选项 seed={SEED}")
print(f"1) 确定性序列({len(det)} 步)回放两次:digest {d1} == {d2} -> {d1 == d2}")
print(f" 终局节点 {replay(spec, det)['node']} 与卷二 ne_01_1 的 4162796975 一致:{d1 == 4162796975}")
print(f"2) 前缀重放:{len(det) + 1} 个前缀从零重放全部等于全程记录:{prefix_ok}")
print(f" 步 0..{len(det)} 的 digest 前 4 / 末 1:{full[:4]} .. {full[-1:]}")
print(f"3) 采样 {N} 条序列各自回放两次,逐位一致 {same_n} / {N}")
print(f"4) 与卷二对照:{peer_match} / {N} 条摘要相同({peer_note})")
print(f"整数不变量:好感度越界 {bad}({N} 条终局状态全部落在 [{AFF_LO},{AFF_HI}])")
if not (d1 == d2 == 4162796975) or not prefix_ok or same_n != N or bad:
print("FAIL 回放未复现")
return 1
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({
"seed": SEED, "n": N,
"deterministic_choices": det, "deterministic_digest": d1,
"prefix_digests": full, "prefix_ok": prefix_ok,
"sampled_choices": seqs, "sampled_digests": dg, "sampled_same": same_n,
"peer_match": peer_match, "peer_note": peer_note,
}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"wrote out/nm_01_3_replay.json deterministic_digest={d1}")
print(f"OK nm_01_3_replay:同序列两次回放逐位一致,终局摘要 {d1}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_01_3_replay.py:
spec=雾港夜航 策略=首选项 seed=20261016
1) 确定性序列(25 步)回放两次:digest 4162796975 == 4162796975 -> True
终局节点 e_lit 与卷二 ne_01_1 的 4162796975 一致:True
2) 前缀重放:26 个前缀从零重放全部等于全程记录:True
步 0..25 的 digest 前 4 / 末 1:[4127714464, 3085224634, 3641975660, 2314516345] .. [4162796975]
3) 采样 500 条序列各自回放两次,逐位一致 500 / 500
4) 与卷二对照:500 / 500 条摘要相同(卷二 ne_04_3_py.json 500 条)
整数不变量:好感度越界 0(500 条终局状态全部落在 [0,100])
wrote out/nm_01_3_replay.json deterministic_digest=4162796975
OK nm_01_3_replay:同序列两次回放逐位一致,终局摘要 4162796975
P 层:在 TS 内核上回放同一串序列
同一批选择序列喂给 TypeScript 内核 core/src/inkwell/engine.ts(只 import,不改动它),终局摘要必须与 Python 侧逐条相同。门禁测试 core/test/nm_01_3_replay.test.ts 读 Python 落盘的产物逐条断言:
// file: core/test/nm_01_3_replay.test.ts
// tier: P
// verify: node --test 'core/test/nm_01_3_replay.test.ts'
// 卷三 1.3(P 层门禁):同一选择序列在 TS 内核上回放,逐位复现 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 { replay, digest } 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 spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
const data = JSON.parse(readFileSync(join(ROOT, "out/nm_01_3_replay.json"), "utf8")) as {
seed: number;
n: number;
deterministic_choices: string[];
deterministic_digest: number;
prefix_digests: number[];
prefix_ok: boolean;
sampled_choices: string[][];
sampled_digests: number[];
sampled_same: number;
peer_match: number;
};
test("确定性序列回放两次,终局摘要逐位一致且与 Python 侧相同", () => {
const a = digest(replay(spec, data.deterministic_choices));
const b = digest(replay(spec, data.deterministic_choices));
assert.equal(a, b, "两次回放摘要应一致");
assert.equal(a, data.deterministic_digest, "与 Python 侧 deterministic_digest 应一致");
assert.equal(a, 4162796975, "应与卷二 ne_01_1 的终局摘要一致");
});
test("每个前缀从零重放出的摘要等于全程记录的该步摘要", () => {
const det = data.deterministic_choices;
assert.equal(data.prefix_digests.length, det.length + 1);
for (let k = 0; k <= det.length; k += 1) {
assert.equal(digest(replay(spec, det.slice(0, k))), data.prefix_digests[k], `前缀 ${k} 摘要不一致`);
}
assert.equal(data.prefix_ok, true);
});
test("500 条采样序列在 TS 内核上回放,摘要与 Python 侧逐条一致", () => {
assert.equal(data.n, 500);
assert.equal(data.sampled_choices.length, 500);
assert.equal(data.sampled_digests.length, 500);
for (let i = 0; i < data.sampled_choices.length; i += 1) {
assert.equal(digest(replay(spec, data.sampled_choices[i])), data.sampled_digests[i], `#${i} 摘要不一致`);
}
});
本机实跑 node --test 'core/test/nm_01_3_replay.test.ts':
✔ 确定性序列回放两次,终局摘要逐位一致且与 Python 侧相同 (1.044458ms)
✔ 每个前缀从零重放出的摘要等于全程记录的该步摘要 (0.906583ms)
✔ 500 条采样序列在 TS 内核上回放,摘要与 Python 侧逐条一致 (15.360083ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.835625
判读:逐位一致到底保证了什么
终局摘要 4162796975 三处相同。 Python 回放两次得到它,TS 内核回放得到它,卷二 1.1 的逐步骤轨迹末行也是它。同一个策略、同一份规格、同一个 FNV-1a 算法,终局状态就被钉死在一个 32 位整数上——只要它变,就说明规格或推进语义变了。这是本卷全部回归判据的最小样本。
26 个前缀全部从零重放一致,说明回放没有隐藏状态。 如果引擎在推进里偷偷依赖了某个「上一次走到哪」的全局变量,那么「一次性走完」记录的第 k 步摘要,就会和「只走前 k 步」重放出的摘要对不上。两者逐位相同,说明状态完全由「初始状态 + 前 k 个选择」决定,不依赖任何外部记忆。
500 条采样序列 500 / 500 一致,排除了样本偏差。 而且这 500 条与卷二 out/ne_04_3_py.json 的 500 条摘要逐条相同——这不是本卷新算的,是同一套算法在跨卷复现,说明「同种子同序列」的口径从卷二一直延续到卷三。
这里的每一个数都是 proxy 口径下的结构量——摘要、前缀一致性、回放条数,只用于判等与相对比较,不声称任何体验结论。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 同序列两次回放 | 终局摘要相同 | 4162796975 == 4162796975 |
| 无隐藏状态 | 每个前缀从零重放 == 全程记录 | 26 / 26 |
| 批量一致 | 采样序列逐条回放一致 | 500 / 500 |
| 跨语言复现 | TS 内核回放同序列摘要相同 | 3 条断言全绿 |
| 跨卷复现 | 与卷二同种子摘要相同 | 500 / 500 |
| 整数不变量 | 好感度 ∈ [0,100] | 越界 0 |
路径到这里就完整了:能被编码,也能被复现。第 2 章开始,我们不再只看一条路径,而是问:要采样多少条路径,才能把这张故事图覆盖到设计想要的程度——以及覆盖不到的地方,缺口在哪里。
阅读导航:上一节:1.2 路径的表示与编码 · 下一节:2.1 采样策略:随机、贪心、穷举 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。