《MMO 服务器开发》9.1 属性计算与加成来源

属性是玩法系统的地基,装备、buff、称号都会改数值,聚合顺序写错就会算错伤害。本节从零写 internal/attr 包,用万分比定点做「平加 → 加算百分比 → 乘算百分比」三层聚合,实现同类取最强与缓存失效,并复用卷一 CmdAttrSync 0x0103 做增量同步,附 go test 验收。

9.1 属性计算与加成来源

属性是玩法系统的地基。玩家的血量、攻击、暴击,最终都由一堆来源拼出来:基础值来自等级,装备加固定值,buff 加百分比,称号再叠一层。这些来源什么时候加、什么时候乘、按什么顺序,决定了同一身装备在结算时到底打出多少伤害。

难的地方不在于加法本身,而在于三个口径必须全服统一:聚合顺序(平加先还是百分比先)、取整规则(浮点还是定点、每层取整还是最后取整)、失效时机(穿上一件装备后,哪些缓存要重算)。任何一处两端不一致,客户端显示的数字和服务端结算的数字就会差,玩家一眼就能看出来。

本节从零写 internal/attr 包,把属性从「一堆来源」算成「一个最终值」,再用卷一定好的 CmdAttrSync 0x0103 把变化同步出去。全部是 L1,go test ./internal/attr/ 可以直接跑。

本章与站内既有内容的分工

站内已有几篇文章讲过属性系统的架构、成长状态机、离线推进与体力服务的方案与取舍。本章不复述这些结论,只把第 9 章要讲的三个机制从零实现出来并跑出真结果。分工如下。

既有文章它负责什么本章只做什么
/game-server-attribute-calculation-architecture/属性系统的整体架构与来源划分只写三层聚合与增量同步的实现
/game-server-progression-state-machine-architecture/成长状态机的状态与迁移设计只写经验曲线与相位迁移(见 9.2)
/game-server-offline-progression-architecture-design/离线推进的架构与结算时机只写封顶与时钟回拨的处理(见 9.3)
/game-server-energy-stamina-service-architecture/体力服务的架构与限流只写回复不丢点的整数周期结算(见 9.3)

三层聚合:平加、加算、乘算

先写核心。设计上定三条规则。第一,百分比一律用万分比定点整数(Scale = 10000),不用浮点——同一件装备穿脱几百次、buff 每秒抖动,浮点误差会一点点累积,最后服务端和客户端对不上。第二,聚合固定按「平加 → 加算百分比 → 乘算百分比」三层,顺序写死,任何来源都只能落在某一层。第三,同一属性上 Group 相同的平加只取最大值,多份食物 buff 只吃最高那份,避免「吃满一背包」这种堆叠。

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

// file: internal/attr/attribute.go
// tier: L1
// verify: go test ./internal/attr/
package attr

// AttrID 是属性编号,落在配置表里,不是字符串名。
type AttrID uint16

const (
	AttrMaxHP AttrID = 1
	AttrMaxMP AttrID = 2
	AttrATK   AttrID = 3
	AttrDEF   AttrID = 4
)

// Scale 是百分比固定点标度:万分之几。用整数避免浮点误差在多个来源叠加时累积。
const Scale int64 = 10000

// Op 决定一条加成参与哪一层聚合。
type Op uint8

const (
	OpAdd        Op = iota // 平加:进入 base + Σadd
	OpPercentAdd           // 加算百分比:同类求和后一次性乘
	OpPercentMul           // 乘算百分比:各自独立连乘
)

// Source 标记加成来自哪里,用于审计与整组移除。
type Source uint8

const (
	SrcBase Source = iota
	SrcEquip
	SrcBuff
	SrcTitle
)

// Modifier 是一条加成来源。Group 用于同类取最强:同一属性上 Group 相同、
// Op 为 OpAdd 的修正只保留最大值(例如多份食物 buff 只吃最高那份)。
type Modifier struct {
	Source Source
	Group  string
	Attr   AttrID
	Op     Op
	Value  int64
}

// AttributeSet 保存基础属性与全部加成,Compute 时才聚合成最终值。
type AttributeSet struct {
	base  map[AttrID]int64
	mods  []Modifier
	final map[AttrID]int64
	dirty bool
}

func NewAttributeSet() *AttributeSet {
	return &AttributeSet{base: map[AttrID]int64{}, final: map[AttrID]int64{}, dirty: true}
}

// SetBase 设置某个属性的基础值(来自等级曲线,见 9.2)。
func (s *AttributeSet) SetBase(a AttrID, v int64) {
	s.base[a] = v
	s.dirty = true
}

// Add 追加一条加成。装备穿上、buff 生效都走这里。
func (s *AttributeSet) Add(m Modifier) {
	s.mods = append(s.mods, m)
	s.dirty = true
}

// Remove 按「来源 + 组」整组移除。卸装备、buff 到期的正确粒度是整组,
// 而不是按数值删——同一件装备可能对多个属性各有加成。
func (s *AttributeSet) Remove(source Source, group string) {
	out := s.mods[:0]
	for _, m := range s.mods {
		if m.Source == source && m.Group == group {
			continue
		}
		out = append(out, m)
	}
	s.mods = out
	s.dirty = true
}

// Compute 返回某属性的最终值。任何 Add/Remove/SetBase 之后首次查询会触发一次全量重建。
func (s *AttributeSet) Compute(a AttrID) int64 {
	if s.dirty {
		s.rebuild()
	}
	return s.final[a]
}

// rebuild 只在脏标记置位时跑一次,把全部属性算好放进缓存。
func (s *AttributeSet) rebuild() {
	s.final = make(map[AttrID]int64, len(s.base))
	for a := range s.base {
		s.final[a] = s.computeOne(a)
	}
	// 只在加成里出现、没有基础值的属性也要算(例如纯 buff 提供的暴击率)。
	for _, m := range s.mods {
		if _, ok := s.final[m.Attr]; !ok {
			s.final[m.Attr] = s.computeOne(m.Attr)
		}
	}
	s.dirty = false
}

// computeOne 按「平加 → 加算百分比 → 乘算百分比」三层聚合。
func (s *AttributeSet) computeOne(a AttrID) int64 {
	// 先求每个 Group 里 OpAdd 的最大值,用于同类取最强。
	best := map[string]int64{}
	for _, m := range s.mods {
		if m.Attr != a || m.Op != OpAdd || m.Group == "" {
			continue
		}
		if v, ok := best[m.Group]; !ok || m.Value > v {
			best[m.Group] = m.Value
		}
	}
	var add, pctAdd int64
	mul := Scale // 10000 表示 1.0
	applied := map[string]bool{}
	for _, m := range s.mods {
		if m.Attr != a {
			continue
		}
		switch m.Op {
		case OpAdd:
			if m.Group != "" {
				if applied[m.Group] {
					continue
				}
				applied[m.Group] = true
				add += best[m.Group]
				continue
			}
			add += m.Value
		case OpPercentAdd:
			pctAdd += m.Value
		case OpPercentMul:
			mul = mul * (Scale + m.Value) / Scale
		}
	}
	v := s.base[a] + add
	v = v * (Scale + pctAdd) / Scale
	v = v * mul / Scale
	if v < 0 {
		v = 0
	}
	return v
}

为什么是三层而不是两层?看加算与乘算的差别:两条 +20%,加算是 (1+0.2+0.2)=1.4,乘算是 1.2×1.2=1.44。computeOne 里加算把所有 OpPercentAdd 先求和再一次乘,乘算则逐个连乘——同一批 buff,落哪一层直接决定最终数值。测试 TestAdditiveVsMultiplicative 就是钉这条:加算得 140、乘算得 144,谁改错一层立刻红。

Group 的去重要按整组做。applied 这张表保证同一组只加一次,且加的是 best[group] 那个最大值;若偷懒写成「每遇到一条就比一次」,两条同值 buff 会被加两次。TestSameGroupTakesStrongest 里两条 food 组、值 30 和 50,期望结果 150(吃 50),而不是 180。

缓存失效:重算只在变更时发生

属性查询在战斗里是热点:一次技能结算要读攻击、防御、暴击好几个属性,每秒可能几十次。如果每次 Compute 都遍历全部加成,mods 一多就成了瓶颈。这里的做法是脏标记 + 全量重建:Add / Remove / SetBase 只把 dirty 置位,真正的聚合推迟到下一次 Compute 才做,一次把所有属性算完塞进 final 缓存。

为什么是「全量重建」而不是「只重算被问到的那个属性」?因为一次装备变更几乎总会牵动多个属性(一件盔甲同时给血、防、韧性),而 rebuild 只跑一次、循环里按属性分桶,均摊下来比逐属性重算更省。代价是缓存里可能存了一些本次没人问的属性,但属性总数是个位到几十的量级,这点浪费可以忽略。

Remove 按「来源 + 组」整组删,而不是按数值删,也是同一考量:卸下武器要一次性摘掉它带来的所有加成,按数值删会误伤别的来源恰好同值的加成。测试 TestCacheInvalidationAndRemove 覆盖了「加上→缓存命中→卸下→回基线」这条链路。

增量同步:复用 CmdAttrSync 0x0103

属性算完了要发给客户端。卷一已经占用世界域命令号 CmdAttrSync 0x0103,本节直接复用,不另起炉灶。帧格式沿用卷一的固定头:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1),length = 7 + N,帧总长 11 + N。进世界时发一次全量快照,之后只走增量——属性每秒都可能因 buff 抖动,全量重发会把带宽吃光。

下面这段同样是 L1,go test ./internal/attr/ 会一并跑到:

// file: internal/attr/attrsync.go
// tier: L1
// verify: go test ./internal/attr/
package attr

import (
	"encoding/binary"
	"errors"
	"sort"
)

// CmdAttrSync 复用卷一世界域的命令号 0x0103,不另起炉灶。
const CmdAttrSync uint16 = 0x0103

const (
	HeaderSize = 11 // length(4) + cmd(2) + seq(4) + flags(1)
	FlagsNone  = 0
)

var ErrShortFrame = errors.New("attr: 帧长度不足")

// AttrDelta 是同步里的一项:只发发生变化的属性。
type AttrDelta struct {
	ID    AttrID
	Value int64
}

// EncodeAttrSync 把变化集编成一条完整帧:11 字节头 + 计数(2) + 每项 10 字节。
// 进世界时发一次全量快照,之后只走增量——属性每秒都可能因 buff 抖动,
// 全量重发会把带宽吃光。
func EncodeAttrSync(seq uint32, deltas []AttrDelta) []byte {
	// 先排序,保证同一集合编出同一串字节,便于测试与抓包比对。
	sort.Slice(deltas, func(i, j int) bool { return deltas[i].ID < deltas[j].ID })
	payload := make([]byte, 0, 2+len(deltas)*10)
	payload = binary.BigEndian.AppendUint16(payload, uint16(len(deltas)))
	for _, d := range deltas {
		payload = binary.BigEndian.AppendUint16(payload, uint16(d.ID))
		payload = binary.BigEndian.AppendUint64(payload, uint64(d.Value))
	}
	out := make([]byte, HeaderSize, HeaderSize+len(payload))
	binary.BigEndian.PutUint32(out[0:], uint32(7+len(payload))) // length = cmd+seq+flags+payload
	binary.BigEndian.PutUint16(out[4:], CmdAttrSync)
	binary.BigEndian.PutUint32(out[6:], seq)
	out[10] = FlagsNone
	return append(out, payload...)
}

// DecodeAttrSync 解析 EncodeAttrSync 的产物,越界返回错误而非 panic。
func DecodeAttrSync(frame []byte) (uint32, []AttrDelta, error) {
	if len(frame) < HeaderSize {
		return 0, nil, ErrShortFrame
	}
	if cmd := binary.BigEndian.Uint16(frame[4:]); cmd != CmdAttrSync {
		return 0, nil, errors.New("attr: 命令号不是 CmdAttrSync")
	}
	seq := binary.BigEndian.Uint32(frame[6:])
	body := frame[HeaderSize:]
	if len(body) < 2 {
		return 0, nil, ErrShortFrame
	}
	n := int(binary.BigEndian.Uint16(body))
	body = body[2:]
	if len(body) < n*10 {
		return 0, nil, ErrShortFrame
	}
	deltas := make([]AttrDelta, n)
	for i := 0; i < n; i++ {
		deltas[i] = AttrDelta{
			ID:    AttrID(binary.BigEndian.Uint16(body[i*10:])),
			Value: int64(binary.BigEndian.Uint64(body[i*10+2:])),
		}
	}
	return seq, deltas, nil
}

编码前先 sort 一次,是为了让同一组变化永远编出同一串字节——测试能直接比对字节,抓包时也容易肉眼对。AttrID 用 uint16、值用 uint64,每项固定 10 字节;属性个数通常不超过几十,一次同步几百字节封顶。解码端每一步都先校验长度,n 是被网络传来、攻击者可控的,body[i*10:] 若不先确认 len(body) >= n*10 就是一次越界切片。TestAttrSyncRoundTrip 除了往返一致,还专门喂了一个截断帧,断言返回 ErrShortFrame 而不是 panic。

本节小结 / 实测结论

本机(Go 1.26.0,darwin/arm64)实跑结果:

$ go vet ./...
(无输出)

$ go test ./internal/attr/
ok  	aegis/internal/attr	0.4s     (15 个 test 全绿)

本节定下三个口径:百分比走万分比定点、聚合固定三层、缓存按脏标记全量重建;同步复用 CmdAttrSync 0x0103,只发增量。属性算出来了,下一步要回答的是「基础值从哪来」——等级。9.2 写经验曲线与成长状态机,SetBase 里那个基础值正是它喂进来的。以上数字是同机相对量级,绝对数值不可移植。

阅读导航:上一节:目录 · 下一节:9.2 等级与成长状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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