《MMO 服务器开发》18.3 进入玩法卷

卷一交出的七个零件——会话表、路由表、Actor 邮箱、定时器、幂等窗口、同步模型、权威校验——到了卷二《单服架构与玩法系统》手里会长成什么。本节给出「零件 → 卷二章节 → 用途」的交接映射表,讲清为什么玩法必须先有这套地基,把卷二不写的跨服、压测、运营划给卷三与卷四,最后把整张契约编码成可测试的 Go 类型。

18.3 进入玩法卷

18.1 把卷一的十八个零件串了一遍,18.2 把它们跑出来的数字汇总成一张表。两节都在往回看:我们造了什么、它有多快。这一节掉个头往前看——卷一交出去的这些东西,到了卷二《单服架构与玩法系统》手里,会长成什么样。

先说清楚卷一和卷二的关系。卷一从头到尾没有写过一行「玩法」:没有怪物、没有伤害、没有背包、没有公会。它只做了一件事——把一条长连接从「网线插上」到「一条消息被正确裁决」之间的所有机制造出来,并证明它们在两万连接下不塌。卷二要做的,是在这套机制之上盖房子:实体怎么活、场景怎么切、AOI 怎么算、战斗怎么裁、掉落怎么发、任务聊天组队公会排行榜邮件交易匹配房间怎么写。

有人会问:玩法不是独立的吗,为什么非得先有卷一那套东西?答案在第 1 章就埋下了——MMO 服务端是有状态的、长连接的、每帧都在变的。一个玩法每次生效,背后都是一条被正确切出来的帧、一次正确的路由、一个被正确推进的定时器、一次被去重过的请求、一次服务端说了算的裁决。卷一不是玩法的前置阅读,它是玩法能不能跑起来的前提。 一个没处理粘包的战斗服,会在玩家放 AOE 时把两条技能帧粘成一条、伤害翻倍;一个没有幂等窗口的掉落系统,会在客户端重发时把同一件装备发两次。这些坑卷一都替你踩过了。

这一节做三件事:给出一张「卷一的零件 → 卷二哪一章用它做什么」的交接映射表;讲清卷二不写什么(跨服归卷三、运营归卷四);最后把整张交接契约编码成可测试的 Go 类型,让「这些零件真的被用上了」变成一条能失败的断言,而不是一句口号。

卷一交出的七个零件,到了卷二各归何处

卷一造了很多东西,但真正会被玩法反复消费的,是下面七个:

零件卷一出处一句话
会话表第 7 章连接 ↔ 账号的双向索引,玩法的「这个玩家在不在线、在哪条连接上」全问它
路由表第 8 章命令号 → 处理函数的分发,玩法的每一种操作都是注册进来的一条路由
Actor 邮箱第 10 章消息驱动的执行单元,把并发收进一个串行消费的邮箱,换来免锁的状态
定时器第 9 章时间轮,承载冷却、到期、周期结算这类海量短周期定时
幂等窗口第 11 章至少一次投递下的去重,保证「重发一次」不会变成「发两次」
同步模型第 12–14 章快照 / 增量 / 兴趣过滤,决定「谁在什么时候收到谁的状态」
权威校验第 15 章服务端说了算的合法性裁决,客户端只能上报意图

这七个零件不是并列摆着的,它们有依赖:路由表把消息喂给 Actor 邮箱,邮箱里的实体靠定时器推进,所有对外可见的变更都要过权威校验,再经同步模型下发,而幂等窗口守在入口保证重发不重复。卷二把这些依赖原样继承下来,只是把「实体」从卷一的 Player 换成了真正的玩法对象。

下面这张表就是交接契约:左列是卷一的零件,右列是卷二哪一章会用它、拿去做什么。

卷一的零件(出处)卷二消费章节 → 用途
会话表(第 7 章)第 1 章 分层与状态归属:在线态挂会话上,玩法逻辑用 sessionID 定位玩家实体
第 2 章 实体运行时:连接断开时摘除运行时实体,保留其落库状态
第 8 章 聊天:私聊按会话定位接收者,离线则转邮件
第 14 章 匹配与房间:房间成员靠会话判在线,掉线即退出队列
路由表(第 8 章)第 2 章 实体运行时:每种玩法命令注册一个 handler,复用卷一的 cmd→handler 分发
第 5 章 战斗管线:攻击 / 施法命令走同一张路由表进管线
第 7 章 任务系统:任务事件(击杀 / 拾取 / 到达)注册成路由的回调
Actor 邮箱(第 10 章)第 3 章 场景实例:一个场景实例 = 一个 actor,玩法消息串行进邮箱,免锁
第 2 章 实体运行时:每个实体一个邮箱,状态只被自己的消息推进
第 10 章 公会:公会是一个 actor,成员操作在公会邮箱里串行
第 14 章 匹配与房间:房间是一个 actor,匹配成功后成员消息进房间邮箱
定时器(第 9 章)第 5 章 战斗管线:技能冷却与 buff 到期挂时间轮,按 tick 推进
第 6 章 掉落与背包:掉落物过期回收走时间轮,不占场景主循环
第 11 章 排行榜:周期结算与刷新用时间轮的周期任务
第 12 章 邮件:邮件过期与附件退回挂定时器
幂等窗口(第 11 章)第 6 章 掉落与背包:防重复拾取,同一掉落物只入一次背包
第 9 章 组队:防重复邀请 / 重复入队请求
第 13 章 交易:防重复扣款,同一笔交易只结算一次
同步模型(第 12–14 章)第 4 章 AOI 与兴趣管理:AOI 是兴趣过滤的具体实现,决定谁收到谁的快照
第 5 章 战斗管线:战斗结果按快照 / 增量下发给兴趣范围内的玩家
权威校验(第 15 章)第 5 章 战斗管线:伤害裁决在服务端算,客户端只上报意图
第 6 章 掉落与背包:拾取合法性(距离 / 归属 / 冷却)在服务端校验

从表里能读出三件事。

第一,卷二几乎没有「新造机制」。它造的全是「内容」——实体、场景、战斗规则、掉落表、任务链——而支撑这些内容的机制,卷一已经造完了。这解释了为什么卷二可以写得快:地基不用重打。

第二,一个零件往往服务多个玩法。定时器同时被战斗冷却、掉落回收、排行榜结算、邮件过期四条线用;幂等窗口同时守在拾取、组队、交易三个入口。这也是为什么卷一要把它们写成通用机制而不是塞进某个玩法里——通用的东西才复用得起。

第三,依赖是有方向的。玩法依赖零件,零件不依赖玩法。所以卷二写战斗管线时,改的是「怎么用路由表和定时器」,而不是去动路由表本身。任何一次「为了玩法去改卷一零件」的冲动,都该先怀疑是不是卷一的抽象没做对。

这些玩法在站内已经有一批「架构方案」文章了。卷二和它们的分工,跟卷一与站内内容的分工一模一样:别人讲方案与取舍,卷二写实现与真跑。

玩法系统站内既有「方案」文章卷二只补
AOI/aoi-system-design-for-large-game-worlds/ · /game-server-entity-interest-subscription-architecture/接进卷一的同步模型真跑一遍
战斗/game-server-combat-command-pipeline-architecture/把管线挂在路由表与定时器上
掉落/game-server-loot-table-service-architecture/用幂等窗口防重复发奖
公会/game-server-guild-system-backend-design/把公会写成一个 actor
匹配/game-server-match-lobby-lifecycle-architecture/房间复用会话表与邮箱

卷二不写什么:跨服归卷三,运营归卷四

一个系统最容易失控的方式,是边界模糊。卷二也会明确写下它不碰的四类东西,并把它们交给后两卷:

卷二不写为什么归谁
跨服传送、跨服战斗一旦跨进程,卷一「单进程内串行 actor」的假设就不再成立,需要分布式一致性与消息总线卷三《分布式与跨服》
分服、合服、分库分表属于数据分片与迁移,是部署形态问题,不是单服玩法问题卷三
压测、承载数字、容量规划玩法写完之后才谈得上压,压的是整卷而非单个系统卷四《运维与运营》
热更新、灰度、线上运营活动属于发布与运营流程,与玩法逻辑正交卷四
监控告警、故障演练属于可观测性与稳定性工程卷四

这条边界的意义在于:卷二里的每一个玩法,都默认跑在「一个进程、一个世界」里。 一旦你发现某个玩法需求逼着你跨进程(比如「两个服的人要一起打世界 BOSS」),那说明它已经不属于卷二,而是卷三的问题。这不是回避,是让每一卷都能独立跑通、独立验证——卷二跑通的前提,正是「不跨服」。

顺带把卷一的边界也复述一句:卷一不写玩法(归卷二)、不写分布式(归卷三)、不写运维(归卷四)。三卷的边界拼起来,正好是一本完整 MMO 服务端的开发路径:先把网络与并发内核写稳,再把单服玩法写满,然后才谈得上跨服与运营。

可运行产物:把交接契约编码成 Go

上面两张表是给人看的。但「这些零件真的会被用上」「卷二真的不越界」这两件事,如果只靠自觉,写到一半就会漂。所以本节把它们编码成可测试的 Go 类型:零件、卷二章节、消费关系、越界清单,全部进结构体,用 go test 断言。

下面这段是 L1,go test ./internal/handoff/ 可以直接跑:

// file: internal/handoff/handoff.go
// tier: L1
// verify: go test ./internal/handoff/
// Package handoff encodes the contract between volume one (《网络与并发内核》)
// and volume two (《单服架构与玩法系统》): which reusable part each volume-two
// chapter consumes, and which topics volume two deliberately leaves to volumes
// three and four. Section 18.3's tables are for humans; this file is the
// machine-readable version, so the handoff is a thing a test can fail on
// instead of a promise nobody re-reads.
package handoff

// Part is a reusable piece of the network/concurrency core that volume one
// hands over.
type Part struct {
	// Name is the part, e.g. "会话表".
	Name string
	// From is where volume one built it, e.g. "第 7 章".
	From string
}

// Use says how one volume-two chapter consumes a part.
type Use struct {
	// Chapter must name a chapter in VolumeTwoChapters.
	Chapter string
	// How is the one-line "what it does with it".
	How string
}

// Row is one line of the handoff table: a part and every chapter that uses it.
type Row struct {
	Part Part
	Uses []Use
}

// VolumeTwoChapters is volume two's table of contents. Every Use.Chapter must
// appear here; a Use naming a chapter that does not exist is a dangling
// reference — the "I'll wire it up later" promise that rots.
var VolumeTwoChapters = []string{
	"第 1 章 分层与状态归属",
	"第 2 章 实体运行时",
	"第 3 章 场景实例",
	"第 4 章 AOI 与兴趣管理",
	"第 5 章 战斗管线",
	"第 6 章 掉落与背包",
	"第 7 章 任务系统",
	"第 8 章 聊天",
	"第 9 章 组队",
	"第 10 章 公会",
	"第 11 章 排行榜",
	"第 12 章 邮件",
	"第 13 章 交易",
	"第 14 章 匹配与房间",
}

// Table is the handoff table, in code. Order follows volume one's chapters.
var Table = []Row{
	{
		Part: Part{Name: "会话表", From: "第 7 章"},
		Uses: []Use{
			{Chapter: "第 1 章 分层与状态归属", How: "在线态挂会话上,玩法逻辑用 sessionID 定位玩家实体"},
			{Chapter: "第 2 章 实体运行时", How: "连接断开时摘除运行时实体,但保留其落库状态"},
			{Chapter: "第 8 章 聊天", How: "私聊按会话定位接收者,离线则转邮件"},
			{Chapter: "第 14 章 匹配与房间", How: "房间成员靠会话表判在线,掉线即退出队列"},
		},
	},
	{
		Part: Part{Name: "路由表", From: "第 8 章"},
		Uses: []Use{
			{Chapter: "第 2 章 实体运行时", How: "每种玩法命令注册一个 handler,复用卷一的 cmd→handler 分发"},
			{Chapter: "第 5 章 战斗管线", How: "攻击 / 施法命令走同一张路由表进管线"},
			{Chapter: "第 7 章 任务系统", How: "任务事件(击杀 / 拾取 / 到达)注册成路由的回调"},
		},
	},
	{
		Part: Part{Name: "Actor 邮箱", From: "第 10 章"},
		Uses: []Use{
			{Chapter: "第 3 章 场景实例", How: "一个场景实例 = 一个 actor,玩法消息串行进邮箱,免锁"},
			{Chapter: "第 2 章 实体运行时", How: "每个实体一个邮箱,状态只被自己的消息推进"},
			{Chapter: "第 10 章 公会", How: "公会是一个 actor,成员操作在公会邮箱里串行"},
			{Chapter: "第 14 章 匹配与房间", How: "房间是一个 actor,匹配成功后成员消息进房间邮箱"},
		},
	},
	{
		Part: Part{Name: "定时器", From: "第 9 章"},
		Uses: []Use{
			{Chapter: "第 5 章 战斗管线", How: "技能冷却与 buff 到期挂时间轮,按 tick 推进"},
			{Chapter: "第 6 章 掉落与背包", How: "掉落物过期回收走时间轮,不占场景主循环"},
			{Chapter: "第 11 章 排行榜", How: "周期结算与刷新用时间轮的周期任务"},
			{Chapter: "第 12 章 邮件", How: "邮件过期与附件退回挂定时器"},
		},
	},
	{
		Part: Part{Name: "幂等窗口", From: "第 11 章"},
		Uses: []Use{
			{Chapter: "第 6 章 掉落与背包", How: "防重复拾取:同一掉落物只入一次背包"},
			{Chapter: "第 9 章 组队", How: "防重复邀请 / 重复入队请求"},
			{Chapter: "第 13 章 交易", How: "防重复扣款:同一笔交易只结算一次"},
		},
	},
	{
		Part: Part{Name: "同步模型", From: "第 12–14 章"},
		Uses: []Use{
			{Chapter: "第 4 章 AOI 与兴趣管理", How: "AOI 是兴趣过滤的具体实现,决定谁收到谁的快照"},
			{Chapter: "第 5 章 战斗管线", How: "战斗结果按快照 / 增量下发给兴趣范围内的玩家"},
		},
	},
	{
		Part: Part{Name: "权威校验", From: "第 15 章"},
		Uses: []Use{
			{Chapter: "第 5 章 战斗管线", How: "伤害裁决在服务端算,客户端只上报意图"},
			{Chapter: "第 6 章 掉落与背包", How: "拾取合法性(距离 / 归属 / 冷却)在服务端校验"},
		},
	},
}

// Volume identifies which book owns a topic.
type Volume int

const (
	// Vol1 is 《网络与并发内核》 (this book).
	Vol1 Volume = 1
	// Vol2 is 《单服架构与玩法系统》.
	Vol2 Volume = 2
	// Vol3 is the distributed / cross-server volume.
	Vol3 Volume = 3
	// Vol4 is the operations / live-ops volume.
	Vol4 Volume = 4
)

// OutOfScope is what volume two deliberately does NOT cover, and which volume
// does. This is the "don't wander into the next book" guard: a topic that
// shows up in volume two's outline but belongs here is a scope leak.
var OutOfScope = []struct {
	Topic string
	Owner Volume
}{
	{"跨服传送", Vol3},
	{"分布式一致性与分服", Vol3},
	{"分库分表与合服迁移", Vol3},
	{"压测与承载数字", Vol4},
	{"线上运营与热更新", Vol4},
	{"监控告警与故障演练", Vol4},
}

// Consumes returns the chapters of volume two that consume the named part.
func Consumes(part string) []string {
	for _, r := range Table {
		if r.Part.Name == part {
			out := make([]string, 0, len(r.Uses))
			for _, u := range r.Uses {
				out = append(out, u.Chapter)
			}
			return out
		}
	}
	return nil
}

配套的 internal/handoff/handoff_test.go 断言六件事:每个零件至少有一个消费章节、每条消费关系都指向真实存在的卷二章节、零件名不重复、卷二每一章都至少消费了一个卷一零件(没有「凭空出现」的章节)、越界清单里的每个话题都属于卷三或卷四、以及 Consumes 的查表结果正确。本机实跑结果:

=== RUN   TestEveryPartHasAtLeastOneUse
--- PASS: TestEveryPartHasAtLeastOneUse (0.00s)
=== RUN   TestEveryUsePointsAtARealChapter
--- PASS: TestEveryUsePointsAtARealChapter (0.00s)
=== RUN   TestPartNamesAreUnique
--- PASS: TestPartNamesAreUnique (0.00s)
=== RUN   TestEveryVolumeTwoChapterConsumesSomething
--- PASS: TestEveryVolumeTwoChapterConsumesSomething (0.00s)
=== RUN   TestOutOfScopeBelongsToOtherVolumes
--- PASS: TestOutOfScopeBelongsToOtherVolumes (0.00s)
=== RUN   TestConsumes
--- PASS: TestConsumes (0.00s)
PASS
ok  	aegis/internal/handoff	0.263s

其中 TestEveryVolumeTwoChapterConsumesSomething 最值得说:它要求卷二的每一章都消费至少一个卷一零件。这条不变量在防什么?防的是「卷二偷偷冒出一个不需要任何地基的新机制」。如果有一天卷二加了一章「跨服排行榜」,这个测试会立刻失败——因为跨服排行榜依赖的是分布式一致性,不是卷一的任何零件,它就该被赶去卷三。把边界写成会失败的断言,比写进文档管用。

本节小结

这一节是卷一的出口,也是卷二的入口:

  1. 交出七个零件:会话表、路由表、Actor 邮箱、定时器、幂等窗口、同步模型、权威校验。它们是有依赖的通用机制,卷二把它们原样继承。
  2. 给出交接映射表:每个零件在卷二哪一章、被拿去做什么。卷二几乎不新造机制,只造内容,所以它能写得快。
  3. 划清边界:跨服与分布式归卷三,压测与运营归卷四;卷二默认跑在「一个进程、一个世界」里。
  4. 把契约编码成 Go:零件、章节、消费关系、越界清单进结构体,go test 保证「地基真的被用上了」「边界真的没被越过」。

卷一到这里就结束了。接下来请翻开卷二《单服架构与玩法系统》——那里会用这一卷造好的七个零件,把《晨曦大陆》的玩法一个接一个装起来。你在这里踩过的每一个坑(粘包、乱序、重发、瞬移),到了卷二都会以「玩法 bug」的形式再出现一次;区别只是这一次,你已经知道该往哪一层去看。

阅读导航:上一节:18.2 承载数字汇总 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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