玩家离线三天,回来该拿多少
《群岛纪元》国庆活动结束后的第一个工作日,客服收到一串相似工单:
- 玩家 A 离线 3 天,铁矿只增加了 1 天的量;
- 玩家 B 离线 3 天,铁矿增加了 7 天的量,还触发了仓库上限;
- 玩家 C 的补偿邮件显示“已领取”,但他从没点过;
- 玩家 D 把手机时间调到一周后,反复登录,试图多领离线收益。
四个工单对应四个不同的问题:每日封顶、补算窗口、补偿幂等、客户端时间不可信。它们看起来都是“离线收益”,实际上分属结算触发、时间口径、状态上限和运营补偿四条路径。把它们塞进一个 now - lastSeen 的公式里,任何一条路径出错都会变成经济漏洞。
第 4.3 节 已经给出通用的时间模型:按本地自然日切段、每日封顶、时钟回拨返回零。本节不重复那个模型,而是往上走一层,回答 SLG 的运营边界:产出、消耗、仓库上限、补算窗口和运营补偿,如何在一次结算里算清,并且只算一次。
三个结算触发点
离线收益不是只有“登录时补一次”。SLG 至少有三个触发点:
| 触发点 | 时机 | 延迟要求 | 幂等键 | 主要风险 |
|---|---|---|---|---|
| 读时结算 | 玩家登录、打开经济界面 | 必须同步返回 | 结算窗口 + 配置版本 | 登录洪峰放大计算量 |
| 定时结算 | 每小时/每天批量跑 | 可延迟 | 批次 ID + 玩家 ID | 批次重跑导致重复发放 |
| 事件驱动 | 建筑完成、部队到达 | 事件时刻附近 | 事件 ID | 事件丢失或重复消费 |
《群岛纪元》的选择是:玩家经济走读时结算,排行榜与赛季奖励走定时结算,跨玩家事件只读取已裁决结果。 读时结算保证玩家一登录就看到正确库存;定时结算处理“没有玩家在线也要推进”的全局状态;事件驱动只用于建筑完成这类单玩家事件,不用于战斗结果。
读时结算的代价是登录洪峰。一个玩家离线 30 天,按天切段要循环 30 次;十万玩家同时回归,就是三百万次循环。所以外层必须先做最大窗口截断,再进入按天循环。截断不是优化,是正确性的一部分:没有窗口上限,补算成本随离线时长线性增长,而且经济会无限膨胀。
时间口径:只信服务器
离线结算的第一原则是:客户端时间永远不是输入。 客户端可以改系统时间、改时区、重放请求;服务端只能相信自己的时钟和持久化的 lastSettled。
具体做法:
lastSettled由服务端在每次成功结算后写入,和库存、账本在同一个事务里;- 结算时使用服务器当前时间
now,而不是客户端上传的时间戳; - 每日重置按赛区配置的时区计算,时区变更要记录
economy_timezone_version; - 时钟回拨直接拒绝并告警,不发放、不更新
lastSettled; - 重复请求(
now == lastSettled)返回空结算,由幂等账本去重。
时区与夏令时是最容易被低估的部分。下面三种口径会产生不同结果:
口径 A:UTC 自然日 -> 全球统一重置,玩家本地体验不一致
口径 B:赛区时区自然日 -> 本地 00:00 重置,符合玩家预期
口径 C:滚动 24 小时 -> 与“每日”语义不符,跨时区活动难对齐
SLG 通常选 B:每个赛区一个时区,每日重置在本地 00:00。夏令时切换时,本地日长度可能是 23 或 25 小时;按本地日历切段会自动处理,按固定 24 小时切段会在切换日错位。第 4.3 节的 AccrueInZone 已经按本地日历切段,本节直接复用它,不再写第二套时间模型。
产出、消耗与仓库上限
离线结算不是“产出多少给多少”。SLG 的库存同时受三件事影响:
| 因素 | 方向 | 结算方式 | 边界 |
|---|---|---|---|
| 资源产出 | 增加 | 每小时产率 × 时长,按日封顶 | 每日上限、最大窗口 |
| 维护消耗 | 减少 | 每小时消耗 × 时长 | 库存不能为负 |
| 仓库上限 | 截断 | 结算后取 min(库存, 上限) | 超出部分记为溢出 |
| 运营补偿 | 增加 | 按补偿单领取 | 过期、重复领取 |
一个简化但可解释的模型是:
库存' = clamp(库存 + 产出 - 消耗, 0, 仓库上限)
溢出 = max(库存 + 产出 - 消耗 - 仓库上限, 0)
这个模型假设产出和消耗在整个窗口内连续发生,库存不足时只把库存截到 0,不额外模拟“维护费付不起导致停产”。对大多数 SLG 的资源田和主城维护,这个近似足够;如果维护消耗占比很高,应该缩短结算窗口,而不是把模型写得更复杂。架构上要明确这条边界,避免读者以为公式能覆盖所有经济规则。
仓库上限与每日上限是两道不同的闸门:
- 每日上限限制“一天最多产出多少”,防止离线越久收益越高;
- 仓库上限限制“最多存多少”,防止资源无限堆积;
- 最大窗口限制“最多补多久”,防止离线一年补一年。
三者缺一不可。只做每日上限,玩家可以每天登录一次把资源花掉再离线;只做仓库上限,离线一个月的收益仍然会顶满仓库;只做最大窗口,回归玩家会一次性拿到过多资源。
代码:Settle 与幂等账本
下面的 settle 包把产出、消耗、仓位、窗口截断和幂等键写成一个纯函数,并把“结算结果只应用一次”和“补偿只领取一次”放进 Ledger:
// file: internal/settle/settle.go
// tier: L1
// verify: go test ./internal/settle/
package settle
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"gsarchlab/internal/offline"
)
var (
ErrInvalidConfig = errors.New("settle: invalid config")
ErrClockRollback = errors.New("settle: clock moved backwards")
ErrInvalidGrant = errors.New("settle: invalid compensation")
ErrExpired = errors.New("settle: compensation expired")
)
// Config is the SLG economy contract. DailyCap is the per-local-day production
// cap from the generic time model; StorageCap is the warehouse limit; Upkeep is
// paid continuously out of stock.
type Config struct {
ProductionPerHour float64
UpkeepPerHour float64
StorageCap float64
DailyCap float64
MaxWindow time.Duration
Location *time.Location
Version int
}
type Result struct {
PlayerID string
From time.Time
To time.Time
Gross float64
Upkeep float64
Stored float64
Overflow float64
Days int
Capped bool
Truncated bool
Key string
}
// Settle replays one settlement window. The caller must pass server-recorded
// lastSettled and a server time; client clocks are never an input. A retry must
// replay the same (from, to) pair, which is what makes the key stable.
func Settle(playerID string, lastSettled, now time.Time, stock float64, cfg Config) (Result, error) {
if playerID == "" || cfg.ProductionPerHour < 0 || cfg.UpkeepPerHour < 0 ||
cfg.DailyCap <= 0 || cfg.StorageCap <= 0 || cfg.MaxWindow < 0 {
return Result{}, ErrInvalidConfig
}
if now.Before(lastSettled) {
return Result{}, ErrClockRollback
}
loc := cfg.Location
if loc == nil {
loc = time.UTC
}
from := lastSettled
truncated := false
if cfg.MaxWindow > 0 && now.Sub(from) > cfg.MaxWindow {
from = now.Add(-cfg.MaxWindow)
truncated = true
}
acc := offline.AccrueInZone(from, now, cfg.ProductionPerHour, cfg.DailyCap, loc)
upkeep := cfg.UpkeepPerHour * now.Sub(from).Hours()
stored := stock + acc.Amount - upkeep
overflow := 0.0
if stored < 0 {
stored = 0
}
if stored > cfg.StorageCap {
overflow = stored - cfg.StorageCap
stored = cfg.StorageCap
}
return Result{
PlayerID: playerID,
From: from,
To: now,
Gross: acc.Amount,
Upkeep: upkeep,
Stored: stored,
Overflow: overflow,
Days: acc.Days,
Capped: acc.Capped,
Truncated: truncated,
Key: key(playerID, from, now, cfg, loc),
}, nil
}
// key covers the window, the economy contract and the timezone. It deliberately
// does not include stock: after a successful write the client may retry with
// the old lastSettled, and the stock has already changed.
func key(playerID string, from, to time.Time, cfg Config, loc *time.Location) string {
raw := fmt.Sprintf("%s|%d|%d|%.4f|%.4f|%.4f|%.4f|%d|%s",
playerID, from.UnixNano(), to.UnixNano(),
cfg.ProductionPerHour, cfg.UpkeepPerHour,
cfg.StorageCap, cfg.DailyCap, cfg.Version, loc.String())
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Ledger is the idempotency boundary for one player. It is intentionally not
// concurrency-safe: a player's settlement is serialized by the owning shard.
type Ledger struct {
applied map[string]Result
claimed map[string]time.Time
}
func NewLedger() *Ledger {
return &Ledger{applied: make(map[string]Result), claimed: make(map[string]time.Time)}
}
// Apply returns the first result for a key and applied=false on a replay.
func (l *Ledger) Apply(r Result) (Result, bool) {
if l.applied == nil {
l.applied = make(map[string]Result)
}
if prev, ok := l.applied[r.Key]; ok {
return prev, false
}
l.applied[r.Key] = r
return r, true
}
// Compensation is an operator-issued grant such as a maintenance apology. It
// is separate from natural production so the two can be reconciled.
type Compensation struct {
GrantID string
PlayerID string
Amount float64
ExpiresAt time.Time
}
// Claim grants a compensation once. An expired grant is an error, not a silent
// zero, because the operator needs to see rejected claims.
func (l *Ledger) Claim(c Compensation, now time.Time) (bool, error) {
if c.GrantID == "" || c.PlayerID == "" || c.Amount <= 0 {
return false, ErrInvalidGrant
}
if !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) {
return false, ErrExpired
}
if l.claimed == nil {
l.claimed = make(map[string]time.Time)
}
if _, ok := l.claimed[c.GrantID]; ok {
return false, nil
}
l.claimed[c.GrantID] = now
return true, nil
}
几个设计点:
Settle只接收服务器时间。lastSettled和now都由调用方从服务端状态读取,客户端没有任何时间参数可以注入。- 最大窗口在进入按天循环之前截断。
from = now - MaxWindow,并标记Truncated;玩家能看到“离线收益最多计算 N 天”,而不是沉默地少给。 - 产出复用
offline.AccrueInZone,按本地自然日封顶;消耗按整个窗口连续计算;最后统一做库存下限与仓库上限截断。 Key包含玩家、结算窗口、经济配置和时区,但不包含库存。原因很实际:如果第一次结算已经写了库存,客户端没收到响应而重试,库存已经变了;把库存放进 key 会让重试变成一次新的结算,直接重复发放。Ledger.Apply以 key 为唯一索引,重复 key 返回第一次的结果和applied=false。调用方应该把返回的旧结果直接回给客户端,而不是重新计算。Ledger.Claim处理运营补偿:GrantID唯一、Amount > 0、过期返回错误而不是静默零。补偿与自然产出分开记账,才能在对账时解释差额来源。Ledger不是并发安全的。同一个玩家的结算必须由所属分片串行化;跨分片并发写同一个玩家,是比公式错误更严重的架构问题。
幂等键为什么必须绑定“同一窗口”
重试有两种常见形态:
形态 A:客户端重试登录,服务端重新取 now
形态 B:定时任务重跑同一批次,使用批次内固定的 now
形态 A 的 now 每次都变,key 每次都不同,幂等账本无法识别。正确做法是让客户端重试时带上第一次的结算票据,或者由服务端把 lastSettled 作为窗口起点、把“本次结算的服务器时间”持久化后再返回。形态 B 更简单:批次开始时固定 now,重跑时复用同一个批次时间。
无论哪种形态,幂等键绑定的是“结算窗口 + 经济配置 + 时区”,不是“请求”。同一窗口重复请求只发一次;不同窗口是两次合法结算。把这两件事混在一起,就会出现“玩家重连拿到两份”或者“跨天收益被吞掉”。
补偿与自然产出分账
运营补偿(维护道歉、活动补发、客服赔付)和自然产出必须分开记账:
| 来源 | 幂等键 | 过期 | 对账口径 |
|---|---|---|---|
| 自然产出 | 结算窗口 key | 无 | 按窗口汇总 |
| 运营补偿 | GrantID | 有 | 按补偿单核对 |
| 战斗掉落 | battle_id | 无 | 按战斗记录 |
| 商店购买 | order_id | 无 | 按支付订单 |
每日对账时,库存变化应该等于四类来源之和减去消耗。差额超过阈值就告警,并冻结异常账号的补偿领取,先人工核对。只有“当前库存”而没有来源账本,永远无法解释差额来自哪里。
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/settle
$ go test -count=1 ./internal/settle/
ok gsarchlab/internal/settle 0.649s
5 个测试全部通过,覆盖产出/消耗/仓库上限、时钟回拨与窗口截断、UTC 与 UTC+8 的日边界差异、账本幂等、补偿过期与重复领取。时间为本机一次运行结果,后续运行可能显示 (cached)。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 重复发放 | 玩家重连后资源翻倍 | 幂等 key + 唯一索引 + 返回首次结果 |
| 响应丢失 | 客户端没收到结算结果 | 重试同一窗口,账本返回旧结果 |
| 时钟回拨 | 离线时长为负或收益异常 | 返回错误、告警、不更新 lastSettled |
| 时区变更 | 跨天收益口径突变 | 记录时区版本,变更时做一次迁移 |
| 夏令时切换 | 某天多算或少算一小时 | 按本地日历切段,不按固定 24 小时 |
| 窗口未截断 | 离线一年补一年,经济膨胀 | MaxWindow 截断并在响应中说明 |
| 补偿过期 | 玩家点了旧邮件却领不到 | 明确提示过期,记录拒绝原因 |
| 客户端改时间 | 反复领取离线收益 | 服务端时间 + lastSettled 持久化 |
| 结算与写库不同事务 | 发了资源但 lastSettled 没前进 | 同一事务或条件写 + 版本号 |
| 并发结算 | 同一玩家两个请求同时通过 | 按玩家串行化,或数据库唯一键兜底 |
常见错误
第一,用客户端时间算离线时长。改系统时间、改时区、重放请求都能绕过;服务端时间没有商量余地。
第二,把库存放进幂等键。第一次结算后库存已经变化,重试会生成新 key,重复发放。
第三,每日上限、仓库上限、最大窗口只做其中一个。三者解决三个不同的问题,缺一个就会留下套利路径。
第四,用滚动 24 小时代替自然日。它和运营配置的每日重置不一致,跨时区活动会错位。
第五,补偿和自然产出写同一本账但不标来源。出现差额时无法判断是产出、补偿、战斗还是支付。
第六,结算成功但 lastSettled 更新失败。发钱和推进时间必须在同一事务;最终一致性存储要用条件写和版本号。
第七,把战斗、交易、集结放进离线补算。它们必须在发生时刻裁决,补算只能读取已落定结果,不能重新模拟。
小结
SLG 的离线结算不是“时间乘产率”,而是产出、消耗、仓位、窗口和补偿五条路径的合成。工程上要守住三条线:只用服务器时间;每日上限、仓库上限、最大窗口同时存在;结算与补偿都以幂等键为准,重复请求返回第一次结果。
本节的可运行产物是 internal/settle:Settle 负责窗口截断、产出复用、消耗与仓位截断,Ledger 负责结算去重和补偿领取。下一节进入休闲小游戏:无状态大厅加有状态房间,讨论当品类从“长线 SLG”切到“快速上线的小游戏”时,前面这些重装备哪些可以拆掉。
阅读导航:上一节:8.2 分区分服、跨服与合服 · 下一节:9.1 无状态大厅加有状态房间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。