9.3 可播种随机与确定性回放

随机一旦可播种,整局就是一段确定性函数。本节把同一份规格在 Python(sim/ashlib.py)与 TypeScript(core/src/ashdeck/)两处各跑一遍,终态 digest 逐字相同;再换 seed 证明种子真的进了洗牌。P 层 10 条断言全绿,其中两条是负例,证明「回放真的依赖输入」不是一句空话。

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 20261015digest 改变种子真的进了洗牌,不是摆设
负例:改一条命令digest 改变回放真的依赖输入,不是恒等输出
负例:换 seed 洗牌不同袋序不同种子不同则洗牌不同,否则种子无意义

两条负例是这张表的关键。没有它们,「确定性」可能只是因为实现忽略了输入(比如硬编码一个结果)——那样的「确定性」毫无价值。负例保证:改输入则改输出,同输入则同输出,两面都成立。

可复现意味着什么

  • 调试可复现:线上出问题的种子直接拿到本地重放,终态一致。
  • 回归可断言:改规则后跑同一批种子,digest 变了就是行为变了,一目了然。
  • 跨语言可信:规格是唯一真源,Python 仿真与 TS 内核给同一结果,谁是权威不再需要争论。
  • 代价是纪律:禁浮点、禁 Math.random()、禁语言自带随机数——任何一处松口,逐字一致立刻崩。
检查项合格标准
PRNG 可播种种子是显式输入,不是内部隐式状态
键按字母序digest 键顺序固定,两个语言相同
分隔符统一无空格分隔符,Python 与 TS 对齐
有序字段不排序hand 等顺序字段保持原序
负例齐全改输入必改输出,否则确定性是假的
无浮点无 random整数运算 + 自实现 PRNG,全程可复现

三节合起来回答了一个问题:随机可以被设计,而不是被忍受。9.1 选结构、9.2 量方差、9.3 保证可复现——三者串起来,随机从「不可控的运气」变成了「可分析、可断言、可复跑的一份规格」。下一章把视角从随机转到状态:一局游戏能到达哪些状态,哪些状态根本不允许存在。

阅读导航:上一节:9.2 方差与体验 · 下一节:10.1 状态的可达性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡