17 分钟
本页目录

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

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

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

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

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

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

本文记录的是一套适用于“权威玩法内核运行在浏览器进程、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

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

  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 写入失败旧 payloadabort 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 身份,会把表示细节误当成状态身份。

更稳妥的协议是:

  1. 把值限制为明确的数据模型;
  2. 使用确定性编码,例如规范 CBOR;
  3. 整数与长度采用最短表示;
  4. map key 按编码后的长度与字节稳定排序;
  5. 权威数值拒绝浮点数,改用整数或固定点;
  6. 在摘要输入外包一层 schema、版本和 domain tag;
  7. 最后使用 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, 存档事务