一、行军状态机只有三个关键时间点
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 包,能让调度问题在单元测试里暴露,而不是在凌晨重启后变成玩家投诉。
常见错误与边界
- 把定时器当成唯一的状态机驱动,进程重启后行军永远停在路上。
- 用客户端上报的到达时间或本地时间结算,玩家改时间就能提前到达。
- 结算时重新读取防守方实时状态,同一场战斗在不同时刻重算结果不同。
- 随机种子不落库,客服复现时重新掷随机,战报无法自证。
- 结算入口没有幂等,定时器、查询兜底和补偿脚本各结算一次,生成多份战报。
- 取消只改状态不返还兵力,或者返还和状态变更不在同一个事务里。
- 加速把到达时间推到出发之前,进度出现负数,客户端显示异常。
- 到达后仍允许取消,已经发生的战斗被撤销,防守方战报和资源对不上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。