《游戏服务端编程实践》8.1.3 战斗状态机

把战斗拆成准备、进行、结算、回放四个阶段,用显式迁移表拒绝非法跳转,讨论单位存活、死亡、眩晕、无敌的互斥与优先级,说明进入退出钩子为何不能递归触发迁移,并用 Go 实现带幂等结算的 CombatFSM 与合法、非法、钩子重入测试,最后给出接入战斗循环的边界建议。

一、阶段不是一组布尔开关

战斗流程看起来只有几步:开始、打、结算、看回放。很多实现用几个布尔值表达:started、fighting、settled、replay。一开始能跑,功能一多就开始出现组合爆炸:既 fighting 又 settled 是什么状态?replay 时还能不能收到伤害事件?停服时 closed 和 settled 谁先?

状态机的价值不是“多写一个类”,而是把合法状态和合法迁移收拢到一处。只要迁移表是唯一事实来源,任何“从准备直接跳结算”“结算后再回到进行”的请求都会被明确拒绝,而不是靠调用方自觉。

战斗阶段建议至少四个:准备(Prepare)、进行(Fighting)、结算(Settling)、回放(Replay)。准备阶段做匹配确认、阵容加载、初始属性计算;进行阶段接收行动或帧输入;结算阶段只做一次奖励与战绩落库;回放阶段只读,不再改变结果。关闭(Closed)作为终态,表示资源已释放、不再接受任何迁移。

二、阶段迁移表

当前阶段允许迁移到说明
准备进行、关闭加载完成后进入进行;匹配失败或取消直接关闭
进行结算、关闭胜负条件满足进入结算;异常中止直接关闭
结算回放、关闭结算完成可进入回放;不需要回放则直接关闭
回放关闭回放只读,结束后释放
关闭无终态,任何迁移都拒绝

这张表故意不允许“准备 → 结算”。即使准备阶段因为人数不足判平局,也应该先进入一个明确的结算阶段,再走结算逻辑;否则奖励、战绩、邮件这些副作用会散落在两个入口。也不允许“结算 → 进行”,因为结算一旦开始,战斗结果就不应该再被新输入改变。

迁移表还有一个好处:它可以被测试完整覆盖。状态数量有限,合法边有限,非法边可以按矩阵枚举。相比之下,布尔标志的组合很难穷举,测试往往只覆盖“正常路径”。

阶段的数量也不要无限扩张。有的项目把“加载中”“等待玩家确认”“暂停”都做成阶段,最后迁移表变得难以维护。建议把“必须拒绝非法操作”的边界做成阶段,把可以叠加的临时条件做成状态或效果;否则阶段会退化成另一个布尔标志集合。

关闭阶段要明确“关闭什么”。是停止接收输入、释放战斗内存、通知匹配服、还是落库?这些动作应该有顺序,并且可重试。把关闭做成一个终态,不等于所有清理都塞进一个钩子;更稳的做法是关闭阶段内部再分步骤,步骤失败可重入。

三、单位状态:互斥与优先级

战斗阶段描述整场战斗,单位状态描述单个实体。两者不要混在一个枚举里。单位至少有四种状态:存活、死亡、眩晕、无敌。

单位状态语义是否可行动是否可被选中优先级
存活正常参战是是最低
眩晕暂时失去行动能力否是中
无敌不承受伤害,但仍可行动是是中高
死亡生命值归零,退出战斗否否最高

互斥的意思是同一时刻只保留一个主状态,而不是把 dead、stunned、invincible 三个布尔值同时置位。优先级用于解决冲突:死亡最高,一旦死亡,眩晕和无敌都应被清除;无敌高于眩晕,处于无敌的单位不应因为眩晕而失去行动;眩晕高于存活,但不应阻止死亡判定。

如果业务确实需要“无敌且眩晕”这种组合,应该把它建模成两个维度:主状态(存活/死亡)与效果集合(眩晕、无敌、护盾)。主状态互斥,效果可以叠加,但效果之间要有明确的结算顺序。把“能否行动”“能否受伤”“能否被选中”拆成独立查询,比在一个枚举里塞组合更清晰。

单位状态还需要超时。眩晕通常有持续时间,无敌有持续时间,死亡可能是永久也可能是可复活。没有超时的状态会让单位永久卡住,尤其是在断线重连或配置热更之后。

超时的计时基准要统一。用服务器逻辑时间,而不是客户端本地时间;用战斗开始的逻辑帧,而不是进程启动时间。否则暂停、回放、断线重连后的剩余时间会不一致。状态进入时记录“到期帧”,每帧检查一次,比用一堆定时器更容易测试。

状态变更要产生事件,而不是只改内存字段。进入眩晕、解除眩晕、死亡、复活都应该有事件,供表现层、战斗日志和成就系统消费。事件里带单位 ID、状态类型、来源技能、到期帧,回放时才能重建。

四、钩子:进入与退出,但不要递归

状态机通常需要钩子:进入战斗时加载怪物,进入结算时发奖励,关闭时释放房间。钩子的诱惑是“在钩子里再触发一次迁移”,比如“结算完成后自动进入回放”。这会把一次迁移变成递归调用,轻则重复执行副作用,重则死锁或栈溢出。

推荐规则是:钩子只做通知和记录,不发起新的迁移;需要串联的阶段由外层调度器显式调用。如果确实要在钩子里触发,至少要让状态机检测重入并返回错误,而不是默默递归。

另一个细节是钩子的执行时机。迁移应该在钩子执行前生效,这样钩子里读到的是新阶段;钩子失败不应回滚阶段,而应记录错误并由外层决定是否关闭战斗。回滚听起来安全,实际上会让“已经发出去的奖励”和“回滚后的状态”不一致。

钩子也要有幂等意识。进程崩溃后重放事件、消息重投、手动补偿都可能让同一个钩子执行两次。发奖励、扣体力、写流水这类副作用,应该带战斗 ID 或事件 ID,由下游去重,而不是假设钩子只执行一次。

退出钩子和进入钩子的顺序也要固定。推荐先执行退出钩子,再提交新阶段,再执行进入钩子;或者先提交阶段,再依次通知退出与进入。两种都可以,关键是团队内统一,并写进测试。顺序不固定时,退出钩子读到新阶段、进入钩子读到旧阶段,会出现难以复现的问题。

五、失败场景:三个常见退化

第一个是布尔标志堆叠。某项目用 isFighting、isSettled、isReplay 三个布尔值控制流程,结果出现了 isFighting=true 且 isSettled=true 的战斗:结算逻辑已经发奖,战斗循环还在接收伤害。根因不是某个判断写错,而是根本没有“当前阶段”这个概念。

第二个是结算重复执行。结算接口被客户端重试、网关重发或运维手动触发,每次都发一遍奖励。修复方式不是“让客户端别重试”,而是给结算一个幂等键(settlementID),同一场战斗、同一个结算请求只生效一次;重复请求返回“已结算”,而不是报错或再发一次。

第三个是状态无超时。眩晕、无敌、准备阶段都可能永久停留。准备阶段没有超时,房间会被挂机玩家占住;眩晕没有超时,单位永远不能行动;回放没有超时,房间资源永远不释放。每个状态都应该有进入时间,并由调度器检查超时。

这三个问题的共同点是:它们都不是“某一行代码写错”,而是缺少一个统一的状态边界。状态机把边界显式化,测试才有地方落。

还有一个退化是“阶段和单位状态混用”。比如把“全员死亡”当成一个战斗阶段,或者把“战斗进行中”塞进单位状态。前者会让阶段数量随规则变化,后者会让单位状态承担世界状态。正确做法是保持两层:战斗阶段描述流程,单位状态描述实体,胜负条件作为进行阶段内部的一个判断。

状态机的测试应该按“边”覆盖,而不是只按“节点”覆盖。每个合法迁移至少一条用例,每个非法迁移至少一条用例,结算幂等和钩子重入单独覆盖。这样新增阶段时,测试会立刻提示哪些边没有定义。

六、Go 实现:显式迁移表与幂等结算

下面的 CombatFSM 把迁移表写成包级变量,Transition 只做三件事:校验、提交、通知钩子。结算用 settlementID 做幂等,重复请求不会二次发奖。

// file: ch08/model/fsm.go
// tier: L1
// verify: go test ./ch08/model/

package model

import (
	"errors"
	"fmt"
	"sync"
)

// CombatPhase 是战斗阶段,不是“是否结束”的布尔值。
type CombatPhase uint8

const (
	PhasePrepare CombatPhase = iota
	PhaseFighting
	PhaseSettling
	PhaseReplay
	PhaseClosed
)

var phaseNames = [...]string{"prepare", "fighting", "settling", "replay", "closed"}

func (p CombatPhase) String() string {
	if int(p) < len(phaseNames) {
		return phaseNames[p]
	}
	return fmt.Sprintf("phase(%d)", p)
}

var (
	ErrIllegalTransition  = errors.New("fsm: illegal combat transition")
	ErrFSMClosed          = errors.New("fsm: combat is closed")
	ErrHookReentry        = errors.New("fsm: transition inside hook is not allowed")
	ErrSettlePhase        = errors.New("fsm: settle only allowed while fighting or settling")
	ErrSettlementConflict = errors.New("fsm: settlement id conflict")
)

// combatTransitions 是唯一的事实来源:不在表里的迁移一律拒绝。
var combatTransitions = map[CombatPhase]map[CombatPhase]bool{
	PhasePrepare:  {PhaseFighting: true, PhaseClosed: true},
	PhaseFighting: {PhaseSettling: true, PhaseClosed: true},
	PhaseSettling: {PhaseReplay: true, PhaseClosed: true},
	PhaseReplay:   {PhaseClosed: true},
	PhaseClosed:   {},
}

type PhaseEvent struct {
	From CombatPhase
	To   CombatPhase
}

// Hook 在迁移提交后调用,只用于通知与记录;不要在钩子里再次 Transition。
type Hook func(from, to CombatPhase)

// CombatFSM 管理战斗阶段与一次性结算。结算以 settlementID 幂等:
// 相同 ID 的重复请求返回 applied=false,不会二次发奖。
type CombatFSM struct {
	mu           sync.Mutex
	phase        CombatPhase
	inHook       bool
	hooks        []Hook
	events       []PhaseEvent
	settled      bool
	settlementID string
	reward       int
}

func NewCombatFSM() *CombatFSM {
	return &CombatFSM{phase: PhasePrepare}
}

func (f *CombatFSM) Phase() CombatPhase {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.phase
}

func (f *CombatFSM) AddHook(h Hook) {
	if h == nil {
		return
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	f.hooks = append(f.hooks, h)
}

// Transition 校验并提交阶段迁移。迁移在钩子执行前已生效,
// 钩子中的重入请求返回 ErrHookReentry,不会递归触发下一轮迁移。
func (f *CombatFSM) Transition(to CombatPhase) error {
	f.mu.Lock()
	if f.inHook {
		f.mu.Unlock()
		return ErrHookReentry
	}
	from := f.phase
	if from == PhaseClosed {
		f.mu.Unlock()
		return fmt.Errorf("%w: %s -> %s", ErrFSMClosed, from, to)
	}
	if !combatTransitions[from][to] {
		f.mu.Unlock()
		return fmt.Errorf("%w: %s -> %s", ErrIllegalTransition, from, to)
	}

	f.phase = to
	f.inHook = true
	f.events = append(f.events, PhaseEvent{From: from, To: to})
	hooks := append([]Hook(nil), f.hooks...)
	f.mu.Unlock()

	for _, h := range hooks {
		h(from, to)
	}

	f.mu.Lock()
	f.inHook = false
	f.mu.Unlock()
	return nil
}

// Settle 在执行阶段或结算阶段生效一次。重复调用同一 settlementID
// 返回 applied=false 且不改变奖励;不同 ID 视为业务冲突。
func (f *CombatFSM) Settle(settlementID string, reward int) (bool, error) {
	if settlementID == "" {
		return false, errors.New("fsm: settlement id required")
	}
	f.mu.Lock()
	defer f.mu.Unlock()

	if f.settled {
		if settlementID != f.settlementID {
			return false, ErrSettlementConflict
		}
		return false, nil
	}
	if f.phase != PhaseFighting && f.phase != PhaseSettling {
		return false, ErrSettlePhase
	}

	f.settled = true
	f.settlementID = settlementID
	f.reward += reward
	return true, nil
}

func (f *CombatFSM) Settled() bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.settled
}

func (f *CombatFSM) Reward() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.reward
}

func (f *CombatFSM) Events() []PhaseEvent {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := make([]PhaseEvent, len(f.events))
	copy(out, f.events)
	return out
}

几个实现细节。combatTransitions 是唯一事实来源,新增阶段必须同时补表,否则迁移会被拒绝。Transition 在调用钩子前已经把 phase 改为目标阶段,因此钩子看到的是新状态;inHook 标志让钩子内的重入请求返回 ErrHookReentry,而不是递归。Settle 只允许在进行或结算阶段调用,第一次生效后记录 settlementID,相同 ID 返回 applied=false,不同 ID 返回冲突。

测试覆盖四类行为:合法路径(准备 → 进行 → 结算 → 回放 → 关闭)、非法迁移(准备直接到结算、进行直接到回放、关闭后再迁移)、结算幂等(同一 ID 重复调用不增加奖励、不同 ID 报冲突、准备阶段结算被拒绝)、钩子重入(钩子里再次迁移返回 ErrHookReentry 且阶段不变)。

七、把状态机接入战斗循环

状态机本身不推进时间,也不处理网络。接入战斗循环时,建议由外层调度器负责:每帧读取当前阶段,准备阶段检查加载完成,进行阶段处理输入与胜负判定,结算阶段调用一次 Settle,回放阶段只读事件,关闭阶段释放资源。

阶段切换要有日志和指标。每次迁移记录 from、to、战斗 ID、时间戳;非法迁移单独计数。线上出现“战斗卡在进行阶段”时,先看最后一次迁移和当前阶段,再看是否有超时任务没有触发,而不是在业务代码里到处加布尔判断。

如果战斗需要断线重连,重连逻辑只读取阶段和必要状态,不重新执行进入钩子。结算幂等键要持久化,不能只放在内存里;否则进程重启后重试仍可能二次发奖。

回放阶段建议只暴露只读接口。任何写操作,包括修改单位状态、追加事件、触发结算,都应该返回错误。只读不是靠约定,而是靠接口边界保证;否则某个“临时修复”会在回放时改掉历史结果。

最后,状态机要能导出当前快照。快照包含阶段、回合、单位状态、结算 ID、事件序号;恢复时从快照重建,再重放之后的事件。没有快照,进程崩溃后只能从头重放整场战斗,恢复时间会随战斗时长增长。

状态机的日志要能回答三个问题:战斗现在在哪个阶段、最后一次迁移是谁触发的、结算是否已经生效。把这三个问题做成查询接口,客服和运维就不必翻业务日志猜状态。

小结

战斗阶段用显式迁移表管理,准备、进行、结算、回放各有明确职责,非法迁移一律拒绝。单位状态互斥且有优先级,死亡最高,效果层可以单独建模。钩子只做通知,不在钩子里递归迁移。结算必须幂等,用持久化的 settlementID 保证重复请求不二次发奖。

常见错误与边界

  • 用多个布尔值代替状态机。组合状态会失控,测试也无法穷举。
  • 允许“准备 → 结算”或“结算 → 进行”这类捷径。副作用会散落,结果可能被新输入推翻。
  • 结算不幂等。客户端重试、网关重发都会导致重复发奖。
  • 在进入钩子里再次触发迁移,造成递归或死锁。
  • 单位状态只用布尔标志,死亡、眩晕、无敌可以同时为真。
  • 状态没有超时。准备、眩晕、回放都可能永久占住资源。
  • 重连时重新执行进入钩子,导致重复加载或重复扣费。
  • 结算幂等键只放内存。进程重启后幂等失效。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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