11.3 数值的可复现
《输入手感与战斗实现》那篇 讲的是表现层怎么把输入变成反馈——帧缓冲、动画、打击感;本节划的是它的另一面:哪些数值可以要求逐字相等,哪些只能要求落在容差内。这条边界不清,跨语言对照就会要么过度收紧(把表现层的浮点抖动也当成 bug),要么过度放宽(把机制层的不一致放过去)。11.1 和 11.2 已经把机制层钉在整数上,本节把「可复现」这句话拆成两层,分别给出不同的断言口径。
两层可复现,两种断言口径
| 层 | 代表量 | 数值类型 | 断言口径 | 正文能不能写「逐位相等」 |
|---|---|---|---|---|
| 机制层 | digest、血量、能量、伤害、牌数 | 整数 / 布尔 / 字符串数组 | 逐字相等 | 能 |
| 表现层 | 像素位移、动画时长、相机抖动 | 浮点 | 声明容差内相等 | 不能 |
分野很干脆:凡是参与胜负判定的量,一律在机制层,一律整数;表现层只是把机制层的整数画出来,它算错了也不影响谁赢。因此机制层可以要求「逐位一致」,表现层只能要求「肉眼不可辨」——这两句话的严格程度差了好几个数量级,混用就会出错。
机制层:digest 逐字相等
机制层的可复现有一个可验证点:digest。它把快照里参与判定的字段按字母序拼成一段规范化 JSON,Python 侧 ashlib.digest(snap) 与 TS 侧 digestSnap(snap) 必须给出同一个字符串。默认规格跑一局,两侧的实跑结果是:
{"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}
这串 172 字符里没有一个小数点:dealt 是 50、playerHp 是 48、turn 是 4、hand 是三个卡牌 id。分隔符必须两侧统一——Python 用 separators=(",", ":") 才能对上 TS 的 JSON.stringify,否则比对全是空格噪音(第 15 章会细讲 digest 的三处消费)。
S 层脚本打印这串 digest,并给一个表现层的反例:
#!/usr/bin/env python3
# file: sim/gd_mech_11_reproducibility.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_11_reproducibility.py
"""11.3 数值的可复现(S 层)。
可复现分两层,断言口径完全不同:
机制层(整数):digest 是纯整数 / 布尔 / 字符串数组的规范化 JSON,
跨语言必须**逐字相等**——这是可以写「逐位一致」的地方。
表现层(浮点):像素位移等派生量由宿主浮点库算出,浮点加法不满足结合律,
同一组数换个求和顺序末位就不同,因此**只能按声明容差断言**,
正文与断言都**禁写「逐位相等」**。
本脚本打印机制层的 digest 字符串(TS 侧 digestSnap 必须给出同一串),
并给一个表现层的反例:三段位移按两种顺序求和,结果不相等,但落在容差内。
"""
from __future__ import annotations
import os
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
# 表现层容差:像素级视觉差 ≤ 1e-6 px 即肉眼不可辨。
PIXEL_TOL = 1e-6
# 伤害数字的三段屏幕位移(浮点,属表现层,不在 config/ 的整数域内)。
SEGMENTS = [18.4, 6.7, 2.9]
def main() -> int:
spec = ashlib.load_spec()
snap = ashlib.run_spec(spec)
digest = ashlib.digest(snap)
out = os.path.join(ROOT, "out", "mech11_digest.txt")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
f.write(digest + "\n")
print(f"digest={digest}")
print(f"digest_len={len(digest)}")
print(f"snap_turn={snap['turn']} snap_won={str(snap['won']).lower()} "
f"snap_dealt={snap['dealt']} snap_cardCount={snap['cardCount']}")
left = (SEGMENTS[0] + SEGMENTS[1]) + SEGMENTS[2]
right = SEGMENTS[0] + (SEGMENTS[1] + SEGMENTS[2])
delta = abs(left - right)
print(f"perceptual_left={left!r}")
print(f"perceptual_right={right!r}")
print(f"perceptual_equal={left == right}")
print(f"perceptual_delta={delta!r} tol={PIXEL_TOL!r} within_tol={delta <= PIXEL_TOL}")
print(f"wrote out/mech11_digest.txt bytes={os.path.getsize(out)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_11_reproducibility.py:
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}
digest_len=172
snap_turn=4 snap_won=true snap_dealt=50 snap_cardCount=12
perceptual_left=27.999999999999996
perceptual_right=28.0
perceptual_equal=False
perceptual_delta=3.552713678800501e-15 tol=1e-06 within_tol=True
wrote out/mech11_digest.txt bytes=173
表现层:只能按容差断言
perceptual_equal=False 是本节的关键实测。表现层里,伤害数字的屏幕位移由三段组成(弹出、漂移、回落),把同一组浮点数按两种顺序求和:
(18.4 + 6.7) + 2.9=27.99999999999999618.4 + (6.7 + 2.9)=28.0
同样的三个数,换个加法顺序,末位就不同——因为浮点加法不满足结合律。这不是实现 bug,是浮点的固有性质:任何一次加法都会舍入,舍入点不同,结果就不同。所以表现层的断言只能写成「差值 ≤ 容差」:|left - right| = 3.55e-15 ≤ 1e-6,肉眼不可辨,判通过。正文与断言都不许写「逐位相等」——那是一句根本不可能成立的话。
P 层测试把两层口径都钉下来,并对容差断言本身给一个反面:
// file: core/test/gd_mech_11_reproducibility.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_11_reproducibility.test.ts'
//
// 11.3 数值的可复现:机制层(整数)必须逐字相等,表现层(浮点)只能按容差断言。
// 机制层:digestSnap(runSpec(spec)) 必须与 Python 侧 ashlib.digest 给出同一串。
// 表现层:同一组位移换个求和顺序末位就变,故断言「落在容差内」而非「相等」。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import type { 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"),
);
// 与 sim/gd_mech_11_reproducibility.py 的实跑输出逐字相同。
const PY_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 PIXEL_TOL = 1e-6;
test("机制层:digest 与 Python 侧逐字相等(可写「逐位一致」)", () => {
const d = digestSnap(runSpec(SPEC));
assert.equal(d, PY_DIGEST);
assert.equal(d.length, 172);
assert.equal(d, digestSnap(runSpec(SPEC))); // 再跑一遍,纯函数恒等
});
test("机制层:digest 只含整数 / 布尔 / 字符串数组,无浮点", () => {
const snap = runSpec(SPEC);
for (const v of [snap.turn, snap.energy, snap.playerHp, snap.enemyHp,
snap.block, snap.dealt, snap.played, snap.cardCount]) {
assert.equal(Number.isInteger(v), true);
}
assert.equal(Number.isInteger(snap.won), false); // 布尔不是整数
});
test("表现层:浮点求和顺序不同结果不同,只能按容差断言", () => {
const seg = [18.4, 6.7, 2.9];
const left = (seg[0] + seg[1]) + seg[2];
const right = seg[0] + (seg[1] + seg[2]);
assert.notEqual(left, right); // 禁写「逐位相等」
assert.equal(Math.abs(left - right) <= PIXEL_TOL, true);
assert.equal(left, 27.999999999999996);
assert.equal(right, 28);
});
test("表现层容差断言本身要有反面:超容差必须判失败", () => {
const a = 28.0;
const b = 28.01;
assert.equal(Math.abs(a - b) <= PIXEL_TOL, false);
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_11_reproducibility.test.ts':
✔ 机制层:digest 与 Python 侧逐字相等(可写「逐位一致」) (0.685625ms)
✔ 机制层:digest 只含整数 / 布尔 / 字符串数组,无浮点 (0.094875ms)
✔ 表现层:浮点求和顺序不同结果不同,只能按容差断言 (0.171375ms)
✔ 表现层容差断言本身要有反面:超容差必须判失败 (0.068959ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
容差怎么定:三档声明
容差不是随手写个 1e-6,它由「肉眼能不能看出」和「下游会不会拿它做判定」共同决定。本项目把表现层容差分三档声明:
| 档 | 用途 | 容差 | 理由 |
|---|---|---|---|
| 视觉位 | 屏幕位移、尺寸 | 1e-6 px | 亚像素差肉眼不可辨 |
| 时长位 | 动画时长、缓动曲线 | 1e-3 ms | 1 毫秒以下不可能被感知 |
| 统计位 | 采样均值、占比 | 1e-4(相对) | 大样本下抖动由样本量决定 |
纪律有两条。一是容差必须有名字、有理由、写在规格或测试里,不能是散落在断言中的魔法数字——没有名字的容差无法评审,换宿主时也没法判断「这个偏差算不算超」。二是容差只属于表现层:任何参与胜负判定的量都不许走容差,dealt == 50 必须是等式,不能写成「50 ± 1」。把这两条守住,机制层与表现层的边界才不会在调试中被悄悄挪动。
判读:四类断言各自守什么
- 逐字相等(机制层):
digestSnap(runSpec(SPEC)) === PY_DIGEST,而且长度必须是 172。这是唯一可以写「逐位一致」的地方。 - 类型检查(机制层):
turn / energy / dealt / cardCount都是Number.isInteger,won是布尔不是整数——把「digest 里没有浮点」变成一条可执行的断言。 - 容差断言(表现层):
Math.abs(left - right) <= 1e-6,并显式断言left !== right——先把「不相等」写下来,读者才不会被「逐位相等」误导。 - 反面(容差自身):
|28.0 - 28.01| = 0.01 > 1e-6必须判失败。一个永远返回true的容差断言和没有一样。
为什么正文要逐字写出 digest
上面那串 172 字符在正文里被完整抄写,而不是只写一句「两侧 digest 一致」——这是刻意的。读者可以把它贴进终端,分别跑 Python 与 TS 两侧的脚本,逐字符比对;一旦某侧改了字段顺序、分隔符或类型(比如把 true 写成 1、把 hand 排成逆序),字符串立刻对不上,问题暴露在肉眼可见的地方。可复现不是一句形容词,是一串能贴进终端的字符串。
| 检查项 | 合格标准 |
|---|---|
| 分层 | 机制层整数、表现层浮点,各自有独立的断言口径 |
| 机制层 | digest 逐字相等,且不含浮点字段 |
| 表现层 | 只按容差断言,禁写「逐位相等」 |
| 反面 | 容差断言必须能被超容差判失败 |
| 容差三档 | 视觉 / 时长 / 统计各有一档,且有名字与理由 |
| 可验证点 | digest 字符串在正文里逐字给出,可手工核对 |
到这里,机制数值的三块地基就齐了:整数化保证单点计算跨语言一致,闭式与递推的等价保证曲线一致,分层断言把「可复现」的边界划到该划的地方。接下来第 12 章回到机制本身——当多个机制同时在场,它们之间会怎么相互作用。
阅读导航:上一节:11.2 增长与衰减的闭式与递推 · 下一节:12.1 机制之间的耦合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。