3.2 浏览器里的真玩与断言

在无头浏览器里真的玩一局:注入的是整段 25 条策略序列,不是逐帧点按;断言的是胜负与状态(won / over / enemyHp / dealt 与不变量),而不只是一条摘要。页面 digest 必须与 Node 侧 digestSnap(runSpec(...)) 逐字相同——同一段源码的两个宿主本就该给出同一个答案。

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.png4 断言全绿,60309 B
node --test '…/core/test/gd_proto_03_play_assert.test.ts'断言结果3 测试全绿

到这里,「能玩」已经被断言过了,但它还是本地的:截图能发给别人,局面发不了。下一节把一局变成一段可以贴出去的链接。

阅读导航:上一节:3.1 可玩原型的规格内联 · 下一节:3.3 原型的可分享与可回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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