10.1 任务依赖图与循环检测
任务系统看上去是「玩家做一件事、服务器记一笔账」,但真正的麻烦在配置阶段就埋下了:任务之间是有前置关系的。主线 3 要你完成主线 2,支线 7 要你先跟某个 NPC 对话,而那个 NPC 只在完成主线 5 之后才出现。这些关系凑在一起就是一张图,只要图上有一个环,环上的每个任务都永远接不了——玩家卡住、客服来单、运营查不出原因。
这一节只解决图这一层,不碰「玩家进度存在哪」「事件怎么上报」(那是 10.2)也不碰「每日任务几点重置」(那是 10.3)。要回答的问题是:一张手写的任务配置表,怎么在服务器启动时被证明是无环的?如果它有环,能不能直接告诉设计者「你画错的是 3→7→12→3」?
难度在三处。第一,环不是一眼能看出来的——A 依赖 B、B 依赖 C、C 又依赖 A,散在几十行配置里,人眼扫不出来。第二,「有环」这个结论本身没用,设计者需要知道环上到底是哪几个节点,否则面对几百个任务无从下手。第三,检测要在启动时做完,不能等玩家接了任务才发现,那时代价已经付出了。
下面从零写一张任务依赖图:加载即校验、拓扑排序给出合法顺序、三色 DFS 把环定位到具体的一圈。
与站内 practice 文章的分工
站内已经有一组讨论任务系统架构的 practice 文章,它们负责「架构上任务依赖怎么组织、进度服务怎么切、重置窗口怎么设计」;本书这一节负责「从零实现 + 真跑 + 端到端验收」。读法建议:先看架构文章建立判断,再回来看这里的可运行代码。
| 站内文章(绝对路径) | 负责的「架构方案与取舍」 | 本书 10.1 负责的「从零实现 + 真跑」 |
|---|---|---|
/game-server-quest-dependency-graph-architecture/ | 任务依赖图的架构位置、和配置加载/热更的关系 | 用 Kahn 与三色 DFS 从零实现建图、排序与环定位 |
/game-server-quest-progress-service-architecture/ | 进度服务如何独立部署、状态如何归属 | 只做图这一层;进度累加见 10.2 |
/game-server-daily-reset-and-time-window-design/ | 每日/周常重置的时间窗口如何设计 | 只做前置依赖;时区与重置见 10.3 |
一句话区分:站内文章回答「任务依赖架构上该怎么分层」,本书回答「这张图怎么用 Go 建出来、有环时怎么把环指出来」。
把前置关系建成有向图
最直接的建模是:每个任务是一个节点,A 依赖 B 是一条从 B 指向 A 的边(前置 → 后继)。方向选得很关键——我们关心的问题是「完成 B 之后能解锁什么」,所以让边从 B 出发、指向所有以 B 为前置的任务,解锁查询就是一次邻接表遍历。
配置表在加载时就要校验两件事:ID 不能重复,前置引用不能悬空(指向一个不存在的任务)。这两类错误如果留到运行时,表现会是「玩家接了任务却永远完不成」,比直接报错难查得多。下面这段是 L1,go test 可以直接跑:
// file: internal/quest/graph.go
// tier: L1
// verify: go test ./internal/quest/
package quest
import (
"errors"
"fmt"
"sort"
)
// QuestID identifies a quest definition. Definitions come from the config table;
// per-player runtime progress lives in the progress service (see 10.2).
type QuestID uint32
// QuestDef is one row of the quest config table. Prereqs are quests that must be
// *completed* before this quest can be accepted.
type QuestDef struct {
ID QuestID
MinLevel int
Prereqs []QuestID
}
// ErrCycle is returned when the prerequisite graph is not a DAG.
var ErrCycle = errors.New("quest: prerequisite cycle")
// ErrUnknownPrereq is returned when a quest references an undefined prereq.
var ErrUnknownPrereq = errors.New("quest: unknown prerequisite")
// Graph is the quest prerequisite graph. Edges point from a prerequisite to the
// quest that depends on it (prereq -> dependent), which is the direction that
// makes "what does completing X unlock?" a direct adjacency lookup.
type Graph struct {
defs map[QuestID]QuestDef
edges map[QuestID][]QuestID
}
// NewGraph validates and indexes a slice of quest definitions. It rejects
// duplicate IDs and dangling prerequisite references at load time, so the
// running server never has to defend against a half-valid table.
func NewGraph(defs []QuestDef) (*Graph, error) {
g := &Graph{
defs: make(map[QuestID]QuestDef, len(defs)),
edges: make(map[QuestID][]QuestID),
}
for _, d := range defs {
if _, dup := g.defs[d.ID]; dup {
return nil, fmt.Errorf("quest: duplicate id %d", d.ID)
}
g.defs[d.ID] = d
}
for _, d := range defs {
for _, p := range d.Prereqs {
if _, ok := g.defs[p]; !ok {
return nil, fmt.Errorf("%w: %d depends on %d", ErrUnknownPrereq, d.ID, p)
}
g.edges[p] = append(g.edges[p], d.ID)
}
}
return g, nil
}
// Def returns the definition for id and whether it exists.
func (g *Graph) Def(id QuestID) (QuestDef, bool) {
d, ok := g.defs[id]
return d, ok
}
// Len reports how many quests the graph holds.
func (g *Graph) Len() int { return len(g.defs) }
// TopoSort returns quests in an order where every quest appears after all of its
// prerequisites (Kahn's algorithm). When a cycle exists it returns ErrCycle plus
// the prefix that could be ordered; the caller should use FindCycle to show the
// designer the actual loop.
func (g *Graph) TopoSort() ([]QuestID, error) {
indeg := make(map[QuestID]int, len(g.defs))
for id := range g.defs {
indeg[id] = 0
}
for _, ds := range g.edges {
for _, d := range ds {
indeg[d]++
}
}
ready := make([]QuestID, 0, len(g.defs))
for id, n := range indeg {
if n == 0 {
ready = append(ready, id)
}
}
sortIDs(ready)
out := make([]QuestID, 0, len(g.defs))
for len(ready) > 0 {
id := ready[0]
ready = ready[1:]
out = append(out, id)
for _, d := range g.edges[id] {
indeg[d]--
if indeg[d] == 0 {
ready = append(ready, d)
}
}
sortIDs(ready) // keep output deterministic across runs
}
if len(out) != len(g.defs) {
return out, ErrCycle
}
return out, nil
}
// FindCycle returns one concrete prerequisite cycle as a closed path
// (first element repeated at the end), or nil when the graph is a DAG. It runs
// an iterative DFS with three colours so a deep chain cannot blow the stack, and
// reconstructs the loop from the parent chain instead of just reporting "there
// is a cycle somewhere" — designers need the exact ring they drew.
func (g *Graph) FindCycle() []QuestID {
const (
white = 0
gray = 1
black = 2
)
color := make(map[QuestID]int, len(g.defs))
parent := make(map[QuestID]QuestID)
roots := make([]QuestID, 0, len(g.defs))
for id := range g.defs {
roots = append(roots, id)
}
sortIDs(roots)
type frame struct {
id QuestID
next int
}
for _, root := range roots {
if color[root] != white {
continue
}
color[root] = gray
stack := []frame{{root, 0}}
for len(stack) > 0 {
f := &stack[len(stack)-1]
if f.next < len(g.edges[f.id]) {
child := g.edges[f.id][f.next]
f.next++
switch color[child] {
case white:
color[child] = gray
parent[child] = f.id
stack = append(stack, frame{child, 0})
case gray:
// Back edge f.id -> child: child is still on the stack, so
// the loop is child ... f.id -> child.
cyc := []QuestID{}
for cur := f.id; ; cur = parent[cur] {
cyc = append(cyc, cur)
if cur == child {
break
}
}
reverse(cyc)
return append(cyc, child)
}
} else {
color[f.id] = black
stack = stack[:len(stack)-1]
}
}
}
return nil
}
// Unlockable returns the quests a player may accept right now: every prereq is
// in completed, the quest is not itself completed, and the level gate passes.
// The result is sorted by ID so the network sync payload is byte-stable.
func (g *Graph) Unlockable(completed map[QuestID]bool, level int) []QuestID {
var out []QuestID
for id, d := range g.defs {
if completed[id] || d.MinLevel > level {
continue
}
ok := true
for _, p := range d.Prereqs {
if !completed[p] {
ok = false
break
}
}
if ok {
out = append(out, id)
}
}
sortIDs(out)
return out
}
func sortIDs(s []QuestID) {
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
}
func reverse(s []QuestID) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
NewGraph 分两趟:第一趟建 ID 索引、查重复;第二趟连边、查悬空引用。分成两趟是必须的——第二趟需要全部 ID 已经就位,否则一个「前置引用后面才定义的任务」会被误判成悬空。
拓扑排序:Kahn 算法
有了图,第一个要回答的问题是「这批任务有没有合法完成顺序」。Kahn 算法的做法很朴素:反复取出一个入度为 0 的节点(没有任何未满足的前置),删掉它、把它所有后继的入度减一。如果最后删完了所有节点,说明存在合法顺序;如果还剩节点删不掉,那些剩下的节点必然都在环里。
入度在这里的含义就是「还没完成的前置任务个数」。入度为 0 的任务随时可以接,删掉它等价于「这个任务完成了,它的后继少了一个前置」。代码里 ready 每次重排(sortIDs)不是为了正确性,而是为了让输出确定——同一张表跑两次必须得到同一个顺序,否则快照测试会随机红。
找出具体的那一圈环
Kahn 只能告诉你「有环」,但它删剩下的节点是「所有被困住的节点」的并集,未必是单独的一圈。设计者要的是能直接改的那条环,所以还需要一个能输出具体路径的检测器:三色 DFS。
三个颜色是这样分工的:
- 白色:还没访问过。
- 灰色:正在当前 DFS 栈上(访问中,还没回溯)。
- 黑色:已经完整访问过。
关键不变式是:一条边如果指向一个灰色节点,那这个灰色节点一定在当前栈上,这条边就是回边,栈上从那个灰色节点到当前节点的一段就是一个环。 用迭代式 DFS(显式维护 stack 而不是递归)是为了防深链爆栈——一条几百个任务的长链用递归会把 goroutine 栈顶穿。找到回边后,顺着 parent 链从当前节点回溯到回边的终点,就还原出了环的完整路径。
下面这段是 L1 测试节选,构造一个三节点的环 1→2→3→1,断言拓扑排序报 ErrCycle、FindCycle 返回一条闭合且每段都是真边的路径:
// snippet: internal/quest/graph_test.go(节选)
func TestTopoSortDetectsCycle(t *testing.T) {
// 1 -> 2 -> 3 -> 1 : a three-node ring.
g, err := NewGraph([]QuestDef{
{ID: 1, Prereqs: []QuestID{3}},
{ID: 2, Prereqs: []QuestID{1}},
{ID: 3, Prereqs: []QuestID{2}},
})
if err != nil {
t.Fatalf("NewGraph: %v", err)
}
if _, err := g.TopoSort(); !errors.Is(err, ErrCycle) {
t.Fatalf("TopoSort err = %v, want ErrCycle", err)
}
cyc := g.FindCycle()
if len(cyc) != 4 {
t.Fatalf("cycle = %v, want 4 entries (closed path)", cyc)
}
if cyc[0] != cyc[len(cyc)-1] {
t.Fatalf("cycle %v is not closed", cyc)
}
// Every consecutive pair must be a real dependency edge.
for i := 0; i+1 < len(cyc); i++ {
from, to := cyc[i], cyc[i+1]
if !hasEdge(g, from, to) {
t.Fatalf("cycle %v: %d->%d is not an edge", cyc, from, to)
}
}
}
测试里的 len(cyc) != 4 是因为闭合路径把起点重复了一次:三个节点的环输出成 [1 2 3 1],首尾相同才说明它「回到了自己」。最后那段逐边校验是防自欺——一个检测器如果只是把「入度非零的节点」随便拼起来,也可能凑出看起来闭合的序列,只有每一段都对应真实依赖边,才能证明它指的确实是设计者画的那一圈。
本节小结
- 前置关系建成「前置 → 后继」的有向图:边的方向决定了「完成 X 解锁什么」是一次邻接表查询。
- 加载即校验:ID 重复、前置悬空在
NewGraph里就报错,不留到运行时变成「永远完不成的任务」。 - Kahn 拓扑排序判断有无环,入度即「未完成前置数」,重排
ready是为了输出确定。 - 三色 DFS 才是给设计者用的:回边 + parent 链能还原出具体那一圈环,迭代式实现防深链爆栈。
下一节把这张图接到玩家身上:任务接下了,战斗里杀的每一只怪、捡的每一件东西,怎么变成进度条上的 +1。
阅读导航:上一节:目录 · 下一节:10.2 事件上报与进度服务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。