16.2 体验度量的联调
《小游戏开发实战》
那套是单款产品口径——品类选择、立项、变现、上线;本卷是机制与体验口径——一份规格跑起来涌现什么、体验怎么算,三卷共用一份 play_spec.json。产品立项文档的装配与评审
评的是一份文档;本节联调的是一份可执行的规格——它同时被 S / P / W 三处消费,三处必须给出同一个答案。16.1 只说了「契约完整」,本节把它跑起来:三处一起跑,产出同一条摘要、同一个代理指标。
联调协议:同摘要 + 同指标
协议两条。其一,同摘要:给定同一份规格,S(Python 仿真)/ P(TS 内核)/ W(浏览器真玩)必须产出逐字相同的 172 字符 digest——摘要的键清单是契约(第 15 章),键按字母序、分隔符无空格。其二,同指标:三处算出的体验代理指标——血量余裕度 hpMarginPermille(种子池上平均剩余 playerHp 占上限的千分比)——必须落在同一个整数上。
必须显式声明:hpMarginPermille 是代理指标(proxy),只用于相对比较(基线 vs 覆盖 energyMax=2),禁绝对化。「余裕度从 823 降到 671」是可说的;「因此更好玩」不可说。
W 层:构建与真玩
页面由 web/build_play16.mjs 构建,它复用 web/pack.mjs 的 bundle() 把 core/src/ashdeck/ 的五个模块擦类型后内联,暴露 globalThis.__ashdeck.run()(返回 digest)与 proxy()(返回代理指标)。构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/play16_build.json,作为三处对照的「P 侧」证据:
// file: web/build_play16.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_play16.mjs
//
// 16.2 体验度量的联调:把 core/src/ashdeck/ 的确定性内核与 config/play_spec.json 一起
// 塞进**一个** HTML(out/play16.html)。页面暴露 globalThis.__ashdeck:
// run() -> 机制 digest(与 S 层 Python / P 层 TS 逐字相同);snapshot() -> 最近快照;
// proxy() -> 体验代理指标(hpMarginPermille,只做相对比较,禁绝对化);canvasDrawn。
// 构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/play16_build.json,作为 S/P/W
// 三处对照的「P 侧」证据。擦类型复用 web/pack.mjs 的 bundle(),本章不重写擦除逻辑。
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
"core/src/ashdeck/types.ts",
"core/src/ashdeck/rng.ts",
"core/src/ashdeck/cards.ts",
"core/src/ashdeck/combat.ts",
"core/src/ashdeck/digest.ts",
];
const spec = JSON.parse(readFileSync(join(ROOT, "config/play_spec.json"), "utf8"));
const POOL = Array.from({ length: 32 }, (_, i) => 301 + i); // 与 S 层同一批种子
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "play16.html");
const script = core + `
const SPEC = ` + inlineConfig(spec) + `;
const POOL = ` + inlineConfig(POOL) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;
// 体验代理指标:种子池上的血量余裕度(千分比)。只做相对比较,禁绝对化。
function hpMargin(spec) {
let sum = 0;
for (const sd of POOL) {
const sp = JSON.parse(JSON.stringify(spec));
sp.seed = sd;
sum += runSpec(sp).playerHp;
}
return Math.floor((1000 * sum) / (POOL.length * spec.params.playerHp));
}
function bar(x, y, w, h, num, den, color) {
ctx.fillStyle = "#334155";
ctx.fillRect(x, y, w, h);
ctx.fillStyle = color;
ctx.fillRect(x, y, Math.floor((w * num) / den), h);
}
function render(s) {
const W = cv.width, H = cv.height, p = SPEC.params;
ctx.fillStyle = "#111827";
ctx.fillRect(0, 0, W, H);
ctx.font = "13px system-ui, sans-serif";
bar(20, 24, W - 40, 16, s.enemyHp, p.enemyHp, "#f87171");
bar(20, H - 40, W - 40, 16, s.playerHp, p.playerHp, "#34d399");
ctx.fillStyle = "#e2e8f0";
ctx.fillText("敌方 HP " + s.enemyHp + " / " + p.enemyHp, 20, 60);
ctx.fillText("手牌 " + s.hand.join(" · ") + " 回合 " + s.turn +
" 能量 " + s.energy + " / " + p.energyMax, 20, 92);
ctx.fillText("我方 HP " + s.playerHp + " / " + p.playerHp, 20, H - 48);
document.getElementById("out").textContent = digestSnap(s);
document.getElementById("margin").textContent = String(hpMargin(SPEC));
}
globalThis.__ashdeck = {
spec: SPEC,
run(seq, override) {
const sp = JSON.parse(JSON.stringify(SPEC));
if (seq) sp.strategy = seq;
if (override) for (const k of Object.keys(override)) sp.params[k] = override[k];
snap = runSpec(sp);
render(snap);
return digestSnap(snap);
},
snapshot() { return snap; },
proxy() { return hpMargin(SPEC); },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 体验度量的联调</h1>
<div class="sub">S 层 Python / P 层 TypeScript / W 层浏览器:读同一个 play_spec.json,产出同一条 digest</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">digest(应与 S / P 层逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">代理指标 · 血量余裕度(千分比,只做相对比较)</div><div class="big" id="margin">—</div></div>`;
const html = page({ title: "《灰烬牌库》· 第 16.2 节体验度量的联调",
subtitle: "S / P / W 三处同摘要 + 代理指标相对化", body, script });
const bytes = writePage(OUT, html);
const nodeDigest = digestSnap(runSpec(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "play16_build.json"),
JSON.stringify({ spec: spec.id, modules: MODULES.length, kernelChars: core.length,
page: "out/play16.html", pageBytes: bytes, pool: POOL.length, nodeDigest }, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);
构建与真玩分两步,先构建后断言(只跑断言等于在验上一版页面):
# snippet: 先构建页面,再用通用验证器无头真玩
node web/build_play16.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/play16.html --shot out/shots/play16.png --expect-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}'
modules=5 kernelChars=4123
built /Users/bingrong.yan/.gdbook/ashdeck/out/play16.html 9481 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}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(59128 B)
ok digest 与 --expect-digest 一致
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}
OK W 层通过
PAGE_DIGEST 与构建期的 NODE_DIGEST 一字不差——W 与 P 是同一段源码的两个宿主(第 5 章),本就该相等。canvas 已绘制(230400 px) 是 640×360 画布的全部像素;断言只写「≥ 1 px」,因为冷启动首个 page 读 canvas 像素恒为空白,页面靠「两次 rAF 后再置 canvasDrawn」让验证器等到首帧合成。
S 层:三处对照与代理指标
S 层脚本 sim/gd_play_16_integration.py 读 W 落下的 nodeDigest,与 Python 现算的对照,再算代理指标:
#!/usr/bin/env python3
# file: sim/gd_play_16_integration.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_16_integration.py
"""卷二 16.2 体验度量的联调(S 层):S / P 三处对齐同一条 digest 与同一个代理指标。
S Python 用 ashlib.run_spec 现算;P TypeScript 的基线 digest 由 web/build_play16.mjs 在构建期
落下(out/play16_build.json 的 nodeDigest);W 浏览器的 digest 由通用验证器 verify_mech.mjs
以 --expect-digest 断言。本节把三处**并排**:基线的 172 字符 digest 必须逐字相同。
再算一个**体验代理指标**——血量余裕度 hpMarginPermille:种子池上平均剩余 playerHp 占上限的
千分比。它只做**相对比较**(基线 vs 覆盖 energyMax=2),**禁绝对化**:「余裕度从 X 降到 Y」可
说,「因此更好玩」不可说。产出 out/gd_play_16_integration.json。全整数、无浮点、无 random。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
POOL = list(range(301, 333))
OVERRIDE = {"energyMax": 2}
def hp_margin_permille(spec, seq, pool) -> int:
total = 0
for sd in pool:
s = json.loads(json.dumps(spec))
s["seed"] = sd
total += ashlib.run_spec(s, seq)["playerHp"]
return 1000 * total // (len(pool) * spec["params"]["playerHp"])
def main() -> int:
spec = ashlib.load_spec()
seq = list(spec["strategy"])
dpath = os.path.join(OUT, "play16_build.json")
if not os.path.exists(dpath):
print("缺 out/play16_build.json:请先跑 web/build_play16.mjs", file=sys.stderr)
return 2
with open(dpath, encoding="utf-8") as f:
build = json.load(f)
py_digest = ashlib.digest(ashlib.run_spec(spec, seq))
node_digest = build["nodeDigest"]
base_margin = hp_margin_permille(spec, seq, POOL)
pert = json.loads(json.dumps(spec))
pert["params"].update(OVERRIDE)
pert_margin = hp_margin_permille(pert, seq, POOL)
same_sp = py_digest == node_digest
checks = {"digestS_eq_P": same_sp, "proxyRelativeOnly": True,
"proxyChanged": base_margin != pert_margin}
bad = [k for k, v in checks.items() if not v]
report = {
"id": "gd_play_16_integration", "pool": len(POOL), "strategyLength": len(seq),
"hosts": [{"host": "S Python", "baseline": py_digest},
{"host": "P TypeScript", "baseline": node_digest}],
"baselineSameSP": same_sp, "override": OVERRIDE,
"proxy": {"name": "hpMarginPermille", "unit": "permille", "relativeOnly": True,
"absoluteClaims": False, "baseline": base_margin, "perturbed": pert_margin,
"delta": pert_margin - base_margin},
"checks": checks, "fail": len(bad),
"evidence": {"S": "sim/gd_play_16_integration.py",
"P": "core/test/gd_play_16_integration.test.ts",
"W": "out/play16.html (verify_mech.mjs)"},
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_play_16_integration.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 16.2 S / P 两处基线 digest 对照 ===")
for h in report["hosts"]:
print(f"{h['host']:14s} {h['baseline']}")
print(f"基线一致(S==P)={same_sp}")
print(f"代理指标 hpMarginPermille 基线={base_margin} 扰动(energyMax=2)={pert_margin} "
f"delta={pert_margin - base_margin}(只做相对比较)")
print(f"fail={len(bad)} {'(三处对齐)' if not bad else bad}")
print("wrote out/gd_play_16_integration.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
=== 16.2 S / P 两处基线 digest 对照 ===
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}
基线一致(S==P)=True
代理指标 hpMarginPermille 基线=823 扰动(energyMax=2)=671 delta=-152(只做相对比较)
fail=0 (三处对齐)
wrote out/gd_play_16_integration.json
两行 digest 一字不差,基线一致(S==P)=True。第二半是代理指标:基线 823、覆盖 energyMax=2 后 671、delta=-152。能量上限从 3 降到 2,一回合能打的牌变少,平均剩余血量下降——这是机制的,不是随机的。注意口径:只说「余裕度相对下降」,不说「体验变差」——代理指标只用于相对比较。
P 层:TS 内核复现同一结论
P 层把上面两条结论固化成断言,落在 core/test/gd_play_16_integration.test.ts:基线 digest、代理指标 823/671、以及「扰动后不变量仍成立」:
# snippet: 跑 P 层联调测试(全 glob;node --test <目录> 不发现 .ts)
node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_play_16_integration.test.ts'
✔ 基线 digest 复现 S 层(逐字一致)
✔ 代理指标 hpMarginPermille 基线复现为 823
✔ 覆盖 energyMax=2 后代理指标相对下降(只做相对比较)
✔ 扰动后不变量仍成立(改参数不破坏守恒)
✔ 若联调记录已存在,TS digest 必须等于记录里的 P 层条目
ℹ tests 5
ℹ pass 5
ℹ fail 0
五条全绿。第四条尤其重要:改参数不破坏守恒——能量上限降到 2 之后,牌数仍守恒(12)、伤害仍守恒,只是余裕度变了。代理指标的扰动动的是体验量,不是不变量。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node web/build_play16.mjs | out/play16.html + out/play16_build.json | 5 模块内联,9481 B |
NODE_PATH=… node web/verify_mech.mjs --page out/play16.html … | out/shots/play16.png | 4 断言全绿,59128 B |
/opt/homebrew/bin/python3 sim/gd_play_16_integration.py | out/gd_play_16_integration.json | 三处对齐,823→671 |
node --test '…/gd_play_16_integration.test.ts' | 断言结果 | 5 测试全绿 |
三处证据落在三个不同的文件里,却指向同一条 172 字符的字符串与同一个 823。下一节收口:把这条已经被验证过的动态基线交付给卷三——不是交一份报告,是交一组冻结的接口。
阅读导航:上一节:16.1 动态度量装配 · 下一节:16.3 交付卷三 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。