本页目录
让挂机结算与调用次数无关
同样离开一小时,结算一次和每分钟结算一次,不能只得到“差不多相同”的数量,而应得到完全相同的状态、余数和实例序列。
挂机系统看起来只是“时间乘速率”。真正进入存档、随机掉落和在线 tick 以后,它会迅速变成一个确定性问题:浏览器可能每秒调用一次,后台标签页可能几十秒才醒一次,玩家也可能关闭页面后一次结算数小时。若结果取决于调用次数,设备性能、页面可见性和保存时机会悄悄改变收益。
这类错误最初往往只有一两个单位的误差,难以从界面察觉。但当产物是带随机属性的真实实例时,一个数量差就会改变后续全部实例 ID、词条、报告顺序和随机游标。此后即使总收益再次碰巧相同,两份存档也已经无法重放为同一条历史。
本文讨论的是一套“分段不变”的后台演进方法。目标不是让统计期望相同,而是满足更强的不变量:
settle(state, a + b)
== settle(settle(state, a), b)
等号比较的是完整权威状态,而不只是一个资源总数。
三个最常见的破坏源
每次调用各自向下取整
最直觉的实现是:
earned = floor(elapsed × rate)
如果速率是一小时一件,在线系统每二十分钟结算一次,每次都得到零;离线一小时一次结算却得到一件。调用粒度变成了隐藏的收益参数。
保存一个浮点余数似乎可以补救,但浮点在不同平台、序列化往返和长时间累加中仍可能产生边界差异,也不适合参与权威摘要。
所有掉落共用一条全局随机流
如果后台掉落只是顺序调用 rng.next(),新增一类奖励、调整遍历顺序,甚至在别处多做一次无关判定,都会把后续实例整体推移。相同存档与时长无法再生成相同物品。
结算时读取当前配置
玩家开始挂机后换了装备、解锁了新掉落,或内容表更新了速率。如果旧任务在下一次 tick 时直接读取当前状态,一段已经发生的时间会被新配置追溯改写。在线频繁结算与离线一次结算会读取到不同的历史输入。
解决方案必须同时固定时间积分、随机身份和任务输入,只修其中一项不够。
先定义一份冻结的后台任务
后台任务开始时,应保存所有会影响其结果的输入,而不是只保存一个“正在挂机”的布尔值:
BackgroundJob {
job_id,
target_id,
frozen_build,
elapsed_ms,
reward_rows: {
definition_id -> {
rate_per_hour_fixed,
remainder,
next_ordinal
}
}
}
根据玩法不同,冻结输入通常包括:
- 唯一任务或部署 ID;
- 目标、难度与已经验证过的完整战斗输入;
- 当时有效的生产速率;
- 当时已经解锁且由该目标允许的掉落定义;
- 每个定义独立的固定点余数与下一个 ordinal;
- 必要时的内容版本或内容摘要。
后续换装、转交物品和新解锁不应穿透这份快照。玩家停止任务再重新开始时,才读取最新配置。
这不是为了拒绝动态系统,而是为了明确时间边界。如果设计确实允许中途改速率,应在变更时先按旧配置结算到切换点,再创建一个使用新配置的新时间段;不能让一次离线恢复自行猜测变化发生在什么时候。
用累计函数之差消除分段误差
设:
t = 从任务开始累计的毫秒数
r = 每小时产生的固定点进度
H = 一小时的毫秒数
先定义从任务起点到任意时刻的累计理论产出:
C(t) = floor(t × r / H)
从时刻 a 结算到 b 时,不直接计算区间长度,而是取两个累计值之差:
delta(a, b) = C(b) - C(a)
它天然满足望远镜求和:
delta(0, a) + delta(a, b)
= [C(a) - C(0)] + [C(b) - C(a)]
= C(b) - C(0)
= delta(0, b)
向下取整仍然存在,但它只发生在共同起点的累计函数上,不会在每个调用分段中重复丢失余数。
例如,一件产物需要 1000 点进度,速率也是每小时 1000 点。每二十分钟结算一次时:
C(20m) = 333
C(40m) = 666
C(60m) = 1000
三段增量 = 333 + 333 + 334 = 1000
第三段自动补回前两段取整留下的差,最终与整小时一次结算完全一致。
实现时应用足够宽的整数计算乘法,例如先扩到 128 位,再除以小时常量;外层时间、速率和累计上限也要有明确边界。饱和运算可以作为防崩溃兜底,却不应替代输入上限和边界测试,因为一旦真正到达饱和值,普通代数不变量就可能不再成立。
每个产物定义独立保存余数
不同产物可以有不同速率,因此每个定义需要自己的进度行:
earned = cumulative_delta(previous_elapsed, next_elapsed, rate)
accumulated = remainder + earned
count = accumulated / threshold
remainder = accumulated % threshold
然后为 0..count 逐个创建真实实例。
若合法冻结输入下的 mint 被定义为不可失败,这个前置条件必须由内容与存档校验保证;若 mint 仍可能失败,则本次结算中的进度、ordinal、实例 ID 和随机游标要作为一个事务共同回滚,不能只跳过失败实例却已经消费进度。
独立进度行有几个重要性质:
- 新增或移除另一个定义,不会改变本定义的余数;
- 某个定义跨过阈值,不会阻塞其它定义继续累计;
- 报告可以精确解释每一类产物,而不是只显示一个混合进度条;
- 保存往返后无需从总资源反推历史。
余数必须进入存档。只保存已经产生的整数件数,会在每次重启时丢掉尚未满一件的真实进度。
随机数按语义坐标寻址
固定点积分解决了“产生几件”,还没有解决“产生哪几件”。要让完整实例序列分段不变,随机决策不能依赖一条全局流走到了第几步,而应由稳定语义坐标定位。
一种实用键结构是:
RandomKey {
domain, // 战斗、掉落等大域
purpose, // 装备词条、后台掉落等具体用途
activity, // 本次任务或部署 ID
source // 目标、定义与 ordinal 的稳定组合
}
对第 n 件某定义产物,可以把下面这些量编码进 key:
(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:
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”这一层,时钟异常就不会污染内容身份。
归来报告是结算结果的投影
离线报告不应反过来驱动发奖。正确顺序是:
- 权威状态用统一积分器完成结算;
- 真实实例立即进入唯一库存;
- 根据本次实际 delta 构造报告;
- 没有任何有效变化时,不用空报告打断玩家。
报告可以是瞬时投影,因为奖励、余数和 ordinal 已经进入权威状态。但“是否已经结算过这段时间”必须与这些结果一起持久化;否则页面在展示报告后、下次保存前崩溃,仍可能重复计算同一时间段。
持久化边界可以采用原子命令与存档协议中的原则:候选状态同时包含新的观察边界、产物、余数、ordinal 和随机账本,持久化成功后才发布归来结果。分段不变解决的是数学与随机身份,原子保存解决的是崩溃重放,两者不能互相替代。
内容更新必须有明确策略
长时间后台任务可能跨过游戏版本更新。若保存只记录 definition_id,新版本却修改了速率、掉落表或实例生成规则,旧任务恢复时会面临歧义。
常见策略有三种:
- 完全冻结:任务保存所有影响结果的数值和生成版本,直到停止;
- 内容摘要锁定:恢复时要求摘要一致,不一致则执行显式迁移或停止任务;
- 切段迁移:先按旧规则结算到保存边界,再从该边界创建新规则任务。
最不安全的是静默读取新内容表,并把全部离线时长都按新规则计算。这样更新时刻会由“玩家何时打开游戏”决定,在线与离线玩家获得不同历史。
测试要比较完整状态,不只比较总数
最核心的性质测试是任意切分:
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, 存档一致性, 属性测试