5.3 原型与内核的同源性

浏览器与 Node 跑的不是两份对齐的实现,而是 web/pack.mjs 的 bundle() 把同一个 combat.ts 擦类型后内联的同一段源码,所以结果必须逐字相同。给出 Node / 浏览器 / Python 三方 digest 对照、产物级同源与自足检查(内核标记在、外部引用为 0),并说清 Python 侧是移植而非同源。

5.3 原型与内核的同源性

5.2 节 断言了浏览器 digest 与 Node digest 逐字相同,但还没回答为什么必须相同——如果只是「碰巧一样」,那它随时可能不一样。本节把这件事说透:页面里的战斗内核不是照着重写的第二份实现,而是 bundle() 把同一个 combat.ts 擦掉类型后内联进去的。分工照旧:《小游戏开发实战》卷一 管产品口径(做多大、何时止损),《引擎实现与体验打磨》 管引擎 API 与渲染管线;本节只管一件事——同一份规格、同一份内核,换一个宿主之后,结果还认不认账。跨卷的交付物验收另见 《小游戏开发实战》的交付与交接 ,那是流程口径,本节是机制口径。

同源不是「两份实现对齐」

「两份实现对齐」意味着两处独立代码,靠测试保证它们不漂移;一旦有人只改了一边,测试就红。本套不这么做。看 out/mech05.html 里内联进去的模块头,五个文件的来源标注原样保留着,按拓扑序依次是:

  1. core/src/ashdeck/types.ts
  2. core/src/ashdeck/rng.ts
  3. core/src/ashdeck/cards.ts
  4. core/src/ashdeck/combat.ts
  5. core/src/ashdeck/digest.ts

顺序不是审美问题:bundle() 去掉了跨文件的 import,五个模块落在同一个作用域里顺序执行,所以「先声明后使用」只能由调用方保证。把 combat.ts 排到 rng.ts 前面,页面会直接报 shuffleDeck is not defined。

同一个函数的源码与内联后形态对照如下,可以看到擦类型只动了类型标注的位置,逻辑一字未改:

位置runSpec 的签名
core/src/ashdeck/combat.tsexport function runSpec(spec: Spec, strategy?: string[]): Snapshot {
out/mech05.html(内联后)function runSpec(spec , strategy ) {

擦除后参数位上留下了对齐空格(类型被删、空白保留),这不影响语义;runSpec 调用的 shuffleDeck 就是前面 rng.ts 里那一个,不存在第二个实现。

所以浏览器侧与 Node 侧的一致性不是被测出来的,是被构造出来的:它们执行的是同一段字节。

同源链上的四个断点

内联能保证同源,但它自己也有四个断点,每一个都会让「逐字相同」失效:

断点现象怎么发现
拓扑序写错页面报 xxx is not defined,canvasDrawn 永不置位waitForFunction 15 秒超时
模块间重名后声明的悄悄覆盖先声明的,digest 变了但页面看着正常只有断言红
擦类型后语法不合法整段脚本不执行(例如用了 enum、装饰器)控制台 SyntaxError,页面全空
忘了重建页面断言仍绿,但内核已经改过只有「先构建、再断言」的顺序能抓到

前三条是构建期问题,靠 bundle() 的拓扑序约定与「禁 enum / namespace」的写法纪律挡掉;第四条是流程问题,所以每次动内核都必须先重跑构建,再跑断言——只跑断言,等于在验上一版。

一条摘要的三个来源

宿主入口产生摘要的函数所在位置
Nodeimport 原文digestSnapcore/src/ashdeck/digest.ts
浏览器内联的同一段digestSnap(同一个函数)内联在 out/mech05.html
Pythonimport ashlibdigestsim/ashlib.py

前两行是同一个函数,第三行是口径相同的另一个函数。这个区别决定了失效时的定位方向:Node 与浏览器不一致,问题一定在构建(内联错了);它们与 Python 不一致,问题一定在口径(整数、PRNG、分隔符)。

三方 digest 对照

Python 侧的情况不同,必须说清楚:sim/ashlib.py 的 run_spec 是逐条对应的移植,不是同一份源码。它能给出逐字相同的 digest,靠的不是同源,而是三条纪律——全整数运算、同算法同种子的自实现 PRNG(Lehmer MINSTD)、同一种 JSON 序列化口径。三方实测:

宿主内核来源一致性靠什么实测 digest
Nodecore/src/ashdeck/combat.ts同一份文件{"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}
浏览器同文件,bundle() 擦类型后内联同一次构建的同一个产物同上(逐字相同)
Pythonsim/ashlib.py 的 run_spec整数 + 同算法 PRNG + 同分隔符同上(逐字相同)

三个 digest 摆在一起,每条 172 字符,一个字符都不差。这就是「一份规格、四处消费」能被验证的最小形态:同一段策略序列,三个宿主,同一个摘要。

摘要比对 vs 字段比对

为什么不逐字段比,而是比一整条摘要字符串?

比对方式能抓到抓不到
只比胜负(won)结论层面的分歧中间量不同(一个 4 回合结束、一个 5 回合)
逐字段比对全部字段字段漏比(新增字段时没人补)
整条摘要字符串全部字段 + 字段集合本身无(前提是序列化口径统一)

摘要的额外好处是把「比什么」固化成一份键清单:digestSnap 里列了 12 个键,Python 侧 DIGEST_KEYS 必须列同样 12 个。任何一方加了字段而另一方没加,字符串立刻不等——漏比这种最隐蔽的错误被结构性地堵住了。这也是为什么第 15 章要把跨语言 digest 单独立节:摘要不是打印用的,它是契约。

同源性的产物级检查

下面这段脚本做两件事:三方 digest 对照,以及产物级的同源与自足检查——out/mech05.html 里必须找得到内核的源码标记,且外部引用必须为 0。

#!/usr/bin/env python3
# file: sim/gd_mech_05_same_origin.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_05_same_origin.py
"""5.3 原型与内核的同源性:同一份规格、同一份内核、不同宿主。

W 层页面里的战斗内核不是「照着 P 层重写的第二份实现」,而是 web/pack.mjs 的 bundle()
把 core/src/ashdeck/combat.ts 同一个文件擦掉类型后**内联**进去的。所以浏览器侧与 Node
侧的结果不是「应该一样」,而是**必须逐字一样**——它们跑的是同一段源码。

本脚本做两件产物级核对:
① 三方对照:读 out/mech05_digests.json(Node 侧与浏览器侧 digest),再用 ashlib 在
   Python 侧跑同一份规格 + 同一段策略序列,三个 digest 必须逐字相同;
② 同源与自足:out/mech05.html 里必须出现内核的源码标记(runSpec / digestSnap /
   shuffleDeck / RNG_MUL),且**零外部引用**(无 http(s)://、无 <link、无 <script src)——
   这才叫单文件可分享、离线可跑。

落 out/gd_mech_05_same_origin.json。跨语言一致的前提:整数运算 + 同算法同种子 PRNG。
"""
from __future__ import annotations

import hashlib
import json
import os
import sys

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
MARKERS = ("function runSpec", "function digestSnap", "function shuffleDeck", "RNG_MUL = 48271")
EXTERNAL = ("http://", "https://", "<link", "<script src")


def scan_html(path: str) -> dict:
    """产物级核对:内核标记必须在、外部引用必须为 0。"""
    html = open(path, encoding="utf-8").read()
    return {
        "chars": len(html),
        "markers": {m: html.count(m) for m in MARKERS},
        "external": {p: html.count(p) for p in EXTERNAL},
    }


def main() -> int:
    spec = ashlib.load_spec()
    wpath = os.path.join(ROOT, "out", "mech05_digests.json")
    if not os.path.exists(wpath):
        print("缺 out/mech05_digests.json:请先跑 node web/verify_mech05.mjs", file=sys.stderr)
        return 2
    w = json.load(open(wpath, encoding="utf-8"))
    seq = list(spec["strategy"])

    py_digest = ashlib.digest(ashlib.run_spec(spec, seq))
    hosts = [("Node", w["nodeDigest"]), ("浏览器", w["browserDigest"]), ("Python", py_digest)]
    same = len({d for _, d in hosts}) == 1

    html = scan_html(os.path.join(ROOT, "out", "mech05.html"))
    inlined = all(v >= 1 for v in html["markers"].values())
    self_contained = all(v == 0 for v in html["external"].values())

    canon = json.dumps([d for _, d in hosts], separators=(",", ":"))
    report = {
        "spec": spec["id"],
        "strategyLength": len(seq),
        "hosts": [{"host": h, "digest": d} for h, d in hosts],
        "sameOrigin": same,
        "kernelInlined": inlined,
        "selfContained": self_contained,
        "html": html,
        "canonical": canon,
        "fingerprint": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
    }

    out = os.path.join(ROOT, "out", "gd_mech_05_same_origin.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")

    for h, d in hosts:
        print(f"{h:8s} {d}")
    print(f"sameOrigin={same} strategyLength={len(seq)}")
    print(f"kernelInlined={inlined} selfContained={self_contained} htmlChars={html['chars']}")
    print(f"markers={json.dumps(html['markers'], ensure_ascii=False, sort_keys=True)}")
    print(f"external={json.dumps(html['external'], sort_keys=True)}")
    print(f"fingerprint={report['fingerprint']}")
    print("wrote out/gd_mech_05_same_origin.json")
    return 0 if (same and inlined and self_contained) else 1


if __name__ == "__main__":
    sys.exit(main())

先跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.png 落好 out/mech05_digests.json,再跑 /opt/homebrew/bin/python3 sim/gd_mech_05_same_origin.py:

Node     {"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}
浏览器      {"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}
Python   {"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}
sameOrigin=True strategyLength=25
kernelInlined=True selfContained=True htmlChars=8842
markers={"RNG_MUL = 48271": 1, "function digestSnap": 1, "function runSpec": 1, "function shuffleDeck": 1}
external={"<link": 0, "<script src": 0, "http://": 0, "https://": 0}
fingerprint=69ea85d6be927dd1
wrote out/gd_mech_05_same_origin.json

读出来:sameOrigin=True 是三方对照的结论;markers 四项各出现 1 次,说明内核真的在页面里(runSpec、digestSnap、shuffleDeck 与常数 RNG_MUL = 48271);external 四项全 0,说明这个 HTML 没有任何外部引用——它自己就是一个完整的、可离线运行的宿主。htmlChars=8842 是字符数,对应构建时的 9448 字节(中文占 3 字节,两者本就不等)。fingerprint=69ea85d6be927dd1 是三个 digest 拼成的规范串的 sha256 前 16 位,换一个字符它就会变。

换一个新宿主怎么验

要往这套里加第四个宿主(比如 Lua、Rust、WASM),不必先证明「实现等价」——先让摘要对齐:

  1. 新宿主只读 config/play_spec.json,不许把参数抄成常量,否则改规格时它不会跟着动。
  2. 用注入的方式喂同一段策略序列,不用宿主自己的默认值;默认值一致是巧合,注入一致才是等价。
  3. 摘要序列化口径必须一致:键按字母序、分隔符无空格(Python 要写 separators=(",", ":"))、布尔输出小写 true / false。这一条最容易翻车,且翻车时满屏都是空格噪音。
  4. 先在整数上对齐,再谈容差。任何一处浮点都会把「逐字相同」降级成「差不多」——而「差不多」在跨语言对照里等于没验。
宿主源码来源一致性靠什么典型失效
Nodecombat.ts 原文同一份文件内核改了但没重跑断言
浏览器bundle() 擦类型内联同一次构建忘了重建页面,跑的是旧 HTML
Pythonrun_spec 逐条移植整数 + 同算法 PRNG + 同分隔符用了默认分隔符,摘要全是空格
新宿主待定自己走完上面四步直接用浮点或语言自带随机数

同源性买到了什么

一句话:它把「跨宿主一致性」从测试问题变成了构建问题。测试问题需要长期维护——写用例、防漂移、处理 flaky;构建问题只需要一次正确的封装,之后每章都自动继承。这也是本卷敢把 W 层放在第 5 章的原因:内核的确定性在第 4 章已经建好,第 5 章只是把它原样搬到另一个宿主,而不是在浏览器里重建一套。搬的是同一个文件,所以「一致」不需要再证明一次。

反过来说,如果第 5 章是「在浏览器里重写一遍战斗规则」,那么这一章的每一个断言都只是在验证两份实现今天恰好相同——明天就不一定了。

还有一个容易忽略的收益:同源让「浏览器里的 bug」变成「内核里的 bug」。因为跑的是同一段代码,页面上一旦出现异常行为,第一反应不是「浏览器兼容性」,而是去 core/ 里找逻辑错误——调试路径短了一整截。

这张表也解释了为什么第 11 章要把「整数化」单独立章:同源性只保证同一份源码的多个宿主一致,跨语言一致必须另外挣——挣的方式就是把浮点和语言随机数从机制层赶出去。

本节实跑清单

命令产物实测
node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.pngout/mech05_digests.json + 截图9 条断言全绿 / 48443 字节
/opt/homebrew/bin/python3 sim/gd_mech_05_same_origin.pyout/gd_mech_05_same_origin.jsonsameOrigin=True,指纹 69ea85d6be927dd1
三方摘要Node / 浏览器 / Python 各一条各 172 字符,逐字相同

这三行摆在一起,才叫「同源性」被验证过:不是「跑起来了」,而是「三个宿主的摘要一字不差,且产物里没有一行外部引用」。

两件事必须按顺序做:先跑 W 层脚本落下 out/mech05_digests.json,再跑 S 层脚本读它。顺序反了,S 层脚本会因为找不到浏览器侧摘要而直接报错退出——这个「先构建、再断言、最后对照」的顺序,第 16 章做整卷装配时还会再出现一次。

第 5 章到此结束:规格内联成单文件、无头浏览器真玩、三方摘要逐字对齐。下一章换个方向——不再问「跑得对不对」,而问「参数动一下会怎样」,从参数敏感度一路扫到相变点。

阅读导航:上一节:5.2 Playwright 真玩:注入策略、断言状态 · 下一节:6.1 参数敏感度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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