同一个“击杀”,三种可信度
《群岛纪元》的日常任务是“击杀 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 每日重置、时区与赛季 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。