9.3 可播种随机与确定性回放
《独立游戏平衡与经济设计》 里的复现靠的是「记下参数、重跑一遍、看手感对不对」——那是人工复现。本节要的是逐字复现:同一个种子、同一串命令,在 Python 与 TypeScript 两处跑出的终态,连字符串都一模一样。这不是工程洁癖,它是 9.1 与 9.2 所有方差数字能被断言的前提。
可播种随机 = 把「运气」变成「输入」
普通随机数生成器(Math.random()、Python 的 random)内部藏着不可控的种子,跑两次结果不同。可播种 PRNG 把种子变成显式输入:
state₀ = seed
stateₙ₊₁ = stateₙ × 48271 % 2147483647 (Lehmer MINSTD)
于是整局游戏成了一段纯函数:digest = f(spec.seed, spec.strategy)。给定同样的 seed 与命令序列,输出必然相同——随机性没有消失,它被折叠进了输入。本套的 config/play_spec.json 里 seed = 20261014、rng.algo = lehmer-minstd、mod = 2147483647、mul = 48271,Python 与 TS 两侧用同一组常数、同一套整数运算,这是跨语言逐位一致的唯一保证。
S 层:把 digest 落盘成跨语言锚点
Python 侧先跑,把终态、回放前缀、换种子的三个 digest 写进 out/mech09_digest.json:
#!/usr/bin/env python3
# file: sim/gd_mech_09_seeded_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_09_seeded_replay.py
"""9.3 可播种随机与确定性回放(S 层)。
随机一旦可播种,整局游戏就成了一段**可重放的确定性函数**:digest(spec) 只依赖
(spec.seed, spec.strategy)。本脚本把这件事落成三个实证:
① 同 seed 跑两遍 → digest 逐字相同(自反确定性)
② 同一 seed 的回放前缀(前 12 条命令)→ 一个稳定的中间 digest
③ 换 seed(20261015)→ 终态 digest 改变(种子真的进了洗牌)
产物 out/mech09_digest.json 是**跨语言的锚点**:core/test/gd_mech_09_seeded_replay.test.ts
读它并与 TS 侧 digestSnap(runSpec(spec)) 逐字比对——Python 与 TypeScript 两处消费
同一份规格,必须给出同一个字符串。
"""
from __future__ import annotations
import json
import os
import sys
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PREFIX = 12 # 回放前缀长度
def main() -> int:
spec = ashlib.load_spec()
snap_a = ashlib.run_spec(spec)
snap_b = ashlib.run_spec(spec)
digest_a = ashlib.digest(snap_a)
digest_b = ashlib.digest(snap_b)
prefix_seq = spec["strategy"][:PREFIX]
snap_p = ashlib.run_spec(spec, prefix_seq)
digest_p = ashlib.digest(snap_p)
other = dict(spec)
other["seed"] = 20261015
digest_other = ashlib.digest(ashlib.run_spec(other))
payload = {
"seed": spec["seed"],
"algo": spec["rng"]["algo"],
"mod": spec["rng"]["mod"],
"mul": spec["rng"]["mul"],
"prefix": PREFIX,
"digest": digest_a,
"digestAgain": digest_b,
"prefixDigest": digest_p,
"otherSeed": 20261015,
"otherDigest": digest_other,
"sameAcrossRuns": digest_a == digest_b,
"seedMatters": digest_a != digest_other,
}
out = os.path.join(ROOT, "out", "mech09_digest.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"algo={spec['rng']['algo']} mod={spec['rng']['mod']} mul={spec['rng']['mul']} seed={spec['seed']}")
print(f"digest = {digest_a}")
print(f"digestAgain = {digest_b}")
print(f"prefix({PREFIX:>2}) = {digest_p}")
print(f"otherSeed = {digest_other}")
print(f"sameAcrossRuns={digest_a == digest_b} seedMatters={digest_a != digest_other}")
print(f"wrote out/mech09_digest.json bytes={os.path.getsize(out)}")
return 0 if digest_a == digest_b and digest_a != digest_other else 1
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_09_seeded_replay.py:
algo=lehmer-minstd mod=2147483647 mul=48271 seed=20261014
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}
digestAgain = {"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}
prefix(12) = {"block":0,"cardCount":12,"damageConserved":true,"dealt":41,"enemyHp":9,"energy":1,"hand":["heavy","strike","jab"],"over":false,"played":8,"playerHp":54,"turn":3,"won":false}
otherSeed = {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["guard","strike","heavy"],"over":true,"played":8,"playerHp":53,"turn":3,"won":true}
sameAcrossRuns=True seedMatters=True
wrote out/mech09_digest.json bytes=1069
P 层:TS 侧读同一份 JSON,断言逐字一致
TypeScript 侧的测试读 out/mech09_digest.json,把里面的 digest 与 digestSnap(runSpec(spec)) 逐字比对。两个语言、两套实现、同一份规格:
// file: core/test/gd_mech_09_seeded_replay.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_09_seeded_replay.test.ts'
//
// 9.3 可播种随机与确定性回放。本章的核心实证:**同 seed 同输入,Python(sim/ashlib.py)
// 与 TypeScript(core/src/ashdeck/)给出逐字相同的 digest**。
//
// 对照物是 Python 落盘的 out/mech09_digest.json(由 sim/gd_mech_09_seeded_replay.py 写出);
// 文件缺失时退回字面量常量,字面量与 Python 的输出逐字相同。
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { rngNext, shuffleDeck, RNG_MOD, RNG_MUL } from "../src/ashdeck/rng.ts";
import type { Snapshot, Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const SPEC: Spec = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8"),
);
const PY_JSON = join(HERE, "..", "..", "out", "mech09_digest.json");
/** Python 侧落盘锚点;缺失时退回字面量。 */
function pyAnchor(): Record<string, unknown> {
if (!existsSync(PY_JSON)) return {};
return JSON.parse(readFileSync(PY_JSON, "utf8")) as Record<string, unknown>;
}
const PY = pyAnchor();
// Python ashlib.digest 的实跑输出,逐字抄录。
const 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}';
const PREFIX_DIGEST =
'{"block":0,"cardCount":12,"damageConserved":true,"dealt":41,"enemyHp":9,"energy":1,' +
'"hand":["heavy","strike","jab"],"over":false,"played":8,"playerHp":54,"turn":3,"won":false}';
const OTHER_DIGEST =
'{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,' +
'"hand":["guard","strike","heavy"],"over":true,"played":8,"playerHp":53,"turn":3,"won":true}';
test("P①:TS 侧 digest 与 Python 落盘 JSON 逐字一致(跨语言锚点)", () => {
const ts = digestSnap(runSpec(SPEC));
assert.equal(ts, DIGEST);
if (typeof PY.digest === "string") {
assert.equal(ts, PY.digest);
assert.equal(PY.sameAcrossRuns, true);
assert.equal(PY.seedMatters, true);
}
});
test("同 seed 跑两遍:digest 逐字相同(自反确定性)", () => {
const a = digestSnap(runSpec(SPEC));
const b = digestSnap(runSpec(SPEC));
assert.equal(a, b);
assert.equal(a, DIGEST);
});
test("回放前缀 12 条命令:中间 digest 与 Python 一致", () => {
const seq = SPEC.strategy.slice(0, 12);
assert.equal(seq.length, 12);
const mid = digestSnap(runSpec(SPEC, seq));
assert.equal(mid, PREFIX_DIGEST);
if (typeof PY.prefixDigest === "string") assert.equal(mid, PY.prefixDigest);
});
test("换 seed 20261015:终态 digest 改变(种子真的进了洗牌)", () => {
const other: Spec = { ...SPEC, seed: 20261015 };
const d = digestSnap(runSpec(other));
assert.equal(d, OTHER_DIGEST);
assert.notEqual(d, DIGEST);
if (typeof PY.otherDigest === "string") assert.equal(d, PY.otherDigest);
});
test("shuffleDeck 同 seed 两次同序,且是原数组的一个置换", () => {
const a = shuffleDeck([0, 1, 2, 3, 4, 5], 20261014);
const b = shuffleDeck([0, 1, 2, 3, 4, 5], 20261014);
assert.deepEqual(a, b);
assert.deepEqual(a.slice().sort((x, y) => x - y), [0, 1, 2, 3, 4, 5]);
});
test("shuffleDeck 的袋序与 Python ashlib.shuffle_deck 逐字一致", () => {
assert.deepEqual(shuffleDeck([0, 1, 2, 3, 4, 5], 20261014), [3, 2, 4, 1, 0, 5]);
});
test("rngNext 的 MINSTD 前 5 项与 Python 逐字一致", () => {
assert.equal(RNG_MOD, 2147483647);
assert.equal(RNG_MUL, 48271);
assert.equal(rngNext(1), 48271);
let s = 1;
const seq: number[] = [];
for (let i = 0; i < 5; i += 1) {
s = rngNext(s);
seq.push(s);
}
assert.deepEqual(seq, [48271, 182605794, 1291394886, 1914720637, 2078669041]);
});
test("负例:不同 seed 的洗牌必须不同(否则种子形同虚设)", () => {
const a = shuffleDeck([0, 1, 2, 3, 4, 5], 20261014);
const b = shuffleDeck([0, 1, 2, 3, 4, 5], 20261015);
assert.notDeepEqual(a, b);
});
test("负例:改一条命令,终态 digest 必须变(回放真的依赖输入)", () => {
const seq = SPEC.strategy.slice();
seq[0] = "p3"; // 第一条换成手牌第 4 张
assert.notEqual(digestSnap(runSpec(SPEC, seq)), DIGEST);
const cut = digestSnap(runSpec(SPEC, SPEC.strategy.slice(0, 8)));
assert.notEqual(cut, DIGEST); // 截断到 8 条,未取胜
});
test("两次运行的终态都满足不变量(回放不破坏机制自洽)", () => {
const s: Snapshot = runSpec(SPEC);
assert.equal(invariantsHold(SPEC, s), true);
assert.equal(s.won, true);
assert.equal(s.turn, 4);
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_09_*.test.ts',本节 10 条的真实输出:
✔ P①:TS 侧 digest 与 Python 落盘 JSON 逐字一致(跨语言锚点) (0.683ms)
✔ 同 seed 跑两遍:digest 逐字相同(自反确定性) (0.111833ms)
✔ 回放前缀 12 条命令:中间 digest 与 Python 一致 (0.073292ms)
✔ 换 seed 20261015:终态 digest 改变(种子真的进了洗牌) (0.088208ms)
✔ shuffleDeck 同 seed 两次同序,且是原数组的一个置换 (0.616334ms)
✔ shuffleDeck 的袋序与 Python ashlib.shuffle_deck 逐字一致 (0.082959ms)
✔ rngNext 的 MINSTD 前 5 项与 Python 逐字一致 (0.077042ms)
✔ 负例:不同 seed 的洗牌必须不同(否则种子形同虚设) (0.082791ms)
✔ 负例:改一条命令,终态 digest 必须变(回放真的依赖输入) (0.115292ms)
✔ 两次运行的终态都满足不变量(回放不破坏机制自洽) (0.136292ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 146.832625
两处 digest,同一个字符串
这是本章最要紧的一张对照表。左列是 Python 侧 ashlib.digest(run_spec(spec)) 的实跑输出,右列是 TypeScript 侧 digestSnap(runSpec(spec)) 的实跑输出——逐字相同:
| 场景 | Python(ashlib) | TypeScript(core) | 一致 |
|---|---|---|---|
| 终态 seed=20261014 | {"block":0,...,"won":true} | {"block":0,...,"won":true} | ✓ |
| 前缀 12 条 | {"block":0,...,"won":false} | {"block":0,...,"won":false} | ✓ |
| 换 seed=20261015 | {"block":0,...,"won":true} | {"block":0,...,"won":true} | ✓ |
三个场景的完整 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 {"block":0,"cardCount":12,"damageConserved":true,"dealt":41,"enemyHp":9,"energy":1,"hand":["heavy","strike","jab"],"over":false,"played":8,"playerHp":54,"turn":3,"won":false}
换种子 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["guard","strike","heavy"],"over":true,"played":8,"playerHp":53,"turn":3,"won":true}
能做到逐字一致,靠的是三条纪律:键按字母序排列(block 到 won)、分隔符无空格(Python 用 separators=(",", ":"),TS 用 JSON.stringify 默认值)、布尔值小写(true 而非 True)。任何一条破了,比对的就全是噪音而不是结果。注意 hand 是有序数组——它记录了手牌顺序,顺序也是状态的一部分,不能排序。
三个场景各自证明了什么
| 场景 | 断言 | 证明了什么 |
|---|---|---|
| 同 seed 跑两遍 | 两遍 digest 相同 | 实现是纯函数,没有隐藏状态 |
| 回放前缀 12 条 | 前缀 digest 稳定 | 任意中间状态都可复现,不只是终态 |
| 换 seed 20261015 | digest 改变 | 种子真的进了洗牌,不是摆设 |
| 负例:改一条命令 | digest 改变 | 回放真的依赖输入,不是恒等输出 |
| 负例:换 seed 洗牌不同 | 袋序不同 | 种子不同则洗牌不同,否则种子无意义 |
两条负例是这张表的关键。没有它们,「确定性」可能只是因为实现忽略了输入(比如硬编码一个结果)——那样的「确定性」毫无价值。负例保证:改输入则改输出,同输入则同输出,两面都成立。
可复现意味着什么
- 调试可复现:线上出问题的种子直接拿到本地重放,终态一致。
- 回归可断言:改规则后跑同一批种子,digest 变了就是行为变了,一目了然。
- 跨语言可信:规格是唯一真源,Python 仿真与 TS 内核给同一结果,谁是权威不再需要争论。
- 代价是纪律:禁浮点、禁
Math.random()、禁语言自带随机数——任何一处松口,逐字一致立刻崩。
| 检查项 | 合格标准 |
|---|---|
| PRNG 可播种 | 种子是显式输入,不是内部隐式状态 |
| 键按字母序 | digest 键顺序固定,两个语言相同 |
| 分隔符统一 | 无空格分隔符,Python 与 TS 对齐 |
| 有序字段不排序 | hand 等顺序字段保持原序 |
| 负例齐全 | 改输入必改输出,否则确定性是假的 |
| 无浮点无 random | 整数运算 + 自实现 PRNG,全程可复现 |
三节合起来回答了一个问题:随机可以被设计,而不是被忍受。9.1 选结构、9.2 量方差、9.3 保证可复现——三者串起来,随机从「不可控的运气」变成了「可分析、可断言、可复跑的一份规格」。下一章把视角从随机转到状态:一局游戏能到达哪些状态,哪些状态根本不允许存在。
阅读导航:上一节:9.2 方差与体验 · 下一节:10.1 状态的可达性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。