《MMO 服务器开发》6.3 战斗结算与防重复发奖
一次公会战结算要同时做对三件事:命中扣血、判定死亡、发放奖励,而且重复到达的结算请求只能发一次奖。本节给 aegis 从零写战斗结算与防重复发奖:为什么结算和发奖必须分开、迟到的伤害包怎么被死亡状态挡掉、为什么发奖的去重键是业务指纹而不是传输序号,并链回卷一的幂等窗口。
category
一次公会战结算要同时做对三件事:命中扣血、判定死亡、发放奖励,而且重复到达的结算请求只能发一次奖。本节给 aegis 从零写战斗结算与防重复发奖:为什么结算和发奖必须分开、迟到的伤害包怎么被死亡状态挡掉、为什么发奖的去重键是业务指纹而不是传输序号,并链回卷一的幂等窗口。
战斗里最容易写崩的两块是技能冷却和 Buff 叠加。本节给 aegis 从零建技能与 Buff 模型:施法为什么拆成请求、前摇、命中三段,技能冷却与公共冷却怎么记,Buff 的刷新与叠层两种策略怎么选、周期伤害怎么按 tick 精确结算,并用真跑的 L1 测试把每条边界钉死。
客户端在多个连接线程里收包,战斗却必须在一个场景线程里按固定顺序结算。本节从零写 aegis 的按帧指令队列:每实体单调序号怎么挡住重试与乱序、排空顺序为什么必须确定、每帧预算怎么把突发流量摊平,并用真跑的 L1 测试与本机实测的投递成本把这几条边界钉死。
权威移动的前两节假设落点可信,这一节把不可信也纳入设计。本节从零写 internal/move 的三道闸:序号单调拦重放、速度上限拦加速外挂、DDA 走格视线检测拦穿墙;被拒的移动绝不更新基准位置,并把原因码随 0x0202 回给客户端让它自行回拉,配 go test 逐条验收。
上一节让角色会走,这一节让它知道往哪走。本节用 container/heap 从零实现八邻域 A*:对角代价根号二、octile 启发、禁止切角穿墙;再把「格子」换成凸多边形导航网格,用多边形邻接图 A* 求通道,最后用拉绳算法把锯齿路径拉成直线,并实测 200x200 网格的单次寻路开销。
服务器权威移动是防作弊的地基:位置只能由服务器算,客户端只能提交「我要去哪」。本节从零写 internal/move 包的 Vec2 逻辑坐标与 Mover 状态机,用 20Hz 固定步长推进、到达即吸附不过冲,并只广播真正产生位移的实体,配 go test 验收终点精确与超距请求被拒。
把暴力扫描、九宫格、十字链表三种 AOI 放在同一批 5000 个玩家、同一张地图上实跑基准:九宫格查询约 890ns,十字链表约 4350ns 且移动慢得多,暴力扫描约 37500ns。再把平均兴趣集合 6.21 人换算成每帧出站消息,量化兴趣管理到底省下了多少带宽。
十字链表 AOI 给每个实体同时挂两条有序链表——一条按 X 升序、一条按 Y 升序,查询时两条「带」取交,不需要格边长这个参数。本节从零实现四指针节点、哨兵环链、O(1) 索引、带内取交查询,并与九宫格逐点比对证明语义一致,同时讲清它移动 O(n) 的代价。
九宫格 AOI 把连续地图切成 GridSize=8 的方格,玩家只关心自己所在格与相邻八格。本节用 aegis 的共享坐标类型从零实现格点哈希、O(1) 跨格移动、圆形半径过滤,以及把「谁进入/离开视野」翻译成 CmdAoiAdd 与 CmdAoiRemove 的 Watcher,并附一份 Java 21 对照实现。
《MMO 服务器开发》卷二《单服架构与玩法系统》全书导航页:说明本卷在四卷本中的位置与承接关系,交代读者画像、前置知识与三条阅读路径,讲清分层与状态归属这一条贯穿全卷的主线,并按六个部分列出 18 章 54 节的完整章节索引,另含《晨曦大陆》单服可玩的验收清单与常见问题速查表,方便读者按需定位与随时查阅。
同一张地图模板,落地方式却要分三六九等:野外和主城该共享分线,副本要一人一实例。本节从零写 internal/scene 的 LineManager 与 Router:分线按容量塞满一条开下一条,副本每次进入开独立实例,并用统一入口把两种落点收敛到一个 EnterWorld。全部 L1,go test 可直接跑。
地图模板是只读的,真正装玩家的是地图实例。本节从零写 internal/scene 的 InstanceManager:单调递增的 InstanceID、高 16 位实例号加低 48 位序号拼出的 EntityID、Active→Draining→Closed 状态机,以及把空置超时实例回收进对象池复用。L1 全绿,另附池化对照基准。
地图是场景逻辑的地基。本节从零写 internal/scene 的地图数据结构:一张 Width×Height 格网加一张阻挡位图描述静态地图,float64 逻辑坐标 Vec2 与客户端像素解耦,格边长固定 GridSize=8.0,并实现世界坐标与格号互转、可走判定与边界夹取。全部是 L1,go test 可直接跑。
实体散落在多个逻辑线程与场景里,核心问题是「谁有权写它」。本节实现单写者模型下的归属表(正向 + 反向双索引),再把跨运行时搬迁拆成冻结—传输—接管三段,用缓冲重放解决冻结期间在飞的消息,最后用一个 Router 固化分流规则,并用 go test 验证迁移幂等与消息不丢不重。
2.1 的立即 Despawn 不够用:创建要先登记再可见,销毁要先结算再回收。本节把实体生命周期拆成 None/Pending/Active/Dying/Dead 五态,用一张迁移表管住所有合法跳转,再把载荷接进对象池,最后用 go test 验证回收与复用真的发生。
实体、句柄、运行时是三个层次。本节从零定下 EntityID 的高 16 位场景号 + 低 48 位序号位布局,用代际计数解决销毁后序号复用带来的 ABA 问题,再实现一张 O(1) 校验的运行时表,并用 16 个 go test 用例把三种过期引用逐一跑出来。
站内已经有一大批游戏服务器架构文章,卷一又写完了网络内核,那这一卷到底写什么、不写什么?本节把三份内容的分工摊开讲清楚:站内 practice 架构文负责「应该怎么设计」,卷一负责「网络底座怎么写」,本卷负责「玩法系统从零实现并跑出数字」,并把这条边界编码成一张可查询的表。
上一节立了「一个状态只能有一个写者」,但现实里这条原则会被两种方式破坏:写者其实不止一个,以及写者写的是旧数据。它们对应两个最经典的事故——重复发奖与旧数据覆盖。本节先把两个事故的根因拆开,再推出版本单调与幂等键两条守卫,最后用一份能跑的 Go 代码把这两条守卫钉成断言。
卷一讲完网络内核的四层,这一卷换一个视角重新切一遍:从玩法系统的状态归属出发,回答 gateway / logic / scene / data 各自该持有什么状态。本节先把「谁写状态,谁就是那一层」立成第一原则,再把每条命令归到唯一的处理层、把每份状态归到唯一的写入者,最后用一份能跑的 Go 代码把这两条归属规则钉成断言。
附录 D 把卷一各章真跑出来的坑收成一张速查表:帧头长度、载荷越界、varint 边界、KCP 重传、时间轮级联、邮箱背压、幂等窗口与帧同步 Digest 漂移,逐条给出根因、修法与出处;再回答十二个读者最常问的问题,并附一段把错误写法写成失败测试的 L1 对照代码。