11.1 整数化与跨语言一致
《经济平衡》那篇 讲的是经济概念——资源怎么产出、怎么消耗、怎么沉淀,配的是示意代码;本节讲的是更靠下的一件事:同一份规格在 Python 与 TypeScript 两侧必须算出逐字相同的数字。这不是数值平衡问题,而是「一份规格四处消费」能不能成立的前提。第 9 章已经用自实现 PRNG 保证了随机可复现,第 15 章会把三处消费的 digest 摆在一起比对;本节要先把地基打平:为什么必须整数、整数怎么写、两语言怎么给出同一串结果。
三条跨语言纪律
《灰烬牌库》的规格里,任何一处「看起来需要小数」的地方都被改写成整数。三条纪律:
| 纪律 | 内容 | 违反它长什么样 |
|---|---|---|
| ① 预算与阈值一律整数 | 字节、回合、能量、伤害、概率千分比、熵 ×1000 | 暴击率写成 0.25 |
| ② 随机一律自实现可播种 PRNG | Lehmer MINSTD:state = state * 48271 % 2147483647 | 用了 Math.random() 或 Python 的 random |
③ config/ 禁浮点 | 规格里不出现小数点;幂运算 TS 侧走 BigInt | 成长率写成 1.15 |
三条是一件事的三面:只要有一处落进浮点,跨语言「逐位一致」就失效了——浮点的加、乘、幂在不同宿主、不同编译器、不同 libm 下都可能给出不同的末位。整数运算没有这个问题:a * b 与 a // b 在任意语言里都是同一个数学值。
整数化前后的对照表
把每一类小数改写成整数的对应关系如下(左列是「想表达的」浮点写法,右列是规格里真正跑的整数写法):
| 场景 | 浮点写法(禁用) | 整数写法 | 整数结果 |
|---|---|---|---|
| 暴击判定(25%) | roll / 1000 < 0.25 | roll < 250(roll ∈ [0,1000)) | 命中 |
| 1.5× 重击 | damage * 1.5 | damage * 3 // 2 | 21 |
| 30% 减伤 | damage * (1 - 0.3) | damage * 700 // 1000 | 9 |
| 千分比 | n / d * 1000 | n * 1000 // d | 583 |
| 向上取整 | ceil(n / d) | (n + d - 1) // d | 9 |
| 四舍五入 | round(n / d) | (n + d // 2) // d | 8 |
六条有一个共同形状:先乘后除。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 增长与衰减的闭式与递推 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。