站内已有一篇小游戏开发综述 ,那篇是平台能力的单篇综述,读完你只知道「平台有这些能力」;本章给的是可跑、可断言的抽象接口 + 两套宿主实现,读完你能把同一份逻辑跑在 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 抽象:登录 / 支付 / 广告 / 分享 / 云存档 · 下一节:多平台差异与兼容矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。