10.2 事件上报与进度服务
10.1 把任务表变成了一张无环图,解决了「哪些任务能接」。但一个已经接下的任务怎么推进?玩家在野外杀了三只狼,服务器要把它变成「击杀 3/5」的进度;玩家把两株草药放进背包,任务要变成「采集 2/2 可完成」。这些「杀了什么、捡了什么、跟谁说了话」都发生在别的子系统里,任务系统不可能自己去监听战斗、监听背包。
这一节要回答两个问题。第一,别的系统怎么把「发生了一件事」告诉任务系统,而不把任务系统耦合到战斗、背包、NPC 的细节里? 第二,玩家点两下「交任务」、客户端重发一次「接任务」,进度会不会被加两次、奖励会不会发两份?
第一个问题的答案是「事件归一化」:所有子系统把各自的动作降级成一个统一的三元组 Event{类型, 目标, 数量},任务系统只认这三样,永远不知道「狼」是怪物还是「草药」是物品。第二个问题的答案是「请求序号去重」:命令帧里那个 seq 字段(卷一的 11 字节头)就是为这个准备的。
事件是唯一的跨系统接口
任务目标是有限的几类:杀怪、采集、对话、到达。与其让任务系统去订阅四套子系统的事件流,不如反过来——让四套子系统各自把动作翻译成同一个结构。这个翻译动作就是「降级(lowering)」:
战斗结算 ──(击杀 5001 ×3)──┐
背包变动 ──(获得 7001 ×2)──┼──► Event{Kind, Target, Delta} ──► 进度服务
NPC 对话 ──(对话 3021 ×1)──┘
场景移动 ──(进入区域 88 ×1)─┘
好处是任务系统只依赖一个数据结构,不依赖任何玩法模块。战斗内部从「技能 → 命中 → 伤害 → 死亡」的整条链路,到任务系统眼里就压缩成一句「目标 5001 死了 3 只」。背包同理:堆叠、拆分、跨页移动这些细节都不外泄,只有「净获得 N 个物品 7001」这一条语义。
Event 里没有玩家 ID——事件总是在某个玩家的上下文里被处理的,玩家身份由调用路径携带(单写者模型里,这个事件就是发给这个玩家 actor 的消息)。这样 Apply 才能做到无锁:它只在拥有者 goroutine 上跑。
进度累加:封顶与就绪
进度服务收到事件后做三件事:匹配目标、累加计数、判断就绪。这里有两个容易被忽略的坑,都被下面这段代码挡住了。
第一个坑是封顶。一个范围伤害技能可能一次打死十二只狼,事件带着 Delta: 12 过来;如果目标只需要 3 只,计数绝不能变成 12。计数在 Need 处截断,好处是「进度 3/5」永远是合法的,客户端进度条不会画出 250% 的诡异画面。第二个坑是重复上报:同一个事件被投递两次(网络重传、重连补发),计数会被加两次。封顶让「第二次投递一个已经满的目标」变成无操作,changed 为假,就绪转换只发生一次。
下面这段是 L1,go test 可以直接跑。它实现了进度本体的全部状态机:
// file: internal/quest/progress.go
// tier: L1
// verify: go test ./internal/quest/
package quest
import (
"errors"
)
// PlayerID is the globally unique character id (same width as every other
// subsystem in the book).
type PlayerID uint64
// ObjKind is the objective family. Two objectives match an event only when both
// the kind and the target id agree.
type ObjKind uint8
const (
ObjKill ObjKind = iota // kill N of a monster
ObjCollect // hold N of an item
ObjTalk // talk to an NPC
ObjReach // arrive at a location id
)
// Objective is one completion requirement of a quest.
type Objective struct {
Kind ObjKind
Target uint32
Need int
}
// Quest is a definition plus its objectives. 10.1's Graph only needs QuestDef;
// the progress service needs the objectives too, so it carries both.
type Quest struct {
Def QuestDef
Objectives []Objective
}
// Event is what gameplay emits: "this player just killed monster 1003, three
// times". It is intentionally tiny — combat, inventory and NPC dialogue all
// lower their own semantics into this one shape before it reaches the quest
// service, so the service never learns about skills or item slots.
type Event struct {
Kind ObjKind
Target uint32
Delta int
}
// ActiveQuest is one accepted-but-unfinished quest with per-objective counters.
type ActiveQuest struct {
ID QuestID
Counts []int
Ready bool // every objective met; waiting for the finish request
}
// Book is one player's quest state. It is mutated only on the owning player's
// goroutine (single-writer), so it carries no lock; sharing it across goroutines
// is the caller's bug, not this type's.
type Book struct {
PlayerID PlayerID
Completed map[QuestID]bool
Active map[QuestID]*ActiveQuest
quests map[QuestID]Quest
}
// ErrAlreadyActive / ErrNotActive / ErrNotReady describe the three ways a
// state-machine request can be rejected.
var (
ErrAlreadyActive = errors.New("quest: already active")
ErrNotActive = errors.New("quest: not active")
ErrNotReady = errors.New("quest: objectives incomplete")
)
// NewBook builds an empty quest book over a static, read-only quest table.
func NewBook(pid PlayerID, quests map[QuestID]Quest) *Book {
return &Book{
PlayerID: pid,
Completed: map[QuestID]bool{},
Active: map[QuestID]*ActiveQuest{},
quests: quests,
}
}
// Accept starts a quest. Prerequisites are checked by the caller against 10.1's
// Graph (they depend on the *completed* set, which lives here); this method only
// guards the local state machine so a duplicated CmdQuestAcceptReq cannot fork a
// second copy of the same quest.
func (b *Book) Accept(id QuestID) (*ActiveQuest, error) {
if b.Completed[id] {
return nil, ErrAlreadyActive
}
if _, ok := b.Active[id]; ok {
return nil, ErrAlreadyActive
}
q, ok := b.quests[id]
if !ok {
return nil, ErrNotActive // unknown id: treat as not acceptable
}
aq := &ActiveQuest{ID: id, Counts: make([]int, len(q.Objectives))}
b.Active[id] = aq
return aq, nil
}
// Apply folds one gameplay event into every active quest and returns the quest
// IDs that became ready to finish *because of this event*. Counters are clamped
// at Need, so an over-delivered event (a single AoE that kills twelve) cannot
// overflow and a replay of the same event cannot push past the target.
func (b *Book) Apply(ev Event) []QuestID {
var ready []QuestID
for _, aq := range b.Active {
q := b.quests[aq.ID]
changed := false
for i, o := range q.Objectives {
if o.Kind != ev.Kind || o.Target != ev.Target {
continue
}
before := aq.Counts[i]
aq.Counts[i] = clamp(before+ev.Delta, o.Need)
if aq.Counts[i] != before {
changed = true
}
}
if changed && !aq.Ready && b.allMet(aq, q) {
aq.Ready = true
ready = append(ready, aq.ID)
}
}
sortIDs(ready)
return ready
}
// Finish moves a ready quest into the completed set and returns it. It refuses
// a quest whose objectives are not met, which is the server-side half of the
// "client says I'm done" trust boundary.
func (b *Book) Finish(id QuestID) (*ActiveQuest, error) {
aq, ok := b.Active[id]
if !ok {
return nil, ErrNotActive
}
if !aq.Ready {
return nil, ErrNotReady
}
delete(b.Active, id)
b.Completed[id] = true
return aq, nil
}
func (b *Book) allMet(aq *ActiveQuest, q Quest) bool {
for i, o := range q.Objectives {
if aq.Counts[i] < o.Need {
return false
}
}
return true
}
func clamp(v, max int) int {
if v < 0 {
return 0
}
if v > max {
return max
}
return v
}
注意 Finish 里那道 !aq.Ready 的检查——这是信任边界的服务端一侧。客户端会在进度满了之后发 CmdQuestFinishReq,但「客户端说做完了」不等于「服务端确认做完了」。服务端只认自己维护的 Ready,客户端那句话只是触发一次重算,永远不能直接把任务标记成完成。
命令分发与重传去重
进度服务对外是三个命令号(0x06 模块):CmdQuestAcceptReq 0x0601、CmdQuestProgressNotify 0x0602、CmdQuestFinishReq 0x0603。接任务和交任务都是客户端发起、服务端裁决的请求,因此必须能扛住重传:玩家的确认包丢了,客户端会带着同一个 seq 重发,服务端要是当成新请求,就会重复接任务、重复发奖。
去重的钥匙就是卷一 11 字节固定头里的 seq。下面这段是 L1 服务分发的核心(完整文件在 internal/quest/service.go),演示「按 seq 单调去重 + 前置校验 + 状态机」三层如何串起来:
// snippet: internal/quest/service.go(节选)
const (
CmdQuestAcceptReq = 0x0601
CmdQuestProgressNotify = 0x0602
CmdQuestFinishReq = 0x0603
)
// freshSeq rejects a request whose seq was already handled. A dropped ack makes
// the client resend CmdQuestAcceptReq with the same seq; without this the second
// copy would hit ErrAlreadyActive and the client would wrongly conclude failure.
func (s *Service) freshSeq(pid PlayerID, seq uint32) bool {
last, seen := s.dedup[pid]
if seen && seq <= last {
return false
}
s.dedup[pid] = seq
return true
}
// HandleAccept processes a CmdQuestAcceptReq payload (u32 quest id). It checks
// the level gate and prerequisites against 10.1's graph, then starts the quest.
func (s *Service) HandleAccept(pid PlayerID, seq uint32, payload []byte) error {
if len(payload) < 4 {
return ErrShortFrame
}
if !s.freshSeq(pid, seq) {
return nil // duplicate retransmit: idempotent no-op
}
id := QuestID(binary.BigEndian.Uint32(payload))
def, ok := s.graph.Def(id)
if !ok {
return ErrNotActive
}
b := s.Book(pid)
for _, p := range def.Prereqs {
if !b.Completed[p] {
return ErrNotReady
}
}
_, err := b.Accept(id)
return err
}
freshSeq 用单调判定(seq <= last 即重复)而不是查一个去重集合,理由是:每个连接上的 seq 是单调递增的,所以「不大于上次见过的」就是「老的或重传的」,一次比较搞定,内存里只留一个整数。代价是 seq 回绕(uint32 转一圈)时窗口会乱,但一个连接走完 42 亿个请求不现实,不值得为它加复杂度。
下面这段测试把整条链路跑通:接任务 → 打怪推进 → 交任务 → 解锁后续任务,中间穿插一次超量击杀和一次同 seq 重传,断言它们都不产生副作用:
// snippet: internal/quest/progress_test.go(节选)
func TestFullQuestFlow(t *testing.T) {
s := testService(t)
const pid PlayerID = 42
// Accept quest 100 (needs 3 kills of monster 5001).
if err := s.HandleAccept(pid, 1, acceptPayload(100)); err != nil {
t.Fatalf("accept 100: %v", err)
}
// Quest 101 is still blocked: its prereq 100 is not completed yet.
if err := s.HandleAccept(pid, 2, acceptPayload(101)); err == nil {
t.Fatal("accept 101 succeeded before prereq completed")
}
// One more kill crosses the threshold.
if ready := s.HandleEvent(pid, Event{Kind: ObjKill, Target: 5001, Delta: 1}); !reflect.DeepEqual(ready, []QuestID{100}) {
t.Fatalf("ready after 3 kills = %v, want [100]", ready)
}
// Over-delivery is clamped: a 10-kill event on a finished-in-progress quest
// must not overflow the counter or re-fire the "ready" transition.
if ready := s.HandleEvent(pid, Event{Kind: ObjKill, Target: 5001, Delta: 10}); len(ready) != 0 {
t.Fatalf("over-delivery re-fired ready = %v", ready)
}
if err := s.HandleFinish(pid, 3, acceptPayload(100)); err != nil {
t.Fatalf("finish 100: %v", err)
}
// Now 101 unlocks (prereq satisfied).
if err := s.HandleAccept(pid, 4, acceptPayload(101)); err != nil {
t.Fatalf("accept 101: %v", err)
}
}
本节小结
- 事件归一化是唯一的跨系统接口:战斗、背包、对话各自把动作降级成
Event{Kind, Target, Delta},任务系统不耦合任何玩法模块。 - 计数在
Need处封顶:超量击杀不会把进度画成 250%,重复投递一个已满的目标变成无操作。 - 就绪与完成分离:
Apply只负责把任务推到Ready;Finish才把它移进Completed,且服务端只认自己的Ready,不认客户端的一句话。 seq单调去重挡住重传:同一个请求重发是幂等空操作,不会重复接任务或重复发奖。
下一节换一个维度:前面的任务都是「一次性」的,而每日任务、周常任务要按日历重复触发——「今天」到底从几点开始算,是整条时间链上又一个容易被时区坑掉的地方。
阅读导航:上一节:10.1 任务依赖图与循环检测 · 下一节:10.3 每日重置与时区窗口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。