《游戏服务端架构·核心系统与契约》7.2 任务事件与进度服务

《群岛纪元》的击杀任务、《泡泡工坊》的点击任务和《裂隙战场》的结算任务,对进度可信度的要求并不相同。本节对比客户端上报与服务端订阅两种模型,讨论事件去重键、任务链前置、门控回填与并发完成幂等,并给出可运行的 questprogress 包与边界。

同一个“击杀”,三种可信度

《群岛纪元》的日常任务是“击杀 10 只野狼”,事实发生在战斗服;《泡泡工坊》的周任务是“在小游戏里点击 50 次气泡”,事实只发生在客户端;《裂隙战场》的赛季任务是“赢下 3 局”,事实由结算服产生。三件事看起来都是“计数”,但服务端对它们的信任程度完全不同。

北辰在《泡泡工坊》上线首周遇到过一次进度翻倍:玩家断线重连后,客户端把本地缓存的点击批次重新上报了一遍,服务端按“收到就加”处理,任务提前完成。第二周,《群岛纪元》的事件总线重投了一批击杀事件,同一个任务被完成了两次,奖励发了双份。两个问题的根因相同:进度服务没有幂等键,也没有区分事实来源。

本节不讨论任务编辑器,也不讨论奖励配置,只讨论一件事:一条“玩家完成了某事”的消息,如何安全地变成任务进度。答案由三个决定组成:事实由谁产生、事件如何标识、完成如何只发生一次。

两种进度模型:客户端上报与服务端订阅

模型事实来源可信度成本延迟适用场景
客户端上报仅客户端知道的操作低,需要 nonce、限速、抽样复核低低休闲小游戏、纯表现任务
服务端订阅战斗、背包、经济等域事件高,可重放中,需要事件总线与去重存储秒级SLG、MMO、竞技结算
混合服务端事件为主,客户端补最后一段中中低结算类任务

判据不是“客户端不可信所以全部服务端算”,而是“这个事实服务端能不能自己观察到”。野狼死亡在战斗服有结算记录,就该订阅;气泡点击没有服务端权威状态,强行搬到服务端只会增加一次无意义的往返。《裂隙战场》的胜负由结算服产生,但“本局使用某个英雄”只有客户端知道,于是用混合模型:胜负信服务端,英雄选择信客户端,两者用同一局 matchID 关联。

客户端上报必须带三样东西:稳定的会话标识、单调递增的 nonce、以及服务端可校验的上限。nonce 用来去重,上限用来拒绝“一次上报 9999 次点击”;两者缺一,进度服务就会变成客户端说了算。

事件去重键:ID 必须比消息活得久

服务端订阅模型里,消息中间件通常只保证“至少一次”,不保证“恰好一次”。消费方重启、ack 丢失、分区重平衡都会让同一条事件再来一次。进度服务不能假设上游只发一次,只能假设同一个事实的 ID 永远相同。

事件ID 组成重投时的行为
战斗击杀battle:{matchID}:{seq}相同 ID 直接忽略
每日汇总daily:{date}:{player}:{kind}补算重跑不会重复计数
商城购买order:{orderID}与订单系统共用幂等键
客户端点击client:{player}:{session}:{nonce}nonce 落在窗口内才接受

这里最容易犯错的是“每日汇总”。补算任务往往按天重跑,如果 ID 里带的是处理时间而不是业务日期,同一天重跑两次就会得到两个 ID,进度翻倍。正确做法是把业务日期写进 ID,让补算本身成为幂等操作。

还需要一条保留策略:去重集合不能无限增长。示例实现把已见 ID 放在玩家状态里,生产环境应按“任务周期 + 补算窗口”设置 TTL,例如保留 35 天;超过窗口的重复事件即使再次到达,也不会影响已经归档的进度。

任务链、门控与条件回填

任务之间有两种关系,经常被混为一谈:

  • 前置:B 任务在 A 完成前不能接取。检查发生在 Accept,看的是 A 的最终状态,不是 A 的进度百分比。
  • 门控:B 任务可以接取,但某个目标只有在 A 完成后才开始计数。检查发生在事件应用时,A 未完成就先跳过。

门控带来的问题是“事件先到、门后开”。玩家先杀了狼,之后才完成解锁击杀目标的任务;如果跳过的事件直接丢掉,玩家会觉得进度被吞了。示例实现保留一份事件日志,等门控任务完成时重放日志,并用“任务 + 目标下标 + 事件 ID”作为应用键,保证重放不会重复计数。

完成本身也必须幂等。状态只有 locked、active、completed 三态,drain 在每次事件后检查目标是否全部满足,满足就把 active 改成 completed 并记录一次;再次进入时状态已经不是 active,不会重复完成。并发完成同一任务时,先拿到状态迁移的一方生效,另一方看到 completed 后直接返回。

可运行片段:questprogress

下面的 internal/questprogress 把事件去重、进度累加、门控回填和完成幂等放在同一个包里。它不依赖消息中间件,Ingest 就是消费入口;生产环境只需要把总线消息反序列化成 Event 再调用它。

// file: internal/questprogress/questprogress.go
// tier: L1
// verify: go test ./internal/questprogress/
package questprogress

import (
	"errors"
	"fmt"
	"slices"
	"strconv"
	"strings"
)

// Status is the only lifecycle a quest may have. There is no failed state:
// a failed quest is still active and its progress is not rolled back.
type Status int

const (
	StatusLocked Status = iota
	StatusActive
	StatusCompleted
)

// Objective is one counter. Gate defers counting until another quest is
// completed; deferred events are replayed from the retained log.
type Objective struct {
	Kind   string
	Target string
	Need   int64
	Gate   string
}

type Quest struct {
	ID         string
	Prereqs    []string
	Objectives []Objective
}

// Event is an already-normalised domain fact. ID is the idempotency key and
// must stay stable across redelivery, retries and cross-day compensation.
type Event struct {
	ID     string
	Kind   string
	Target string
	Amount int64
}

var (
	ErrUnknownQuest  = errors.New("questprogress: unknown quest")
	ErrAlreadyActive = errors.New("questprogress: quest already active")
	ErrPrereq        = errors.New("questprogress: prerequisite not completed")
	ErrEmptyEvent    = errors.New("questprogress: empty event id")
	ErrInvalidEvent  = errors.New("questprogress: invalid event")
	ErrInvalidQuest  = errors.New("questprogress: invalid quest")
)

type Result struct {
	Duplicate bool
	Advanced  []string
	Completed []string
}

type player struct {
	status   map[string]Status
	progress map[string][]int64
	seen     map[string]bool
	applied  map[string]bool
	log      []Event
}

type Service struct {
	quests  map[string]Quest
	order   []string
	players map[string]*player
}

// New validates the whole catalog before serving anything: a broken chain
// must fail deployment, not one player's quest at runtime.
func New(quests []Quest) (*Service, error) {
	s := &Service{quests: map[string]Quest{}, players: map[string]*player{}}
	for _, q := range quests {
		if strings.TrimSpace(q.ID) == "" || len(q.Objectives) == 0 {
			return nil, fmt.Errorf("%w: quest %q", ErrInvalidQuest, q.ID)
		}
		if _, ok := s.quests[q.ID]; ok {
			return nil, fmt.Errorf("%w: duplicate %q", ErrInvalidQuest, q.ID)
		}
		for _, o := range q.Objectives {
			if strings.TrimSpace(o.Kind) == "" || o.Need <= 0 {
				return nil, fmt.Errorf("%w: quest %q objective", ErrInvalidQuest, q.ID)
			}
		}
		s.quests[q.ID] = q
		s.order = append(s.order, q.ID)
	}
	for _, q := range quests {
		for _, pre := range q.Prereqs {
			if _, ok := s.quests[pre]; !ok {
				return nil, fmt.Errorf("%w: %q needs %q", ErrInvalidQuest, q.ID, pre)
			}
		}
		for _, o := range q.Objectives {
			if o.Gate == "" {
				continue
			}
			if _, ok := s.quests[o.Gate]; !ok {
				return nil, fmt.Errorf("%w: %q gate %q", ErrInvalidQuest, q.ID, o.Gate)
			}
		}
	}
	if s.hasCycle() {
		return nil, fmt.Errorf("%w: prerequisite cycle", ErrInvalidQuest)
	}
	return s, nil
}

func (s *Service) hasCycle() bool {
	color := map[string]int{}
	var visit func(string) bool
	visit = func(id string) bool {
		switch color[id] {
		case 1:
			return true
		case 2:
			return false
		}
		color[id] = 1
		for _, pre := range s.quests[id].Prereqs {
			if visit(pre) {
				return true
			}
		}
		color[id] = 2
		return false
	}
	for _, id := range s.order {
		if visit(id) {
			return true
		}
	}
	return false
}

// Accept checks prerequisites against completed status, never against partial
// progress, so a half-finished predecessor cannot leak progress.
func (s *Service) Accept(playerID, questID string) (Result, error) {
	q, ok := s.quests[questID]
	if !ok {
		return Result{}, fmt.Errorf("%w: %s", ErrUnknownQuest, questID)
	}
	p := s.state(playerID)
	switch p.status[questID] {
	case StatusActive:
		return Result{}, fmt.Errorf("%w: %s", ErrAlreadyActive, questID)
	case StatusCompleted:
		return Result{}, nil
	}
	for _, pre := range q.Prereqs {
		if p.status[pre] != StatusCompleted {
			return Result{}, fmt.Errorf("%w: %s needs %s", ErrPrereq, questID, pre)
		}
	}
	p.status[questID] = StatusActive
	res := Result{}
	s.replay(p, &res)
	s.drain(p, &res)
	return res, nil
}

// Ingest applies one event at most once per player. Redelivery returns
// Duplicate instead of an error: the caller should ack and move on.
func (s *Service) Ingest(playerID string, ev Event) (Result, error) {
	if strings.TrimSpace(ev.ID) == "" {
		return Result{}, ErrEmptyEvent
	}
	if strings.TrimSpace(ev.Kind) == "" || ev.Amount <= 0 {
		return Result{}, fmt.Errorf("%w: kind=%q amount=%d", ErrInvalidEvent, ev.Kind, ev.Amount)
	}
	p := s.state(playerID)
	if p.seen[ev.ID] {
		return Result{Duplicate: true}, nil
	}
	p.seen[ev.ID] = true
	p.log = append(p.log, ev)
	res := Result{}
	s.apply(p, ev, &res)
	s.drain(p, &res)
	return res, nil
}

func (s *Service) apply(p *player, ev Event, res *Result) {
	for _, qid := range s.order {
		if p.status[qid] != StatusActive {
			continue
		}
		changed := false
		for i, o := range s.quests[qid].Objectives {
			if o.Kind != ev.Kind || (o.Target != "" && o.Target != ev.Target) {
				continue
			}
			if o.Gate != "" && p.status[o.Gate] != StatusCompleted {
				continue
			}
			key := qid + "#" + strconv.Itoa(i) + "#" + ev.ID
			if p.applied[key] {
				continue
			}
			p.applied[key] = true
			before := p.progress[qid][i]
			next := before + ev.Amount
			if next > o.Need {
				next = o.Need
			}
			p.progress[qid][i] = next
			if next != before {
				changed = true
			}
		}
		if changed && !slices.Contains(res.Advanced, qid) {
			res.Advanced = append(res.Advanced, qid)
		}
	}
}

// drain completes every active quest whose objectives are full, then replays
// the log because a completion may open another quest's Gate. Applied keys
// make the replay safe; the loop stops when a pass completes nothing.
func (s *Service) drain(p *player, res *Result) {
	for {
		changed := false
		for _, qid := range s.order {
			if p.status[qid] != StatusActive || !s.done(p, qid) {
				continue
			}
			p.status[qid] = StatusCompleted
			res.Completed = append(res.Completed, qid)
			changed = true
		}
		if !changed {
			return
		}
		s.replay(p, res)
	}
}

func (s *Service) replay(p *player, res *Result) {
	for _, ev := range p.log {
		s.apply(p, ev, res)
	}
}

func (s *Service) done(p *player, qid string) bool {
	for i, o := range s.quests[qid].Objectives {
		if p.progress[qid][i] < o.Need {
			return false
		}
	}
	return true
}

func (s *Service) state(playerID string) *player {
	p := s.players[playerID]
	if p != nil {
		return p
	}
	p = &player{status: map[string]Status{}, progress: map[string][]int64{}, seen: map[string]bool{}, applied: map[string]bool{}}
	for _, id := range s.order {
		p.progress[id] = make([]int64, len(s.quests[id].Objectives))
	}
	s.players[playerID] = p
	return p
}

func (s *Service) Status(playerID, questID string) Status {
	p := s.players[playerID]
	if p == nil {
		return StatusLocked
	}
	return p.status[questID]
}

func (s *Service) Progress(playerID, questID string) []int64 {
	p := s.players[playerID]
	if p == nil {
		return nil
	}
	return append([]int64(nil), p.progress[questID]...)
}

包内另有 5 个测试,其中 4 个是表驱动:重复事件只计一次、错误目标不计、进度封顶、前置未完成时拒绝接取、门控回填、完成后迟到事件不再触发完成、非法任务目录(重复 ID、未知前置、未知门控、前置环)被拒绝。本机执行 gofmt -l internal/questprogress 与 go vet ./internal/questprogress/... 无输出,go test -count=1 ./internal/questprogress/ 通过。

失败模式与兜底

失败表现兜底
客户端重复上报进度翻倍,任务提前完成nonce + 服务端去重集合
总线至少一次投递同一任务完成两次、重复发奖稳定事件 ID + 状态机幂等
事件先于任务接取早期进度丢失保留日志,接取时重放
门控未开就计数玩家绕过任务链应用事件时检查门控状态
每日补算重复同一天进度累加两次业务日期写进去重键
去重集合无限增长内存或存储膨胀按补算窗口设 TTL
前置成环任务永远无法接取发布前做环检测

最后一行值得单独说:环检测必须在发布流程里做,而不是等玩家卡住再查。示例的 New 在加载目录时做深度优先检查,发现环就拒绝启动;这比线上出现“某个任务永远灰着”便宜得多。

常见错误

第一,把“收到事件”当成“事实发生”。事件只是事实的声明,去重键才是事实的身份;没有身份,重投就是新事实。

第二,用处理时间做补算 ID。补算重跑会生成新 ID,幂等立刻失效;ID 应该由业务对象和业务日期决定。

第三,前置检查看进度而不是状态。A 任务 90% 完成不等于完成,B 任务不应提前解锁。

第四,完成时先发奖再改状态。进程在两步之间崩溃会重复发奖;应先做状态迁移,再用事务或 outbox 保证奖励只发一次。

第五,把事件日志当永久存储。日志只用于门控回填,必须有保留上限和归档策略,否则它会变成第二套业务数据库。

小结

任务进度服务的核心不是计数器,而是三条不变量:同一事实只计一次、同一任务只完成一次、前置未完成不产生进度。把这三条写进事件契约和状态机,客户端上报与服务端订阅可以共存,补算和重投也不会改变结果。

下一节处理时间维度:每日重置在哪个时区切、错过一次怎么补、赛季切换时未领取的奖励归谁。进度服务只回答“做了多少”,重置服务回答“什么时候归零”。

阅读导航:上一节:7.1 属性、成长与状态机 · 下一节:7.3 每日重置、时区与赛季 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查