《游戏服务端编程实践》9.1.2 装备系统与强化

说明装备槽位、穿戴条件与绑定规则,对比属性增量修改与整组重算在幂等性和性能上的差异,拆解强化成功率、失败惩罚和保底设计,并用 Go 实现注入随机源的 EquipSet 与纯状态迁移 Enhance,覆盖成功率边界、失败不吞材料、保底跳过随机与事务边界测试。

一、装备槽位与穿戴条件

装备不是背包里的普通物品,它有一个明确的“位置”。武器、护甲、饰品是最常见的三类槽位,实际项目还会拆出头、胸、腿、戒指、项链。槽位划分不是越细越好:槽位越多,掉落表、养成线和 UI 越复杂;槽位太少,又会限制流派搭配。

槽位典型装备主要属性特殊规则
武器剑、弓、法杖攻击、暴击可能双手,占用副手
护甲头盔、胸甲、靴子防御、生命可能有职业限制
饰品戒指、项链、护符命中、抗性可能允许同槽两件

穿戴条件至少包含等级、职业、阵营和槽位。条件检查必须在服务端完成,客户端提示只是体验。检查顺序建议固定:先检查物品是否存在、再检查槽位是否为空、再检查等级与职业,最后才提交变更。顺序固定后,错误码才能稳定,客户端也不必猜是哪一条不满足。

同槽位替换是常见需求。服务端不要把“替换”实现成两次独立操作,否则中间态会出现“旧装备已脱下、新装备没穿上”。正确做法是一个事务内完成:校验新装备、把旧装备放回背包、把新装备放进槽位;任一步失败则整体回滚。

双手武器会额外占用副手槽。这个规则不要散落在 UI 和战斗代码里,应该在装备定义中声明占用的槽位集合,由 EquipSet 统一检查。否则玩家可以同时拿双手剑和盾,属性计算就会重复。

装备实例仍然需要 instance_id。两把同名剑的强化等级、附魔、耐久、绑定状态都可能不同。槽位里存的是实例,不是 item_id;这一点与上一篇的物品实例模型一致。

二、绑定发生在哪一刻

绑定有三种常见时机:获得时绑定、穿戴时绑定、使用/强化时绑定。规则不同,交易市场的经济模型完全不同。获得时绑定适合任务奖励,穿戴时绑定适合“用过的装备不能卖”,使用后绑定适合消耗型道具。

绑定时机触发点玩家可交易窗口适用场景
获得绑定拾取/发放无剧情奖励、活动赠品
穿戴绑定首次穿戴穿戴前大部分养成装备
使用绑定首次强化/附魔使用前高价值打造装备

绑定状态必须由服务端写入实例,并且与“从背包移动到装备槽”在同一个事务里。常见错误是先在背包里标记绑定,再执行穿戴;如果穿戴失败,物品已经绑定,玩家会投诉。反过来,先穿戴再标记绑定,如果进程崩溃,会出现已穿戴但未绑定的窗口。

脱下装备不会解除绑定。绑定是实例的永久属性,除非规则明确提供“解绑道具”。解绑本身也应该是一个独立事务,记录消耗、操作人、时间,不能直接改布尔值。

装备槽里的实例与背包里的实例是同一个身份。穿戴时应该从背包移除该实例,而不是复制一份;否则同一 instance_id 会同时存在于两处,后续交易、删除、审计都会冲突。

三、属性进入属性系统的两条路

装备属性进入角色属性系统有两条路:增量修改和整组重算。

增量修改是在穿戴时加上属性、脱下时减去属性。它看起来高效,但非常容易重复计算:重登、换线、复活、buff 刷新都可能再执行一次“加上”。一旦加多了,减去时又可能减错对象,最后只能靠全量重置兜底。

整组重算是每次需要属性时,从当前装备槽重新求和,再与基础属性、buff、称号合并。它的代价是一次遍历,但换来了幂等:无论穿戴多少次、重登多少次,结果只取决于当前槽位内容。

方式幂等性性能风险
增量修改差高重复加减、状态漂移
整组重算好中遍历成本、缓存失效
增量 + 周期校验中高校验延迟内仍可能错

推荐把整组重算作为唯一事实来源,增量只用于推送通知。属性缓存可以存在,但必须带版本号或脏标记,装备变更时失效;缓存失效逻辑要覆盖穿戴、脱下、强化、附魔、耐久归零所有路径。

属性合并顺序也要固定。通常先算基础属性,再加装备固定值,再乘装备百分比,最后加 buff。顺序不同,结果不同;如果不同系统各算各的,战斗服和展示服会给出两套数值。

装备属性里还要区分“面板属性”和“战斗属性”。命中、暴击、抗性这类属性可能只影响战斗计算,不一定直接显示在面板上。定义里应该标注属性类型,避免把内部系数直接透给客户端。

四、强化:等级、成功率、惩罚、保底

强化系统由四个要素组成:等级上限、成功率曲线、失败惩罚、保底规则。四者缺一不可,否则要么玩家体验失控,要么经济系统被击穿。

成功率通常随等级下降:0 级 60%,每级降 10%,最低 20%。曲线可以用表配置,也可以用公式;公式的好处是连续,表的好处是可控。无论哪种,服务端都要能回答“当前等级的成功率是多少”,并且这个值要写进日志。

失败惩罚至少有四种:无惩罚、降一级、降多级、破碎。无惩罚最友好,但材料消耗就是唯一成本;降级会增加挫败感;破碎风险最高,通常只用于高价值装备。惩罚必须在配置里显式声明,不能由代码临时决定。

保底是连续失败后的补偿。常见规则是“连续失败 N 次后,下一次必定成功”,或者“失败累积幸运值,幸运值满则成功”。保底状态要跟着装备实例走,不能放在角色或全局,否则换装备会继承保底。

要素配置示例必须记录常见错误
成功率60% 起,每级 -10%等级、概率、随机值用全局随机不可复现
失败惩罚降一级 / 不变惩罚前后等级扣了材料却不降级
保底连续失败 2 次必成连续失败次数保底状态不落库
材料每次 1 个扣除数量与余额失败也扣但规则未声明

强化等级上限要与装备品质、角色等级解耦。把上限写死在一个全局常量里,后续开放新等级就要改代码;更好的做法是定义里带 MaxLevel,或者由强化配置按品质给出。

五、强化的随机必须可审计

强化是玩家最敏感的随机之一。随机不可审计,客服就无法解释“为什么连续失败”,运营也无法复现异常。可审计的最低要求是:记录请求 ID、装备实例、强化前后等级、成功率、随机值、是否保底、材料扣除。

随机源必须可注入。生产环境用带种子的加密随机或平台随机服务;测试环境注入固定序列,才能覆盖“随机值等于概率”“随机值略小于概率”“保底触发不调用随机”这些边界。

不要在业务代码里直接调用全局 math/rand。全局随机的问题不是性能,而是不可控:种子什么时候设置、并发下序列如何交错、测试如何复现,都没有答案。把随机抽象成一个只暴露 Float64() 的接口,业务代码只依赖接口。

比较方式也要固定。成功率 60%,随机值 0.6 算成功还是失败?必须二选一并写进测试。推荐使用严格小于:roll < rate 成功,roll == rate 失败。这样概率区间是 [0, rate),边界清晰。

保底触发时是否还消耗随机数,也要明确。推荐不消耗:保底是规则保证,不应该因为多取一个随机值而影响后续随机序列。测试里可以用一个“被调用就 panic”的随机源来验证。

六、失败场景:四个强化事故

第一个事故是“全局随机不可复现”。玩家投诉连续 10 次失败,客服查日志只有“强化失败”,没有随机值和成功率。运营无法判断是概率正常还是代码写错,最后只能补偿。修复方式是记录完整强化上下文,并把随机源注入。

第二个事故是“失败扣材料但等级不降”。配置写的是“失败降一级”,代码只扣了材料,忘了降级。玩家发现失败没有代价,强化到满级只是时间问题,经济系统被击穿。根因是失败分支没有和配置对齐,也没有测试。

第三个事故是“装备属性重复计算”。穿戴时给角色加攻击,重登时又加了一次;脱下时只减一次,角色永久多出一份攻击。根因是增量修改没有幂等保证。修复方式是改为整组重算,并用版本号让缓存失效。

第四个事故是“强化回滚导致复制”。强化事务先扣材料,再提升等级;提升失败后回滚了材料,但装备等级已经写进缓存。玩家看到等级提升,重登后又变回去,反复操作可以刷属性。根因是缓存与数据库不在同一事务边界内。

这四个事故的共同点是:随机、属性、事务三件事没有分开建模。随机要可复现,属性要可重算,事务要有明确边界;三者混在一起,任何一个小改动都可能引发资产问题。

七、Go 实现:注入随机源与纯状态迁移

下面的 EquipSet 用槽位映射保存已穿戴实例,属性每次从槽位重算;Enhancer 把强化写成纯状态迁移,随机源通过接口注入,材料扣除与等级提升都发生在返回的新状态里,调用方负责一次性落库。

// file: ch09/bag/equip.go
// tier: L1
// verify: go test ./ch09/bag/

package bag

import (
	"errors"
	"fmt"
)

type SlotType string

const (
	SlotWeapon    SlotType = "weapon"
	SlotArmor     SlotType = "armor"
	SlotAccessory SlotType = "accessory"
)

// EquipDef 描述装备定义:槽位、穿戴等级、属性修正与绑定规则。
type EquipDef struct {
	ItemID       string
	Slot         SlotType
	LevelReq     int
	Attrs        map[string]int
	BoundOnEquip bool
}

var (
	ErrUnknownEquip      = errors.New("equip: unknown equipment")
	ErrSlotOccupied      = errors.New("equip: slot occupied")
	ErrLevelTooLow       = errors.New("equip: level too low")
	ErrEmptyEquipSlot    = errors.New("equip: equipment slot is empty")
	ErrBadEnhanceConfig  = errors.New("enhance: bad config")
	ErrMaxLevel          = errors.New("enhance: already at max level")
	ErrNotEnoughMaterial = errors.New("enhance: not enough material")
)

// EquipSet 用槽位映射保存已穿戴装备,属性总是从当前槽位重算,
// 因此反复穿戴/脱下不会产生重复加成。
type EquipSet struct {
	defs  map[string]EquipDef
	slots map[SlotType]Item
}

func NewEquipSet(defs []EquipDef) *EquipSet {
	byID := make(map[string]EquipDef, len(defs))
	for _, d := range defs {
		byID[d.ItemID] = d
	}
	return &EquipSet{defs: byID, slots: make(map[SlotType]Item)}
}

// Equip 穿戴一件装备。装备必须是单件、等级达标、目标槽位为空。
// 绑定规则在穿戴时生效,返回的 Item 是已绑定的副本,调用方应据此
// 在同一事务里更新背包。
func (s *EquipSet) Equip(it Item, level int) (Item, error) {
	def, ok := s.defs[it.ItemID]
	if !ok {
		return Item{}, fmt.Errorf("%w: %s", ErrUnknownEquip, it.ItemID)
	}
	if it.Count != 1 {
		return Item{}, ErrBadCount
	}
	if level < def.LevelReq {
		return Item{}, fmt.Errorf("%w: need %d, got %d", ErrLevelTooLow, def.LevelReq, level)
	}
	if _, busy := s.slots[def.Slot]; busy {
		return Item{}, fmt.Errorf("%w: %s", ErrSlotOccupied, def.Slot)
	}
	if def.BoundOnEquip {
		it.Bound = true
	}
	s.slots[def.Slot] = it
	return it, nil
}

// Unequip 脱下指定槽位,返回装备实例;绑定状态保留。
func (s *EquipSet) Unequip(slot SlotType) (Item, error) {
	it, ok := s.slots[slot]
	if !ok {
		return Item{}, ErrEmptyEquipSlot
	}
	delete(s.slots, slot)
	return it, nil
}

func (s *EquipSet) Equipped(slot SlotType) (Item, bool) {
	it, ok := s.slots[slot]
	return it, ok
}

// Attrs 每次从已穿戴装备重新求和,是属性系统的唯一入口。
func (s *EquipSet) Attrs() map[string]int {
	out := make(map[string]int)
	for _, it := range s.slots {
		def := s.defs[it.ItemID]
		for k, v := range def.Attrs {
			out[k] += v
		}
	}
	return out
}

// Rand 只暴露 Float64,测试可以注入确定性随机源。
type Rand interface {
	Float64() float64
}

type RandFunc func() float64

func (f RandFunc) Float64() float64 { return f() }

type EnhanceConfig struct {
	BaseRate         float64
	Step             float64
	MinRate          float64
	MaxLevel         int
	MaterialCount    int
	ConsumeOnFailure bool
	DropOnFailure    bool
	GuaranteeAfter   int
}

type EnhanceState struct {
	Level      int
	FailStreak int
	Materials  int
}

type EnhanceResult struct {
	Success       bool
	From          int
	To            int
	Rate          float64
	Roll          float64
	UsedFailsafe  bool
	MaterialsUsed int
}

type Enhancer struct {
	cfg EnhanceConfig
	rng Rand
}

func NewEnhancer(cfg EnhanceConfig, rng Rand) (*Enhancer, error) {
	if rng == nil {
		return nil, fmt.Errorf("%w: random source is required", ErrBadEnhanceConfig)
	}
	if cfg.MaxLevel <= 0 || cfg.MaterialCount < 0 || cfg.GuaranteeAfter < 0 ||
		cfg.BaseRate < 0 || cfg.BaseRate > 1 || cfg.MinRate < 0 || cfg.MinRate > 1 ||
		cfg.Step < 0 {
		return nil, ErrBadEnhanceConfig
	}
	return &Enhancer{cfg: cfg, rng: rng}, nil
}

func (e *Enhancer) Rate(level int) float64 {
	rate := e.cfg.BaseRate - e.cfg.Step*float64(level)
	if rate < e.cfg.MinRate {
		rate = e.cfg.MinRate
	}
	if rate < 0 {
		return 0
	}
	if rate > 1 {
		return 1
	}
	return rate
}

// Enhance 是纯状态迁移:输入旧状态,返回新状态与结果,不修改外部对象。
// 材料不足或已满级时状态原样返回;成功时扣材料与升级同时发生在返回值里。
func (e *Enhancer) Enhance(st EnhanceState) (EnhanceState, EnhanceResult, error) {
	res := EnhanceResult{From: st.Level, Rate: e.Rate(st.Level)}
	if st.Level >= e.cfg.MaxLevel {
		return st, res, ErrMaxLevel
	}
	if st.Materials < e.cfg.MaterialCount {
		return st, res, ErrNotEnoughMaterial
	}

	if e.cfg.GuaranteeAfter > 0 && st.FailStreak >= e.cfg.GuaranteeAfter {
		res.UsedFailsafe = true
		res.Success = true
	} else {
		res.Roll = e.rng.Float64()
		res.Success = res.Roll < res.Rate
	}

	if res.Success {
		st.Level++
		st.FailStreak = 0
		st.Materials -= e.cfg.MaterialCount
		res.MaterialsUsed = e.cfg.MaterialCount
	} else {
		if e.cfg.ConsumeOnFailure {
			st.Materials -= e.cfg.MaterialCount
			res.MaterialsUsed = e.cfg.MaterialCount
		}
		st.FailStreak++
		if e.cfg.DropOnFailure && st.Level > 0 {
			st.Level--
		}
	}
	res.To = st.Level
	return st, res, nil
}

实现细节值得展开。Equip 要求数量为 1,避免把一堆材料当装备穿;BoundOnEquip 只修改返回的副本,调用方必须用返回值更新背包,这样“绑定”和“移出背包”可以放进同一个事务。Attrs 遍历当前槽位,不保存增量,因此重复调用不会累加。

Enhance 先检查满级和材料,再判断保底;保底命中时不调用随机源。成功时同时增加等级、清零连败、扣材料;失败时默认不扣材料,只有 ConsumeOnFailure 明确为真才扣。这个默认值很重要:规则没写“失败扣材料”,代码就不应该扣。

测试覆盖成功率边界(roll == rate 失败)、保底跳过随机、材料不足状态不变、满级状态不变、失败降级、装备属性重算与绑定。所有随机都来自 RandFunc,测试不需要 sleep,也不需要重试。

八、事务边界与调用顺序

一次穿戴的推荐顺序是:读取背包实例、校验装备定义与穿戴条件、在数据库事务中删除背包行并写入装备槽、提交后失效属性缓存、发送变更事件。任何一步失败都不能留下“背包没了、装备没穿上”的中间态。

一次强化的推荐顺序是:读取装备与材料、校验等级与数量、用注入随机源计算结果、在事务中扣材料并写新等级与保底状态、提交后写强化日志、推送结果。日志可以在事务内写,也可以异步写,但必须能通过请求 ID 关联。

强化日志至少要能重放:同样的等级、成功率、随机值、保底状态,必须得到同样的结果。做不到重放,就不能称为可审计。

装备与背包的边界也要清晰。背包负责实例的存放与数量,装备槽负责实例的穿戴位置,属性系统只读装备槽。三者不要互相直接改对方的数据结构,而是通过明确的方法调用,否则事务边界会被打穿。

小结

装备槽位固定、穿戴条件服务端校验、绑定与穿戴同事务。属性用整组重算保证幂等,增量只做通知。强化由等级、成功率、失败惩罚、保底四要素组成,随机源必须注入并记录完整上下文。材料扣除与等级提升要在同一事务里完成,失败是否扣材料必须由配置显式声明。

常见错误与边界

  • 用全局随机强化,线上无法复现,客服无法解释。
  • 成功率边界没有测试,roll == rate 的行为随实现漂移。
  • 失败扣了材料却没有执行配置里的降级或破碎。
  • 保底状态放在角色或全局,换装备后继承。
  • 装备属性用增量加减,重登或换线后重复计算。
  • 穿戴时复制实例而不是移动,同一 instance_id 出现在两处。
  • 绑定标记与穿戴不在同一事务,失败后物品已被绑定。
  • 强化日志缺少随机值和成功率,无法重放。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist