10.2 一份逻辑两种宿主:mock 宿主与真机宿主

把同一份会话逻辑分别注入 Node mock 宿主与浏览器宿主,用同一组输入序列驱动,断言两侧终局摘要逐位相同。给出可跑的 session.ts、单测、浏览器宿主构建脚本与 Playwright 无头验收,正文给出两侧真实摘要值与 ok/fail/cancel 三态分布。

站内已有一篇小游戏开发综述 ,那篇是平台能力的单篇综述,读完你只知道「平台有这些能力」;本章给的是可跑、可断言的抽象接口 + 两套宿主实现,读完你能把同一份逻辑跑在 Node 和浏览器上并断言它们一致。另有一组 25 篇的小游戏 SaaS 平台 ,那 25 篇是做一个游戏服务平台(SaaS 创业口径:BP / 融资 / 编辑器);本章是在平台上做一款游戏时,如何把平台 API 抽象成可测试的接口,不是教你搭平台。

上一节把平台能力收成了 PlatformAPI 五个方法,但抽象层本身只是「形状」,它还没证明同一份逻辑真的能在两种宿主上跑出同一个结果。这一节就来把这个主张变成一条能跑的断言——这是本卷的核心。

主张:逻辑只依赖接口,宿主可替换

先把这个主张说清楚。session.ts 里的业务逻辑(登录、看广告、内购、分享、存档)只 import 一个类型 PlatformAPI,不 import mockPlatform,更不碰 window / wx / tt。它的输入是一串命令(Cmd),输出是一个 SessionState,而把状态压成一行字符串就是终局摘要。宿主换不换,逻辑一个字不改;只要两个宿主对同一组输入给出同样的三态结果,摘要就必然逐位相同——这不是巧合,是设计的必然:摘要只是 (plan, cmds) 的函数,与宿主实现无关。

P 层:会话逻辑只依赖接口

下面这段是 P 层(内核层)代码,会话逻辑本身。注意第一行 import 的只有 PlatformAPI 类型,业务分支里没有一处 try/catch,每个平台调用的结果都用三态 status 归一处理。

可复现的输入配置(本次补齐)

旧稿引用了 config/e10_session.json 却没有交付。以下是明确标注的合成输入:三次登录、四次广告、两次支付、三次失败存档、两次取消分享,覆盖原单测的 9 成功/3 失败/2 取消。浏览器宿主也只演示相同脚本结果,不是真实平台回调,更不验证支付、广告 SDK 或真机。

{
  "note": "本次补齐的合成验收输入;不是实际平台支付/广告/分享记录",
  "plan": {
    "openId": "u_1024",
    "nickname": "demo",
    "rewardMs": 30000,
    "outcomes": {
      "login": "ok",
      "pay": "ok",
      "showAd": "ok",
      "share": "cancel",
      "cloudSave": "fail"
    }
  },
  "cmds": [
    {
      "kind": "login",
      "arg": "",
      "amount": 0
    },
    {
      "kind": "login",
      "arg": "",
      "amount": 0
    },
    {
      "kind": "login",
      "arg": "",
      "amount": 0
    },
    {
      "kind": "ad",
      "arg": "reward",
      "amount": 0
    },
    {
      "kind": "ad",
      "arg": "reward",
      "amount": 0
    },
    {
      "kind": "ad",
      "arg": "reward",
      "amount": 0
    },
    {
      "kind": "ad",
      "arg": "reward",
      "amount": 0
    },
    {
      "kind": "pay",
      "arg": "demo-0",
      "amount": 100
    },
    {
      "kind": "pay",
      "arg": "demo-1",
      "amount": 100
    },
    {
      "kind": "save",
      "arg": "slot-0",
      "amount": 0
    },
    {
      "kind": "save",
      "arg": "slot-1",
      "amount": 0
    },
    {
      "kind": "save",
      "arg": "slot-2",
      "amount": 0
    },
    {
      "kind": "share",
      "arg": "friend",
      "amount": 0
    },
    {
      "kind": "share",
      "arg": "friend",
      "amount": 0
    }
  ]
}
// file: core/src/e10/session.ts
// tier: P
// verify: node --test 'core/test/e10_*.test.ts'
//
// 《合成小岛》首局会话:业务逻辑只依赖 PlatformAPI 接口,不碰 window / wx / tt / my。
// 输入是一串命令(Cmd),输出是终局摘要。同一份逻辑注入 mock 宿主或浏览器宿主,
// 应产出逐位相同的摘要 —— 这就是「一份逻辑、两种宿主」的可断言形态。
// 全整数、无 Math.random、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

import type { PlatformAPI, OpStatus } from "./platform.ts";

export type CmdKind = "login" | "ad" | "pay" | "share" | "save";

/** 一条命令 = 一次「玩家意图」。宿主差异不体现在命令里,只体现在注入的 PlatformAPI 里。 */
export interface Cmd {
  readonly kind: CmdKind;
  readonly arg: string;
  readonly amount: number;
}

export interface SessionState {
  openId: string;
  gold: number;
  items: number;
  adWatches: number;
  paySuccess: number;
  shares: number;
  savedSlots: number;
  calls: number;
  okCount: number;
  failCount: number;
  cancelCount: number;
}

/** 会话奖励常量:全整数,写死在这里,两侧宿主都读到同一组值。 */
export const REWARD = { adGold: 50, payGold: 100, shareGold: 30 } as const;

export function newSession(): SessionState {
  return {
    openId: "guest", gold: 0, items: 0, adWatches: 0, paySuccess: 0, shares: 0,
    savedSlots: 0, calls: 0, okCount: 0, failCount: 0, cancelCount: 0,
  };
}

/** 把一次平台调用结果记进会话:三态计数 + 各命令的成功副作用。失败 / 取消无副作用。 */
export function applyCommand(api: PlatformAPI, prev: SessionState, cmd: Cmd): SessionState {
  const s: SessionState = { ...prev, calls: prev.calls + 1 };
  let status: OpStatus;
  if (cmd.kind === "login") {
    const r = api.login();
    if (r.status === "ok" && r.value) s.openId = r.value.openId;
    status = r.status;
  } else if (cmd.kind === "ad") {
    const r = api.showAd(cmd.arg);
    if (r.status === "ok") { s.adWatches += 1; s.gold += REWARD.adGold; }
    status = r.status;
  } else if (cmd.kind === "pay") {
    const r = api.pay(cmd.arg, cmd.amount);
    if (r.status === "ok") { s.paySuccess += 1; s.items += 1; s.gold += REWARD.payGold; }
    status = r.status;
  } else if (cmd.kind === "share") {
    const r = api.share(cmd.arg, "card-" + cmd.arg);
    if (r.status === "ok") { s.shares += 1; s.gold += REWARD.shareGold; }
    status = r.status;
  } else {
    const r = api.cloudSave(cmd.arg, "island:" + s.gold);
    if (r.status === "ok") s.savedSlots += 1;
    status = r.status;
  }
  if (status === "ok") s.okCount += 1;
  else if (status === "fail") s.failCount += 1;
  else s.cancelCount += 1;
  return s;
}

export function runSession(api: PlatformAPI, cmds: readonly Cmd[]): SessionState {
  let s = newSession();
  for (const c of cmds) s = applyCommand(api, s, c);
  return s;
}

/** 终局摘要:一行、逐位可比的字符串。两种宿主只要实现一致,这一行必然相同。 */
export function summary(s: SessionState): string {
  return ["openId=" + s.openId, "gold=" + s.gold, "items=" + s.items, "ad=" + s.adWatches,
    "pay=" + s.paySuccess, "share=" + s.shares, "save=" + s.savedSlots,
    "ok=" + s.okCount, "fail=" + s.failCount, "cancel=" + s.cancelCount].join("|");
}

P 层单测:Node mock 宿主的终局摘要

下面是 P 层单测,它把 config/e10_session.json 里的输入序列注入上一节的 mock 宿主,断言终局摘要等于一个写死的字符串。这个字符串就是「Node 侧」的基准值。

// file: core/test/e10_session.test.ts
// tier: P
// verify: node --test 'core/test/e10_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { mockPlatform } from "../src/e10/platform.ts";
import { runSession, summary, type Cmd } from "../src/e10/session.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e10_session.json"), "utf-8"));
const cmds = cfg.cmds as Cmd[];

test("同一组输入序列在 mock 宿主上产出确定的终局摘要", () => {
  const s = runSession(mockPlatform(cfg.plan), cmds);
  assert.equal(summary(s), "openId=u_1024|gold=400|items=2|ad=4|pay=2|share=0|save=0|ok=9|fail=3|cancel=2");
});

test("可复现:同输入跑两遍,摘要逐位相同", () => {
  const a = summary(runSession(mockPlatform(cfg.plan), cmds));
  const b = summary(runSession(mockPlatform(cfg.plan), cmds));
  assert.equal(a, b);
});

test("取消与失败不产生副作用:分享全取消、云存档全失败", () => {
  const s = runSession(mockPlatform(cfg.plan), cmds);
  assert.equal(s.shares, 0);
  assert.equal(s.savedSlots, 0);
  assert.equal(s.failCount, 3);
  assert.equal(s.cancelCount, 2);
});

test("登录失败时 openId 退回 guest,不污染摘要其余部分", () => {
  const failPlan = { ...cfg.plan, outcomes: { ...cfg.plan.outcomes, login: "fail" as const } };
  const s = runSession(mockPlatform(failPlan), cmds);
  assert.equal(s.openId, "guest");
  assert.equal(summary(s).startsWith("openId=guest|"), true);
});

W 层:浏览器宿主

W 层要证明的不是「Node 上算得对」,而是「换一个宿主,结果不变」。下面这段是 W 层构建脚本:它把 platform.ts + session.ts 打成单文件 HTML,并在页面里定义第二种宿主实现——browserHost。它与 mock 宿主接口完全相同,但副作用走的是浏览器设施:登录读 document.body.dataset.openId,广告真的往 DOM 插一个遮罩再移除,云存档真的写 localStorage。

#!/usr/bin/env node
// file: web/build_e10.mjs
// tier: W
// verify: node web/build_e10.mjs
// 把 core/src/e10/{platform,session}.ts 的平台抽象层 + 会话逻辑打成单文件 HTML。
// 页面里再定义一个「浏览器宿主」——同一份 PlatformAPI 接口的第二种实现,副作用走 DOM /
// localStorage。逻辑只依赖接口,故 Node mock 宿主与浏览器宿主应产出逐位相同的终局摘要。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/e10.html。

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", "e10_session.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e10/platform.ts", "core/src/e10/session.ts"]);

const script = `const cfg = ${inlineConfig(cfg)};
document.body.dataset.openId = cfg.plan.openId;
${js}
// ---- 浏览器宿主:PlatformAPI 的第二种实现,副作用走 DOM / localStorage ----
function browserHost(plan) {
  const calls = [], orders = [];
  const decide = (op, value) => {
    calls.push(op);
    const st = plan.outcomes[op];
    if (st === "ok") return ok(op, "OK", value);
    if (st === "fail") return fail(op, "E_PLATFORM");
    return cancel(op, "E_CANCEL");
  };
  return {
    login() { return decide("login", { openId: document.body.dataset.openId || plan.openId, nickname: plan.nickname }); },
    pay(orderId, amountFen) {
      const r = decide("pay", { orderId, amountFen });
      if (r.status === "ok") orders.push(orderId);
      return r;
    },
    showAd(adUnit) {
      const el = document.createElement("div");
      el.className = "ad"; el.textContent = "广告播放中:" + adUnit;
      document.body.appendChild(el);
      const r = decide("showAd", { adUnit, rewardMs: plan.rewardMs });
      el.remove();
      return r;
    },
    share(channel, cardId) { return decide("share", { channel, cardId }); },
    cloudSave(slot, payload) {
      const r = decide("cloudSave", { slot, bytes: payload.length });
      if (r.status === "ok") localStorage.setItem("e10:" + slot, payload);
      return r;
    },
    calls: () => calls.slice(),
    orders: () => orders.slice(),
  };
}
// 一次性回放:同一组 cmds 注入浏览器宿主,返回终局摘要字符串
function runAll(cmds) { return summary(runSession(browserHost(cfg.plan), cmds)); }
document.getElementById("run-all").addEventListener("click", () => {
  document.getElementById("sum").textContent = runAll(cfg.cmds);
});
globalThis.__pebble = { host: "browser", run: (cmds) => runAll(cmds), summary: runAll(cfg.cmds), cmdCount: cfg.cmds.length, canvasDrawn: true };
document.getElementById("pre").textContent = globalThis.__pebble.summary;
`;
const body = `  <h1>《合成小岛》· 平台抽象层原型</h1>
  <div class="sub">core/src/e10/ 的平台接口 + 会话逻辑,注入浏览器宿主;应与 Node mock 宿主同摘要</div>
  <div class="card"><div class="k">终局摘要(浏览器宿主)</div><pre id="pre">…</pre></div>
  <div class="card"><div class="k">实时回放</div><pre id="sum"></pre><button id="run-all">跑完整个序列</button></div>`;
const css = `#pre { font-size: 13px; word-break: break-all; white-space: pre-wrap; color: #38bdf8; margin: 0; }
#sum { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: #e2e8f0; margin: 8px 0; }
.ad { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,.7); color: #fff; }`;
const bytes = writePage(join(ROOT, "web/dist/e10.html"), page({ title: "《合成小岛》平台抽象层原型 · e10", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e10.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

W 层验收:两宿主同摘要

下面这段是 W 层验收脚本。它在同一个进程里先跑 Node mock 宿主得到 nodeSummary,再用 Playwright 打开单文件 HTML、调用页面里的浏览器宿主得到 browserSummary,最后断言两者逐位相等。这就是本章的硬证据。

#!/usr/bin/env node
// file: web/verify_e10.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e10.mjs web/dist/e10.html
// W 层验收:Node mock 宿主与浏览器宿主用同一组输入序列驱动同一份 session 逻辑,
// 断言两侧终局摘要逐位相同;再用真实点击跑一遍页面按钮 + 截图。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync, readFileSync } from "node:fs";
import assert from "node:assert/strict";
import { mockPlatform } from "../core/src/e10/platform.ts";
import { runSession, summary } from "../core/src/e10/session.ts";

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/e10.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });

const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e10_session.json"), "utf8"));
const cmds = cfg.cmds;

// ---- Node 侧:mock 宿主 ----
const nodeSummary = summary(runSession(mockPlatform(cfg.plan), cmds));
const nodeAgain = summary(runSession(mockPlatform(cfg.plan), cmds));

const browser = await chromium.launch();
const errors = [];
const open = async () => {
  const p = await browser.newPage({ viewport: { width: 820, height: 900 } });
  p.on("pageerror", (e) => errors.push(String(e)));
  await p.goto(url);
  await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
  return p;
};

const page = await open();
const browserSummary = await page.evaluate((c) => globalThis.__pebble.run(c), cmds);
const pageHost = await page.evaluate(() => globalThis.__pebble.host);

// 真实点击:点「跑完整个序列」,读 DOM 里的摘要
await page.click("#run-all");
const domSummary = (await page.textContent("#sum")).trim();
await page.screenshot({ path: join(ROOT, "out/shots/e10_summary.png"), fullPage: true });
await page.close();

// 第二遍:全新加载再跑一次,验可复现
const page2 = await open();
const browserAgain = await page2.evaluate((c) => globalThis.__pebble.run(c), cmds);
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(pageHost, "browser", "页面宿主应标识为 browser");
assert.equal(nodeSummary, "openId=u_1024|gold=400|items=2|ad=4|pay=2|share=0|save=0|ok=9|fail=3|cancel=2", "Node 侧摘要应为已知值");
assert.equal(nodeSummary, nodeAgain, "Node 侧应可复现");
assert.equal(browserSummary, nodeSummary, "浏览器宿主与 Node mock 宿主摘要应逐位相同");
assert.equal(browserSummary, browserAgain, "浏览器侧应可复现");
assert.equal(domSummary, nodeSummary, "点击按钮后的 DOM 摘要应与回放一致");

console.log(JSON.stringify({
  page: "web/dist/e10.html", host: pageHost,
  nodeSummary, browserSummary, domSummary, browserAgain,
  cmds: cmds.length, screenshot: "out/shots/e10_summary.png",
}, null, 2));
console.log("SMOKE-W10: PASS");

实跑输出:两侧的真实摘要

nodeSummary    = openId=u_1024|gold=400|items=2|ad=4|pay=2|share=0|save=0|ok=9|fail=3|cancel=2
browserSummary = openId=u_1024|gold=400|items=2|ad=4|pay=2|share=0|save=0|ok=9|fail=3|cancel=2
domSummary     = openId=u_1024|gold=400|items=2|ad=4|pay=2|share=0|save=0|ok=9|fail=3|cancel=2
SMOKE-W10: PASS

三行完全相同,node --test 的 4 条单测全绿,Playwright 无头验收打印 SMOKE-W10: PASS。读这行摘要能看出这条序列跑了什么:14 条命令里 ok=9(3 次登录 + 4 次广告 + 2 次内购)、fail=3(3 次云存档全失败)、cancel=2(2 次分享全被取消);gold=400 正好是 4×50 + 2×100,而 share=0 / save=0 说明取消与失败确实没有产生副作用。同一组输入、两份不同实现的宿主,产出逐位相同的一行——主张到此被证明。

为什么两侧必然相同,而不是碰巧

有人会说「你把剧本写死了,当然一样」。这个质疑值得正面回答:摘要相同的前提,恰恰是两侧宿主对同一组输入给出了同样的三态结果。如果浏览器宿主把一次「取消」错当成「成功」(比如 showAd 忽略用户中途划走),ad 计数就会多 1、gold 多 50、ok / cancel 各差 1,摘要立刻对不上,断言报红。所以这条断言不是在证明「两个宿主一样」,而是把「宿主实现是否忠于三态语义」变成一个可自动发现的错误。这也正是「一份逻辑、两种宿主」的工程价值:平台相关的代码被压缩进两个互不依赖的宿主实现里,平台无关的逻辑只写一遍。新增一个平台,只是再写一个 PlatformAPI 实现,逻辑、单测、摘要断言一行都不动。第 15 章的端到端回放会把这条思路扩展到整局游戏——那时的「宿主」不只是平台 API,还有时钟、随机数与输入源。

小结与下一步

这一节把「一份逻辑、两种宿主」从口号变成了一条能跑的断言:同一份 session.ts,注入 Node mock 宿主与浏览器宿主,用同一组输入序列驱动,终局摘要逐位相同(ok=9|fail=3|cancel=2,gold=400)。两份宿主实现完全不同,却能产出同一行——因为它们都忠于同一个三态接口。到这里,平台适配层的「代码形状」和「可断言性」都有了;但真实世界不止两个平台,还有微信 / 抖音 / QQ / 百度等一串宿主,它们的登录、支付、广告、分享、云存档各差在哪?下一节把它们摆成一张多平台兼容矩阵,并算出适配它们要付多少代价。

阅读导航:上一节:平台 API 抽象:登录 / 支付 / 广告 / 分享 / 云存档 · 下一节:多平台差异与兼容矩阵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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