---
title: 让挂机结算与调用次数无关
url: https://doc.liz6.com/game-development/deterministic-offline-progression
locale: zh
area: game-development
tags:
- game-development
date: 2026-08-27
modified: 2026-08-27
description: 让挂机、在线 tick 和离线归来共享同一个固定点积分器，并用冻结输入、语义随机键与持久 ordinal 保证整段和分段结算得到同一实例序列。
---

# 让挂机结算与调用次数无关

> 同样离开一小时，结算一次和每分钟结算一次，不能只得到“差不多相同”的数量，而应得到完全相同的状态、余数和实例序列。

挂机系统看起来只是“时间乘速率”。真正进入存档、随机掉落和在线 tick 以后，它会迅速变成一个确定性问题：浏览器可能每秒调用一次，后台标签页可能几十秒才醒一次，玩家也可能关闭页面后一次结算数小时。若结果取决于调用次数，设备性能、页面可见性和保存时机会悄悄改变收益。

这类错误最初往往只有一两个单位的误差，难以从界面察觉。但当产物是带随机属性的真实实例时，一个数量差就会改变后续全部实例 ID、词条、报告顺序和随机游标。此后即使总收益再次碰巧相同，两份存档也已经无法重放为同一条历史。

本文讨论的是一套“分段不变”的后台演进方法。目标不是让统计期望相同，而是满足更强的不变量：

```text
settle(state, a + b)
== settle(settle(state, a), b)
```

等号比较的是完整权威状态，而不只是一个资源总数。

## 三个最常见的破坏源

### 每次调用各自向下取整

最直觉的实现是：

```text
earned = floor(elapsed × rate)
```

如果速率是一小时一件，在线系统每二十分钟结算一次，每次都得到零；离线一小时一次结算却得到一件。调用粒度变成了隐藏的收益参数。

保存一个浮点余数似乎可以补救，但浮点在不同平台、序列化往返和长时间累加中仍可能产生边界差异，也不适合参与权威摘要。

### 所有掉落共用一条全局随机流

如果后台掉落只是顺序调用 `rng.next()`，新增一类奖励、调整遍历顺序，甚至在别处多做一次无关判定，都会把后续实例整体推移。相同存档与时长无法再生成相同物品。

### 结算时读取当前配置

玩家开始挂机后换了装备、解锁了新掉落，或内容表更新了速率。如果旧任务在下一次 tick 时直接读取当前状态，一段已经发生的时间会被新配置追溯改写。在线频繁结算与离线一次结算会读取到不同的历史输入。

解决方案必须同时固定时间积分、随机身份和任务输入，只修其中一项不够。

## 先定义一份冻结的后台任务

后台任务开始时，应保存所有会影响其结果的输入，而不是只保存一个“正在挂机”的布尔值：

```text
BackgroundJob {
  job_id,
  target_id,
  frozen_build,
  elapsed_ms,
  reward_rows: {
    definition_id -> {
      rate_per_hour_fixed,
      remainder,
      next_ordinal
    }
  }
}
```

根据玩法不同，冻结输入通常包括：

- 唯一任务或部署 ID；
- 目标、难度与已经验证过的完整战斗输入；
- 当时有效的生产速率；
- 当时已经解锁且由该目标允许的掉落定义；
- 每个定义独立的固定点余数与下一个 ordinal；
- 必要时的内容版本或内容摘要。

后续换装、转交物品和新解锁不应穿透这份快照。玩家停止任务再重新开始时，才读取最新配置。

这不是为了拒绝动态系统，而是为了明确时间边界。如果设计确实允许中途改速率，应在变更时先按旧配置结算到切换点，再创建一个使用新配置的新时间段；不能让一次离线恢复自行猜测变化发生在什么时候。

## 用累计函数之差消除分段误差

设：

```text
t = 从任务开始累计的毫秒数
r = 每小时产生的固定点进度
H = 一小时的毫秒数
```

先定义从任务起点到任意时刻的累计理论产出：

```text
C(t) = floor(t × r / H)
```

从时刻 `a` 结算到 `b` 时，不直接计算区间长度，而是取两个累计值之差：

```text
delta(a, b) = C(b) - C(a)
```

它天然满足望远镜求和：

```text
delta(0, a) + delta(a, b)
= [C(a) - C(0)] + [C(b) - C(a)]
= C(b) - C(0)
= delta(0, b)
```

向下取整仍然存在，但它只发生在共同起点的累计函数上，不会在每个调用分段中重复丢失余数。

例如，一件产物需要 `1000` 点进度，速率也是每小时 `1000` 点。每二十分钟结算一次时：

```text
C(20m) = 333
C(40m) = 666
C(60m) = 1000

三段增量 = 333 + 333 + 334 = 1000
```

第三段自动补回前两段取整留下的差，最终与整小时一次结算完全一致。

实现时应用足够宽的整数计算乘法，例如先扩到 128 位，再除以小时常量；外层时间、速率和累计上限也要有明确边界。饱和运算可以作为防崩溃兜底，却不应替代输入上限和边界测试，因为一旦真正到达饱和值，普通代数不变量就可能不再成立。

## 每个产物定义独立保存余数

不同产物可以有不同速率，因此每个定义需要自己的进度行：

```text
earned     = cumulative_delta(previous_elapsed, next_elapsed, rate)
accumulated = remainder + earned
count       = accumulated / threshold
remainder   = accumulated % threshold
```

然后为 `0..count` 逐个创建真实实例。

若合法冻结输入下的 mint 被定义为不可失败，这个前置条件必须由内容与存档校验保证；若 mint 仍可能失败，则本次结算中的进度、ordinal、实例 ID 和随机游标要作为一个事务共同回滚，不能只跳过失败实例却已经消费进度。

独立进度行有几个重要性质：

- 新增或移除另一个定义，不会改变本定义的余数；
- 某个定义跨过阈值，不会阻塞其它定义继续累计；
- 报告可以精确解释每一类产物，而不是只显示一个混合进度条；
- 保存往返后无需从总资源反推历史。

余数必须进入存档。只保存已经产生的整数件数，会在每次重启时丢掉尚未满一件的真实进度。

## 随机数按语义坐标寻址

固定点积分解决了“产生几件”，还没有解决“产生哪几件”。要让完整实例序列分段不变，随机决策不能依赖一条全局流走到了第几步，而应由稳定语义坐标定位。

一种实用键结构是：

```text
RandomKey {
  domain,      // 战斗、掉落等大域
  purpose,     // 装备词条、后台掉落等具体用途
  activity,    // 本次任务或部署 ID
  source       // 目标、定义与 ordinal 的稳定组合
}
```

对第 `n` 件某定义产物，可以把下面这些量编码进 key：

```text
(target, difficulty, definition, job_id, ordinal = n)
```

再由全局 seed、完整 key 和该 key 的局部 cursor 混合出随机值。每个 key 拥有独立游标，因而：

- 战斗多抽二十次，不会移动掉落流；
- 同一掉落域中的另一个定义多抽一次，也不会移动本定义；
- 新增无关随机决策，不会重写旧实例；
- 随机账本保存并恢复后，各条流继续位于原位置。

`ordinal` 是这套设计的关键。它表示“这项任务为这个定义生成的第几件实例”，必须单调保存。结算分成多少次，不会改变 `0, 1, 2...` 的序列；同一个 ordinal 也永远对应同一组随机结果与来源证明。

不要直接使用数组下标、当前时间戳或新分配的实例 ID 充当随机来源。数组顺序会因内容编辑改变，墙钟不稳定，实例 ID 又通常是在 mint 之后才分配。随机身份应来自 mint 之前就已确定的领域坐标。

## 确定性还要求稳定遍历和稳定 ID 分配

使用 keyed RNG 并不自动保证完整状态相同。若多个任务或定义由无序 hash map 遍历，它们的随机属性也许各自相同，但全局实例 ID 和归来报告顺序仍可能不同。

因此结算还要规定：

- 后台位置按稳定 slot 或 job ID 排序；
- 每个任务的产物定义按稳定 definition ID 排序；
- 同一定义按 ordinal 从小到大 mint；
- 实例 ID 只在这个稳定顺序中分配；
- 报告沿用实际 mint 顺序，或显式按稳定键排序；
- 使用规范、有序的数据结构进入存档与摘要。

如果并行化结算，应先让每个分区生成带完整稳定键的候选结果，再按键归并并分配全局 ID。直接让多个线程竞争一个递增 ID，即使数量和随机属性一致，也会失去可复现顺序。

## 在线和离线必须调用同一个积分器

不要分别维护一套“在线 tick 算法”和一套“离线公式”。两条路径只应负责取得时间区间，最后进入同一个 `settle_elapsed`：

```text
online tick:
    settle_elapsed(job, elapsed_since_last_tick)

offline restore:
    observed = max(0, now - saved_at)
    credited = min(observed, offline_cap)
    settle_elapsed(job, credited)
```

`max(0, ...)` 或无符号饱和减法可以处理系统时钟回拨；单次离线时长上限用于控制经济膨胀与溢出风险。这个上限是产品规则，不是反作弊证明：完全控制本地时钟和存档的玩家仍能篡改本地游戏。

离线恢复只推进明确允许后台演进的任务。交互战斗、未确认选择、剧情对话和其它需要玩家输入的状态不能因为离线时间自行“模拟完成”。对这些状态，正确行为通常是保持冻结，等待玩家回来。

保存中的 `saved_at` 只是观察边界，不应用于决定掉落种类、实例属性或随机 seed。把墙钟限制在“计算可计入的 elapsed”这一层，时钟异常就不会污染内容身份。

## 归来报告是结算结果的投影

离线报告不应反过来驱动发奖。正确顺序是：

1. 权威状态用统一积分器完成结算；
2. 真实实例立即进入唯一库存；
3. 根据本次实际 delta 构造报告；
4. 没有任何有效变化时，不用空报告打断玩家。

报告可以是瞬时投影，因为奖励、余数和 ordinal 已经进入权威状态。但“是否已经结算过这段时间”必须与这些结果一起持久化；否则页面在展示报告后、下次保存前崩溃，仍可能重复计算同一时间段。

持久化边界可以采用[原子命令与存档协议](./atomic-web-game-commands.md)中的原则：候选状态同时包含新的观察边界、产物、余数、ordinal 和随机账本，持久化成功后才发布归来结果。分段不变解决的是数学与随机身份，原子保存解决的是崩溃重放，两者不能互相替代。

## 内容更新必须有明确策略

长时间后台任务可能跨过游戏版本更新。若保存只记录 `definition_id`，新版本却修改了速率、掉落表或实例生成规则，旧任务恢复时会面临歧义。

常见策略有三种：

1. **完全冻结**：任务保存所有影响结果的数值和生成版本，直到停止；
2. **内容摘要锁定**：恢复时要求摘要一致，不一致则执行显式迁移或停止任务；
3. **切段迁移**：先按旧规则结算到保存边界，再从该边界创建新规则任务。

最不安全的是静默读取新内容表，并把全部离线时长都按新规则计算。这样更新时刻会由“玩家何时打开游戏”决定，在线与离线玩家获得不同历史。

## 测试要比较完整状态，不只比较总数

最核心的性质测试是任意切分：

```text
whole = settle(initial, total)

split = clone(initial)
for duration in random_partition(total):
    split = settle(split, duration)

assert whole == split
```

比较对象至少包括：

- 所有普通资源总量；
- 每个定义的固定点余数；
- 每个定义的 `next_ordinal`；
- 实例 ID、定义、品质、词条和来源；
- 库存顺序或规范表示；
- 随机账本的全部 key 与 cursor；
- 后台任务累计时间；
- 本次报告中的实例序列。

还应覆盖以下边界：

- 恰好低于、等于和越过一个产物阈值；
- 一次跨过多个阈值；
- 零时长与很短时长；
- 在线小 tick、离线整段和两者混合；
- 中途保存、恢复后继续结算；
- 多任务、多定义的稳定遍历顺序；
- 在同一随机域加入无关抽取，既有实例不变；
- 新解锁定义不穿透已经冻结的任务；
- 时钟回拨、离线上限和整数边界；
- mint 失败时，余数、ordinal 与随机游标共同回滚；
- 内容摘要变化时走预定迁移，而不是静默重算。

属性测试很适合随机生成总时长和切分点；另外保留几组 golden，锁住具体实例序列和规范存档。前者擅长寻找数学边界，后者能发现随机键、遍历顺序或序列化格式被无意改动。

## 常见但不充分的修补

**“把 tick 固定成一秒。”**

浏览器会节流后台计时器，进程也可能暂停。调用间隔不是可靠时钟，更不能约束离线恢复。

**“多存几位浮点小数。”**

这只能缩小误差，不能给出跨平台、跨保存的完全相等。权威经济应使用有界整数固定点。

**“保存一个全局 RNG 状态就能复现。”**

它只能复现完全相同的调用顺序。功能新增或结算切分改变调用次数后，后续结果仍会整体漂移。

**“只要最终件数一样就可以。”**

真实实例还包含 ID、词条、来源和报告顺序。件数相同不等于状态相同。

**“离线直接按当前战力重新模拟。”**

这会让玩家归来时的装备配置追溯影响过去，并让结果依赖打开游戏的时机。后台任务要么冻结输入，要么显式按变化点切段。

## 最小实现清单

- [ ] 明确定义整段／分段完整状态相等的不变量；
- [ ] 任务开始时冻结所有影响产出的输入；
- [ ] 权威速率与余数使用整数固定点；
- [ ] 区间收益使用累计函数之差，不在每个调用中独立取整；
- [ ] 每个产物定义独立保存余数与 `next_ordinal`；
- [ ] 随机键包含 domain、purpose、activity、source 和稳定 ordinal；
- [ ] 不相关随机流拥有独立 cursor；
- [ ] 任务、定义、ordinal 和实例 ID 分配顺序稳定；
- [ ] 在线 tick 与离线恢复调用同一个积分器；
- [ ] 墙钟只决定 elapsed，并处理回拨与单次上限；
- [ ] 只推进允许后台演进的状态；
- [ ] 新观察边界、奖励、余数和随机账本一起原子保存；
- [ ] 随机切分性质测试比较完整状态；
- [ ] 内容更新有冻结、摘要拒绝或切段迁移策略。

## 结语：时间是输入，不是调用次数

可靠的挂机系统应当把结果定义成冻结输入、稳定 seed 与累计时间的函数，而不是定义成“结算函数被调用了多少次”。

累计差分让取整误差在任意分段下自动抵消，固定点余数保存尚未完成的真实进度，语义随机键与 ordinal 固定每一件实例的身份，稳定遍历保证全局 ID 和报告也能复现。最后再用原子保存保护观察边界，在线、离线、后台节流和刷新才只是不同的时间输入方式，而不是四套暗中不同的经济规则。

*Keywords: 游戏开发, 离线结算, 挂机系统, 固定点, partition invariance, deterministic RNG, keyed random, ordinal, 存档一致性, 属性测试*
