《游戏服务端编程实践》20.3 战斗与调度系统

从行军状态机、出发到达返回三个时间点出发,比较定时器与惰性结算的取舍,说明服务端权威战斗、随机种子复现、取消返还、加速上限和跨服归属,并给出可测试的 Go 调度实现,覆盖到达结算、结算幂等、取消返还、加速后到达时间和重复 Tick 等边界。

一、行军状态机只有三个关键时间点

SLG 的行军调度看起来很复杂,核心其实只有三个时间点:出发时间、到达时间、返回时间。玩家点下出征时,服务端记录出发时刻和行军时长,算出到达时刻;到达后结算战斗,再根据结果安排返回。只要这三个时间点由服务端权威生成,客户端只负责播放动画,调度就不会被本地时间或网络延迟带偏。行军状态必须显式建模,不能只用一个 done 布尔值。本文用 marching、arrived、cancelled 三个状态:行军中可以取消和加速,到达后只能结算,取消后不能再结算。状态之间的转换只能由服务端事件触发,客户端发来的“我已到达”只能作为提示,不能作为结算依据。

状态进入条件允许操作结算数据常见错误
marching出征成功、兵力已扣查看、加速、取消尚未生成客户端自行判定到达
arrived服务端时间到达 ArriveAt查询战报、返回已落库重复结算、重新掷随机
cancelled到达前取消查询返还不生成战报取消后仍被 Tick 结算

“到达后只能结算”不是限制,而是幂等性的基础。如果允许到达后取消,服务端就要处理“战斗已经打完但玩家要求撤销”的边界,最后往往变成客服工单。规则越简单,线上争议越少。

二、定时器与惰性结算的取舍

最常见的实现是给每个行军挂一个定时器,到点触发结算。单机小规模时这很直观,但定时器有三个问题:第一,进程重启后内存里的定时器全部丢失;第二,系统负载高时定时器会延迟触发,延迟期间玩家可能重复点击;第三,行军数量大时,每个定时器都占资源,时钟回拨或休眠还会让触发时间漂移。

本文采用“全局 Tick 加惰性结算”:调度器定期扫描已到点的行军,批量结算;玩家查询战报时也会触发一次结算。定时器只作为“尽快处理”的优化,不作为正确性的唯一来源。即使 Tick 晚了几秒,结算仍然使用权威的 ArriveAt,战报里的到达时间不会因为 Tick 延迟而改变。生产环境通常选最后一种,但兜底逻辑必须和定时器走同一个 Settle,不能两套代码各算一遍。

方案触发精度进程重启单行军成本适合场景
每行军一个定时器高定时器丢失高小规模、单进程
查询时惰性结算取决于访问自动恢复最低读多写少、可延迟
定时器加惰性兜底高需重建中对时效要求高的玩法

三、战斗结算:服务端权威与可复现随机

战斗结算必须由服务端完成,客户端只提交“打谁”,不能提交“我赢了”。服务端根据进攻方兵力、加成、防守方快照和随机种子计算结果,把战报落库。随机种子要一起落库,客服处理申诉时可以复现同一场战斗;如果每次重算都重新取随机数,同一份战报会出现两个结果,申诉永远说不清。

输入作用是否落库风险
进攻兵力计算基础战力是结算前被其他玩法改掉
防守快照防守方兵力与加成是结算时再读世界状态
随机种子复现战斗浮动是重放时重新生成

表中的“防守快照”很关键。结算时如果再去读防守方的实时兵力,那么从行军出发到到达之间,防守方所有变化都会影响结果,同一场战斗在不同时刻重算会得到不同答案。正确做法是在结算入口一次性读取防守方状态,形成快照,后续计算只依赖快照和种子。随机只影响浮动,不影响规则。本文用种子生成 90% 到 110% 的浮动区间,再比较双方战力;同样的种子、同样的输入必然得到同样的胜负和损失。随机种子为 0 时,用行军 ID 的哈希派生,保证同一场行军在多次重试中得到同一个种子。

四、取消、加速与被打断

取消只在行军途中有效,到达后必须先结算。取消成功要返还兵力,返还操作和状态变更必须在同一个事务里:先把状态从 marching 改成 cancelled,再写回部队账本;如果先返还兵力、后改状态,进程崩溃会让玩家既拿到兵又能继续行军。取消后的行军不能再被 Tick 扫到,也不能再生成战报。加速改变的是到达时间,不是出发时间,也不是已经发生的战斗结果。加速量大于剩余时间时,到达时间最多提前到当前时间,不能提前到出发之前;否则进度计算会出现负数,客户端显示也会异常。加速后是否需要重新计算行军时长、是否影响返回时间,要在协议里写清楚,本文只调整去程到达时间。

操作前置条件成功结果失败返回
取消行军中、未到达状态改为取消、返还兵力已结算、已到达、未出发
加速行军中、已出发到达时间前移、不早于当前已结算、已取消、未出发

“被打断”在 SLG 里通常指目标被盟友占领、活动结束或跨服迁移。处理方式不是修改已经出发的行军结果,而是让行军在到达时重新校验目标状态:目标无效就按规则返回或取消,并记录原因。已经扣除的兵力不能凭空消失,必须有明确的返还或损失规则。

五、失败场景:定时器漂移与结算重放

某次版本更新后,战斗服务在凌晨低峰期重启,内存里的行军定时器全部丢失。玩家第二天上线发现部队停在路上,既不到达也不返回;运营手动补发时又触发了重复结算,同一场战斗生成了两份战报,一份扣兵、一份不扣,玩家资源对不上。根因不是定时器本身,而是把定时器当成了唯一的状态机驱动。

修复分三步。第一,行军状态和到达时间全部落库,进程启动后按到达时间重建索引;第二,结算入口做幂等,已经生成战报的行军直接返回原战报,不重新掷随机;第三,用全局 Tick 扫描到期行军,定时器只负责唤醒 Tick。这样即使定时器丢失、Tick 延迟,正确性也不受影响。另一个常见问题是取消不返还资源。玩家在到达前取消,服务端把状态改成取消,但兵力返还写在另一个异步任务里,任务失败后没有重试。修复要把状态变更和返还放在同一个事务,或者用可靠事件表保证最终返还,并给返还操作加幂等键,避免重复返还。

六、Go 实现:行军、结算与调度

下面的 march.go 把行军状态、到达结算、随机种子、取消返还、加速上限和调度器放在同一个包里。Settle 以 ArriveAt 为准,重复调用返回同一份战报;Scheduler.Tick 只负责找出到期行军,不依赖每个行军各自的定时器。

// file: ch20/march/march.go
// tier: L1
// verify: go test ./ch20/march/

package march

import (
	"errors"
	"fmt"
	"hash/fnv"
	"math/rand"
	"sort"
	"time"
)

const (
	StateMarching  = "marching"
	StateArrived   = "arrived"
	StateCancelled = "cancelled"
)

var (
	ErrNotArrived    = errors.New("march: not arrived yet")
	ErrCancelled     = errors.New("march: cancelled")
	ErrCannotCancel  = errors.New("march: cannot cancel")
	ErrNotDeparted   = errors.New("march: not departed yet")
	ErrSettled       = errors.New("march: already settled")
	ErrDuplicateID   = errors.New("march: duplicate march id")
	ErrInvalidMarch  = errors.New("march: invalid march")
	ErrInvalidTroops = errors.New("march: invalid troops")
)

// Defense 是防守方在结算时提供的快照,避免结算过程中再读世界状态。
type Defense struct {
	Troops   int
	BonusPct int
}

// Report 是服务端权威结算结果。Seed 必须落库,方便客服复现和申诉。
type Report struct {
	MarchID      string
	Owner        uint64
	Winner       string
	AttackerLoss int
	DefenderLoss int
	Seed         uint64
	SettledAt    time.Time
}

// March 只保留调度需要的字段。ArriveAt 是权威到达时间,
// 是否已经结算由 report 是否为 nil 判断,而不是依赖内存定时器。
type March struct {
	ID       string
	Owner    uint64
	From     string
	To       string
	Troops   int
	BonusPct int
	DepartAt time.Time
	ArriveAt time.Time
	State    string
	seed     uint64
	report   *Report
}

func New(id string, owner uint64, from, to string, troops int, departAt time.Time, travel time.Duration, seed uint64) (*March, error) {
	if id == "" {
		return nil, fmt.Errorf("%w: empty id", ErrInvalidMarch)
	}
	if troops <= 0 {
		return nil, ErrInvalidTroops
	}
	if travel < 0 {
		return nil, ErrInvalidTroops
	}
	return &March{
		ID:       id,
		Owner:    owner,
		From:     from,
		To:       to,
		Troops:   troops,
		DepartAt: departAt,
		ArriveAt: departAt.Add(travel),
		State:    StateMarching,
		seed:     seed,
	}, nil
}

// Report 返回已经落库的结算结果,第二个返回值表示是否已结算。
func (m *March) Report() (Report, bool) {
	if m.report == nil {
		return Report{}, false
	}
	return *m.report, true
}

// Settle 按到达时间做惰性结算,重复调用返回同一份报告,不会重新掷随机数。
func (m *March) Settle(now time.Time, d Defense) (Report, error) {
	if m.report != nil {
		return *m.report, nil
	}
	if m.State == StateCancelled {
		return Report{}, ErrCancelled
	}
	if now.Before(m.ArriveAt) {
		return Report{}, fmt.Errorf("%w: arrive at %s", ErrNotArrived,
			m.ArriveAt.UTC().Format(time.RFC3339))
	}
	rep := m.resolve(now, d)
	m.report = &rep
	m.State = StateArrived
	return rep, nil
}

func (m *March) resolve(now time.Time, d Defense) Report {
	seed := m.seed
	if seed == 0 {
		h := fnv.New64a()
		_, _ = h.Write([]byte(m.ID))
		seed = h.Sum64()
	}
	rng := rand.New(rand.NewSource(int64(seed)))
	swing := 90 + rng.Intn(21)
	atkPower := power(m.Troops, m.BonusPct) * swing / 100
	defPower := power(d.Troops, d.BonusPct)
	winner := "defender"
	atkLoss, defLoss := m.Troops, 0
	switch {
	case atkPower > defPower:
		winner = "attacker"
		atkLoss = m.Troops * defPower / (atkPower + defPower)
		defLoss = d.Troops
	case defPower > 0:
		defLoss = d.Troops * atkPower / (atkPower + defPower)
	}
	return Report{
		MarchID:      m.ID,
		Owner:        m.Owner,
		Winner:       winner,
		AttackerLoss: atkLoss,
		DefenderLoss: defLoss,
		Seed:         seed,
		SettledAt:    now,
	}
}

// power 把兵力和百分比加成换算成整数战力,至少保留 1 点,避免除零。
func power(troops, bonusPct int) int {
	if troops <= 0 {
		return 0
	}
	if bonusPct < -100 {
		bonusPct = -100
	}
	if p := troops * (100 + bonusPct) / 100; p > 0 {
		return p
	}
	return 1
}

// SpeedUp 缩短行军时间,最早只能到达 now,避免加速把到达时间推到出发之前。
func (m *March) SpeedUp(now time.Time, d time.Duration) error {
	if m.report != nil {
		return ErrSettled
	}
	if m.State == StateCancelled {
		return ErrCancelled
	}
	if now.Before(m.DepartAt) {
		return ErrNotDeparted
	}
	if d <= 0 {
		return nil
	}
	arrive := m.ArriveAt.Add(-d)
	if arrive.Before(now) {
		arrive = now
	}
	if arrive.Before(m.DepartAt) {
		arrive = m.DepartAt
	}
	m.ArriveAt = arrive
	return nil
}

// Cancel 只在行军途中有效,到达后必须先结算。返还的兵力由调用方写回部队账本。
func (m *March) Cancel(now time.Time) (int, error) {
	if m.report != nil {
		return 0, ErrSettled
	}
	if m.State == StateCancelled {
		return 0, ErrCannotCancel
	}
	if now.Before(m.DepartAt) {
		return 0, ErrNotDeparted
	}
	if !now.Before(m.ArriveAt) {
		return 0, fmt.Errorf("%w: arrival reached, settle first", ErrCannotCancel)
	}
	m.State = StateCancelled
	return m.Troops, nil
}

// Scheduler 不持有一个行军一个定时器,而是把到期行军集中在一个 Tick 里结算。
type Scheduler struct {
	marches map[string]*March
}

func NewScheduler() *Scheduler {
	return &Scheduler{marches: make(map[string]*March)}
}

func (s *Scheduler) Add(m *March) error {
	if m == nil || m.ID == "" {
		return ErrInvalidMarch
	}
	if _, ok := s.marches[m.ID]; ok {
		return fmt.Errorf("%w: %s", ErrDuplicateID, m.ID)
	}
	s.marches[m.ID] = m
	return nil
}

// Due 返回已经到点但尚未结算的行军,按到达时间和 ID 排序,保证结算顺序稳定。
func (s *Scheduler) Due(now time.Time) []*March {
	due := make([]*March, 0)
	for _, m := range s.marches {
		if m.State != StateMarching {
			continue
		}
		if !now.Before(m.ArriveAt) {
			due = append(due, m)
		}
	}
	sort.Slice(due, func(i, j int) bool {
		if !due[i].ArriveAt.Equal(due[j].ArriveAt) {
			return due[i].ArriveAt.Before(due[j].ArriveAt)
		}
		return due[i].ID < due[j].ID
	})
	return due
}

// Tick 结算所有到点行军。防守数据由调用方按目标点批量查好,
// 结算函数本身不访问数据库,保证同一次 Tick 的输入稳定。
func (s *Scheduler) Tick(now time.Time, defense func(*March) Defense) ([]Report, error) {
	reports := make([]Report, 0)
	for _, m := range s.Due(now) {
		d := Defense{}
		if defense != nil {
			d = defense(m)
		}
		rep, err := m.Settle(now, d)
		if err != nil {
			return nil, fmt.Errorf("march: tick %s: %w", m.ID, err)
		}
		reports = append(reports, rep)
	}
	return reports, nil
}

resolve 先用种子生成浮动,再用双方战力和防守快照计算损失;种子为 0 时从行军 ID 派生,保证可复现。Cancel 只在到达前成功,返回兵力数量,状态变更由调用方和返还操作放在同一个事务里。SpeedUp 把到达时间前移,但不会早于当前时间或出发时间。Scheduler.Due 按到达时间和 ID 排序,Tick 对每场行军调用同一个 Settle,所以定时器、查询兜底和补偿脚本无论谁先触发,都不会产生第二份战报。调度器本身不访问数据库,防守数据由调用方批量查好后传入,保证一次 Tick 的输入稳定。

七、测试覆盖哪些边界

测试覆盖七类行为,都是调度系统上线前必须验证的:

  • 到达前一分钟结算返回 ErrNotArrived,到达时刻结算成功并切换状态。
  • 同一场战斗重复结算返回原战报,换一个防守快照也不会重新计算。
  • 相同种子、相同输入得到相同胜负和损失;种子为 0 时从 ID 派生且不为 0。
  • 取消返还全部兵力,重复取消返回 ErrCannotCancel,取消后结算返回 ErrCancelled,到达后取消被拒绝。
  • 加速后到达时间正确,早一分钟仍不可结算,加速量过大时钳制到当前时间。
  • 调度器按到达时间和 ID 稳定排序,重复 Tick 不会重复结算,重复添加 ID 返回 ErrDuplicateID。

运行命令与输出如下:

cd .local/checks/gs-practice-lab && gofmt -l ch20/march && go vet ./ch20/march/ && go test ./ch20/march/ -count=1
ok  	gspractice/ch20/march	0.451s

gofmt -l 没有输出,go vet 没有告警。测试使用显式构造的时间点和固定种子,不依赖真实时钟和网络;它验证的是行军规则和结算幂等,不验证数据库事务、跨服网络分区和定时器精度,那些需要集成测试和故障演练。

小结

行军调度以出发、到达、返回三个服务端时间为准,状态机只允许合法转换;定时器只做唤醒优化,正确性由落库的到达时间和全局 Tick 保证;战斗结算使用防守快照和落库种子,重复结算返回原战报;取消要返还兵力,加速不能把到达时间推到当前或出发之前。把这些规则收进可测试的 Go 包,能让调度问题在单元测试里暴露,而不是在凌晨重启后变成玩家投诉。

常见错误与边界

  • 把定时器当成唯一的状态机驱动,进程重启后行军永远停在路上。
  • 用客户端上报的到达时间或本地时间结算,玩家改时间就能提前到达。
  • 结算时重新读取防守方实时状态,同一场战斗在不同时刻重算结果不同。
  • 随机种子不落库,客服复现时重新掷随机,战报无法自证。
  • 结算入口没有幂等,定时器、查询兜底和补偿脚本各结算一次,生成多份战报。
  • 取消只改状态不返还兵力,或者返还和状态变更不在同一个事务里。
  • 加速把到达时间推到出发之前,进度出现负数,客户端显示异常。
  • 到达后仍允许取消,已经发生的战斗被撤销,防守方战报和资源对不上。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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