5.2 Playwright 真玩:注入策略、断言状态

用 web/verify_mech05.mjs 做无头真玩:注入 25 步整段策略序列,断言页面 digest 与 Node 侧 digestSnap(runSpec(spec)) 逐字相同,再换一段截断序列证明注入真被消费。含预热页的必要性、canvas 已绘制断言、两遍确定性、48443 字节截图与完整实跑输出。

5.2 Playwright 真玩:注入策略、断言状态

上一节产出了一个 9448 字节的单文件 HTML;但「能打开」离「能验证」还差一步。本节做的是 W 层真正的价值:让无头浏览器把整段策略序列打完,再拿它的结果和 Node 侧逐字比对。先说清分工:这不是 《小游戏开发实战》卷一的模拟采样 ——那篇在 S 层跑大量种子、给的是占比类指标与置信区间;本节只跑一局,验证的是宿主等价性:浏览器里跑出来的东西,和命令行里跑出来的,是不是同一个东西。最小可玩原型的产品口径 (做多大、验证什么假设、何时止损)由那一卷负责,本节只负责把它做成可断言的。

预热页不是可选项

Playwright 冷启动时,第一个 page 读 canvas 像素恒为空白——不是画错了,是合成器还没热起来,getImageData 拿到的是全 0。这个坑的可怕之处在于它只影响第一次:反复重跑脚本、偶尔换一台机器,就会得到「有时过有时不过」的假象,而失败点离真正的原因很远。做法是在打开真正页面前,先开一个 about:blank 预热页画一次 8×8 再丢弃:

// snippet: 预热页——先画一次 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();

同一个道理,canvas 断言只写「已绘制(≥ 1 px)」,绝不写 === 0——后者在预热失败时会假绿,把一个坏掉的渲染当成「没画也是对的」。

注入策略、断言 digest

下面这段是本章的真玩脚本。它做三件事:把规格自带的 25 步策略序列注入页面;断言页面返回的 digest 与 Node 侧 digestSnap(runSpec(spec, seq)) 逐字相同;再换一段 6 步的截断序列跑一遍,确认注入真的被消费(否则页面完全可以用自带策略糊弄过去)。截图与关键数字落 out/mech05_digests.json,供第 5.3 节做三方对照。

// file: web/verify_mech05.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.png
//
// 5.2 无头真玩:往页面里**注入整段策略序列**,断言页面返回的 digest 与 Node 侧
// digestSnap(runSpec(spec, seq)) 逐字相同;再断言 canvas 已绘制、两遍确定性、截图落盘。
// 预热页是必需的:Playwright 冷启动首个 page 读 canvas 像素恒为空白。
// 落 out/mech05_digests.json,供第 5.3 节做 Node / 浏览器 / Python 三方对照。

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 HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");

function arg(name, def = null) {
  const i = process.argv.indexOf("--" + name);
  return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
}

// 命令行给相对路径时先转绝对:file:// 只认绝对路径。
const PAGE = resolve(arg("page", join(ROOT, "out", "mech05.html")));
const SHOT = resolve(arg("shot", join(ROOT, "out", "shots", "mech05.png")));
const SPEC_PATH = arg("spec", join(ROOT, "config", "play_spec.json"));

const spec = JSON.parse(readFileSync(SPEC_PATH, "utf8"));
const STRATEGY = spec.strategy.slice(); // 注入的整段策略序列
const ALT = STRATEGY.slice(0, 6); // 截断序列:用来证明注入被真的消费
const nodeDigest = digestSnap(runSpec(spec, STRATEGY));
const nodeAlt = digestSnap(runSpec(spec, ALT));

const fails = [];
const check = (c, m) => {
  console.log(`  ${c ? "ok  " : "FAIL"} ${m}`);
  if (!c) fails.push(m);
};

let browserDigest = "";
let browserDigest2 = "";
let browserAlt = "";
let painted = 0;
let bytes = 0;
let played = 0;

const browser = await chromium.launch();
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();

  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();
  browserDigest = String(await a.evaluate((seq) => globalThis.__ashdeck.run(seq), STRATEGY));
  const snap = await a.evaluate(() => globalThis.__ashdeck.snapshot());
  played = snap.played;
  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;
  browserAlt = String(await a.evaluate((seq) => globalThis.__ashdeck.run(seq), ALT));
  await a.close();

  const b = await open();
  browserDigest2 = String(await b.evaluate((seq) => globalThis.__ashdeck.run(seq), STRATEGY));
  await b.close();

  console.log("=== 第 5.2 节真玩断言 ===");
  check(browserDigest === nodeDigest, "页面 digest == Node 侧 digestSnap(runSpec(spec, seq))");
  check(browserAlt === nodeAlt, "换一段截断序列,页面 digest 仍与 Node 侧一致(注入生效)");
  check(browserAlt !== browserDigest, "换序列确实换了结果(注入被真的消费,不是摆设)");
  check(browserDigest === browserDigest2, "两遍独立加载 digest 逐字一致(确定性)");
  check(played > 0, `策略序列真的被执行(出牌 ${played} 次)`);
  check(snap.cardCount === 12, `牌数守恒 = 12(实为 ${snap.cardCount})`);
  check(snap.damageConserved === true, "伤害守恒成立");
  check(painted >= 1, `canvas 已绘制(${painted} px >= 1)`);
  check(bytes > 1000, `截图已落盘(${bytes} B)`);
} finally {
  await browser.close();
}

const report = {
  spec: spec.id,
  strategyLength: STRATEGY.length,
  altLength: ALT.length,
  nodeDigest,
  browserDigest,
  browserDigest2,
  nodeAlt,
  browserAlt,
  played,
  painted,
  shotBytes: bytes,
  page: "out/mech05.html",
  shot: "out/shots/mech05.png",
};
writeFileSync(join(ROOT, "out", "mech05_digests.json"), JSON.stringify(report, null, 2) + "\n");

console.log(`NODE_DIGEST=${nodeDigest}`);
console.log(`BROWSER_DIGEST=${browserDigest}`);
console.log(`PAINTED=${painted} SHOT_BYTES=${bytes}`);
console.log(fails.length === 0 ? "OK W 层通过" : `FAIL ${fails.length} 条`);
process.exit(fails.length === 0 ? 0 : 1);

实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.png:

=== 第 5.2 节真玩断言 ===
  ok   页面 digest == Node 侧 digestSnap(runSpec(spec, seq))
  ok   换一段截断序列,页面 digest 仍与 Node 侧一致(注入生效)
  ok   换序列确实换了结果(注入被真的消费,不是摆设)
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   策略序列真的被执行(出牌 9 次)
  ok   牌数守恒 = 12(实为 12)
  ok   伤害守恒成立
  ok   canvas 已绘制(230400 px >= 1)
  ok   截图已落盘(48443 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}
BROWSER_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}
PAINTED=230400 SHOT_BYTES=48443
OK W 层通过

九条全绿,其中三条值得单独读:

  • 两条 digest 字符串完全一致,逐字相同(不是「数值接近」):enemyHp 归 0、dealt 恰好 50、played 9 次、第 4 回合结束、won 为真。浏览器与 Node 不只是结论一样,连中间量一样。
  • 截断序列给出了不同的 digest:6 步时是 dealt=23、enemyHp=27、turn=2、won=false。两段序列结果不同,说明 run(seq) 真的用了传进去的参数,而不是忽略它去跑规格自带的 strategy。
  • PAINTED=230400 恰好等于 640 × 360,说明整张 canvas 都被填过(背景 + 血条 + 手牌),不是「只有一个像素」的侥幸通过。

截图 out/shots/mech05.png 48443 字节,可以直接贴进评审材料——但它不是这一节的证据,digest 才是。

共享门禁:web/verify_mech.mjs

除了本章专属脚本,还有一个所有章共用的通用门禁 web/verify_mech.mjs(只读复用,本章不修改它)。它只做与章节无关的四件事:两遍独立加载 digest 一致、canvas 已绘制、截图落盘、可选比对 --expect-digest:

# snippet: 共享门禁(只读复用,本章不修改 web/verify_mech.mjs)
D='{"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_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
  --page "$PWD/out/mech05.html" --shot "$PWD/out/shots/mech05.png" --expect-digest "$D"
=== 通用断言 ===
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   canvas 已绘制(230400 px >= 1)
  ok   截图已落盘(48443 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 层通过

注意这里必须传绝对路径:脚本内部用 "file://" + PAGE 拼 URL,相对路径会拼出 file://out/mech05.html 这种无效 URL。本章的 verify_mech05.mjs 多做了一步 resolve(),所以相对路径也能跑;调用共享脚本时老老实实给 $PWD。

两遍独立加载不是仪式

脚本开了两次页面、跑了两遍同一个序列,再比对两个 digest。这一步单独存在的理由:同一份代码在同一个进程里跑两次一样,说明不了什么——真正的风险是「跨进程、跨启动」的隐藏状态,比如模块级缓存、全局随机种子,或者某个 Date.now() 混进了结算。两遍独立加载会把这些全部换掉:新的浏览器上下文、新的 JS 引擎实例、新的内存。两遍仍然一致,才排除了「靠进程内残留状态蒙对」这条路径。

再补一条纪律:断言里不写时间、不写性能。截图耗时、加载毫秒数在不同机器上必然不同,把它们写进断言,等于把测试变成一台 flaky 老虎机。

页面 API 与断言的映射

页面接口断言拿它做什么为什么够用
run(seq) 的返回值宿主等价、注入生效、确定性摘要覆盖了全部状态字段
snapshot().played策略真被执行只看摘要变了不知道「为什么变」
snapshot().cardCount牌数守恒守恒是规格层不变量,不是渲染属性
canvasDrawn等待页面就绪比 sleep 稳,比 networkidle 快
canvas 非透明像素渲染没崩只证明「画过」,不证明「画对」

最后一行要强调:像素断言只到「已绘制」为止。画面好不好看不是这套要回答的问题——判定观感需要人,属于卷三的可玩性测试;这里只保证「逻辑跑通,并且确实被看见了」。

断言清单

断言判据失败意味着
宿主等价页面 digest == Node digest,逐字内核内联错了,或页面没跑注入的序列
注入生效换序列后 digest 随之改变run(seq) 忽略了参数
确定性两遍独立加载 digest 相同内核里混进了 Math.random() 或时间
策略真被执行played > 0序列被跳过(例如全部 continue)
守恒cardCount == 12 且 damageConserved抽/弃牌或伤害结算漏了一条
已绘制canvas 非透明像素 ≥ 1渲染报错,或忘了预热页
截图落盘字节数 > 1000路径没写对或页面是空白

七条里前五条都只依赖 digest 与 snapshot(),与像素无关——把渲染和逻辑分开断言,是这套做法的关键:渲染坏了不该让逻辑测试变红,逻辑错了也不该被一张好看的截图掩盖。

下一节把 Node、浏览器、Python 三方的 digest 摆到一起,说清「同源」到底同在哪。

阅读导航:上一节:5.1 规格内联进单文件 HTML · 下一节:5.3 原型与内核的同源性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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