玩家离开了十二小时,铁矿该给多少
《群岛纪元》的玩家习惯晚上收一次菜,第二天早上再上线。某次版本更新后,论坛出现两类帖子:
- 有人离开 12 小时,铁矿只给了 8000,和离开 3 小时一样;
- 有人离开 12 小时,铁矿给了 14400,比同等级玩家多出近一倍。
两件事的根因是同一个:离线补算被写成了 离线秒数 × 每小时产量,再套一个“仓库上限”。第一类玩家撞上了单次上限,第二类玩家的上限配置写错,或者建筑在离线期间升级、产率被重复计算。
SLG 的离线推进看起来是“时间乘产率”,实际是一个跨天、跨事件、跨结算通道的状态机。写错任何一处,都会变成经济系统漏洞。
补算不是把时间乘上产率
离线期间发生的事情至少分两类,处理方式完全不同。
连续产出:可以用公式
铁矿、农田、伐木场这类资源,产出是连续的。给定产率 ratePerHour、离线时长和上限,可以用公式算出应得数量。这类补算不需要逐 tick 模拟。
离散事件:必须按时间点处理
建筑升级完成、部队训练完成、科技研究完成,是离散事件。它们不该用“离线时长 × 速率”计算,而应该按 finishAt 判断:
如果 finishAt <= now:完成,产出按新等级从 finishAt 起算
如果 finishAt > now:仍在进行,剩余时间 = finishAt - now
不能补算的事:战斗与交易
玩家离线时被别人攻击、市场挂单成交、联盟集结到达,这些事件不能靠“回来时再算”。它们必须在发生时刻有服务端裁决,离线补算只读取已经落定的事件结果,不能重新模拟。
把三类放在一起:
| 类型 | 例子 | 补算方式 | 风险 |
|---|---|---|---|
| 连续产出 | 铁矿、农田 | 公式,按天分段 | 上限、时区、产率变化 |
| 离散事件 | 建筑、训练、研究 | 按 finishAt 排序推进 | 事件顺序、完成时间 |
| 实时裁决 | 战斗、交易、集结 | 发生时已结算,不可重放 | 离线期间的结果缺失 |
这张表决定了一个架构约束:离线补算只能处理“可延期”的状态,不能替代实时裁决。 把战斗结果也放进补算,等于让服务端在玩家回来时重新打一遍,既不公平也无法复现。
补算的四个边界
每日上限与仓库上限
大多数 SLG 会给离线产出设每日上限,防止玩家长时间不上线后一次性获得过多资源。上限通常按“服务器自然日”重置,而不是按“离线满 24 小时”。这两种口径差别很大:玩家 23:30 下线、次日 00:30 上线,按自然日算是两个日额度,按滚动 24 小时算只算一小时。选择哪一种不是技术问题,是经济设计问题,但必须写死并让客户端展示一致。
每日上限之外还有仓库容量上限。资源产出超过仓库容量时,超出部分通常丢弃或进入“保护仓库”。补算必须同时检查两个上限,并明确哪一个先生效。
最大离线窗口
即使有每日上限,也不能让玩家离线一年后回来补 365 天。常见做法是设置最大补算窗口,例如 7 天或 30 天;超过窗口的部分不再补算,或者只补一个固定值。
最大窗口同时保护三件事:经济不膨胀、补算循环有界、事件日志可以按窗口保留。
事件与产率的切换点
建筑在离线期间完成时,产率会变化。正确的做法是按完成时刻把窗口切成多段:
[lastSeen, finishAt) 旧产率
[finishAt, now) 新产率
每一段单独计算,但每日上限必须共享。不能让两段各自用完整的日上限,否则一次离线会拿到两份额度。
为什么不能无限补算
“玩家离线多久就补多久”听起来对玩家最友好,实际上会同时破坏经济、公平和可运维性。
经济会膨胀,公平性也无法解释
如果离线产出没有上限,玩家最优策略就是不上线:离开一个月,回来资源爆仓,直接跳过整个中期。活跃玩家的每日产出反而低于挂机玩家,经济曲线会失真。两个玩家同样付费,一个每天上线、一个半年不上线,后者回来拿到半年资源,前者没有补偿;这个规则无法向活跃玩家解释,也无法做活动平衡。
补算成本随时间线性增长
按天分段的补算,成本是 O(离线天数)。一个玩家离线三年是 1095 次循环;十万个回归玩家同时上线,就是上亿次循环。更糟的是,补算通常发生在登录热路径上,会和登录洪峰叠加。
时钟与账号共享会被利用
离线时长依赖时间差。如果服务端时间可以被影响,或者账号可以被共享来“养号”,无限补算就是把漏洞放大。设置最大窗口和每日上限,能让这类套利的收益有上界。
所以补算必须有三个上限:每日上限、仓库上限、最大窗口。三者缺一不可。
代码:Accrue
下面的实现把“按小时产出、每日封顶、按时区分日”写成一个纯函数。它假设窗口内产率恒定;如果建筑在窗口中完成,调用方按完成时刻切段,并把“当日剩余额度”传给下一段,而不是每段都用完整上限。
// file: internal/offline/accrue.go
// tier: L1
// verify: go test ./internal/offline/
package offline
import "time"
// Accrual is the result of replaying offline production. Amount is the total
// granted now, Seconds is the replayed duration, Days is the number of local
// calendar segments crossed, Capped reports whether any segment hit the daily
// cap, and RolledBack reports a non-forward clock.
type Accrual struct {
Amount float64
Seconds float64
Days int
Capped bool
RolledBack bool
}
// Accrue replays production in UTC. Callers that run a regional server should
// use AccrueInZone with the same location that defines their daily reset.
func Accrue(lastSeen, now time.Time, ratePerHour, cap float64) Accrual {
return AccrueInZone(lastSeen, now, ratePerHour, cap, time.UTC)
}
// AccrueInZone walks the interval one local calendar day at a time. Each day
// has its own cap, so a player who returns after a week does not receive seven
// days of production compressed into one day's cap, and does not receive more
// than seven daily caps either.
//
// The loop must never be replaced by a single now.Sub(lastSeen) calculation:
// that loses the day boundary, cannot apply a per-day cap, and makes the
// result depend on which timezone happens to run the settlement job.
func AccrueInZone(lastSeen, now time.Time, ratePerHour, cap float64, loc *time.Location) Accrual {
if loc == nil {
loc = time.UTC
}
if !now.After(lastSeen) {
// Equal timestamps mean a duplicate request; earlier timestamps mean
// the clock moved backwards. Both must grant nothing.
return Accrual{RolledBack: true}
}
if ratePerHour <= 0 || cap <= 0 {
return Accrual{}
}
var out Accrual
cursor := lastSeen
for cursor.Before(now) {
year, month, day := cursor.In(loc).Date()
dayStart := time.Date(year, month, day, 0, 0, 0, 0, loc)
nextDay := dayStart.AddDate(0, 0, 1)
segmentEnd := now
if nextDay.Before(now) {
segmentEnd = nextDay
}
seconds := segmentEnd.Sub(cursor).Seconds()
if seconds < 0 {
seconds = 0
}
amount := seconds / 3600 * ratePerHour
if amount > cap {
amount = cap
out.Capped = true
}
out.Amount += amount
out.Seconds += seconds
out.Days++
cursor = segmentEnd
}
return out
}
几个设计点:
- 用本地日历的零点切段,而不是每 24 小时切一次。跨天重置、活动开始都按自然日,按 24 小时切会在时区边界错位。
Days统计的是切出来的日段数量。23:30 到次日 00:30 是 2 段,即使只有 1 小时。- 每段独立应用上限,所以跨天会重置额度;这也是“每日上限”语义的直接表达。
now不晚于lastSeen时返回RolledBack,不产生负数。调用方必须把它当异常处理,而不是当成 0 收益继续。
AccrueInZone 的时间复杂度是 O(天数)。最大离线窗口应该在外层先截断,不要让这个循环处理几年。
四个必须测的场景
补算测试至少要覆盖:正常补算、超过每日上限、时钟回拨、跨天重置。
// file: internal/offline/accrue_test.go
// tier: L1
// verify: go test ./internal/offline/
package offline
import (
"testing"
"time"
)
func mustZone(t *testing.T, name string, offset time.Duration) *time.Location {
t.Helper()
return time.FixedZone(name, int(offset.Seconds()))
}
func TestAccrueNormal(t *testing.T) {
start := time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC)
got := Accrue(start, start.Add(2*time.Hour), 120, 1000)
if got.Amount != 240 {
t.Fatalf("amount=%v, want 240", got.Amount)
}
if got.Days != 1 || got.Capped || got.RolledBack {
t.Fatalf("got %+v", got)
}
}
func TestAccrueHitsDailyCap(t *testing.T) {
start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
got := Accrue(start, start.Add(10*time.Hour), 100, 500)
if got.Amount != 500 {
t.Fatalf("amount=%v, want 500", got.Amount)
}
if !got.Capped {
t.Fatal("Capped=false, want true")
}
}
func TestAccrueClockRollback(t *testing.T) {
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
got := Accrue(now.Add(time.Hour), now, 100, 500)
if got.Amount != 0 || !got.RolledBack {
t.Fatalf("got %+v, want zero and RolledBack", got)
}
if same := Accrue(now, now, 100, 500); same.Amount != 0 || !same.RolledBack {
t.Fatalf("equal timestamps: %+v", same)
}
}
func TestAccrueResetsCapAcrossDay(t *testing.T) {
loc := mustZone(t, "UTC+8", 8*time.Hour)
start := time.Date(2026, 10, 20, 23, 30, 0, 0, loc)
got := AccrueInZone(start, start.Add(time.Hour), 100, 60, loc)
// 30 minutes before midnight and 30 minutes after each earn 50, and each
// local day carries its own cap of 60. Without a day boundary the total
// would be clamped to 60 instead of 100.
if got.Amount != 100 {
t.Fatalf("amount=%v, want 100", got.Amount)
}
if got.Days != 2 {
t.Fatalf("days=%d, want 2", got.Days)
}
if got.Capped {
t.Fatalf("capped=true, want false: %+v", got)
}
}
func TestAccrueTimezoneChangesDayBoundary(t *testing.T) {
// 15:30Z-16:30Z is one hour inside UTC but crosses midnight in UTC+8.
start := time.Date(2026, 10, 20, 15, 30, 0, 0, time.UTC)
end := start.Add(time.Hour)
utc := AccrueInZone(start, end, 100, 1000, time.UTC)
east := AccrueInZone(start, end, 100, 1000, mustZone(t, "UTC+8", 8*time.Hour))
if utc.Amount != 100 || east.Amount != 100 {
t.Fatalf("amount utc=%v east=%v, want 100/100", utc.Amount, east.Amount)
}
if utc.Days != 1 || east.Days != 2 {
t.Fatalf("days utc=%d east=%d, want 1/2", utc.Days, east.Days)
}
}
跨天测试是核心:23:30 到 00:30 只有 1 小时,但落在两个自然日,每段 30 分钟各得 50,总计 100;如果没有按天切段,整段会被单日上限 60 截断。
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/offline
$ go vet ./internal/offline
$ go test ./internal/offline/ -v -count=1
=== RUN TestAccrueNormal
--- PASS: TestAccrueNormal (0.00s)
=== RUN TestAccrueHitsDailyCap
--- PASS: TestAccrueHitsDailyCap (0.00s)
=== RUN TestAccrueClockRollback
--- PASS: TestAccrueClockRollback (0.00s)
=== RUN TestAccrueResetsCapAcrossDay
--- PASS: TestAccrueResetsCapAcrossDay (0.00s)
=== RUN TestAccrueTimezoneChangesDayBoundary
--- PASS: TestAccrueTimezoneChangesDayBoundary (0.00s)
PASS
ok gsarchlab/internal/offline 0.393s
gofmt -l 与 go vet 均无输出,5 个测试全部通过。时间来自本机首次运行,后续运行可能显示 (cached)。
补算结果如何与在线结算对账
离线补算最危险的不是算错,而是算两次。玩家登录时补算一次,掉线重连又补算一次;或者补算成功、lastSeen 没更新,下次再补一次。
对账的第一层是幂等键:
grant_id = hash(player_id, last_seen_unix, now_unix, economy_version)
补算发放时,把 grant_id 写进账本并加唯一索引。重复请求命中同一个 grant_id,直接返回上次结果,不再发放。
第二层是原子更新。读取 lastSeen、计算、写账本、更新 lastSeen 必须在同一个事务里:
BEGIN
SELECT last_seen FROM player_economy WHERE player_id = ? FOR UPDATE
-- 计算 Accrue
INSERT INTO economy_ledger(grant_id, player_id, amount, source, created_at)
UPDATE player_economy SET last_seen = ?, version = version + 1
COMMIT
如果账本写入成功但 last_seen 更新失败,事务回滚,不会出现“钱发了、时间没走”。如果使用最终一致性存储,则要用条件写:只有 version 匹配时才更新,失败就重读重算。
第三层是每日对账。离线补算和在线产出应该写同一本账,只是 source 不同:
| 来源 | 写入时机 | 幂等键 | 对账口径 |
|---|---|---|---|
| 在线产出 | 每个结算周期 | (player, tick) | 按 tick 汇总 |
| 离线补算 | 登录/重连 | grant_id | 按 last_seen 区间汇总 |
| 战斗掉落 | 战斗结算 | battle_id | 按战斗记录核对 |
| 商店购买 | 支付回调 | order_id | 按订单核对 |
每天跑一次对账任务:
库存快照变化
== 在线产出 + 离线补算 + 战斗掉落 + 商店购买 - 消耗 - 过期
差额超过阈值就告警,并冻结异常账号的离线补算,先人工核对。没有账本,只有“当前库存”,永远无法解释差额来自哪里。
第四层是边界对齐。在线结算的最后一次 tick 和离线补算的 lastSeen 必须是同一个时间点。常见做法是玩家下线时记录 lastSeen 并立即结算到该时刻;如果下线事件丢失,就用最后一次在线账本的 created_at 作为 lastSeen,而不是用登录时间倒推。
失败模式与兜底
补算成功但响应丢失
客户端没收到结果,重试登录。幂等键保证不会重复发放;客户端应该重新拉取账本,而不是相信本地缓存。
补算过程中进程崩溃
事务未提交则全部回滚;已提交则 last_seen 已经前进。两种情况都不会重复。不要在事务外先写缓存再写库。
时区配置变更
运营把每日重置从 UTC+8 改成 UTC+0,历史 lastSeen 的日段口径会变。正确做法是记录 economy_timezone 版本,变更时做一次迁移,而不是让新旧口径混算。
最大窗口被截断
超过窗口的部分不补算,但要在响应里明确告诉玩家“离线收益最多计算 7 天”。沉默地少给,会被当成 bug;明确规则,玩家才能预期。
时钟回拨
Accrue 返回 RolledBack 时,不发放、不更新 last_seen,记录告警。不要用“把 last_seen 改成 now”来掩盖,否则攻击者可以通过回拨时钟反复触发补算。
常见错误与边界
第一,用 now - lastSeen 一次性乘产率。它丢掉了每日上限、跨天重置和建筑完成时刻。
第二,每段补算都用完整日上限。建筑完成切段后,两段各拿一份额度,等于凭空多发。
第三,把战斗和交易放进补算。离线补算只能读取已裁决事件,不能重新模拟。
第四,补算与 last_seen 更新不在同一事务。发钱成功、时间没走,下次登录再发一次。
第五,没有最大窗口。离线一年补一年,经济和补算循环都会失控。
第六,用客户端时间或滚动 24 小时替代服务器自然日。前者可篡改,后者与运营配置的每日重置不一致。
第七,只对账总量,不对账来源。差额出现时,无法判断是在线、离线、战斗还是支付。
小结
SLG 的离线推进不是“时间乘产率”,而是连续产出、离散事件和实时裁决三类状态的分治。补算必须有每日上限、仓库上限和最大窗口;跨天要按自然日切段,建筑完成要按 finishAt 切段;补算结果必须通过幂等键、原子事务和同一本账与在线结算对账。
这一节用 internal/offline 把按天封顶的补算写成了可测试的纯函数。下一章进入连接与协议:长连接、短连接、WebSocket 与 QUIC 各自适合什么品类,以及连接层如何承载前面几节定义的状态与时间。
阅读导航:上一节:4.2 tick、帧预算与世界时钟 · 下一节:5.1 长连接、短连接、WebSocket 与 QUIC 的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。