《游戏服务端编程实践》9.2.1 任务状态机

任务不是完成与未完成的布尔值,而是未接、进行中、可交付、已完成、失败五个状态。本篇拆解前置任务、互斥关系与四类进度模型,给出交付时状态变更与奖励发放的原子实现,并用前置未满足、重复交付、放弃重置三组测试覆盖边界,最后总结任务系统最常见的数据不一致问题。

一、任务状态不是布尔值

很多第一版任务系统只有一个 completed 字段。接任务时把它置为 false,交任务时置为 true。这个模型在单机原型里能用,一旦加入前置任务、可交付提示、放弃与失败,就会立刻不够用。

最直接的问题是“可交付”无法表达。击杀数已经够了,但玩家还没去找 NPC 交任务,此时任务既不是进行中,也不是已完成。如果继续用布尔值,客户端只能自己推断,服务端也无法在交付时判断该不该发奖励。

第二个问题是错误不可恢复。把“奖励已发但状态没写回”和“状态写了但奖励没发”都压进一个布尔值,排查时无法区分。任务系统的状态必须能说明玩家当前处在流程的哪一步。

第三个问题是放弃与失败没有位置。玩家主动放弃、超时失败、被互斥任务顶掉,这些情况都需要保留记录,否则重新接取时无法判断该不该清零进度。

二、五态状态机与合法迁移

任务日志用五个状态描述完整生命周期。每个状态只允许通过明确的方法进入,禁止业务代码直接改字段。

状态进入条件允许操作离开方式
未接没有任务条目接取前置满足后进入进行中
进行中前置满足且无互斥冲突推进进度、放弃目标完成进入可交付,或放弃进入失败
可交付所有目标计数达到要求交付、放弃交付进入已完成,或放弃进入失败
已完成交付成功且奖励已发放查询终态,不可再次接取
失败主动放弃或超时查询、重新接取重新接取时清零进度并进入进行中

这张表的关键不是状态数量,而是“可交付”必须独立存在。它把“目标完成”和“奖励结算”拆成两个动作,服务端才有机会在两者之间做校验、补偿和审计。

已完成是终态。允许重复接取已完成任务,会让一次性奖励变成可刷新的资源。如果业务确实需要重复任务,应该使用新的任务实例 ID,而不是把已完成改回进行中。

失败不是终态。玩家放弃后可以重新接取,但重新接取必须重置进度。把失败当成终态会让玩家永久失去这条任务线;把失败当成“未接”又会丢掉失败记录,二者都不可取。

三、前置任务与互斥关系

前置任务要求的是“已完成”,不是“已接取”,也不是“可交付”。如果只检查条目存在,玩家接了前置任务就能接后续任务,任务链会被跳过。

前置关系应该是一张有向无环图。配置阶段要检测环,运行时只做逐项检查。运行时的环检测代价高,而且一旦出现环,玩家会卡在永远无法满足的条件上,靠日志很难定位。

互斥关系在语义上是无向的,但配置通常只写一侧。加载阶段应该把反向关系补齐,运行时只检查当前任务的排斥列表;如果配置允许只写单向而不做规范化,两个任务谁先接谁就赢,结果取决于请求顺序。前置与互斥都必须在同一个锁内检查并创建条目,否则两个并发请求可能都通过检查,最终同时接取互斥任务。

互斥检查只看进行中和可交付的任务。已经完成或失败的任务不应继续占用互斥位,否则玩家完成一个任务后,另一个任务会永远接不了。

四、进度模型:计数、收集、到达、击杀

任务目标看起来五花八门,底层通常只有四类。把它们收敛成枚举,事件总线与任务系统之间就只需要一种协议。

类型事件语义目标参数计数规则
计数任意行为发生一次行为标识每次加一,可带数量
收集背包中某物品达到数量物品 ID以当前持有量为准,可增可减
到达进入某个区域区域或坐标 ID只记首次到达
击杀击杀某个怪物怪物模板 ID按击杀次数累加

计数与击杀可以按事件累加,收集与到达更适合“状态同步”而不是“事件累加”。收集任务如果只加不减,玩家把物品卖掉后任务仍然显示完成,交付时就会露馅。

同一事件应该能推进多个任务。玩家击杀一只狼,可能同时完成“杀三只狼”“收集狼皮”“探索狼谷”三个目标。任务系统不能假设一个事件只属于一个任务。

进度必须封顶。目标要求三个,事件一次带来十个,当前值应该停在 3,而不是 10。封顶既方便客户端显示,也避免后续比较出现“超额完成”的分支。

重复事件必须在进入任务系统之前去重。任务层再做去重只能挡住一部分,因为事件可能先被其他订阅者消费,等到任务层时已经无法判断这是不是同一次击杀。

五、交付的原子性

交付包含两个动作:把任务状态改成已完成,把奖励发到玩家账户。两个动作必须一起成功或一起失败。

内存实现可以用一把锁包住整个交付过程:检查状态、改状态、加金币都在同一个临界区。只要没有中途返回,调用方看到的就是原子结果。

生产环境通常跨存储或跨服务,锁不够用。此时要么用数据库事务把任务状态与奖励流水写在一起,要么用本地事务加 outbox,把奖励发放变成可重试的异步步骤。

幂等键应该是任务实例 ID,而不是玩家 ID 加任务模板 ID。玩家可能重复接取同一个模板,只有实例 ID 能区分“这一次交付”和“下一次交付”。

重复交付必须在发奖之前拒绝。先发奖再检查状态,第二次请求会看到状态还没变,从而发出第二份奖励;先检查再发奖,第二次请求会在状态检查处直接返回。

奖励发放要留流水。任务状态只说明“已完成”,流水说明“发了多少、发给了谁、对应哪个任务实例”。对账时两者能互相印证,缺一不可。

六、失败场景:奖励发了,状态没变

一个常见实现是这样写的:先给玩家加金币,再写任务状态。加金币成功,写状态时数据库超时,接口返回失败。玩家重试交付,状态仍是可交付,于是又加了一次金币。

反过来,先写状态再发奖励,写状态成功、发奖失败,玩家会看到任务已完成但金币没到账。更糟的是,很多系统把已完成当成终态,玩家没有任何重试入口。

这两种故障的根因相同:状态与奖励不在同一个原子单元里。只靠调整先后顺序无法解决,必须让两者共享事务,或者让奖励发放具备可重试的幂等键。

另一个高频问题是放弃任务保留进度。玩家在 2/3 时放弃,重新接取后直接变成 3/3,等于用放弃绕过进度要求。放弃方法必须从任务定义重新拷贝目标,而不是保留旧条目。

还有一个隐蔽问题:任务状态用字符串存储,代码里比较时大小写不一致。Ready 与 ready 被当成两个状态,交付检查永远不通过,日志里却只看到“状态不对”。

七、Go 实现:QuestLog

下面的实现把五个状态、前置检查、互斥检查、进度推进与交付都收在一个结构里。所有公开方法都先加锁,状态和奖励只在锁内变化。

// file: ch09/quest/quest.go
// tier: L1
// verify: go test ./ch09/quest/

package quest

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

var (
	ErrUnknownQuest = errors.New("quest: unknown quest")
	ErrPrereq       = errors.New("quest: prerequisite not completed")
	ErrInvalidState = errors.New("quest: invalid state for operation")
	ErrExclusive    = errors.New("quest: conflicts with an active quest")
	ErrAlreadyDone  = errors.New("quest: already completed")
)

// QuestState 描述任务在玩家日志中的位置;布尔值无法表达“可交付”。
type QuestState int

const (
	StateAvailable QuestState = iota // 未接
	StateActive                      // 进行中
	StateReady                       // 目标完成,等待交付
	StateCompleted                   // 已交付
	StateFailed                      // 失败或放弃
)

type ObjectiveKind int

const (
	KindCount ObjectiveKind = iota
	KindCollect
	KindReach
	KindKill
)

type Objective struct {
	Kind     ObjectiveKind
	Target   string
	Required int
	Current  int
}

type QuestDef struct {
	ID         string
	Prereqs    []string
	Exclusive  []string
	Objectives []Objective
	RewardGold int
}

type QuestEntry struct {
	Objectives []Objective
	RewardGold int
	State      QuestState
}

type ProgressUpdate struct {
	QuestID   string
	Objective int
	Current   int
	Required  int
}

type QuestLog struct {
	mu      sync.Mutex
	defs    map[string]QuestDef
	entries map[string]*QuestEntry
	gold    int
}

func NewQuestLog(defs []QuestDef) (*QuestLog, error) {
	l := &QuestLog{
		defs:    make(map[string]QuestDef, len(defs)),
		entries: make(map[string]*QuestEntry),
	}
	for _, d := range defs {
		if d.ID == "" {
			return nil, fmt.Errorf("%w: empty id", ErrUnknownQuest)
		}
		if _, ok := l.defs[d.ID]; ok {
			return nil, fmt.Errorf("quest: duplicate definition %q", d.ID)
		}
		l.defs[d.ID] = cloneDef(d)
	}
	for id, d := range l.defs {
		for _, other := range d.Exclusive {
			if o, ok := l.defs[other]; ok && other != id {
				o.Exclusive = append(o.Exclusive, id)
				l.defs[other] = o
			}
		}
	}
	return l, nil
}

// Accept 只创建进行中的条目,前置与互斥都在锁内检查。
func (l *QuestLog) Accept(id string) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	def, ok := l.defs[id]
	if !ok {
		return ErrUnknownQuest
	}
	if e := l.entries[id]; e != nil {
		if e.State == StateCompleted {
			return ErrAlreadyDone
		}
		if e.State == StateActive || e.State == StateReady {
			return ErrInvalidState
		}
	}
	for _, p := range def.Prereqs {
		if e := l.entries[p]; e == nil || e.State != StateCompleted {
			return fmt.Errorf("%w: %s", ErrPrereq, p)
		}
	}
	for _, other := range def.Exclusive {
		if e := l.entries[other]; e != nil && (e.State == StateActive || e.State == StateReady) {
			return fmt.Errorf("%w: %s", ErrExclusive, other)
		}
	}
	l.entries[id] = &QuestEntry{
		Objectives: append([]Objective(nil), def.Objectives...),
		RewardGold: def.RewardGold,
		State:      StateActive,
	}
	return nil
}

// Advance 用一次事件推进所有匹配的进行中任务。
func (l *QuestLog) Advance(kind ObjectiveKind, target string, amount int) []ProgressUpdate {
	if amount <= 0 {
		return nil
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	ids := make([]string, 0, len(l.entries))
	for id := range l.entries {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	var out []ProgressUpdate
	for _, id := range ids {
		e := l.entries[id]
		if e.State != StateActive && e.State != StateReady {
			continue
		}
		for i := range e.Objectives {
			o := &e.Objectives[i]
			if o.Kind != kind || o.Target != target || o.Current >= o.Required {
				continue
			}
			o.Current += amount
			if o.Current > o.Required {
				o.Current = o.Required
			}
			out = append(out, ProgressUpdate{QuestID: id, Objective: i, Current: o.Current, Required: o.Required})
		}
		if e.ready() {
			e.State = StateReady
		}
	}
	return out
}

// TurnIn 在同一临界区完成状态变更与奖励发放,重复交付在发奖前被拒绝。
func (l *QuestLog) TurnIn(id string) (int, error) {
	l.mu.Lock()
	defer l.mu.Unlock()
	e := l.entries[id]
	if e == nil {
		return 0, ErrUnknownQuest
	}
	if e.State == StateCompleted {
		return 0, ErrAlreadyDone
	}
	if e.State != StateReady {
		return 0, ErrInvalidState
	}
	e.State = StateCompleted
	l.gold += e.RewardGold
	return e.RewardGold, nil
}

// Abandon 退回失败态并清零进度,避免重新接取时继承旧计数。
func (l *QuestLog) Abandon(id string) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	e := l.entries[id]
	if e == nil {
		return ErrUnknownQuest
	}
	if e.State == StateCompleted {
		return ErrInvalidState
	}
	e.State = StateFailed
	e.Objectives = append([]Objective(nil), l.defs[id].Objectives...)
	return nil
}

func (l *QuestLog) State(id string) (QuestState, bool) {
	l.mu.Lock()
	defer l.mu.Unlock()
	e := l.entries[id]
	if e == nil {
		return StateAvailable, false
	}
	return e.State, true
}

func (l *QuestLog) Gold() int {
	l.mu.Lock()
	defer l.mu.Unlock()
	return l.gold
}

func (e *QuestEntry) ready() bool {
	for _, o := range e.Objectives {
		if o.Current < o.Required {
			return false
		}
	}
	return true
}

func cloneDef(d QuestDef) QuestDef {
	d.Objectives = append([]Objective(nil), d.Objectives...)
	d.Prereqs = append([]string(nil), d.Prereqs...)
	d.Exclusive = append([]string(nil), d.Exclusive...)
	return d
}

Advance 会遍历所有进行中和可交付的任务,只推进类型与目标都匹配的条目。返回值列出本次真正变化的进度,调用方可以直接用来推送客户端,不需要自己比对前后状态。

TurnIn 先检查已完成,再检查可交付,最后才改状态和加金币。第二次交付会在第一个分支返回 ErrAlreadyDone,不会走到加金币的代码。

Abandon 从任务定义重新拷贝目标,因此当前计数归零。它不允许放弃已完成任务,避免玩家在领奖后把任务退回可接取状态。

八、测试与运行

测试覆盖三组边界:前置未满足时接取失败、重复交付不重复发奖、放弃后重新接取进度归零。互斥检查放在第一组测试里,用一个与已接任务冲突的定义验证。

cd .local/checks/gs-practice-lab
gofmt -l ch09/quest
go vet ./ch09/quest/
go test ./ch09/quest/ -count=1

本机执行结果如下。gofmt 与 go vet 没有输出,表示格式与静态检查通过。

ok  	gspractice/ch09/quest	0.475s

小结

任务状态机的核心是把“目标完成”和“奖励结算”拆开。五个状态让前置、互斥、放弃和重复交付都有明确位置;进度模型收敛为计数、收集、到达、击杀四类,同一事件可以推进多个任务。

交付必须在同一个原子单元里完成状态变更与奖励发放,重复请求在发奖之前拒绝。放弃任务必须清零进度,已完成任务必须是不可回退的终态。

常见错误与边界

  • 用布尔值表示任务状态,无法表达可交付,也无法区分失败与未接。
  • 前置任务只检查条目存在,不检查已完成,任务链可以被跳过。
  • 互斥关系只查单向配置,接取结果取决于请求顺序。
  • 进度不封顶,客户端显示与交付判断出现额外分支。
  • 先发奖励再改状态,重试时重复发奖。
  • 先改状态再发奖,发奖失败后玩家没有重试入口。
  • 放弃任务保留旧进度,玩家可以靠放弃绕过计数要求。
  • 已完成任务允许重新接取,一次性奖励变成可反复领取。
  • 进度更新与交付不在同一把锁内,并发请求可能同时通过检查。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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