《MMO 服务器开发》10.1 任务依赖图与循环检测

任务表里一条前置关系画错,整条任务线就会锁死:玩家接了 A 却发现要先完成 B,而 B 又要求先完成 A。本节把任务配置建成有向图,用 Kahn 算法做拓扑排序,用三色 DFS 把设计者画错的那一圈环精确指出来,再用一组真跑的 Go 测试证明正常表能排出合法顺序、环表能被定位到具体节点。

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 事件上报与进度服务 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标