15.1 跨语言摘要与 digest

摘要不是打印用的,它是「三处消费」的契约:ashlib.digest 与 digestSnap 必须逐字相同。本节写出完整的 172 字符 digest 字符串,逐条说明键序、整数、无浮点三者缺一不可,并给出终态、前缀、换 seed 三个场景在 Python、Node、浏览器三处的对照表。

15.1 跨语言摘要与 digest

《引擎实现与体验打磨》 那套管的是引擎侧——渲染、资源、API,把画面画出来;本节管的是更靠下、也更硬的一件事:同一份规格在三处消费(Python 仿真、TS 内核、浏览器原型)跑完之后,能不能给出同一串摘要。5.3 原型与内核的同源性 已经证明浏览器与 Node 跑的是同一段源码,11.3 数值的可复现 划了机制层与表现层的边界。本节把两条收口:摘要不是打印用的,它是一份契约。

摘要为什么能当契约

ashlib.digest(snap) 与 digestSnap(s) 不是「碰巧一样」,而是必须一样。它们各自把快照里参与胜负判定的 12 个字段,按字母序拼成一段无空格的规范化 JSON。三件事同时成立,字符串才可能逐字相同:

条件做法少它会怎样
键序两侧都按字母序插入Python 的 sort_keys=True 对齐 TS 的手工字母序插入,顺序不同字符串就不同
类型值全是整数 / 布尔 / 字符串数组Python 50.0 序列化成 "50.0",JS 50.0 却是 "50",同一个数两种写法
分隔符无空格(, 与 :)Python 默认 , 与 : ,与 TS 的 JSON.stringify 一比全是空格噪音

三者缺一,字符串就在某个宿主上飘掉。这也是为什么第 15 章要把跨语言 digest 单独立节:它是唯一可以写「逐位一致」的地方——11.3 已经声明过这条边界,本节把它落到一串能贴进终端的字符上。

三个场景

只跑一个默认场景不足以证明契约——那可能只是巧合。本节固定三个场景,让胜负、中途态、随机性各被覆盖一次:

场景序列种子关注点
终态完整 25 条20261014胜负已定(enemyHp=0)
前缀前 6 条20261014中途快照,胜负未定、手牌更多
换 seed完整 25 条42洗牌序变,手牌与出牌都变

S 层:三场景摘要 + 契约自检

脚本对每个场景算一遍 Python 摘要,并做四项契约自检:键集合等于 DIGEST_KEYS、键已按字母序、字符串里没有小数点、能反序列化后重算回原串。若 out/mech15_digests.json 已存在(由 W 层落盘),再拼出三方对照。

#!/usr/bin/env python3
# file: sim/gd_mech_15_digest.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_15_digest.py
"""15.1 跨语言摘要与 digest(S 层)。

同一份 play_spec.json,三个场景,Python 侧的 ashlib.digest 必须与 TS 侧的
digestSnap 逐字相同。三个场景:

  终态    跑完整段默认策略序列(enemyHp<=0,胜负已定);
  前缀    只跑前 6 条指令(中途快照,胜负未定、手牌更多);
  换 seed 种子从 20261014 换成 42,洗牌序变,手牌与出牌都变。

摘要能当契约,靠三件事同时成立——**键按字母序、值全是整数/布尔/字符串数组、
序列化无空格**。少任何一条,字符串就会在某个宿主上飘掉。本脚本:

  ① 三场景 Python 摘要 + 契约自检(键集合 = DIGEST_KEYS、无小数点、可反序列化);
  ② 读 out/mech15_digests.json(浏览器侧与 Node 侧,由 web/verify_mech15.mjs 落),
     三方逐字对照;缺失时只打 Python 列并提示,不因此失败;
  ③ 落 out/gd_mech_15_digest.json 与 out/mech15_digest.csv。
"""
from __future__ import annotations

import copy
import json
import os

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)


def scenarios(spec: dict) -> list[tuple[str, dict, list]]:
    """三个场景:终态 / 前缀 / 换 seed。"""
    seq = list(spec["strategy"])
    seed42 = copy.deepcopy(spec)
    seed42["seed"] = 42
    return [
        ("终态", spec, seq),
        ("前缀", spec, seq[:6]),
        ("换 seed", seed42, seq),
    ]


def contract_check(digest: str) -> dict:
    """摘要契约自检:键集合、无小数点、可反序列化、布尔小写。"""
    obj = json.loads(digest)
    keys = list(obj.keys())
    return {
        "len": len(digest),
        "keys": keys,
        "keys_sorted": keys == sorted(keys),
        "keys_match": keys == sorted(ashlib.DIGEST_KEYS),
        "no_dot": "." not in digest,
        "has_bool": ("true" in digest) or ("false" in digest),
        "roundtrip": ashlib.digest(obj) == digest,
    }


def main() -> int:
    spec = ashlib.load_spec()
    rows = []
    checks = []
    for name, sp, seq in scenarios(spec):
        d = ashlib.digest(ashlib.run_spec(sp, seq))
        c = contract_check(d)
        rows.append([name, str(c["len"]), d])
        checks.append(c)

    # 契约必须逐场景成立:键集合、字母序、无小数点、可反序列化。
    ok = all(
        c["keys_match"] and c["keys_sorted"] and c["no_dot"] and c["roundtrip"]
        for c in checks
    )

    csv_path = os.path.join(ROOT, "out", "mech15_digest.csv")
    ashlib.write_csv(csv_path, ["scenario", "len", "digest"], rows)

    # 浏览器侧 + Node 侧(W 层产物);缺失不失败。
    wpath = os.path.join(ROOT, "out", "mech15_digests.json")
    browser = None
    if os.path.exists(wpath):
        browser = json.load(open(wpath, encoding="utf-8"))

    py = {name: d for name, _, d in [(r[0], r[1], r[2]) for r in rows]}
    three_way = None
    if browser is not None:
        three_way = []
        for sc in browser["scenarios"]:
            three_way.append({
                "name": sc["name"],
                "python": py[sc["name"]],
                "node": sc["nodeDigest"],
                "browser": sc["browserDigest"],
                "all_equal": py[sc["name"]] == sc["nodeDigest"] == sc["browserDigest"],
            })

    report = {
        "spec": spec["id"],
        "scenarios": [
            {"name": r[0], "len": int(r[1]), "digest": r[2], "contract": checks[i]}
            for i, r in enumerate(rows)
        ],
        "contractOk": ok,
        "threeWay": three_way,
        "threeWayOk": None if three_way is None else all(t["all_equal"] for t in three_way),
    }
    out = os.path.join(ROOT, "out", "gd_mech_15_digest.json")
    with open(out, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("scenario len digest")
    for name, ln, d in rows:
        print(f"{name} {ln} {d}")
    print(f"contract_keys={ashlib.DIGEST_KEYS == sorted(ashlib.DIGEST_KEYS)} "
          f"keys_match={all(c['keys_match'] for c in checks)} "
          f"no_dot={all(c['no_dot'] for c in checks)} "
          f"roundtrip={all(c['roundtrip'] for c in checks)}")
    if three_way is None:
        print("three_way=pending(缺 out/mech15_digests.json,先跑 web/verify_mech15.mjs)")
    else:
        for t in three_way:
            print(f"three_way {t['name']} python==node==browser -> {t['all_equal']}")
        print(f"three_way_ok={report['threeWayOk']}")
    print(f"wrote out/gd_mech_15_digest.json wrote out/mech15_digest.csv rows={len(rows)}")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())

实跑 /opt/homebrew/bin/python3 sim/gd_mech_15_digest.py:

scenario len digest
终态 172 {"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}
前缀 187 {"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,"playerHp":54,"turn":2,"won":false}
换 seed 167 {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","heavy"],"over":true,"played":10,"playerHp":52,"turn":4,"won":true}
contract_keys=True keys_match=True no_dot=True roundtrip=True
three_way 终态 python==node==browser -> True
three_way 前缀 python==node==browser -> True
three_way 换 seed python==node==browser -> True
three_way_ok=True
wrote out/gd_mech_15_digest.json wrote out/mech15_digest.csv rows=3

三条摘要长度不同(172 / 187 / 167),因为它们内容不同:前缀场景胜负未定,hand 是四张牌、over:false,长度自然更长;换 seed 场景手牌只剩两张,长度更短。摘要不是定长指纹,是内容的规范化投影——长度本身也是内容的一部分。

P 层:同一组摘要,TS 侧逐字断言

TS 侧不重算「应该是什么」,而是把 Python 侧那三串硬编码进测试,逐字比对。另外给三条反例,把「键序 / 整数 / 无浮点缺一不可」变成可执行的断言(完整用例见 core/test/gd_mech_15_digest.test.ts):

// snippet: core/test/gd_mech_15_digest.test.ts 的核心断言(完整版见工作区)
// 与 sim/gd_mech_15_digest.py 的实跑输出逐字相同(三场景)。
const PY = {
  终态: '{"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}',
  前缀: '{"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,' +
    '"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,' +
    '"playerHp":54,"turn":2,"won":false}',
  换seed: '{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
    '"energy":1,"hand":["strike","heavy"],"over":true,"played":10,' +
    '"playerHp":52,"turn":4,"won":true}',
};

test("三场景:TS digest 与 Python 侧逐字相同", () => {
  const seq = SPEC.strategy;
  assert.equal(digestSnap(runSpec(SPEC, seq)), PY.终态);
  assert.equal(digestSnap(runSpec(SPEC, seq.slice(0, 6))), PY.前缀);
  assert.equal(digestSnap(runSpec({ ...SPEC, seed: 42 }, seq)), PY.换seed);
});

test("摘要契约:12 键、字母序、无小数点、长度随场景变", () => {
  const d = digestSnap(runSpec(SPEC, SPEC.strategy));
  assert.deepEqual(Object.keys(JSON.parse(d)), KEYS); // 键集合 + 插入序 = 字母序
  assert.equal(KEYS.length, 12);
  assert.equal(d.includes("."), false); // 无浮点
  assert.equal(d.length, 172);
});

test("反例二:浮点值让两侧字符串不同(值必须是整数)", () => {
  assert.equal(JSON.stringify({ dealt: 50.0 }), '{"dealt":50}');
  assert.equal(JSON.stringify({ dealt: 50 }), '{"dealt":50}');
});

test("反例三:BigInt 无法进摘要(JSON.stringify 直接抛)", () => {
  assert.throws(() => JSON.stringify({ dealt: 50n }), TypeError);
});

实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_15_digest.test.ts':

✔ 三场景:TS digest 与 Python 侧逐字相同 (0.755ms)
✔ 摘要契约:12 键、字母序、无小数点、长度随场景变 (0.524333ms)
✔ 反例一:键序变了,字符串就变(digestSnap 必须按字母序插入) (0.084167ms)
✔ 反例二:浮点值让两侧字符串不同(值必须是整数) (0.092834ms)
✔ 反例三:BigInt 无法进摘要(JSON.stringify 直接抛) (0.221958ms)
✔ 纯函数:同规格同序列,跑两遍 digest 恒等 (0.069125ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0

W 层:浏览器这一处

浏览器与 Node 跑的是同一段内核(5.3 已证),所以第三个宿主不需要新写实现。第 15 章的页面比第 5 章多一个能力:入口 run(seq, seed) 支持种子覆写,好把「换 seed」场景也搬进浏览器。

// snippet: web/build_mech15.mjs 暴露的入口(完整版见工作区)
globalThis.__ashdeck = {
  run(seq, seed) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    if (seq) sp.strategy = seq;
    if (seed !== undefined && seed !== null) sp.seed = seed;
    snap = runSpec(sp);
    render(snap);
    return digestSnap(snap);
  },
  canvasDrawn: false,
};

实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech15.mjs --page out/mech15.html --shot out/shots/mech15.png:

=== 第 15.1 节浏览器侧断言 ===
  ok   场景「终态」浏览器 digest == Node 侧 digest
  ok   场景「前缀」浏览器 digest == Node 侧 digest
  ok   场景「换 seed」浏览器 digest == Node 侧 digest
  ok   两遍独立加载终态 digest 逐字一致(确定性)
  ok   换前缀确实换了结果(注入被真的消费)
  ok   换 seed 确实换了结果(seed 覆写生效)
  ok   canvas 已绘制(115200 px >= 1)
  ok   截图已落盘(55187 B)
SCENARIO 终态 len=172 match=true
SCENARIO 前缀 len=187 match=true
SCENARIO 换 seed len=167 match=true
PAINTED=115200 SHOT_BYTES=55187 ALL_MATCH=true
OK W 层通过

三处对照表

三个场景,三处消费,九条摘要——没有一条不同。下表每一行是一条场景,三列分别取自 ashlib.digest、Node 的 digestSnap、浏览器内联的同一个 digestSnap:

场景Python(ashlib.digest)Node(digestSnap)浏览器(内联同段)
终态{"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}同左同左
前缀{"block":5,"cardCount":12,"damageConserved":true,"dealt":23,"enemyHp":27,"energy":2,"hand":["guard","strike","strike","strike"],"over":false,"played":4,"playerHp":54,"turn":2,"won":false}同左同左
换 seed{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","heavy"],"over":true,"played":10,"playerHp":52,"turn":4,"won":true}同左同左

「同左」不是省事——它恰好是本节要说的结论:同一列换成任何一列,字符都不变。第 5 章只对默认场景做了三方对照,本节把它扩到三个场景:胜负态、中途态、随机性各覆盖一次,契约才算真的被验过。

键序、整数、无浮点,为什么缺一不可

三条反例各自堵一类失效,且都能在测试里跑出来:

反例现象断言
键序{"b":1,"a":2} ≠ {"a":2,"b":1}digestSnap 必须按字母序插入键
浮点Python 50.0 → "50.0",JS 50.0 → "50"机制层的值必须是整数,且落在安全整数域内
BigIntJSON.stringify({dealt: 50n}) 抛 TypeError摘要里不能出现 BigInt(幂与增长量走另一条路)

换宿主时,这三条是必须先过的关。它们不是风格问题,是「同一串字符」能否成立的结构条件。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_mech_15_digest.pyout/gd_mech_15_digest.json + CSV三场景契约全绿,three_way_ok=True
node --test 'core/test/gd_mech_15_digest.test.ts'P 层断言6 用例全绿 / 6 pass / 0 fail
node web/verify_mech15.mjs --page out/mech15.html --shot out/shots/mech15.pngout/mech15_digests.json + 截图8 条断言全绿 / ALL_MATCH=true

摘要对齐之后,「三处消费」里还有一层更隐蔽的差异:宿主本身。下一节把 Python 的 int 与 JS 的 number、BigInt、字符串编码摆在一起,划清哪些量必须逐位一致、哪些只能按容差。

阅读导航:上一节:14.3 从机制到体验的断言 · 下一节:15.2 同源不同宿主 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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