3.2 浏览器里的真玩与断言
《引擎实现与体验打磨》 那套关心的是帧:一帧里做了多少事、渲染怎么同步、输入延迟多少毫秒;本节关心的是一局:整段策略序列注入之后,胜负与状态是不是可断言的那几个值。3.1 把规格与内核内联进了一个自包含 HTML,本节要把「这个页面能玩」从肉眼判断变成可执行的断言。
关键区分在这里:注入整段策略序列,不是逐帧点按。
为什么是整段序列
逐帧点按(page.click() 几十次、每次等动画)在原型验证里是最脆弱的做法:它依赖布局坐标、依赖动画时长、依赖元素还没被重绘掉。任何一次 CSS 调整都会让脚本红一片,而红的理由和机制毫无关系。
整段注入把「玩」退化成一次确定性的函数调用:页面暴露 run(seq),传进 25 条命令(p0 表示打出手牌第 0 张,e 表示结束回合),一次跑完,返回摘要。这之所以可能,是因为规格驱动的内核本来就是纯函数——同样的 (spec, seq) 必然给出同样的快照。逐帧点按测的是界面;整段注入测的是机制,而机制才是本章要验的东西。
页面暴露的契约如下:
| 成员 | 类型 | 用途 |
|---|---|---|
run(seq, override) | 函数 | 注入整段序列(可附参数覆盖),返回 digest 字符串 |
snapshot() | 函数 | 返回最近一次快照对象,用于断言状态字段 |
canvasDrawn | 布尔 | 推迟到两次 requestAnimationFrame 之后置真 |
canvasDrawn 的推迟不是洁癖:Playwright 冷启动时,首个 page 在首帧合成前读 canvas 像素恒为空白。页面若在 draw() 之后立刻置真,验证器会在合成之前就去读像素,读到 0。推迟两次 rAF 之后,验证器等的就是「已合成一帧」而不是「已调用绘制」。
W 层:共享验证器
本章的 W 层验证用共享脚本 web/verify_mech.mjs——它同时服务三卷,负责通用断言:两遍独立加载 digest 逐字一致、canvas 已绘制、截图落盘、可选比对 --expect-digest。共享脚本只读复用,本章不修改它:
// file: web/verify_mech.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/mech01.html --shot out/shots/mech01.png
//
// 通用 W 层验证器。各章 build_mech<NN>.mjs 生成单文件 HTML,页面暴露
// globalThis.__ashdeck = { run(mode?) -> digest 字符串, snapshot(), canvasDrawn }
// 本脚本负责**通用断言**:两遍独立加载 digest 逐字一致、canvas 已绘制、截图落盘,
// 可选比对 --expect-digest。各章特有的断言写在各自的 build/verify 里。
//
// 预热页是必需的:Playwright 冷启动首个 page 读 canvas 像素恒为空白。
import { createRequire } from "node:module";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
function arg(name, def = null) {
const i = process.argv.indexOf("--" + name);
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
}
const pageArg = arg("page");
// 相对路径也要能跑:Playwright 里 `"file://" + PAGE` 拼相对路径会抛 ERR_INVALID_URL。
const PAGE = pageArg ? resolve(pageArg) : null;
const SHOT = arg("shot");
const RUN = arg("run", "run");
const EXPECT = arg("expect-digest");
const MIN_PX = Number(arg("min-px", "1"));
if (!PAGE) {
console.error("用法: node web/verify_mech.mjs --page <html> [--shot <png>] [--expect-digest <s>]");
process.exit(2);
}
const fails = [];
const check = (c, m) => {
console.log(` ${c ? "ok " : "FAIL"} ${m}`);
if (!c) fails.push(m);
};
const browser = await chromium.launch();
let digest = "";
try {
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();
async function open() {
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();
const d1 = String(await a.evaluate((m) => globalThis.__ashdeck[m](), RUN));
const painted = await a.evaluate(() => {
const cv = document.querySelector("canvas");
if (!cv) return 0;
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;
});
let bytes = 0;
if (SHOT) {
mkdirSync(dirname(SHOT), { recursive: true });
bytes = (await a.screenshot({ path: SHOT })).length;
}
await a.close();
const b = await open();
const d2 = String(await b.evaluate((m) => globalThis.__ashdeck[m](), RUN));
await b.close();
console.log("=== 通用断言 ===");
check(d1 === d2, "两遍独立加载 digest 逐字一致(确定性)");
check(painted >= MIN_PX, `canvas 已绘制(${painted} px >= ${MIN_PX})`);
if (SHOT) check(bytes > 1000, `截图已落盘(${bytes} B)`);
if (EXPECT) check(d1 === EXPECT, "digest 与 --expect-digest 一致");
digest = d1;
} finally {
await browser.close();
}
console.log(`PAGE_DIGEST=${digest}`);
console.log(fails.length === 0 ? "OK W 层通过" : `FAIL ${fails.length} 条`);
process.exit(fails.length === 0 ? 0 : 1);
构建与真玩必须分两步:先 node web/build_proto03.mjs 出页面,再拿 3.1 构建期落下的 NODE_DIGEST 当期望值去验。只跑断言等于在验上一版页面。
# snippet: 先构建,再用共享验证器无头真玩;期望值取自 3.1 的 NODE_DIGEST
node web/build_proto03.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
--page out/proto03.html --shot out/shots/proto03.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}'
W 实跑输出
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(60309 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 与 3.1 构建期的 NODE_DIGEST 一字不差。canvas 已绘制(230400 px) 是 640×360 画布的全部像素——断言只写「≥ 1 px」,因为冷启动读像素的坑是真的,靠预热页与两次 rAF 绕开。截图 60309 B 落在 out/shots/proto03.png。
P 层:把胜负与状态钉死
摘要相同只说明「两边算出了同一串字符」。本节真正要断言的是胜负与状态:打赢了吗、对局结束了吗、敌方血量归零了吗、累计伤害等于敌方初始血量吗、不变量还成立吗。这些在 Node 侧先钉死:
// file: core/test/gd_proto_03_play_assert.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_03_play_assert.test.ts'
//
// 3.2 浏览器里的真玩与断言(P 侧对照):页面注入的是**整段策略序列**(25 条命令),
// 不是逐帧点按;这里在 Node 侧用同一段序列跑同一个内核,把浏览器该看到的结果先钉死。
// 断言的是**胜负与状态**,不只是 digest:won / over / enemyHp / dealt 与不变量。
// 若构建记录 out/proto03_build.json 存在,两条 digest 必须与记录逐字相同——
// 页面与内核是同一段源码的两个宿主,逐字相同是必然,不同即说明有宿主漂了。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const SPEC = JSON.parse(readFileSync(join(ROOT, "config/play_spec.json"), "utf8"));
const LINK = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_03_share_link.json"), "utf8"));
const BUILD = join(ROOT, "out", "proto03_build.json");
test("注入整段策略序列:胜负与状态可断言", () => {
const snap = runSpec(SPEC, SPEC.strategy.slice());
assert.equal(snap.won, true, "整段序列应当打赢");
assert.equal(snap.over, true, "对局应当已结束");
assert.equal(snap.enemyHp, 0, "敌方 HP 应当归零");
assert.equal(snap.dealt, SPEC.params.enemyHp, "累计伤害应当等于敌方初始 HP");
assert.equal(snap.played, 9, "本局共出 9 张牌");
assert.equal(invariantsHold(SPEC, snap), true, "牌数 / 伤害 / 能量 / 格挡不变量成立");
});
test("回放局(seed 301 + 同一段序列)可复现", () => {
const shareSpec = JSON.parse(JSON.stringify(SPEC));
shareSpec.seed = LINK.seed;
const snap = runSpec(shareSpec, SPEC.strategy.slice());
assert.equal(snap.won, true, "换种子后同一段序列仍然赢");
assert.equal(snap.turn, 3, "换种子后 3 回合结束");
assert.equal(snap.played, 7, "换种子后共出 7 张牌");
assert.equal(invariantsHold(shareSpec, snap), true, "回放局不变量成立");
});
test("两条 digest 与构建记录逐字相同", () => {
if (!existsSync(BUILD)) {
assert.ok(true, "无 out/proto03_build.json,跳过记录比对");
return;
}
const build = JSON.parse(readFileSync(BUILD, "utf8"));
const shareSpec = JSON.parse(JSON.stringify(SPEC));
shareSpec.seed = LINK.seed;
assert.equal(digestSnap(runSpec(SPEC, SPEC.strategy.slice())), build.nodeDigest);
assert.equal(digestSnap(runSpec(shareSpec, SPEC.strategy.slice())), build.nodeShareDigest);
assert.equal(build.strategyLength, SPEC.strategy.length);
});
✔ 注入整段策略序列:胜负与状态可断言 (0.668583ms)
✔ 回放局(seed 301 + 同一段序列)可复现 (0.647542ms)
✔ 两条 digest 与构建记录逐字相同 (0.809917ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.491542
三条全绿。第二条尤其值得看:换种子(20261014 → 301)之后同一段 25 条命令仍然赢,但 turn 从 4 变成 3、played 从 9 变成 7——变化是洗牌带来的,不是随机的,因为洗牌用的是自实现可播种 PRNG,同一 (seed, 牌池) 在任何语言下同序。这正是下一节能把「一局」塞进一段链接的前提。
判读与口径
| 断言 | 断言的是什么 | 不成立的后果 |
|---|---|---|
| 两遍加载 digest 一致 | 页面无隐藏状态、无随机源 | 原型不可回放,测试结果不可信 |
| canvas ≥ 1 px | 页面真的画了,不是空壳 | 断言在验一个白屏 |
| digest == 期望值 | W 与 P 是同一段源码的两个宿主 | 宿主漂移,跨语言对照失效 |
| won / over / enemyHp / dealt | 胜负与状态,不只是摘要 | 摘要对了但局面是错的 |
这里要显式声明一次口径:本节出现的可玩性相关数字(回合数、出牌数、胜率这类从快照里读出来的量)都是代理指标(proxy),只用于相对比较——「换种子后回合数从 4 变 3」是可说的,「因此更好玩」不可说。代理指标不是体验本身,它只是让机制层的差异可被断言。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
NODE_PATH=… node web/verify_mech.mjs --page out/proto03.html --shot out/shots/proto03.png --expect-digest '…' | out/shots/proto03.png | 4 断言全绿,60309 B |
node --test '…/core/test/gd_proto_03_play_assert.test.ts' | 断言结果 | 3 测试全绿 |
到这里,「能玩」已经被断言过了,但它还是本地的:截图能发给别人,局面发不了。下一节把一局变成一段可以贴出去的链接。
阅读导航:上一节:3.1 可玩原型的规格内联 · 下一节:3.3 原型的可分享与可回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。