15.2 同源不同宿主

同一份源码换宿主,语言原语的差异在三处咬人:Python 的 int 任意精度,JS 的 number 只到 2^53-1;幂与增长量必须走 BigInt;字符串的码点数、UTF-16 码元数、UTF-8 字节数是三个数。本节实测这些边界,并划清哪些量必须逐位一致、哪些只能按声明容差断言。

15.2 同源不同宿主

15.1 跨语言摘要与 digest 已经让三处消费给出同一串摘要,但那是在「语言原语刚好一致」的前提下成立的。本节把前提本身摊开:《引擎实现与体验打磨》 那套在同一个宿主内打磨渲染与手感,换宿主是它不关心的事;本节恰恰只关心换宿主——同一份源码,Python、Node、浏览器三处的语言原语并不一样。把这些差异量清楚,才能说清「哪些量必须逐位一致、哪些只能按声明容差断言」。

宿主差异的三个战场

同一份逻辑,换宿主后会在三处咬人。三者性质完全不同,处理方式也不同:

战场PythonJavaScript后果
整数域int 任意精度number 是 float64,安全整数到 2^53-1越过上界,JS 静默丢位
大数** 本身就是大整数必须显式用 BigInt幂与增长曲线一侧精确一侧丢位
字符串len 数码点,encode 数字节.length 数 UTF-16 码元非 BMP 字符下两侧长度不同

三条里,只有第一条会静默出错——丢位不抛异常,结果看起来还挺合理。后两条是显式的:BigInt 要手写,字节长度要显式调 API。所以本节的重点放在第一条上。

整数域:2^53 这道墙

JS 的 number 是 IEEE 754 双精度浮点,能精确表示的整数只到 2^53-1(Number.MAX_SAFE_INTEGER)。越过它,相邻整数不再可分辨。实测:

  • Python 的 2^53 + 1 是精确的 9007199254740993;
  • JS 的 2 ** 53 + 1 求值后塌回 2^53,(2**53 + 1) === 2**53 为真。

这不是「四舍五入到接近」,是丢位——9007199254740993 变成了 9007199254740992,差 1。它不抛异常、不警告,只是安静地少了 1。

3 ** 40 更早越过这道墙:Python 是精确的 12157665459056928801,JS 的 3 ** 40 走 number,换算回来已经丢位。这正是纪律 ③ 补的那一句「幂运算 TS 侧用 BigInt」的由来。

哪些量必须逐位一致,哪些只能按容差

宿主差异摆清楚之后,「一致性」这句话要分两半说。判据只有一条:这个量参不参与胜负判定。

量所在层载体断言口径
dealt / enemyHp / turn / energy机制层整数(远小于 2^53)逐位一致,===
hand(卡牌 id 数组)机制层字符串数组逐位一致,元素顺序也一致
种子 / 卡牌计数机制层整数逐位一致
幂与增长曲线(如 3^40)机制层Python 大整数 / TS BigInt精确,但不能进摘要
胜率 / 熵 ×1000机制层派生整数‰按千分比容差(下一节)
像素位移 / 动画时长表现层宿主浮点声明容差内相等

机制层的量为什么能用 number 当载体?因为本规格的数都很小——最大是种子 20261014,离 2^53 还差九个数量级。只要机制层量落在安全整数域内,number 就是精确整数,两侧就能逐位一致。这不是巧合,是设计约束:机制层的预算与阈值一律取整数、且取小整数。

S 层:把三处差异各量一遍

脚本核对本规格的机制层量级是否落在安全整数域内,并把整数域、大数、字符串三处的差异各量一遍。

#!/usr/bin/env python3
# file: sim/gd_mech_15_host.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_15_host.py
"""15.2 同源不同宿主(S 层)。

同一份源码换宿主(Python / Node / 浏览器),语言原语的差异在三处咬人:

  ① 整数域:Python 的 int 任意精度;JS 的 number 是 float64,安全整数只到
     2^53-1。越过它,Number 静默丢位:JS 里 (2**53+1)===2**53 为真。
  ② 大数:幂与增长曲线很快越过 2^53,TS 侧必须走 BigInt;而 BigInt 进不了
     JSON.stringify(第 15.1 节反例三),所以机制层的「摘要」永远不能是大数。
  ③ 字符串:Python len 是码点数,JS .length 是 UTF-16 码元数,UTF-8 字节数又是
     第三个数。中文在 BMP 内时前两者相等(「灰烬牌库」都是 4),表情符号不在
     BMP 内时 JS .length=2 而码点只有 1。

本脚本把三处差异各量一遍,并核对本规格的机制层量全部落在安全整数域内——
这正是「机制层可要求逐字一致、表现层只能按容差」的宿主侧根据。
"""
from __future__ import annotations

import json
import os

import ashlib

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

SAFE = 2 ** 53 - 1  # JS Number.MAX_SAFE_INTEGER
SAMPLES = ["灰烬牌库", "ashdeck", "🎴"]


def spec_numeric_max(spec: dict) -> tuple[int, str]:
    """规格里所有整数字段的最大绝对值(机制层量级)。"""
    best, where = 0, "-"
    for k, v in spec["params"].items():
        if isinstance(v, int) and abs(v) > best:
            best, where = abs(v), f"params.{k}"
    for c in spec["cards"]:
        for k in ("cost", "damage", "block", "count"):
            if abs(c[k]) > best:
                best, where = abs(c[k]), f"cards.{c['id']}.{k}"
    if abs(spec["seed"]) > best:
        best, where = abs(spec["seed"]), "seed"
    return best, where


def main() -> int:
    spec = ashlib.load_spec()

    rows = [
        ["safe_integer_max", str(SAFE)],
        ["py_2p53_plus_1", str(2 ** 53 + 1)],
        ["float_2p53_plus_1", str(int(float(2 ** 53 + 1)))],
        ["float_loses_at_2p53", str(int(float(2 ** 53 + 1)) != 2 ** 53 + 1).lower()],
        ["py_3p40", str(3 ** 40)],
        ["float_3p40_lost_bits", str(int(float(3 ** 40)) != 3 ** 40).lower()],
    ]
    for s in SAMPLES:
        rows.append([f"codepoints({s})", str(len(s))])
        rows.append([f"utf8_bytes({s})", str(len(s.encode("utf-8")))])
        rows.append([f"utf16_units({s})", str(len(s.encode("utf-16-le")) // 2)])

    csv_path = os.path.join(ROOT, "out", "mech15_host.csv")
    ashlib.write_csv(csv_path, ["quantity", "value"], rows)

    maxv, where = spec_numeric_max(spec)
    in_safe = maxv <= SAFE
    report = {
        "spec": spec["id"],
        "safeIntegerMax": SAFE,
        "py2p53plus1": 2 ** 53 + 1,
        "float2p53plus1": int(float(2 ** 53 + 1)),
        "floatLosesAt2p53": int(float(2 ** 53 + 1)) != 2 ** 53 + 1,
        "py3p40": 3 ** 40,
        "float3p40LostBits": int(float(3 ** 40)) != 3 ** 40,
        "encoding": [
            {"s": s, "codepoints": len(s), "utf8Bytes": len(s.encode("utf-8")),
             "utf16Units": len(s.encode("utf-16-le")) // 2}
            for s in SAMPLES
        ],
        "specNumericMax": maxv,
        "specNumericWhere": where,
        "specWithinSafeInteger": in_safe,
    }
    out = os.path.join(ROOT, "out", "gd_mech_15_host.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")

    lost = int(float(2 ** 53 + 1)) != 2 ** 53 + 1
    print(f"safe_integer_max={SAFE} py_2p53_plus_1={2 ** 53 + 1} "
          f"float_2p53_plus_1={int(float(2 ** 53 + 1))} float_loses={lost}")
    print(f"py_3p40={3 ** 40} float_3p40_lost_bits={int(float(3 ** 40)) != 3 ** 40}")
    for s in SAMPLES:
        print(f"encoding {s} codepoints={len(s)} utf8_bytes={len(s.encode('utf-8'))} "
              f"utf16_units={len(s.encode('utf-16-le')) // 2}")
    print(f"spec_numeric_max={maxv} where={where} within_safe_integer={in_safe}")
    print(f"wrote out/gd_mech_15_host.json wrote out/mech15_host.csv rows={len(rows)}")
    return 0 if in_safe else 1


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

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

safe_integer_max=9007199254740991 py_2p53_plus_1=9007199254740993 float_2p53_plus_1=9007199254740992 float_loses=True
py_3p40=12157665459056928801 float_3p40_lost_bits=True
encoding 灰烬牌库 codepoints=4 utf8_bytes=12 utf16_units=4
encoding ashdeck codepoints=7 utf8_bytes=7 utf16_units=7
encoding 🎴 codepoints=1 utf8_bytes=4 utf16_units=2
spec_numeric_max=20261014 where=seed within_safe_integer=True
wrote out/gd_mech_15_host.json wrote out/mech15_host.csv rows=15

读出来:float_2p53_plus_1=9007199254740992 比精确值少 1,float_loses=True;within_safe_integer=True 说明本规格最大的机制层量(种子 20261014)离 2^53-1 还有极大余量,number 在这里就是精确整数。

字符串那三行更直观:「灰烬牌库」码点 4、UTF-8 12 字节、UTF-16 码元 4——同一个字符串三个长度。中文落在 BMP 内,所以码点与 UTF-16 码元相等;表情符号 🎴 不在 BMP 内,码点 1 而 UTF-16 码元 2。任何把字符串长度写进摘要或哈希的做法,都必须先声明用哪一个。

P 层:把三条边界钉成断言

TS 侧把三处差异各写成可执行断言,与 Python 侧实跑输出逐项对应(完整用例见 core/test/gd_mech_15_host.test.ts):

// snippet: core/test/gd_mech_15_host.test.ts 的核心断言(完整版见工作区)
test("整数域:number 的安全上界是 2^53-1,越过就丢位", () => {
  assert.equal(Number.MAX_SAFE_INTEGER, 2 ** 53 - 1);
  assert.equal(2 ** 53 + 1 === 2 ** 53, true); // 求值后塌回 2^53
  assert.equal(2 ** 53 + 1, 9007199254740992);
  assert.equal(Number.isSafeInteger(2 ** 53 - 1), true); // 上界本身安全
  assert.equal(Number.isSafeInteger(2 ** 53), false); // 越界,不再安全
});

test("大数:BigInt 精确,换算回 number 就丢 1", () => {
  assert.equal(2n ** 53n + 1n, 9007199254740993n);
  assert.equal(BigInt(2 ** 53 + 1) !== 2n ** 53n + 1n, true);
  assert.equal(2n ** 53n + 1n - BigInt(2 ** 53 + 1), 1n); // 丢位的具体数值
  assert.notEqual(BigInt(3 ** 40), 3n ** 40n);
});

test("字符串:码点数 / UTF-16 码元数 / UTF-8 字节数是三个数", () => {
  assert.equal("灰烬牌库".length, 4);
  assert.equal(Buffer.byteLength("灰烬牌库", "utf8"), 12);
  assert.equal("🎴".length, 2); // 代理对
  assert.equal([..."🎴"].length, 1); // 码点
  assert.equal(Buffer.byteLength("🎴", "utf8"), 4);
});

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

✔ 整数域:number 的安全上界是 2^53-1,越过就丢位 (0.870667ms)
✔ 大数:BigInt 精确,换算回 number 就丢 1 (0.131625ms)
✔ 字符串:码点数 / UTF-16 码元数 / UTF-8 字节数是三个数 (0.074083ms)
✔ 机制层量全部落在安全整数域内(这是 number 能当机制层载体的根据) (0.0965ms)
✔ 摘要的值域落在安全整数内(大数被挡在机制层之外) (0.263125ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

判读:三个战场各自的处理

  • 整数域:Number.isSafeInteger(2**53 - 1) 为真、2**53 为假——上界就是分水岭。本规格的量全部在这条线以下,所以 number 可用。
  • 大数:2n**53n + 1n 精确,BigInt(2**53 + 1) 少 1。幂与增长曲线必须走 BigInt,但不能进摘要(15.1 反例三:JSON.stringify 遇 BigInt 抛异常)。
  • 字符串:"🎴".length 是 2、[...'🎴'].length 是 1。字符串长度不是「一个数」,是三个数——写进摘要前必须先声明用哪一个。
检查项合格标准
整数域机制层量全部 Number.isSafeInteger
大数越过 2^53 的幂与增长量走 BigInt,且不进摘要
字符串涉及长度处显式声明码点 / 码元 / 字节口径
容差机制层逐位一致,表现层按声明容差(下一节给出可执行判据)

宿主差异量清楚之后,最后一步是把它变成一个可执行的容差判据:什么量容差为 0、什么量容差是 1‰、什么量只能按浮点容差断言。下一节收口。

阅读导航:上一节:15.1 跨语言摘要与 digest · 下一节:15.3 偏差与容差 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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