一、装备槽位与穿戴条件
装备不是背包里的普通物品,它有一个明确的“位置”。武器、护甲、饰品是最常见的三类槽位,实际项目还会拆出头、胸、腿、戒指、项链。槽位划分不是越细越好:槽位越多,掉落表、养成线和 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 出现在两处。
- 绑定标记与穿戴不在同一事务,失败后物品已被绑定。
- 强化日志缺少随机值和成功率,无法重放。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。