16.3 原型回放与方案交付

方案交付的最后一关,是把它变成一次能被人亲手重放的回放。本节先声明回放录制参数(D 层)——种子、列数、步数、时间跨度与合成阈值;再用一段 P 层内核把录制序列重放成确定性的终局与摘要(FNV-1a);最后把同一段内核打成单文件 HTML(W 层),用无头浏览器读回摘要,断言它与 Node 单测逐位相同,并截图留证。

16.2 证明整条链能跑通,但交付的最后一关是:别人拿到方案,能不能亲手把它跑一遍。一份交付物如果只有数字和截图,接手的人没法复现;而如果它能被重放——同一份录制参数,在 Node 里和在浏览器里跑出逐位相同的终局——那它就真的「交出去了」。本节把《合成小岛》的一次可玩会话录成可播种的操作序列,做一次确定性回放,再打成单文件 HTML。

先说分工。本站有一篇小程序小游戏开发综述 ,那是单篇概览,只把原型与回放列成要点;本节给的是可运行、可复现的两端——同一段 replay.ts 既跑 Node 单测,也跑无头浏览器,摘要逐位对齐。这正是卷二一份逻辑两种宿主 在收官章的落点:不是「应该两端一致」,而是「两端真的算出同一个摘要」。

16.3 原型回放与方案交付

16.3.1 交付的不是文档,是一次可重放的回放

前两节把方案装配、验收完毕,但交付物里最该有分量的不是那份 PDF,而是一次能被重放的回放。回放要满足三个条件:可播种(同一个 seed 必得同一段操作序列)、确定性(同输入必同终局,无 Math.random、无浮点)、可移植(同一段内核在 Node 与浏览器跑出同一个摘要)。三者都满足,接手的人才能说「我这边跑出来也是这个数」。

这与卷二的确定性内核一脉相承(见固定步长循环与确定性 ):整数运算、自实现随机数、地板除,是跨宿主逐位一致的唯一办法。本节把这套纪律用在一次真实的会话回放上。

16.3.2 回放录制参数(D 层)

下面这段是 D 层回放录制参数。它声明录制由 seed + count + spanMs + cols 确定性生成:时间戳均匀铺在 (0, spanMs],每次操作投到哪一列由整数 LCG 产生;每步往该列投 startValue 个单位,该列每满 mergeAt 就塌缩一次并向右进位。它只是数据——真正重放它的是下一节的 P 层内核。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "cols": 6,
  "count": 120,
  "spanMs": 6000,
  "stepMs": 50,
  "startValue": 1,
  "mergeAt": 3,
  "note": "原型回放录制参数(D 层)。录制由 seed + count + spanMs + cols 确定性生成:时间戳均匀铺在 (0, spanMs],整数毫秒升序;每次操作的下标由自实现整数 LCG 产生。每步往该列投 startValue 个单位,该列每满 mergeAt 就塌缩一次并把一个单位进位到右列。Node 侧(core/test)与浏览器侧(web/dist/l16.html)读同一份参数、跑同一段 replay.ts,终局摘要与 digest 必须逐位相同。全整数、无浮点、无 Math.random。"
}

16.3.3 回放内核(P 层)

下面这段是 P 层回放内核。它把录制、重放、摘要三件事封在一处:makeLcg 是自实现 32 位整数 LCG(同 seed 必同序列),record 铺时间戳与列号,replay 逐步塌缩并向右进位,digest 用 FNV-1a 覆盖得分、合成次数、步数与整行终局。它只用可擦除语法,除法一律整数地板除。

// file: core/src/l16/replay.ts
// tier: P
// verify: node --test 'core/test/l16_*.test.ts'
//
// 原型回放内核(本卷 16.3 的 P 层)。把一次可玩会话录成「可播种的整数操作序列」,
// 再在任意宿主上重放成同一终局摘要与 digest。这是「一份逻辑、两种宿主」在收官章的
// 落点:Node 单测与浏览器原型跑同一段 replay.ts,终局必须逐位相同。
//
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器);全整数;
// 无 Math.random(用自实现整数 LCG);除法一律整数地板除。

export interface ReplayCfg {
  readonly seed: number;
  readonly cols: number;
  readonly count: number;
  readonly spanMs: number;
  readonly startValue: number;
  readonly mergeAt: number;
}
export interface ReplayInput {
  readonly tMs: number;
  readonly col: number;
}
export interface ReplayState {
  readonly row: number[];
  readonly score: number;
  readonly merges: number;
  readonly moves: number;
}

/** 32 位无符号整数 LCG;同 seed 必同序列,Node 与浏览器逐位一致。 */
export function makeLcg(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1103515245) + 12345) >>> 0;
    return s;
  };
}

/** 录制:时间戳均匀铺在 (0, spanMs],每次操作的下标由 LCG 产生。 */
export function record(cfg: ReplayCfg): ReplayInput[] {
  const next = makeLcg(cfg.seed);
  const out: ReplayInput[] = [];
  for (let i = 0; i < cfg.count; i += 1) {
    const tMs = Math.floor(((i + 1) * cfg.spanMs) / cfg.count);
    const col = next() % cfg.cols;
    out.push({ tMs, col });
  }
  return out;
}

/**
 * 单步:往 col 列投一个 startValue 单位;该列每满 mergeAt 就塌缩一次并把一个单位
 * 进位到右列,进位可能连锁触发右列的塌缩。确定性、整数、无浮点。
 */
function drop(row: number[], col: number, mergeAt: number, startValue: number): { row: number[]; gained: number; merged: number } {
  const next = row.slice();
  next[col] += startValue;
  let gained = 0;
  let merged = 0;
  let c = col;
  while (c < next.length && next[c] >= mergeAt) {
    next[c] -= mergeAt;
    gained += mergeAt;
    merged += 1;
    if (c + 1 < next.length) next[c + 1] += startValue;
    c += 1;
  }
  return { row: next, gained, merged };
}

/** 重放一串操作,返回终局状态。同输入必同输出。 */
export function replay(inputs: readonly ReplayInput[], cols: number, mergeAt: number, startValue: number): ReplayState {
  let row: number[] = new Array(cols).fill(0);
  let score = 0;
  let merges = 0;
  for (const inp of inputs) {
    const r = drop(row, inp.col, mergeAt, startValue);
    row = r.row;
    score += r.gained;
    merges += r.merged;
  }
  return { row, score, merges, moves: inputs.length };
}

/** FNV-1a 32 位摘要,覆盖 score / merges / moves 与整行终局。 */
export function digest(state: ReplayState): number {
  let h = 2166136261 >>> 0;
  const mix = (x: number): void => { h = Math.imul(h ^ (x >>> 0), 16777619) >>> 0; };
  mix(state.score);
  mix(state.merges);
  mix(state.moves);
  for (const c of state.row) mix(c);
  return h;
}

/** 录制 + 重放 + 摘要一步到位。 */
export function runReplay(cfg: ReplayCfg): { state: ReplayState; digest: number; inputs: ReplayInput[] } {
  const inputs = record(cfg);
  const state = replay(inputs, cfg.cols, cfg.mergeAt, cfg.startValue);
  return { state, digest: digest(state), inputs };
}

配套的 core/test/l16_replay.test.ts 有六条断言:录制 120 步、时间戳从 50ms 升到 6000ms、LCG 同 seed 同序列;重放终局 row = [1,0,2,0,1,1]、score = 159、merges = 53、moves = 120、digest = 26895710;同参数跑两次摘要相同、换 seed 摘要必变。这五个数(尤其 26895710)就是后面浏览器侧要逐位对齐的目标。

16.3.4 单文件原型与浏览器验收(W 层)

下面这段是 W 层构建脚本。它复用 web/pack.mjs 的擦除逻辑,把 core/src/l16/replay.ts 打成单文件 HTML,内联录制参数,暴露 reseed / stepAll / stepN / get 四个 API 供验收脚本调用。注意末尾先 rebuild() 再 stepAll()——页面一加载就跑到终局,验收才读得到完整摘要。

#!/usr/bin/env node
// file: web/build_l16_replay.mjs
// tier: W
// verify: node web/build_l16_replay.mjs
// 把 core/src/l16/replay.ts 打成单文件 HTML(原型回放器)。复用 pack.mjs 的擦除逻辑。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "l16_replay.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l16/replay.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d");
let seed = cfg.seed, inputs = null, state = null, dg = 0, cursor = 0;
function rebuild() {
  inputs = record({ ...cfg, seed });
  cursor = 0;
  state = replay([], cfg.cols, cfg.mergeAt, cfg.startValue);
  dg = digest(state);
  draw();
}
function step(n) {
  cursor = Math.min(cursor + n, inputs.length);
  state = replay(inputs.slice(0, cursor), cfg.cols, cfg.mergeAt, cfg.startValue);
  dg = digest(state);
  draw();
}
function draw() {
  const W = cv.width, H = cv.height;
  g.clearRect(0, 0, W, H);
  const pad = 12, cw = Math.floor((W - pad * (cfg.cols + 1)) / cfg.cols), baseY = H - 44, maxH = baseY - 40;
  const peak = Math.max(...state.row, cfg.mergeAt, 1);
  for (let i = 0; i < cfg.cols; i += 1) {
    const x = pad + i * (cw + pad);
    g.fillStyle = "#1e293b"; g.fillRect(x, 40, cw, baseY - 40);
    const h = Math.floor(maxH * state.row[i] / peak);
    g.fillStyle = state.row[i] >= cfg.mergeAt - 1 ? "#f59e0b" : "#38bdf8";
    g.fillRect(x, baseY - h, cw, h);
    g.fillStyle = "#e2e8f0"; g.font = "14px system-ui"; g.textAlign = "center";
    g.fillText(String(state.row[i]), x + cw / 2, baseY + 18);
    g.fillStyle = "#64748b"; g.font = "11px system-ui";
    g.fillText("列 " + i, x + cw / 2, baseY + 34);
    g.textAlign = "start";
  }
  const prog = inputs.length ? cursor / inputs.length : 0;
  g.fillStyle = "#334155"; g.fillRect(12, 20, W - 24, 8);
  g.fillStyle = "#38bdf8"; g.fillRect(12, 20, Math.floor((W - 24) * prog), 8);
  g.fillStyle = "#94a3b8"; g.font = "12px system-ui";
  g.fillText("已回放 " + cursor + " / " + inputs.length + " 步", 12, 14);
  document.getElementById("score").textContent = String(state.score);
  document.getElementById("merges").textContent = String(state.merges);
  document.getElementById("digest").textContent = String(dg);
  globalThis.__pebble = { score: state.score, merges: state.merges, moves: cursor, digest: dg,
    row: state.row.slice(), seed, canvasDrawn: true, api };
}
const api = {
  reseed(s) { seed = s; rebuild(); return { digest: dg, score: state.score }; },
  stepAll() { step(inputs.length); return { digest: dg, score: state.score, merges: state.merges, moves: cursor }; },
  stepN(n) { step(n); return { digest: dg, moves: cursor }; },
  get() { return { score: state.score, merges: state.merges, moves: cursor, digest: dg, row: state.row.slice(), seed }; },
};
rebuild();
api.stepAll();
`;
const body = `  <h1>《合成小岛》· 原型回放器</h1>
  <div class="sub">内核来自 core/src/l16/replay.ts:可播种整数 LCG 录制 + 确定性重放 + FNV-1a 摘要,Node 与浏览器同 digest</div>
  <div class="card row"><div><div class="k">得分</div><div class="big" id="score">0</div></div><div><div class="k">合成次数</div><div class="big" id="merges">0</div></div><div><div class="k">回放摘要</div><div class="big" id="digest">0</div></div></div>
  <div class="card"><canvas id="stage" width="480" height="220"></canvas></div>
  <div class="sub">每步往该列投 1 个单位,满 ${cfg.mergeAt} 塌缩并向右进位;摘要与 Node 单测逐位相同</div>`;
const bytes = writePage(join(ROOT, "web/dist/l16.html"), page({ title: "《合成小岛》原型回放器 · l16", body, script, css: "#stage { width: 480px; height: 220px; }" }));
console.log(JSON.stringify({ html: "web/dist/l16.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));

下面这段是 W 层验收脚本。它用 Playwright 无头打开单文件 HTML,读回回放摘要,断言 digest = 26895710、score = 159、merges = 53、moves = 120,并检查 DOM 文本、中途 stepN(60)、换 seed 摘要变化、二次加载可复现,最后截图留证。第一行 warm 是预热:冷启动的第一个 page 读 canvas 像素恒为空白,必须先开一页再关掉。playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

#!/usr/bin/env node
// file: web/verify_l16_replay.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l16_replay.mjs web/dist/l16.html
// W 层验收:Playwright 无头打开单文件 HTML,读回回放摘要并断言与 Node 单测逐位相同 + 截图。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l16.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const p = await browser.newPage({ viewport: { width: 800, height: 720 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const full = await p.evaluate(() => globalThis.__pebble.api.stepAll());
const domScore = (await p.textContent("#score")).trim();
const domMerges = (await p.textContent("#merges")).trim();
const domDigest = (await p.textContent("#digest")).trim();
const painted = await p.evaluate(() => {
  const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 220).data;
  let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l16_replay.png"), fullPage: true });
const mid = await p.evaluate(() => { globalThis.__pebble.api.reseed(20261012); return globalThis.__pebble.api.stepN(60); });
const reseed = await p.evaluate(() => globalThis.__pebble.api.reseed(20261012 + 7));
await p.close();
const p2 = await browser.newPage();
await p2.goto(url);
await p2.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const rerun = await p2.evaluate(() => globalThis.__pebble.api.get());
await p2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(full.digest, 26895710, "浏览器 digest 应与 Node 单测逐位相同");
assert.equal(full.score, 159, "终局得分应为 159");
assert.equal(full.merges, 53, "合成次数应为 53");
assert.equal(full.moves, 120, "回放步数应为 120");
assert.equal(domScore, "159"); assert.equal(domMerges, "53"); assert.equal(domDigest, "26895710");
assert.equal(mid.moves, 60, "回放 60 步后应停在第 60 步");
assert.notEqual(reseed.digest, full.digest, "换 seed 后摘要应改变");
assert.equal(rerun.digest, full.digest, "同一次加载应可复现");
assert.ok(painted > 300, `canvas 应有绘制内容,实测 ${painted} px`);

console.log(JSON.stringify({
  page: "web/dist/l16.html", full, domScore, domMerges, domDigest, mid, reseedDigest: reseed.digest,
  canvasPaintedPx: painted, rerunMatches: true, screenshot: "out/shots/l16_replay.png",
}));
console.log("SMOKE-W16: PASS");

真跑输出(同机相对量级,绝对数值不可移植):

{"page":"web/dist/l16.html","full":{"digest":26895710,"score":159,"merges":53,"moves":120},"domScore":"159","domMerges":"53","domDigest":"26895710","mid":{"digest":1847190945,"moves":60},"reseedDigest":3370484223,"canvasPaintedPx":59141,"rerunMatches":true,"screenshot":"out/shots/l16_replay.png"}
SMOKE-W16: PASS

浏览器读回的 digest = 26895710 与 Node 单测逐位相同,score = 159、merges = 53、moves = 120 也全对;canvas 实际绘制 59141 像素,截图落在 out/shots/l16_replay.png。到这里,交付物不再是一份「我这边能跑」的声明,而是一条可被别人重跑的链:config/l16_replay.json → core/src/l16/replay.ts → web/dist/l16.html,两端摘要相同即通过。

16.3.5 小结

  • 交付物里最该有分量的是一次可重放的回放:可播种、确定性、可移植,三条件缺一不可。
  • D 层声明录制参数,P 层内核把录制重放成终局与 FNV-1a 摘要(26895710),W 层打成单文件 HTML 并用无头浏览器读回同一个摘要——Node 与浏览器逐位对齐。
  • 收官章的交付闭环:装配(16.1)→ 验收(16.2)→ 回放交付(16.3),每一步都有可运行的证据,而不是一张截图。

阅读导航:上一节:16.2 全链路仿真验收 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制