16.2 四处消费的联调

把 S / P / W / B 四处真的跑一遍:Python 仿真、TS 内核 node --test、浏览器 Playwright 真玩、B 层参数扰动,四处产出同一条 172 字符的摘要,再改一个规则参数(energyMax 3→2)看四处是否同步改变。这是「一份规格,四处消费」主张的可验证形态。

16.2 四处消费的联调

《小游戏开发实战》的产品立项文档装配与评审 评审的是一份文档;本节联调的是一份可执行的规格——它同时被四处消费,四处必须给出同一个答案。《小游戏开发实战》 那套是单款产品的口径,本套是机制口径。16.1 的检查表里 C5 只说了「三处 digest 一致」,本节把它摊开:四处一起跑,并且改一个规则参数,看四处是否同步改变——只一致不变,说明规格是死的;只变不一致,说明四处已经漂移。

四处消费各自的证据形态

四处实现各不相同——S 是 Python 仿真、P 是 TS 内核、W 是浏览器真玩、B 是参数扫描——但它们读的是同一个文件、算的是同一套规则、输出的是同一种摘要;证据形态分别是终端里一行 digest 字符串、node --test 的断言结果、PAGE_DIGEST、扰动前后的对照 JSON。联调就是证明这一点。

联调协议:同摘要 + 一处扰动

协议只有两条。其一,同摘要:给定同一段策略序列,四处必须产出逐字相同的 172 字符 digest——摘要的键清单是契约(第 15 章),键按字母序、分隔符无空格。其二,一处扰动:只改一个规则参数(这里是 params.energyMax 3→2),四处必须同步改变且仍逐字一致;某处没变说明它没读这个参数,某处变的方向不同说明它的规则实现漂了。

为什么挑 energyMax?因为它是真规则参数:能量上限直接决定一回合能打几张牌。降到 2 之后,重牌(费用 2)仍打得动,但一回合打两张 1 费牌的余量没了,于是多花一回合、多挨一次攻击——结果必然改变,且改变可解释。

W 层:构建与真玩

页面由 web/build_mech16.mjs 构建,它复用 web/pack.mjs 的 bundle() 把 core/src/ashdeck/ 的五个模块擦类型后内联,并暴露 run(seq, override)——第二个参数就是参数覆盖对象。真玩脚本如下(构建与真玩必须分两步、先构建后断言,只跑断言等于在验上一版页面):

// file: web/verify_mech16.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech16.mjs --page out/mech16.html --shot out/shots/mech16.png
//
// 16.2 W 层真玩:注入整段策略序列,断言浏览器 digest 与 Node 侧 digestSnap(runSpec(...))
// 逐字相同;再注入参数覆盖 { energyMax: 2 },断言两处同时改变且仍逐字相同——「改一个规则
// 参数,四处同步改变」的可验证形态。预热页必需:冷启动首个 page 读 canvas 像素恒为空白。

import { createRequire } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";

const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const arg = (n, d = null) => {
  const i = process.argv.indexOf("--" + n);
  return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d;
};
const PAGE = resolve(arg("page", join(ROOT, "out", "mech16.html")));
const SHOT = resolve(arg("shot", join(ROOT, "out", "shots", "mech16.png")));
const spec = JSON.parse(readFileSync(join(ROOT, "config", "play_spec.json"), "utf8"));
const SEQ = spec.strategy.slice();
const OVERRIDE = { energyMax: 2 }; // 唯一被扰动的规则参数(整数)
const withOverride = (s, ov) => {
  const c = JSON.parse(JSON.stringify(s));
  for (const k of Object.keys(ov)) c.params[k] = ov[k];
  return c;
};
const nodeDigest = digestSnap(runSpec(spec, SEQ));
const nodePerturb = digestSnap(runSpec(withOverride(spec, OVERRIDE), SEQ));
const fails = [];
const check = (c, m) => {
  console.log(`  ${c ? "ok  " : "FAIL"} ${m}`);
  if (!c) fails.push(m);
};

const browser = await chromium.launch();
let browserDigest = "", browserDigest2 = "", browserPerturb = "", painted = 0, bytes = 0;
try {
  // 预热页:冷启动首个 page 的 canvas 读取恒为空白,先画一次 8×8 再丢弃。
  const warm = await browser.newPage();
  await warm.goto("about:blank");
  await warm.evaluate(() => {
    const c = document.createElement("canvas");
    c.width = 8; c.height = 8;
    c.getContext("2d").getImageData(0, 0, 8, 8).data.length;
  });
  await warm.close();
  const open = async () => {
    const p = await browser.newPage({ viewport: { width: 900, height: 700 } });
    await p.goto("file://" + PAGE);
    await p.waitForFunction(
      () => globalThis.__ashdeck && globalThis.__ashdeck.canvasDrawn === true,
      null, { timeout: 15000 });
    return p;
  };
  const a = await open();
  browserDigest = String(await a.evaluate((seq) => globalThis.__ashdeck.run(seq), SEQ));
  painted = await a.evaluate(() => {
    const cv = document.querySelector("canvas");
    const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
    let n = 0;
    for (let i = 3; i < d.length; i += 4) if (d[i] !== 0) n += 1;
    return n;
  });
  mkdirSync(dirname(SHOT), { recursive: true });
  bytes = (await a.screenshot({ path: SHOT })).length;
  browserPerturb = String(await a.evaluate(
    (x) => globalThis.__ashdeck.run(x.seq, x.ov), { seq: SEQ, ov: OVERRIDE }));
  await a.close();
  const b = await open();
  browserDigest2 = String(await b.evaluate((seq) => globalThis.__ashdeck.run(seq), SEQ));
  await b.close();

  console.log("=== 第 16.2 节四处消费联调断言 ===");
  check(browserDigest === nodeDigest, "W 基线 digest == P 层 digestSnap(runSpec(spec, seq))");
  check(browserPerturb === nodePerturb, "注入 energyMax=2 后,W 扰动 digest == P 层扰动 digest");
  check(browserPerturb !== browserDigest, "扰动确实改变了结果(参数被真的消费)");
  check(browserDigest === browserDigest2, "两遍独立加载基线 digest 逐字一致(确定性)");
  check(painted >= 1, `canvas 已绘制(${painted} px >= 1)`);
  check(bytes > 1000, `截图已落盘(${bytes} B)`);
} finally {
  await browser.close();
}

writeFileSync(join(ROOT, "out", "mech16_digests.json"), JSON.stringify({
  spec: spec.id, strategyLength: SEQ.length, override: OVERRIDE, nodeDigest, nodePerturb,
  browserDigest, browserDigest2, browserPerturb, painted, shotBytes: bytes,
  page: "out/mech16.html", shot: "out/shots/mech16.png",
}, null, 2) + "\n");
console.log(`NODE_DIGEST=${nodeDigest}`);
console.log(`NODE_PERTURB=${nodePerturb}`);
console.log(`PAGE_DIGEST=${browserDigest}`);
console.log(`PAGE_PERTURB=${browserPerturb}`);
console.log(`PAINTED=${painted} SHOT_BYTES=${bytes}`);
console.log(fails.length === 0 ? "OK W 层通过" : `FAIL ${fails.length} 条`);
process.exit(fails.length === 0 ? 0 : 1);

W 实跑输出

=== 第 16.2 节四处消费联调断言 ===
  ok   W 基线 digest == P 层 digestSnap(runSpec(spec, seq))
  ok   注入 energyMax=2 后,W 扰动 digest == P 层扰动 digest
  ok   扰动确实改变了结果(参数被真的消费)
  ok   两遍独立加载基线 digest 逐字一致(确定性)
  ok   canvas 已绘制(230400 px >= 1)
  ok   截图已落盘(50843 B)
NODE_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}
NODE_PERTURB={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}
PAGE_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}
PAGE_PERTURB={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}
PAINTED=230400 SHOT_BYTES=50843
OK W 层通过

六条断言全绿。PAGE_DIGEST 与 NODE_DIGEST 一字不差——W 与 P 是同一段源码的两个宿主(第 5 章),本就该相等;PAGE_PERTURB ≠ PAGE_DIGEST 说明参数覆盖真的被消费了。canvas 已绘制(230400 px) 是 640×360 画布的全部像素,断言只写「≥ 1 px」,因为冷启动首个 page 读 canvas 恒为空白,这里靠预热页绕开。

S 层:四方对照

S 层脚本把 W 落下的 digest 读回来,与 Python 现算的对照,并顺手扫一遍参数敏感度:

#!/usr/bin/env python3
# file: sim/gd_mech_16_integration.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_16_integration.py
"""16.2 四处消费的联调:同一份 play_spec.json 在 S / P / W / B 四处的证据对齐。

S Python 用 ashlib.run_spec 现算;P TypeScript 与 W 浏览器的基线/扰动 digest 由
web/verify_mech16.mjs 落下(out/mech16_digests.json);B 层覆盖一个规则参数
(energyMax 3→2),要求四处**同步改变且仍逐字一致**,并扫一遍参数敏感度。
落 out/gd_mech_16_perturb.json 与 out/gd_mech_16_integration.json。
跨语言逐位一致的唯一保证:整数运算 + 同算法同种子 PRNG + 同分隔符。
"""
from __future__ import annotations
import json
import os
import sys
import ashlib

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OVERRIDE = {"energyMax": 2}
SWEEP = [("energyMax", 2), ("enemyHp", 44), ("enemyAttack", 8),
         ("handSize", 4), ("turnLimit", 10), ("punishHp", 6)]


def override_spec(spec, key, val):
    out = json.loads(json.dumps(spec))
    out["params"][key] = val
    return out


def diff_keys(a, b):
    return [k for k in ashlib.DIGEST_KEYS if a[k] != b[k]]


def main():
    spec = ashlib.load_spec()
    seq = list(spec["strategy"])
    dpath = os.path.join(ROOT, "out", "mech16_digests.json")
    if not os.path.exists(dpath):
        print("缺 out/mech16_digests.json:请先跑 web/verify_mech16.mjs", file=sys.stderr)
        return 2
    w = json.load(open(dpath, encoding="utf-8"))
    base = ashlib.run_spec(spec, seq)
    pert = ashlib.run_spec(override_spec(spec, "energyMax", OVERRIDE["energyMax"]), seq)
    py_base, py_pert = ashlib.digest(base), ashlib.digest(pert)
    hosts = [("S Python", py_base, py_pert),
             ("P TypeScript", w["nodeDigest"], w["nodePerturb"]),
             ("W 浏览器", w["browserDigest"], w["browserPerturb"])]
    base_same = len({b for _, b, _ in hosts}) == 1
    pert_same = len({p for _, _, p in hosts}) == 1
    changed = py_base != py_pert
    print("=== 四处消费基线对照 ===")
    for n, b, _ in hosts:
        print(f"{n:14s} {b}")
    print("=== 扰动后对照(params.energyMax 3 → 2)===")
    for n, _, p in hosts:
        print(f"{n:14s} {p}")
    print(f"基线一致={base_same} 扰动一致={pert_same} 基线≠扰动={changed}")

    rows = []
    for key, val in SWEEP:
        s = ashlib.run_spec(override_spec(spec, key, val), seq)
        d = ashlib.digest(s)
        rows.append({"param": key, "from": spec["params"][key], "to": val, "digest": d,
                     "changed": d != py_base, "diffKeys": diff_keys(base, s)})
    active = [r["param"] for r in rows if r["changed"]]
    inert = [r["param"] for r in rows if not r["changed"]]
    print("=== B 层参数敏感度(覆盖单个规则参数)===")
    for r in rows:
        print(f"  {'改' if r['changed'] else '平'} {r['param']:12s} {r['from']}→{r['to']:<3} "
              f"diff={','.join(r['diffKeys']) or '-'}")

    with open(os.path.join(ROOT, "out", "gd_mech_16_perturb.json"), "w", encoding="utf-8") as f:
        json.dump({"spec": spec["id"], "strategyLength": len(seq), "baselineDigest": py_base,
                   "override": OVERRIDE, "perturbedDigest": py_pert, "diffKeys": diff_keys(base, pert),
                   "sweep": rows, "activeParams": active, "inertParams": inert,
                   "note": "inert 只说明这条策略序列没触及该参数,不代表参数无意义;B 层软告警不硬失败。"},
                  f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    with open(os.path.join(ROOT, "out", "gd_mech_16_integration.json"), "w", encoding="utf-8") as f:
        json.dump({"spec": spec["id"], "strategyLength": len(seq),
                   "hosts": [{"host": n, "baseline": b, "perturbed": p} for n, b, p in hosts],
                   "baselineSame": base_same, "perturbedSame": pert_same,
                   "baselineDiffersPerturbed": changed, "override": OVERRIDE,
                   "activeParams": active, "inertParams": inert,
                   "evidence": {"S": "sim/gd_mech_16_integration.py",
                                "P": "core/src/ashdeck/{combat,digest}.ts via web/verify_mech16.mjs",
                                "W": "out/mech16.html (Playwright 无头)",
                                "B": "out/gd_mech_16_perturb.json"}},
                  f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"activeParams={active} inertParams={inert}")
    print("wrote out/gd_mech_16_perturb.json + out/gd_mech_16_integration.json")
    return 0 if (base_same and pert_same and changed) else 1


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

S 实跑输出

=== 四处消费基线对照 ===
S 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}
P TypeScript   {"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}
W 浏览器          {"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}
=== 扰动后对照(params.energyMax 3 → 2)===
S Python       {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}
P TypeScript   {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}
W 浏览器          {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":46,"turn":5,"won":true}
基线一致=True 扰动一致=True 基线≠扰动=True
=== B 层参数敏感度(覆盖单个规则参数)===
  改 energyMax    3→2   diff=energy,playerHp,turn
  改 enemyHp      50→44  diff=dealt
  改 enemyAttack  6→8   diff=playerHp
  平 handSize     5→4   diff=-
  平 turnLimit    20→10  diff=-
  平 punishHp     4→6   diff=-
activeParams=['energyMax', 'enemyHp', 'enemyAttack'] inertParams=['handSize', 'turnLimit', 'punishHp']
wrote out/gd_mech_16_perturb.json + out/gd_mech_16_integration.json

基线一致=True 是四处消费主张的直接证据:三行基线 digest 一字不差。扰动一致=True 基线≠扰动=True 是第二半:改了参数,三行仍然一字不差,但整体换了另一条摘要。一致 + 同步改变同时成立,「一份规格,四处消费」才算被验证过。

改前改后:只有三个字段动了

把基线与扰动后的 digest 逐字段比,差异集中在三处,且都能用规则解释:

字段基线energyMax=2为什么
turn45每回合少 1 点能量,出牌节奏被拖慢一回合
playerHp4846多打一回合,多挨一次攻击(6 点被格挡抵消后净掉 2)
energy10回合结束时的剩余能量,2 费上限下刚好打光

dealt 与 enemyHp 没变(都是 50 / 0)——这条策略序列最终仍把敌人打死了,只是多花了一回合。这正是「扰动同步改变」的可读之处:变化是机制的,不是随机的。

P 层:TS 内核复现同一结论

P 层把上面两条结论固化成断言,落在 core/test/gd_mech_16_integration.test.ts:基线 digest、扰动 digest、以及「扰动后不变量仍成立」:

# snippet: 跑 P 层联调测试
node --test 'core/test/gd_mech_16_integration.test.ts'
✔ 基线 digest 复现 S / W 两侧(逐字一致)
✔ 扰动 energyMax=2 后 digest 同步改变(逐字一致)
✔ 扰动后不变量仍成立(改参数不破坏守恒)
✔ 若联调记录已存在,TS digest 必须等于记录里的 P 层条目
ℹ tests 4
ℹ pass 4
ℹ fail 0

四条全绿。第三条尤其重要:改参数不破坏守恒——能量上限降到 2 之后,牌数仍守恒(12)、伤害仍守恒,只是回合数变了。B 层扰动的意义正在这里:它动的是可玩性指标,不是不变量。

B 层:敏感度是软告警

敏感度扫描里,六个参数分成两拨:energyMax、enemyHp、enemyAttack 是机制活跃的(改了就变),handSize、turnLimit、punishHp 在这条策略序列下不改变结果。这里必须诚实:inert 不代表参数无意义,只代表这条序列没触及它——handSize 从 5 降到 4,这条序列恰好还没打光手牌就赢了;turnLimit 是上限保护,20 和 10 都够用。B 层按纪律是软告警不硬失败:它提示「这个参数在当前策略下不敏感」,而不是「这个参数是废的」。

本节实跑清单

命令产物实测
node web/build_mech16.mjsout/mech16.html5 模块内联,9826 B
node web/verify_mech16.mjs --page out/mech16.html --shot out/shots/mech16.pngout/mech16_digests.json + 截图6 断言全绿,50843 B
/opt/homebrew/bin/python3 sim/gd_mech_16_integration.pyout/gd_mech_16_perturb.json + out/gd_mech_16_integration.json四处一致,基线≠扰动
node --test 'core/test/gd_mech_16_integration.test.ts'断言结果4 测试全绿

四处证据落在四个不同的文件里,却指向同一条 172 字符的字符串。下一节收口:把这条已经被验证过的规格交付给卷二——不是交一份文档,是交一组冻结的接口。

阅读导航:上一节:16.1 规格装配检查表 · 下一节:16.3 交付卷二 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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