本页目录
让 Web 单机游戏的每次操作真正原子
“命令执行成功后顺手存档”不是原子操作。只要状态、随机数、存档和界面可以分别成功,它们就可能分别失败。
Web 单机游戏经常有一种看似安全的结构:玩法逻辑在内存中执行,成功后把 JSON 写入 IndexedDB,最后刷新界面。正常路径很短,测试也容易通过;真正麻烦的是写盘失败、多标签页竞争、页面在中间崩溃,以及玩家重试同一次操作。
设想一次“支付资源并生成物品”的操作:
- 内存已经扣掉资源;
- 随机数已经抽出物品属性;
- IndexedDB 写入失败;
- 界面仍展示了获得结果,或内存继续接受下一条命令。
此时无论选择保留内存还是回到旧存档,都至少有一部分事实不一致。更危险的是,这类错误通常不会立即报废存档,而会在下一次刷新、重试或结算时表现成重复扣费、重复掉落、随机序列偏移或结果弹窗丢失。
本文记录的是一套适用于“权威玩法内核运行在浏览器进程、IndexedDB 是唯一持久介质”的提交协议。它借用了数据库事务的思路,但不是分布式两阶段提交:系统只有一个持久参与者,目标是确保内存、持久化和可见结果只承认同一份完整 payload。
先定义一次命令究竟要原子保护什么
原子单位不能只写成“玩家状态”。一次语义命令至少共同拥有:
- 领域状态,例如资源、人物、背包和当前活动;
- 随机账本及其所有游标;
- 单调 revision;
- 命令的因果结果与回执;
- 请求去重记录;
- 需要玩家确认的待决结果;
- 本次保存使用的观察时间。
它们必须一起提交或一起放弃。特别是随机数:若失败命令消耗了随机流,玩家虽然看到“状态没有改变”,后续掉落却已经不同,这仍然是一次可观察的半提交。
保存结构也应尽量只有一个权威根:
Profile {
revision,
random_ledger,
permanent_state,
active_run?,
background_jobs,
active_battle?,
pending_receipt?,
processed_requests
}
界面路由、当前页签、可重建预览和动画进度不必进入这个根。相反,只要刷新后丢失会改变玩法事实或造成重复执行,就必须和根 payload 一起保存。
一个根并不是为了让 JSON 更大,而是为了缩小正确性问题:IndexedDB 中只需要原子替换一条记录,不必再证明多个对象仓、日志、提交标记与恢复分支永远一致。
命令信封需要四类身份
一个完整命令信封可以抽象为:
CommandEnvelope {
request_id,
expected_revision,
command,
preview_fingerprint?
}
四部分解决的是四种不同问题:
| 字段 | 回答的问题 |
|---|---|
request_id | 这是不是同一次用户意图的重试? |
expected_revision | 用户操作时看到的是不是当前状态? |
command | 玩家具体要求执行什么? |
preview_fingerprint | 提交的是否仍是刚才预览过的那套精确条件与后果? |
内核还应为规范化后的 command 计算 command_digest。于是请求处理规则可以精确定义为:
- 同一
request_id、同一 digest:返回第一次的原结果,并标记为 replay; - 同一
request_id、不同 digest:拒绝,说明客户端错误复用了请求身份; - 新请求但
expected_revision过期:拒绝并要求刷新; - revision 相同但预览指纹不再匹配:拒绝并重新投影预览。
检查顺序很重要。请求重放必须早于 revision 校验。一次已成功提交的请求重试时,客户端携带的通常仍是旧 revision;若先检查 revision,它会被误报为过期,而不是重放原结果。
第一层原子性:所有逻辑先在私有草稿上执行
内核不要直接修改当前游戏。最简单可靠的办法是克隆完整权威根,在私有草稿上执行:
execute(current, envelope):
digest = digest_of(envelope.command)
if processed_requests contains envelope.request_id:
if saved_digest == digest:
return saved_result with replayed = true
reject REQUEST_ID_CONFLICT
if an unacknowledged receipt blocks this command:
reject RECEIPT_PENDING
if envelope.expected_revision != current.revision:
reject STALE_REVISION
draft = clone(current)
receipts = draft.apply(envelope.command) // 随机数也只改 draft
draft.revision += 1
feedback = diff_causally(current, draft)
result = make_result(receipts, feedback, draft.revision)
draft.processed_requests[request_id] = { digest, result }
draft.pending_receipt = maybe_make_pending(result)
return PreparedCandidate { draft, result }
任何校验或领域操作失败,都只销毁草稿。当前状态、revision、随机游标、请求表和回执完全不动。
这里不应只克隆某个被认为“会改动”的子模块。一次命令常会跨多个领域:扣资源、创建实例、改变归属、更新解锁、推进任务并生成反馈。手工维护补偿操作或字段白名单,随着功能增长很容易漏掉随机账本、ID 分配器或新增状态。对于中小型单机 profile,完整草稿通常比复杂回滚日志更便宜,也更容易证明。
第二层原子性:准备、持久化、发布
草稿执行成功仍不能立即替换内存中的正式游戏,因为 IndexedDB 还可能失败。宿主边界应把后半段拆成三个明确步骤:
sequenceDiagram
participant UI as 界面
participant Core as 玩法内核
participant Host as 浏览器宿主
participant DB as IndexedDB
UI->>Core: prepare(command, revision, request_id)
Core-->>Host: result + candidate_save + before/after digest
Host->>DB: CAS(before_digest, candidate_save)
DB-->>Host: committed payload
Host->>Core: publish(committed payload)
Core-->>UI: 新权威快照与回执
Prepare
适配层克隆当前游戏,在候选上执行命令,导出完整候选存档并计算:
before_digest = 当前已持久 payload 的摘要
after_digest = 候选完整 payload 的摘要
适配层一次只允许保留一个 prepared candidate。浏览器同一页面内的语义命令再通过 Promise 队列串行化,避免两个异步写入同时持有同一个 base。
Persist
宿主在同一个 IndexedDB read-write transaction 中读取当前记录、比较 digest 并替换:
replace_profile(expected_digest, next):
begin readwrite transaction
current = get("profile")
current_digest = current == null ? null : current.digest
if current_digest != expected_digest:
abort CONFLICT
put("profile", next)
commit
这个比较不能在事务外先读后写。否则两个标签页可能同时读到旧摘要,又依次覆盖彼此。把读取、比较和 put 放进同一个 read-write transaction,才能让 IndexedDB 的事务串行性保护 CAS。
同一页面的命令队列解决本地并发,CAS 解决其它标签页、旧页面或外部导入造成的竞争;两者不能互相替代。
Publish
数据库提交后,宿主重新读回记录,再让内核发布:
- request ID 必须与 prepared candidate 一致;
- 当前内存的 base digest 仍等于
before_digest; - 读回的 JSON 与候选保存内容一致;
- JSON 能通过当前保存结构与领域不变量校验;
- 重新计算的摘要等于
after_digest。
通过后,内核最好从实际读回的 payload 重建正式游戏,而不是直接采用先前的内存草稿。这样,内存承认的就是持久介质中的确切字节所表达的状态,序列化遗漏、宿主误传和摘要错配都无法悄悄穿过边界。
只有 publish 成功以后,界面才刷新权威快照并展示成功回执。
失败时只认持久介质里的赢家
这一协议最有价值的地方,不是成功路径更漂亮,而是每个失败窗口都有唯一恢复规则:
| 失败位置 | 持久状态 | 恢复动作 | 玩家可见结果 |
|---|---|---|---|
| 命令校验失败 | 旧 payload | 丢弃草稿 | 状态未改变 |
| 草稿执行中失败 | 旧 payload | 丢弃草稿与其中的随机抽取 | 状态未改变 |
| CAS 发现 base 冲突 | 其它写入的 payload | 丢弃候选,重载存档赢家 | 显示状态已刷新 |
| IndexedDB 写入失败 | 旧 payload | abort prepared,重载旧存档 | 状态未改变 |
| 写入成功、publish 前异常 | 新 payload | 重载新存档 | 已提交,不回滚到旧状态 |
| publish 校验失败 | 以数据库为准 | 丢弃候选内存,重新导入数据库记录 | 不继续使用可疑内存 |
| 成功结果展示前刷新 | 新 payload | 从保存的回执恢复结果 | 不重复执行命令 |
注意第五行:数据库已经成功替换后,就不能因为 JavaScript 随后抛错而把操作描述成“没有发生”。正确动作是读取持久记录并采用它。提交点在 IndexedDB transaction 完成的那一刻;publish 只是让内存追认这个事实。
请求重放记录必须与命令结果一起入档
仅在内存里保存最近请求 ID,不足以处理刷新和崩溃。完整 profile 应保存:
processed_requests[request_id] = {
command_digest,
original_result
}
原结果不仅包括“成功”二字,还应包含 revision、领域回执、数值差分和因果反馈。相同请求重放时返回这份结果,不重新扣费、不重新 mint,也不重新消耗随机数。
这要求客户端把 request ID 绑定到一次用户意图,而不是绑定到一次函数调用。传输超时或宿主重试时复用原 ID;只有玩家发起新的语义操作时才生成新 ID。
去重记录不能无限增长。可采用有界窗口、按确认水位清理或随存档代际压缩,但清理规则必须满足:仍可能重试的请求和待确认回执绝不能先被删掉。容量策略是协议的一部分,不应等存档膨胀后再临时处理。
回执也是状态,不是一次性弹窗参数
有些命令会产生玩家必须看见的重大结果。若命令已经持久化,但页面在弹窗出现前刷新,仅靠组件局部状态就会永久丢失反馈。
可以在 profile 中保存一个 pending_receipt,其中引用 request ID,并复制需要展示的因果结果。保存校验还应证明:
- pending receipt 对应一条 processed request;
- 两者的 request ID、receipt ID 和结果内容一致;
- 在玩家明确确认前,其它可能覆盖语境的语义命令被阻挡;
- “确认回执”本身也是一条 typed、可持久化的幂等命令。
这不是为了把 UI 状态全部塞进存档,而是为了保护已经提交却尚未被玩家消费的因果信息。普通 toast、页签和展开状态仍然可以是瞬时界面状态。
为什么摘要不能直接哈希 JSON 字符串
同一个逻辑对象可以因为字段顺序、空白、转义或序列化器差异得到不同 JSON;不同数值语义也可能被某些运行时压成同一种浮点表示。把原始 JSON 字符串直接做 CAS 身份,会把表示细节误当成状态身份。
更稳妥的协议是:
- 把值限制为明确的数据模型;
- 使用确定性编码,例如规范 CBOR;
- 整数与长度采用最短表示;
- map key 按编码后的长度与字节稳定排序;
- 权威数值拒绝浮点数,改用整数或固定点;
- 在摘要输入外包一层 schema、版本和 domain tag;
- 最后使用 SHA-256 等稳定摘要算法。
digest = SHA256(canonical_encode([
"protocol-digest",
schema_version,
domain, // command / save / preview / content
value
]))
domain separation 防止同一字节在不同语境中被误当成同一种证明。例如,一份命令摘要不能拿来冒充保存摘要。规范编码还需要 golden test,确保不同容器插入顺序得到相同结果,domain 不同得到不同结果,浮点权威被明确拒绝。
revision 不能替代预览指纹
revision 只能证明“整个 profile 是否被另一条命令推进过”。它不能完整表达玩家刚才看到的那份预览。
例如,一个操作页展示了精确成本、来源对象、目标与后果。提交时应把这些影响决策的输入连同 revision、当前活动状态和内容定义一起计算 preview_fingerprint。内核在正式执行前重新计算并比较:
fingerprint = digest(
domain = "resolution-plan",
value = {
revision,
active_state,
relevant_profile_state,
content,
submitted_intent
}
)
若内容热更新、目标对象变化、成本来源变化,或客户端提交的 intent 与预览不一致,即使表面 revision 没有暴露出差异,也会得到 stale preview。恢复动作不是猜测玩家仍然同意,而是重新投影页面,让玩家再确认。
不是每个按钮都需要预览指纹。它适合“玩家依据一组可见后果作出确认”的命令;简单页签切换和纯导航不属于语义提交。
界面可以即时反馈,但不能提前发布权威结果
为了手感,按钮按下后可以立刻进入 loading、播放按压效果,甚至显示可撤销的意图动画;但新的余额、物品、任务结果和成功回执只能来自 publish 后的权威快照。
尤其不要执行下面的顺序:
setState(candidate)
showSuccess(candidate.result)
await save(candidate)
一旦保存失败,组件树、路由、动画和提示都需要人工逆转,任何遗漏都会形成幽灵状态。更简单的顺序是:准备时只锁定当前操作,持久成功后一次刷新完整投影;失败时重载持久 payload,再从它生成界面。
应该怎样测试
原子协议不能只用“执行后存档内容正确”来验收。至少需要以下不变量测试:
- 无效命令前后的完整保存 JSON 完全相同;
- 失败命令不会推进 revision、实例 ID 或任一随机游标;
- 同 request ID、同命令只返回原结果,不重复产生副作用;
- 同 request ID、不同命令被拒绝;
- 请求重放即使携带旧 revision,也先命中 replay;
- pending receipt 阻挡其它语义命令,确认后才释放;
- pending receipt 与 processed request 在保存校验中必须一致;
- IndexedDB 写入异常后,内存重载旧 payload;
- CAS 冲突后采用另一标签页的持久结果,不覆盖它;
- 数据库提交后、publish 前注入异常,刷新仍恢复新状态;
- publish 收到不同字节、错误摘要或非法存档时拒绝;
- 预览所依赖的任一输入改变,旧 fingerprint 都失效;
- canonical digest 有跨平台 golden,并拒绝浮点权威;
- 连续快速点击在单页面内严格串行。
其中最有效的是故障注入:在 prepare 后、CAS 前、transaction 完成后和 publish 前分别抛错,再检查持久 payload、内存快照、随机账本和可见回执。只测领域函数无法覆盖真正危险的宿主边界。
这套方案不解决什么
- 它不是反作弊系统。本地玩家若完全控制浏览器和文件,仍可篡改数据;摘要主要用于一致性与冲突检测。
- 它不替代存档版本和领域校验。一个哈希正确的 payload 仍可能不符合当前规则。
- 它不是跨服务器的分布式事务。若再加入云存档或支付服务,需要新的同步与冲突协议。
- 它不要求所有界面状态入档,只保护会改变玩法事实或导致结果丢失的状态。
- 它不会自动决定 request 记录的保留期限、跨设备合并或时钟可信度,这些仍需单独设计。
最小实现清单
- 只有一个完整、可校验的权威 profile 根;
- 状态、随机账本、revision、回执和请求记录共同提交;
- 新命令先查 request replay,再查待决回执和 revision;
- 同 request ID 同摘要重放,异摘要拒绝;
- 所有领域修改先发生在完整私有草稿;
- 适配层只保留一个 prepared candidate;
- 同页面命令串行,多页面写入使用事务内 CAS;
- IndexedDB 成功前不发布新内存与新界面;
- publish 从实际读回的 payload 重建权威状态;
- 任一失败都丢弃候选,并重载持久介质中的赢家;
- 重要回执与去重记录进入同一保存根;
- 命令、保存和预览使用带 domain 的规范摘要;
- 对每个提交窗口做故障注入测试。
结语:提交点只能有一个
Web 单机游戏虽然没有远程服务器,仍然同时拥有内存、WebAssembly、IndexedDB 和 React 等多个状态载体。只要它们都被当成“差不多权威”,崩溃窗口就会变成玩法 bug。
这套协议最终只做了一件事:把提交点固定在一次成功的持久原子替换上。内核草稿负责让领域状态与随机数共同成败,CAS 负责选出并发赢家,publish 负责让内存承认已落盘事实,请求记录与持久回执负责让刷新和重试不制造第二次结果。边界一旦明确,错误处理反而会比“执行后顺手保存”更简单。
Keywords: 游戏开发, WebAssembly, IndexedDB, 原子命令, compare-and-swap, CAS, 幂等, request id, revision, canonical digest, 存档事务