一、任务状态不是布尔值
很多第一版任务系统只有一个 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
小结
任务状态机的核心是把“目标完成”和“奖励结算”拆开。五个状态让前置、互斥、放弃和重复交付都有明确位置;进度模型收敛为计数、收集、到达、击杀四类,同一事件可以推进多个任务。
交付必须在同一个原子单元里完成状态变更与奖励发放,重复请求在发奖之前拒绝。放弃任务必须清零进度,已完成任务必须是不可回退的终态。
常见错误与边界
- 用布尔值表示任务状态,无法表达可交付,也无法区分失败与未接。
- 前置任务只检查条目存在,不检查已完成,任务链可以被跳过。
- 互斥关系只查单向配置,接取结果取决于请求顺序。
- 进度不封顶,客户端显示与交付判断出现额外分支。
- 先发奖励再改状态,重试时重复发奖。
- 先改状态再发奖,发奖失败后玩家没有重试入口。
- 放弃任务保留旧进度,玩家可以靠放弃绕过计数要求。
- 已完成任务允许重新接取,一次性奖励变成可反复领取。
- 进度更新与交付不在同一把锁内,并发请求可能同时通过检查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。