《游戏服务端编程实践》20.2 资源产出与建筑系统

从基础产出、等级系数和加成公式出发,说明资源上限、离线结算、溢出截断、建筑前置条件、升级消耗、单队列互斥与加速,并给出可测试的 Go 实现,覆盖离线上限、扣资源原子性、队列满不扣费、等级切换和跨时区结算等边界,同时分析客户端时间回拨和并发升级绕过队列的失败场景。

一、资源产出先定公式与口径

资源产出看起来只是一个乘法,真正难的是口径统一。基础产出、等级系数、加成、上限、结算时间点,这五项只要有一项在客户端和服务端算法不同,玩家就会看到“仓库里的数字和战报对不上”。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 小时1201000120当前时间
接近上限2 小时3605050当前时间
已经满仓10 小时180000当前时间
收走后 1 小时1 小时1801000180当前时间
短周期结算70 秒110001推进 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 包,可以让离线上限、队列互斥和资源原子性在单元测试里暴露,而不是变成线上对账问题。

常见错误与边界

  • 离线产出没有上限,或者有上限但满仓后不推进结算点,玩家收走资源后重复领取。
  • 用客户端上报的时间计算离线收益,玩家改系统时间或跨时区旅行就能刷资源或收益归零。
  • 先扣资源再检查队列空位,队列满时退款失败,玩家资源凭空消失。
  • 升级校验和入队各写一套规则,客户端校验通过、服务端拒绝,或者反过来绕过前置条件。
  • 升级期间提前按新等级结算,玩家利用“升级后立刻离线”获得额外产出。
  • 多进程部署只靠单机锁保证队列互斥,两个实例同时入队,资源扣两次或队列项丢失。
  • 加速把完成时间推到开始时间之前,进度计算出现负数,客户端显示异常。
  • 结算、扣费、入队不在同一个事务里,进程崩溃后出现“资源已扣、升级不存在”的脏账。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist