11.3 数值的可复现

可复现分两层。机制层的 digest 是纯整数与布尔的规范化 JSON,跨语言必须逐字相等;表现层的像素位移由宿主浮点算出,浮点加法不满足结合律,换个求和顺序末位就变,只能按声明容差断言。本节给出两侧逐字相同的 digest 字符串,并演示为什么禁写「逐位相等」。

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.999999999999996
  • 18.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 ms1 毫秒以下不可能被感知
统计位采样均值、占比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 机制之间的耦合 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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