11.1 整数化与跨语言一致

浮点让同一份规格在两个宿主上算出不同的末位,跨语言一致就无从谈起。本节给出三条跨语言纪律——预算与阈值一律整数、随机一律自实现可播种 PRNG、config 禁浮点——并把暴击判定、倍率、千分比、向上取整、整数幂逐条改写成整数写法,用一张对照表列出两语言的同一结果。

11.1 整数化与跨语言一致

《经济平衡》那篇 讲的是经济概念——资源怎么产出、怎么消耗、怎么沉淀,配的是示意代码;本节讲的是更靠下的一件事:同一份规格在 Python 与 TypeScript 两侧必须算出逐字相同的数字。这不是数值平衡问题,而是「一份规格四处消费」能不能成立的前提。第 9 章已经用自实现 PRNG 保证了随机可复现,第 15 章会把三处消费的 digest 摆在一起比对;本节要先把地基打平:为什么必须整数、整数怎么写、两语言怎么给出同一串结果。

三条跨语言纪律

《灰烬牌库》的规格里,任何一处「看起来需要小数」的地方都被改写成整数。三条纪律:

纪律内容违反它长什么样
① 预算与阈值一律整数字节、回合、能量、伤害、概率千分比、熵 ×1000暴击率写成 0.25
② 随机一律自实现可播种 PRNGLehmer MINSTD:state = state * 48271 % 2147483647用了 Math.random() 或 Python 的 random
③ config/ 禁浮点规格里不出现小数点;幂运算 TS 侧走 BigInt成长率写成 1.15

三条是一件事的三面:只要有一处落进浮点,跨语言「逐位一致」就失效了——浮点的加、乘、幂在不同宿主、不同编译器、不同 libm 下都可能给出不同的末位。整数运算没有这个问题:a * b 与 a // b 在任意语言里都是同一个数学值。

整数化前后的对照表

把每一类小数改写成整数的对应关系如下(左列是「想表达的」浮点写法,右列是规格里真正跑的整数写法):

场景浮点写法(禁用)整数写法整数结果
暴击判定(25%)roll / 1000 < 0.25roll < 250(roll ∈ [0,1000))命中
1.5× 重击damage * 1.5damage * 3 // 221
30% 减伤damage * (1 - 0.3)damage * 700 // 10009
千分比n / d * 1000n * 1000 // d583
向上取整ceil(n / d)(n + d - 1) // d9
四舍五入round(n / d)(n + d // 2) // d8

六条有一个共同形状:先乘后除。value * num // den 里乘法是精确的(整数乘法不丢信息),除法只做一次、而且是地板除。写成 value // den * num 就错了——先除会把余数丢掉,误差随乘数放大。

为什么禁浮点:一次真实的丢位

浮点最危险的地方不是「不精确」,而是「你以为它精确」。3 ** 40 在数学上是 12157665459056928801,一个 64 位整数。JS 的 3 ** 40 走的是 Number(双精度),BigInt(3 ** 40) 比精确值少 33——不是「四舍五入到接近」,是丢位。115 ** 10 更早就越过了 2 ** 53。所以纪律 ③ 补了一句:幂运算 TS 侧用 BigInt,Python 侧的 ** 本身就是任意精度整数,两侧才对齐。

S 层脚本把上面六条各算一遍,全程整数 // 与 **,不出现浮点。

#!/usr/bin/env python3
# file: sim/gd_mech_11_integerize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_11_integerize.py
"""11.1 整数化与跨语言一致(S 层)。

把《灰烬牌库》里会用到的每一类「看似需要小数」的量改写成纯整数运算,并打印
整数化前后的对照表。全部使用整数 `//`(地板除)与 `**`(整数幂),**不出现浮点**:

  暴击判定   r/1000 < 0.25        →  r < 250            (r ∈ [0,1000))
  1.5× 重击  14 * 1.5             →  14 * 3 // 2        = 21
  30% 减伤   14 * (1 - 0.3)       →  14 * 700 // 1000   = 9
  千分比     7 / 12 * 1000        →  7 * 1000 // 12     = 583
  向上取整   ceil(50 / 6)         →  (50 + 6 - 1) // 6  = 9
  四舍五入   round(50 / 6)        →  (50 + 3) // 6      = 8
  整数幂     3 ** 40              →  精确大整数,Number 会丢位

TS 侧 core/src/ashdeck/integer.ts 用同一组定义实现,P 层测试断言**逐位相同的值**。
"""
from __future__ import annotations

import os

import ashlib

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


def permille(n: int, d: int) -> int:
    return n * 1000 // d


def apply_permille(value: int, pm: int) -> int:
    return value * pm // 1000


def scale(value: int, num: int, den: int) -> int:
    return value * num // den


def ceildiv(n: int, d: int) -> int:
    return (n + d - 1) // d


def rounddiv(n: int, d: int) -> int:
    return (n + d // 2) // d


def main() -> int:
    spec = ashlib.load_spec()
    p = spec["params"]

    rows = [
        ["crit_hit(roll=173,pm=250)", str(173 < 250).lower()],
        ["scale(heavy=14,num=3,den=2)", str(scale(14, 3, 2))],
        ["apply_permille(14,700)", str(apply_permille(14, 700))],
        ["permille(7,12)", str(permille(7, 12))],
        ["ceildiv(enemyHp=50,atk=6)", str(ceildiv(p["enemyHp"], p["enemyAttack"]))],
        ["rounddiv(enemyHp=50,atk=6)", str(rounddiv(p["enemyHp"], p["enemyAttack"]))],
        ["ipow(3,40)", str(3 ** 40)],
    ]

    out = os.path.join(ROOT, "out", "mech11_integerize.csv")
    n = ashlib.write_csv(out, ["scenario", "integer_value"], rows)

    print(f"params: playerHp={p['playerHp']} enemyHp={p['enemyHp']} "
          f"enemyAttack={p['enemyAttack']} energyMax={p['energyMax']}")
    print("scenario integer_value")
    for r in rows:
        print(f"{r[0]} {r[1]}")

    exact = 3 ** 40
    as_number = float(exact)
    print(f"ipow_exact={exact}")
    print(f"ipow_as_float={as_number!r}")
    print(f"float_lost_bits={int(as_number) != exact}")
    print(f"wrote out/mech11_integerize.csv rows={n}")
    return 0


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

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

params: playerHp=60 enemyHp=50 enemyAttack=6 energyMax=3
scenario integer_value
crit_hit(roll=173,pm=250) true
scale(heavy=14,num=3,den=2) 21
apply_permille(14,700) 9
permille(7,12) 583
ceildiv(enemyHp=50,atk=6) 9
rounddiv(enemyHp=50,atk=6) 8
ipow(3,40) 12157665459056928801
ipow_exact=12157665459056928801
ipow_as_float=1.2157665459056929e+19
float_lost_bits=True
wrote out/mech11_integerize.csv rows=7

float_lost_bits=True 就是上面那次丢位的实测:float(3 ** 40) 打印成 1.2157665459056929e+19,而精确值是 12157665459056928801——一旦经过浮点,末位再也回不来。七行结果写进 out/mech11_integerize.csv,供下游脚本直接读。

TS 侧:同一组定义,逐位相同的值

TS 侧的整数工具箱在 core/src/ashdeck/integer.ts。这里只摘关键几个函数,完整版见工作区:

// snippet: core/src/ashdeck/integer.ts 的关键函数(完整版见工作区)
/** 非负整数地板除:纯整数长除,全程不出现浮点 `/`。 */
export function idiv(a: number, b: number): number {
  let q = 0;
  let r = a;
  while (r >= b) {
    let s = b;
    let m = 1;
    while (s <= r - s) { s += s; m += m; }
    r -= s;
    q += m;
  }
  return q;
}

/** 千分比:n / d 的千分表示(×1000 后地板除)。 */
export function permille(n: number, d: number): number {
  return idiv(n * 1000, d);
}

/** 按千分比取比例量:value 的 pm‰。 */
export function applyPermille(value: number, pm: number): number {
  return idiv(value * pm, 1000);
}

/** 整数幂:base^exp,全程 BigInt 精确无浮点。 */
export function ipow(base: number, exp: number): bigint {
  let acc = 1n;
  const b = BigInt(base);
  for (let i = 0; i < exp; i += 1) acc *= b;
  return acc;
}

idiv 特意写成纯整数长除,而不是 Math.floor(a / b):本规格的数都远小于 2 ** 53,两者结果相同,但用长除能让「不出现浮点」这句话不依赖任何数值范围假设。P 层测试把这一点也断言了——idiv 必须与 Math.floor 在规格数域上同值,否则这个替代就没有落脚点。

// file: core/test/gd_mech_11_integerization.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_11_integerization.test.ts'
//
// 11.1 整数化与跨语言一致:断言 TS 侧整数工具箱给出的值与 Python 侧
// sim/gd_mech_11_integerize.py 的实跑输出**逐位相同**。
// 反例一:长除与 Math.floor(a / b) 在本规格的数域上必须同值(否则「用长除
//         替代浮点除」这句话就没有落脚点)。
// 反例二:整数幂必须走 BigInt——3^40 一旦落进 Number 就丢位。

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  idiv, permille, applyPermille, scale, ceildiv, rounddiv, ipow,
} from "../src/ashdeck/integer.ts";

test("idiv 与 Math.floor(a / b) 在规格数域上同值", () => {
  const pairs: [number, number][] = [
    [7, 2], [50, 6], [14, 3], [7000, 1000], [121, 11], [60, 4], [0, 5], [12, 12],
  ];
  for (const [a, b] of pairs) assert.equal(idiv(a, b), Math.floor(a / b));
  assert.equal(idiv(7, 2), 3);
  assert.equal(idiv(50, 6), 8);
});

test("千分比与比例量:与 Python 侧逐位相同", () => {
  assert.equal(permille(7, 12), 583); // 7 * 1000 // 12
  assert.equal(permille(50, 50), 1000);
  assert.equal(applyPermille(14, 700), 9); // 14 * 700 // 1000
  assert.equal(scale(14, 3, 2), 21); // 1.5x 重击
  assert.equal(scale(60, 115, 100), 69); // 15% 成长
});

test("向上取整与四舍五入:整数版 ceil / round", () => {
  assert.equal(ceildiv(50, 6), 9); // 50 血 / 6 攻 = 8.33 -> 9 回合
  assert.equal(rounddiv(50, 6), 8); // 8.33 -> 8
  assert.equal(ceildiv(49, 6), 9);
  assert.equal(rounddiv(51, 6), 9); // 8.5 -> 9(half-up)
});

test("暴击判定:千分比整数比较,不用小数", () => {
  const roll = 173;
  assert.equal(roll < 250, true); // roll/1000 < 0.25
  assert.equal(249 < 250, true);
  assert.equal(250 < 250, false);
});

test("整数幂走 BigInt:3^40 精确,落进 Number 就丢位", () => {
  const exact = ipow(3, 40);
  assert.equal(exact, 12157665459056928801n);
  const asNumber = BigInt(3 ** 40); // 3 ** 40 是 Number,已经被舍入
  assert.notEqual(asNumber, exact);
  assert.equal(asNumber - exact, -33n); // 少 33,丢位的具体数值
  assert.equal(ipow(3, 40), 12157665459056928801n);
});

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

✔ idiv 与 Math.floor(a / b) 在规格数域上同值 (0.51175ms)
✔ 千分比与比例量:与 Python 侧逐位相同 (0.097ms)
✔ 向上取整与四舍五入:整数版 ceil / round (0.072709ms)
✔ 暴击判定:千分比整数比较,不用小数 (0.058542ms)
✔ 整数幂走 BigInt:3^40 精确,落进 Number 就丢位 (0.104208ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

判读:五个断言都指向同一件事

  • 583 与 9 不是随手挑的数:7 * 1000 // 12 在两侧都是 583,14 * 700 // 1000 在两侧都是 9。Python 侧打印的值与 TS 侧断言的值是同一串数字。
  • idiv === Math.floor(a / b) 这条断言是自我约束:它证明「用长除替代浮点除」在本数域上不改变结果,否则纪律 ③ 就只是口号。
  • 3 ** 40 那条是反例:BigInt(3 ** 40) - 12157665459056928801n === -33n,把「丢位」量化成了具体的 33。
  • 暴击判定那条是最日常的一条:roll < 250 与 roll / 1000 < 0.25 在整数域里完全等价,而前者永远不会因为浮点而抖动。

自检表

检查项合格标准
无小数点config/ 与 core/src/ashdeck/ 的规格字段里不出现浮点字面量
除法只用地板除,且先乘后除
幂运算越界前走 BigInt / Python 大整数
跨语言同一组输入,两侧给出同一串数字
反例至少一条断言能把「丢位」量出来

整数化把「跨语言一致」从一句愿望变成了一条可以逐位核对的等式。但等式只解决了单点计算,机制里还有会随步数长起来的量——增长与衰减曲线。下一节回答:闭式与递推在整数域上能不能逐项对上。

阅读导航:上一节:10.3 死锁与可解性检测 · 下一节:11.2 增长与衰减的闭式与递推 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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