一、资源产出先定公式与口径
资源产出看起来只是一个乘法,真正难的是口径统一。基础产出、等级系数、加成、上限、结算时间点,这五项只要有一项在客户端和服务端算法不同,玩家就会看到“仓库里的数字和战报对不上”。SLG 的产出通常按小时配置,服务端用整数百分比计算,避免不同语言、不同机器的浮点误差。
本文采用的口径是:每小时产出等于基础产出乘以建筑等级,再乘以加成百分比。基础产出取示例值 60,一级建筑每小时产出 60;二级、加成 50% 时每小时产出 180。所有计算使用整数,时间差先换算成秒,再按 3600 秒折算,余下的秒数留在下一次结算,不直接丢弃。
| 因子 | 示例值 | 来源 | 变更时机 | 风险 |
|---|---|---|---|---|
| 基础产出 | 60/小时 | 配置表 | 版本更新 | 配置热更后旧队列按新值算 |
| 等级系数 | 1 级=1 倍 | 建筑等级 | 升级完成 | 升级中提前按新等级结算 |
| 加成 | 0% 或 50% | buff、联盟科技 | 实时变化 | 加成到期时区间归属不清 |
| 上限 | 1000 | 仓库等级 | 仓库升级 | 离线期间上限变化 |
| 结算点 | 上次结算时间 | 服务端记录 | 每次结算 | 使用客户端时间 |
这张表里最容易被忽略的是“变更时机”。如果升级在 10:00 开始、12:00 完成,那么 10:00 到 12:00 的产出应该按旧等级计算,12:00 之后才按新等级。把整段离线时间都按新等级补发,会让玩家通过“先升级再离线”获得额外收益;反过来按新等级倒扣,则会引发客诉。结算必须把时间切成区间,每个区间使用当时生效的系数。
二、离线结算:时间差、上限与溢出
离线结算是资源系统最容易出事故的地方。玩家离线八小时,服务端在他上线时用当前时间减去上次结算时间,算出时间差,再按当前等级和加成计算产出。这里有两个必须明确的规则:第一,库存达到上限后不再增加;第二,达到上限后时间仍然要向前推进,否则玩家收走资源后,系统会把溢出的那几个小时重新补发一次。
本文的 Produce 只把结算点推进到“已经兑现的那一秒”,而不是无条件推进到当前时间。这样短时间多次调用不会丢失零头:例如每小时 60 的资源,每 70 秒结算一次,第一次产出 1 并推进 60 秒,剩下 10 秒留到下次。只有达到上限时,才把结算点直接推到当前时间,表示溢出部分主动放弃。
| 场景 | 时间差 | 原始产出 | 可容纳 | 实际增加 | 结算点 |
|---|---|---|---|---|---|
| 正常离线 | 2 小时 | 120 | 1000 | 120 | 当前时间 |
| 接近上限 | 2 小时 | 360 | 50 | 50 | 当前时间 |
| 已经满仓 | 10 小时 | 1800 | 0 | 0 | 当前时间 |
| 收走后 1 小时 | 1 小时 | 180 | 1000 | 180 | 当前时间 |
| 短周期结算 | 70 秒 | 1 | 1000 | 1 | 推进 60 秒 |
表格最后一行说明为什么不能简单用“当前时间减上次时间”后直接覆盖结算点。如果每次结算都覆盖,70 秒只产出 1,剩下 10 秒永远丢失;长期运行后实际产出会低于配置值。反过来,如果永远不覆盖,达到上限后玩家收走资源,系统会认为整段满仓时间都没有结算,一次性补发大量资源。两种极端都要避免。
时区不是结算问题。time.Time 表示的是绝对时刻,只要服务端统一用 UTC 存储和比较,玩家在哪个时区上线都不影响时间差。容易出错的是把客户端传来的“本地时间字符串”直接解析成时间,或者在夏令时切换时用墙上时间做减法;正确做法是只接受服务端时间,客户端时间最多用于展示。
三、建筑升级:前置、消耗、队列、加速
升级不是把等级加一,而是一组必须同时满足的条件:建筑存在、未满级、前置建筑达到要求、资源足够、队列有空位。任何一项不满足都应该在扣资源之前拒绝,否则玩家会遇到“资源扣了但升级没开始”的问题。本文把前置校验放在 CanUpgrade,把资源扣减放在 Enqueue,两者职责分开,便于在不同事务边界复用。
| 升级阶段 | 允许操作 | 产出规则 | 资源状态 |
|---|---|---|---|
| 空闲 | 校验前置、计算消耗、入队 | 按当前等级 | 入队时一次性扣除 |
| 升级中 | 查看进度、加速、取消(按业务) | 仍按当前等级 | 已扣除,取消是否返还由业务定 |
| 已完成 | 领取结果、开始下一次 | 切换到新等级 | 不重复扣除 |
| 队列满 | 拒绝新升级 | 不受影响 | 不扣除 |
“升级中仍按当前等级产出”是本文选择的口径。另一种常见设计是升级期间停产,或者按新旧等级折中;无论选哪种,都要在配置和文档里写清楚。本文不让升级期间提前享受新等级,是为了避免玩家利用“升级刚开始就离线”的时间差。
队列互斥的检查顺序很重要:先看队列是否有空位,再扣资源,最后入队。如果先扣资源再检查队列,队列满时就要走退款流程,退款失败会留下脏账。本文的 Enqueue 在队列满时直接返回错误,不触碰资源;资源不足时也不入队,保证“扣了资源就一定有队列项”。
加速只改变完成时间,不改变已经扣除的资源。SpeedUp 把完成时间往前推,但不会早于当前时间,也不会早于开始时间;如果加速量大于剩余时间,升级立即完成。业务上是否允许“免费加速”“道具加速”“广告加速”,由调用方在调用前校验,队列本身只负责时间运算。
四、失败场景:客户端时间与并发绕过
某款游戏允许客户端上报“最后在线时间”,服务端用它计算离线产出。测试阶段一切正常,上线后出现玩家把手机时间调到未来,离线收益瞬间拉满;另一些玩家跨时区旅行,系统把时间差算成负数,产出直接归零。修复方式不是加更多判断,而是彻底不信任客户端时间:离线结算只用服务端记录的 LastTick 和当前服务器时间,客户端时间只用于界面展示。
第二个问题是并发升级。两个请求几乎同时到达,都读到“队列为空”,都通过了检查,然后各自扣资源、各自入队。单进程内可以用互斥锁解决,多进程部署时锁不住,必须把“队列空位”做成数据库条件更新,或者给玩家加版本号做乐观锁。只靠内存里的 Full() 判断,在网关多实例、世界多分片的环境里一定会被绕过。
第三个问题是无上限离线。运营活动把产出加成调得很高,玩家离线一个月后上线,一次性领取的资源超过仓库上限几十倍。即使代码有上限判断,如果结算点没有在满仓时推进,玩家收走资源后还能再领一次。修复要把“上限截断”和“结算点推进”放在同一个事务里,并记录被截断的数量用于对账。
五、Go 实现:产出、升级与队列
下面的 building.go 把产出结算、上限截断、升级消耗、前置校验、队列互斥和加速放在同一个包里。Produce 的结算点推进逻辑是核心:未满仓时只推进已兑现的秒数,满仓时推进到当前时间;Enqueue 先检查队列再扣资源,保证失败路径不改账。
// file: ch20/building/building.go
// tier: L1
// verify: go test ./ch20/building/
package building
import (
"errors"
"fmt"
"time"
)
const (
// BasePerHour 是一级建筑每小时的基础产出,示例值,实际由配置表决定。
BasePerHour = 60
// MaxLevel 是示例等级上限。
MaxLevel = 30
)
var (
ErrTimeBackwards = errors.New("building: time moved backwards")
ErrNotEnough = errors.New("building: not enough resource")
ErrQueueBusy = errors.New("building: upgrade queue busy")
ErrMaxLevel = errors.New("building: max level reached")
ErrPrerequisite = errors.New("building: prerequisite not met")
)
type Resource string
const (
Wood Resource = "wood"
Stone Resource = "stone"
Iron Resource = "iron"
)
// Cost 是升级消耗。使用 map 便于配置表扩展,比较和扣减都要先全部检查再写入,
// 避免扣到一半失败。
type Cost map[Resource]int
func (c Cost) Total() int {
total := 0
for _, n := range c {
total += n
}
return total
}
// Storage 是玩家资源账户。所有读写都在服务端完成。
type Storage struct {
amounts map[Resource]int
}
func NewStorage(initial map[Resource]int) *Storage {
s := &Storage{amounts: make(map[Resource]int, len(initial))}
for r, n := range initial {
if n > 0 {
s.amounts[r] = n
}
}
return s
}
func (s *Storage) Amount(r Resource) int { return s.amounts[r] }
// Deduct 是原子操作:先检查全部资源,再统一扣减。
func (s *Storage) Deduct(c Cost) error {
for r, n := range c {
if s.amounts[r] < n {
return fmt.Errorf("%w: %s need %d have %d", ErrNotEnough, r, n, s.amounts[r])
}
}
for r, n := range c {
s.amounts[r] -= n
if s.amounts[r] == 0 {
delete(s.amounts, r)
}
}
return nil
}
// Building 只保存产出相关状态。升级中的产出仍按当前等级结算,
// 升级完成时才切换等级,避免一次升级把整段离线时间按新等级补发。
type Building struct {
ID string
Level int
BonusPct int
Prereq string
PrereqLevel int
Stored int
LastTick time.Time
}
// RatePerHour 使用整数百分比,避免浮点误差导致同一段时间在不同机器上算出不同结果。
func (b *Building) RatePerHour() int {
if b.Level <= 0 {
return 0
}
return BasePerHour * b.Level * (100 + b.BonusPct) / 100
}
// Produce 结算 LastTick 到 now 的产出,并把结算点推进到已经兑现的那一秒。
// 达到上限后把 LastTick 直接推进到 now,否则玩家收走资源后会把溢出的时段重新补发。
func (b *Building) Produce(now time.Time, cap int) (int, error) {
if now.Before(b.LastTick) {
return 0, fmt.Errorf("%w: last=%s now=%s", ErrTimeBackwards,
b.LastTick.UTC().Format(time.RFC3339), now.UTC().Format(time.RFC3339))
}
if cap < 0 {
return 0, fmt.Errorf("building: negative cap %d", cap)
}
if b.Level <= 0 {
b.LastTick = now
return 0, nil
}
rate := b.RatePerHour()
if rate <= 0 {
b.LastTick = now
return 0, nil
}
seconds := int(now.Sub(b.LastTick) / time.Second)
if seconds <= 0 {
return 0, nil
}
raw := rate * seconds / 3600
if raw <= 0 {
return 0, nil
}
room := cap - b.Stored
if room <= 0 {
b.LastTick = now
return 0, nil
}
if raw >= room {
b.Stored = cap
b.LastTick = now
return room, nil
}
b.Stored += raw
secured := raw * 3600 / rate
b.LastTick = b.LastTick.Add(time.Duration(secured) * time.Second)
return raw, nil
}
// UpgradeCost 返回升到目标等级的消耗,示例公式,实际按配置表读取。
func UpgradeCost(toLevel int) Cost {
if toLevel < 2 {
return nil
}
return Cost{Wood: 100 * toLevel, Stone: 50 * toLevel, Iron: 10 * toLevel}
}
// CanUpgrade 校验等级上限和前置建筑,前置建筑必须在同一份权威数据里。
func CanUpgrade(b *Building, all map[string]*Building) error {
if b.Level >= MaxLevel {
return fmt.Errorf("%w: level %d", ErrMaxLevel, b.Level)
}
if b.Prereq == "" {
return nil
}
if p, ok := all[b.Prereq]; ok && p.Level >= b.PrereqLevel {
return nil
}
return fmt.Errorf("%w: %s >= %d", ErrPrerequisite, b.Prereq, b.PrereqLevel)
}
// Upgrade 是一条升级队列项。Cost 在入队时已经扣除,取消时由业务层决定是否返还。
type Upgrade struct {
BuildingID string
ToLevel int
Cost Cost
StartedAt time.Time
FinishAt time.Time
}
// Queue 用固定槽位限制并行升级。slots 为 1 时就是最常见的单队列。
type Queue struct {
slots int
items []Upgrade
storage *Storage
}
func NewQueue(slots int, storage *Storage) *Queue {
if slots < 1 {
slots = 1
}
return &Queue{slots: slots, storage: storage}
}
func (q *Queue) Len() int { return len(q.items) }
func (q *Queue) Full() bool { return len(q.items) >= q.slots }
// Enqueue 先检查队列空位,再扣资源。队列满时不扣资源,避免玩家白扣。
func (q *Queue) Enqueue(u Upgrade) error {
if q.Full() {
return fmt.Errorf("%w: %d/%d", ErrQueueBusy, len(q.items), q.slots)
}
if q.storage != nil {
if err := q.storage.Deduct(u.Cost); err != nil {
return err
}
}
q.items = append(q.items, u)
return nil
}
// SpeedUp 缩短指定建筑的升级时间,最早只能完成到 now。
func (q *Queue) SpeedUp(buildingID string, d time.Duration, now time.Time) bool {
if d < 0 {
return false
}
for i := range q.items {
if q.items[i].BuildingID != buildingID {
continue
}
finish := q.items[i].FinishAt.Add(-d)
if finish.Before(now) {
finish = now
}
if finish.Before(q.items[i].StartedAt) {
finish = q.items[i].StartedAt
}
q.items[i].FinishAt = finish
return true
}
return false
}
// Complete 取出已完成的升级并切到目标等级;未完成的保持原顺序。
func (q *Queue) Complete(now time.Time, all map[string]*Building) []Upgrade {
done := make([]Upgrade, 0, len(q.items))
keep := q.items[:0]
for _, u := range q.items {
if now.Before(u.FinishAt) {
keep = append(keep, u)
continue
}
if b, ok := all[u.BuildingID]; ok && b.Level < u.ToLevel {
b.Level = u.ToLevel
}
done = append(done, u)
}
q.items = keep
return done
}
RatePerHour 用整数百分比,Produce 用秒做时间差,两者都不依赖浮点数和本地时区。Storage.Deduct 先全量检查再统一扣减,任何一项不足都不会产生部分扣款。Queue.Complete 只在完成时间到达后切换等级,升级期间的产出仍然记在旧等级上。
CanUpgrade 只做规则校验,不扣资源、不入队,调用方可以先校验再决定是否展示确认框。真正入队时再校验一次,防止校验和入队之间状态变化;这两次校验的规则必须来自同一个函数,不能一份写在客户端、一份写在服务端。
六、测试覆盖哪些边界
测试覆盖八类行为,都是资源系统上线前必须验证的:
- 一级建筑两小时产出 120,结算点推进到当前时间。
- 二级加成 50% 时每小时 180,接近上限的两小时只增加可容纳的部分。
- 满仓十小时不增加资源,收走资源后一小时按正常速率产出,溢出时间不补发。
- 结算时间倒退返回
ErrTimeBackwards,不会产生负数或零产出。 - 70 秒一次的短周期结算累计产出正确,零头不会因为覆盖结算点而丢失。
- 同一时刻用 UTC 和 UTC+8 表示,产出结果一致。
- 升级入队扣除木、石、铁,队列满时第二次入队返回
ErrQueueBusy且不扣资源。 - 资源不足时
Deduct原子失败,队列不增加;前置不足、满级分别返回对应错误;加速后完成时间正确且不早于当前时间。
运行命令与输出如下:
cd .local/checks/gs-practice-lab && gofmt -l ch20/building && go vet ./ch20/building/ && go test ./ch20/building/ -count=1
ok gspractice/ch20/building 0.654s
gofmt -l 没有输出,go vet 没有告警。测试不依赖数据库和真实时钟,所有时间点都由测试显式构造;它验证的是结算规则和队列规则,不验证数据库事务、跨进程锁和配置热更,那些需要集成测试覆盖。
小结
资源产出要先把公式、系数、上限和结算点定成统一口径,离线结算按服务端时间差计算,满仓截断并推进结算点;建筑升级要先校验前置和资源,再在队列有空位时原子扣费;升级期间按当前等级产出,加速只改变完成时间。把这些规则写进可测试的 Go 包,可以让离线上限、队列互斥和资源原子性在单元测试里暴露,而不是变成线上对账问题。
常见错误与边界
- 离线产出没有上限,或者有上限但满仓后不推进结算点,玩家收走资源后重复领取。
- 用客户端上报的时间计算离线收益,玩家改系统时间或跨时区旅行就能刷资源或收益归零。
- 先扣资源再检查队列空位,队列满时退款失败,玩家资源凭空消失。
- 升级校验和入队各写一套规则,客户端校验通过、服务端拒绝,或者反过来绕过前置条件。
- 升级期间提前按新等级结算,玩家利用“升级后立刻离线”获得额外产出。
- 多进程部署只靠单机锁保证队列互斥,两个实例同时入队,资源扣两次或队列项丢失。
- 加速把完成时间推到开始时间之前,进度计算出现负数,客户端显示异常。
- 结算、扣费、入队不在同一个事务里,进程崩溃后出现“资源已扣、升级不存在”的脏账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。