---
title: 让 Web 单机游戏的每次操作真正原子
url: https://doc.liz6.com/game-development/atomic-web-game-commands
locale: zh
area: game-development
tags:
- game-development
date: 2026-08-27
modified: 2026-08-27
description: 把 Web 单机游戏的一次语义操作跨过内核、WebAssembly、IndexedDB 和界面：用私有草稿、CAS、规范摘要、请求重放与持久回执消除半提交状态。
---

# 让 Web 单机游戏的每次操作真正原子

> “命令执行成功后顺手存档”不是原子操作。只要状态、随机数、存档和界面可以分别成功，它们就可能分别失败。

Web 单机游戏经常有一种看似安全的结构：玩法逻辑在内存中执行，成功后把 JSON 写入 IndexedDB，最后刷新界面。正常路径很短，测试也容易通过；真正麻烦的是写盘失败、多标签页竞争、页面在中间崩溃，以及玩家重试同一次操作。

设想一次“支付资源并生成物品”的操作：

1. 内存已经扣掉资源；
2. 随机数已经抽出物品属性；
3. IndexedDB 写入失败；
4. 界面仍展示了获得结果，或内存继续接受下一条命令。

此时无论选择保留内存还是回到旧存档，都至少有一部分事实不一致。更危险的是，这类错误通常不会立即报废存档，而会在下一次刷新、重试或结算时表现成重复扣费、重复掉落、随机序列偏移或结果弹窗丢失。

本文记录的是一套适用于“权威玩法内核运行在浏览器进程、IndexedDB 是唯一持久介质”的提交协议。它借用了数据库事务的思路，但不是分布式两阶段提交：系统只有一个持久参与者，目标是确保内存、持久化和可见结果只承认同一份完整 payload。

## 先定义一次命令究竟要原子保护什么

原子单位不能只写成“玩家状态”。一次语义命令至少共同拥有：

- 领域状态，例如资源、人物、背包和当前活动；
- 随机账本及其所有游标；
- 单调 revision；
- 命令的因果结果与回执；
- 请求去重记录；
- 需要玩家确认的待决结果；
- 本次保存使用的观察时间。

它们必须一起提交或一起放弃。特别是随机数：若失败命令消耗了随机流，玩家虽然看到“状态没有改变”，后续掉落却已经不同，这仍然是一次可观察的半提交。

保存结构也应尽量只有一个权威根：

```text
Profile {
  revision,
  random_ledger,
  permanent_state,
  active_run?,
  background_jobs,
  active_battle?,
  pending_receipt?,
  processed_requests
}
```

界面路由、当前页签、可重建预览和动画进度不必进入这个根。相反，只要刷新后丢失会改变玩法事实或造成重复执行，就必须和根 payload 一起保存。

一个根并不是为了让 JSON 更大，而是为了缩小正确性问题：IndexedDB 中只需要原子替换一条记录，不必再证明多个对象仓、日志、提交标记与恢复分支永远一致。

## 命令信封需要四类身份

一个完整命令信封可以抽象为：

```text
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，它会被误报为过期，而不是重放原结果。

## 第一层原子性：所有逻辑先在私有草稿上执行

内核不要直接修改当前游戏。最简单可靠的办法是克隆完整权威根，在私有草稿上执行：

```text
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 还可能失败。宿主边界应把后半段拆成三个明确步骤：

```mermaid
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

适配层克隆当前游戏，在候选上执行命令，导出完整候选存档并计算：

```text
before_digest = 当前已持久 payload 的摘要
after_digest  = 候选完整 payload 的摘要
```

适配层一次只允许保留一个 prepared candidate。浏览器同一页面内的语义命令再通过 Promise 队列串行化，避免两个异步写入同时持有同一个 base。

### Persist

宿主在**同一个 IndexedDB read-write transaction** 中读取当前记录、比较 digest 并替换：

```text
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

数据库提交后，宿主重新读回记录，再让内核发布：

1. request ID 必须与 prepared candidate 一致；
2. 当前内存的 base digest 仍等于 `before_digest`；
3. 读回的 JSON 与候选保存内容一致；
4. JSON 能通过当前保存结构与领域不变量校验；
5. 重新计算的摘要等于 `after_digest`。

通过后，内核最好从**实际读回的 payload** 重建正式游戏，而不是直接采用先前的内存草稿。这样，内存承认的就是持久介质中的确切字节所表达的状态，序列化遗漏、宿主误传和摘要错配都无法悄悄穿过边界。

只有 publish 成功以后，界面才刷新权威快照并展示成功回执。

## 失败时只认持久介质里的赢家

这一协议最有价值的地方，不是成功路径更漂亮，而是每个失败窗口都有唯一恢复规则：

| 失败位置 | 持久状态 | 恢复动作 | 玩家可见结果 |
|---|---|---|---|
| 命令校验失败 | 旧 payload | 丢弃草稿 | 状态未改变 |
| 草稿执行中失败 | 旧 payload | 丢弃草稿与其中的随机抽取 | 状态未改变 |
| CAS 发现 base 冲突 | 其它写入的 payload | 丢弃候选，重载存档赢家 | 显示状态已刷新 |
| IndexedDB 写入失败 | 旧 payload | abort prepared，重载旧存档 | 状态未改变 |
| 写入成功、publish 前异常 | 新 payload | 重载新存档 | 已提交，不回滚到旧状态 |
| publish 校验失败 | 以数据库为准 | 丢弃候选内存，重新导入数据库记录 | 不继续使用可疑内存 |
| 成功结果展示前刷新 | 新 payload | 从保存的回执恢复结果 | 不重复执行命令 |

注意第五行：数据库已经成功替换后，就不能因为 JavaScript 随后抛错而把操作描述成“没有发生”。正确动作是读取持久记录并采用它。提交点在 IndexedDB transaction 完成的那一刻；publish 只是让内存追认这个事实。

## 请求重放记录必须与命令结果一起入档

仅在内存里保存最近请求 ID，不足以处理刷新和崩溃。完整 profile 应保存：

```text
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 身份，会把表示细节误当成状态身份。

更稳妥的协议是：

1. 把值限制为明确的数据模型；
2. 使用确定性编码，例如规范 CBOR；
3. 整数与长度采用最短表示；
4. map key 按编码后的长度与字节稳定排序；
5. 权威数值拒绝浮点数，改用整数或固定点；
6. 在摘要输入外包一层 schema、版本和 domain tag；
7. 最后使用 SHA-256 等稳定摘要算法。

```text
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`。内核在正式执行前重新计算并比较：

```text
fingerprint = digest(
  domain = "resolution-plan",
  value = {
    revision,
    active_state,
    relevant_profile_state,
    content,
    submitted_intent
  }
)
```

若内容热更新、目标对象变化、成本来源变化，或客户端提交的 intent 与预览不一致，即使表面 revision 没有暴露出差异，也会得到 stale preview。恢复动作不是猜测玩家仍然同意，而是重新投影页面，让玩家再确认。

不是每个按钮都需要预览指纹。它适合“玩家依据一组可见后果作出确认”的命令；简单页签切换和纯导航不属于语义提交。

## 界面可以即时反馈，但不能提前发布权威结果

为了手感，按钮按下后可以立刻进入 loading、播放按压效果，甚至显示可撤销的意图动画；但新的余额、物品、任务结果和成功回执只能来自 publish 后的权威快照。

尤其不要执行下面的顺序：

```text
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, 存档事务*
