8.3 装备与强化
装备是背包里最特殊的一类物品。材料与消耗品只需要回答「有多少」,装备还要回答「穿在哪」和「强化到几」。8.1 把装备定义成 MaxStack = 1 的不可堆叠物品,正是为了给每个实例留出承载个体属性的空间;这一节就把那两件事做出来——装备位决定它穿在哪,强化等级决定它有多强。
先说清楚边界。这一节只管「装备在背包与装备位之间怎么搬」以及「强化等级怎么涨」,至于强化等级最后换算成多少攻击力,那是第 9 章属性计算的事。本节产出的 Enhance 字段,就是交给第 9 章的输入。
两个绕不开的难点。第一是换装的一致性:穿上新武器时,旧武器必须回到背包,如果背包恰好满了,这次换装应该整个失败,而不是「新武器穿上了、旧武器凭空消失」。第二是强化的账:一次强化要扣材料、掷随机、可能掉级,三件事必须绑成一体——材料不足时不能扣、失败回滚时等级不能变。
装备位与换装
装备位用一个 EquipSlot 枚举表示:武器、头盔、护甲、靴子。每个位置至多一件,所以 bagState 里用一张 map[EquipSlot]*ItemStack 存已穿戴装备,与背包的 slots 分开。这样查询「当前武器是什么」是 O(1),不需要扫描整个背包。
穿装备分两步:先从背包摘除,再放进装备位。但摘之前要处理「目标位已经有东西」的情况——旧装备得先有个去处。我们的顺序是先把旧装备放回背包的空格,放不下就直接返回 ErrNoSpace,此时整笔事务回滚,新装备没穿上、旧装备也没动,玩家看到的就是「背包满了,换不了」。
注意这里不能用「交换」的写法。如果先把新装备塞进装备位、再试图把旧装备往背包里放,一旦背包满了,就会陷入「装备位被占、旧装备悬空」的状态。先给旧装备找好落点、确认成功,再动装备位,才是安全的顺序。这也正是 8.2 事务的价值:即使中途出错,clone 出来的副本被丢弃,已提交状态毫发无损。
脱下装备是逆过程:从装备位取出,放进背包第一个空格;没有空格就返回 ErrNoSpace。换装与脱装都不改变物品数量,只改变它「住在哪」——所以判断成功与否看的是有没有地方放,而不是数量够不够。
强化:成功率、材料与保底等级
强化的规则是逐级配置的。我们用一张 EnhanceTable,下标就是当前等级,每一项 EnhanceRule 描述从这一级往上一级要什么:
Rate:成功率,0~1之间的浮点。CostID/CostCount:消耗哪种材料、消耗几个。SafeLevel:当前等级不超过它时,失败只耗材料、不掉级。
SafeLevel 是保底设计的核心。低强化段(比如 +0 到 +3)失败不惩罚,玩家可以放心试;超过安全线之后,失败会掉一级,把「再点一下」变成有真实代价的决策。这张表完全由策划配置,代码只按当前等级查一行——加等级、调概率都不用改代码。
Enhance 的流程是:校验目标确实是装备、当前等级没到顶、查表拿到规则;然后先在同一事务里扣材料——材料不够就返回错误、整笔回滚,等级不动;材料扣成功后掷随机,命中就 Enhance++,没命中且高于安全线就 Enhance--。
把扣材料放在掷随机之前有个实际好处:材料不足是确定性失败,不消耗随机数,也不会因为「先掷了随机再发现没材料」而产生无意义的随机序列推进。这对可复现性很重要。
确定性随机与整笔回滚
强化要掷随机,但绝不能直接用全局 rand。第 7 章定下的口径在这里继续沿用:随机源由调用方注入,用 math/rand/v2 的 rand.New(rand.NewPCG(seed1, seed2)) 构造,种子取自玩家 ID 与批次序号。这样同一份输入、同一个种子,跑出来的强化结果完全一致——排查「这玩家连爆五把是不是有 bug」时,能在测试里精确复现。
Enhance 的签名因此把 *rand.Rand 作为第一个参数显式传入,而不是在函数里自己造一个。谁调用谁决定随机源,服务端运行时装真随机、测试时装固定种子。这也符合「禁止用全局 rand」的纪律:全局随机源无法复现,也无法隔离不同玩家的随机序列。
回滚方面,Enhance 整个跑在 Bag.Do 里,所以「扣材料 → 掷随机 → 改等级」三步是一体的。测试 TestEnhanceInsufficientMaterialRollsBack 专门验证:材料只有 1 颗、规则要 5 颗时,Enhance 返回 ErrNotEnough,且材料的数量停在 1、装备等级停在 0——一步都没动。
可运行产物:equip.go
下面这段是 L1,go test 可以直接跑。它实现装备位读写、换装、脱装与强化:
// file: internal/inventory/equip.go
// tier: L1
// verify: go test ./internal/inventory/
package inventory
import "math/rand/v2"
// Equipped 返回某个装备位上的装备(值拷贝)。
func (s *bagState) Equipped(slot EquipSlot) (ItemStack, bool) {
st, ok := s.equip[slot]
if !ok {
return ItemStack{}, false
}
return *st, true
}
// Equipped 读某装备位当前装备。
func (b *Bag) Equipped(slot EquipSlot) (ItemStack, bool) {
return b.state.Load().Equipped(slot)
}
// Equip 把背包里的某件装备穿上。目标槽位已有装备时,旧装备换下放回背包。
func (b *Bag) Equip(inst ItemInstID) error {
return b.Do(0, func(tx *Tx) error { return tx.Equip(inst) })
}
// Equip 在事务内执行穿戴。
func (t *Tx) Equip(inst ItemInstID) error {
slot, ok := t.st.index[inst]
if !ok {
return ErrNotInBag
}
st := t.st.slots[slot]
def, ok := t.cat.Get(st.ID)
if !ok {
return ErrUnknown
}
if def.Slot == SlotNone {
return ErrNotEquip
}
// 先把旧装备放回背包;放不下就返回错误,整笔事务回滚。
if old := t.st.equip[def.Slot]; old != nil {
eslot := t.st.firstEmpty()
if eslot < 0 {
return ErrNoSpace
}
t.st.slots[eslot] = old
t.st.index[old.Inst] = eslot
}
t.st.clearSlot(slot) // 从背包摘除
t.st.equip[def.Slot] = st
return nil
}
// Unequip 脱下某个装备位上的装备,放回背包。
func (b *Bag) Unequip(slot EquipSlot) error {
return b.Do(0, func(tx *Tx) error { return tx.Unequip(slot) })
}
// Unequip 在事务内执行脱下。
func (t *Tx) Unequip(slot EquipSlot) error {
st := t.st.equip[slot]
if st == nil {
return ErrNotEquip
}
bslot := t.st.firstEmpty()
if bslot < 0 {
return ErrNoSpace
}
t.st.slots[bslot] = st
t.st.index[st.Inst] = bslot
delete(t.st.equip, slot)
return nil
}
// EnhanceRule 描述一次强化尝试的规则。
type EnhanceRule struct {
Rate float64 // 成功率,0~1
CostID ItemID // 消耗材料
CostCount int // 消耗数量
SafeLevel int // 当前等级 ≤ 该值时,失败只耗材料、不掉级
}
// EnhanceTable 按当前强化等级给出规则:下标即当前等级。
type EnhanceTable []EnhanceRule
// EnhanceResult 是一次强化的结果。
type EnhanceResult struct {
Success bool
From int // 强化前等级
To int // 强化后等级
}
// Enhance 尝试把 inst 强化一级。随机源由调用方注入,保证同一输入可复现。
func (b *Bag) Enhance(rng *rand.Rand, table EnhanceTable, inst ItemInstID) (EnhanceResult, error) {
var res EnhanceResult
err := b.Do(0, func(tx *Tx) error {
slot, ok := tx.st.index[inst]
if !ok {
return ErrNotInBag
}
st := tx.st.slots[slot]
def, ok := tx.cat.Get(st.ID)
if !ok {
return ErrUnknown
}
if def.Slot == SlotNone {
return ErrNotEquip
}
lv := st.Enhance
if lv >= len(table) {
return ErrMaxEnhance
}
rule := table[lv]
// 先在同一事务里扣材料:不够则整笔回滚,等级不会被改动。
if err := tx.Remove(rule.CostID, rule.CostCount); err != nil {
return err
}
res.From = lv
if rng.Float64() < rule.Rate {
st.Enhance = lv + 1
res.Success, res.To = true, lv+1
return nil
}
// 失败:高于安全等级掉一级,否则原地不动。
if lv > rule.SafeLevel {
st.Enhance = lv - 1
}
res.To = st.Enhance
return nil
})
if err != nil {
return EnhanceResult{}, err
}
return res, nil
}
配套测试覆盖四条:TestEquipSwap 连穿两把铁剑,验证第二把进武器位、第一把回到背包,脱下后背包恢复两把;TestEquipRejectsNonEquipment 验证消耗品不能穿;TestEnhanceDeterministic 用固定种子构造「必成」与「必败掉级」两级规则,验证等级与材料消耗都对得上;TestEnhanceInsufficientMaterialRollsBack 验证材料不足时整笔回滚。
// file: internal/inventory/equip_test.go
// tier: L1
// verify: go test ./internal/inventory/
package inventory
import (
"math/rand/v2"
"testing"
)
func TestEquipSwap(t *testing.T) {
b := NewBag(testCatalog(), 4)
_ = b.Do(0, func(tx *Tx) error { return tx.Add(3001, 2) }) // 两把铁剑
insts := b.Snapshot()
if len(insts) != 2 {
t.Fatalf("应有两把铁剑,实得 %d", len(insts))
}
if err := b.Equip(insts[0].Inst); err != nil {
t.Fatalf("equip: %v", err)
}
// 再穿第二把:第一把应被换下回到背包。
if err := b.Equip(insts[1].Inst); err != nil {
t.Fatalf("equip: %v", err)
}
eq, ok := b.Equipped(SlotWeapon)
if !ok || eq.Inst != insts[1].Inst {
t.Fatalf("武器位应是第二把,实得 %d", eq.Inst)
}
// 背包里应只剩被换下的第一把。
if got := b.Count(3001); got != 1 {
t.Fatalf("背包 count=%d,期望 1", got)
}
if err := b.Unequip(SlotWeapon); err != nil {
t.Fatalf("unequip: %v", err)
}
if got := b.Count(3001); got != 2 {
t.Fatalf("脱下后背包 count=%d,期望 2", got)
}
}
func TestEquipRejectsNonEquipment(t *testing.T) {
b := NewBag(testCatalog(), 4)
_ = b.Do(0, func(tx *Tx) error { return tx.Add(1001, 1) })
inst := b.Snapshot()[0].Inst
if err := b.Equip(inst); err != ErrNotEquip {
t.Fatalf("err=%v,期望 ErrNotEquip", err)
}
}
func TestEnhanceDeterministic(t *testing.T) {
cat := NewCatalog(
ItemDef{ID: 3001, Name: "铁剑", Type: TypeEquipment, MaxStack: 1, Slot: SlotWeapon},
ItemDef{ID: 4001, Name: "强化石", Type: TypeMaterial, MaxStack: 999},
)
b := NewBag(cat, 8)
_ = b.Do(0, func(tx *Tx) error {
if err := tx.Add(3001, 1); err != nil {
return err
}
return tx.Add(4001, 100)
})
inst := b.Snapshot()[0].Inst
table := EnhanceTable{
{Rate: 1.0, CostID: 4001, CostCount: 2, SafeLevel: 0}, // +0→+1 必成
{Rate: 0.0, CostID: 4001, CostCount: 3, SafeLevel: 0}, // +1→+2 必败
}
rng := rand.New(rand.NewPCG(7, 11))
// 必成。
res, err := b.Enhance(rng, table, inst)
if err != nil || !res.Success || res.To != 1 {
t.Fatalf("第一次应必成:%+v err=%v", res, err)
}
// 必败且高于安全等级 → 掉回 0。
res, err = b.Enhance(rng, table, inst)
if err != nil || res.Success || res.To != 0 {
t.Fatalf("第二次应必败掉级:%+v err=%v", res, err)
}
// 材料共扣 2+3=5,剩 95。
if got := b.Count(4001); got != 95 {
t.Fatalf("材料 count=%d,期望 95", got)
}
}
func TestEnhanceInsufficientMaterialRollsBack(t *testing.T) {
cat := NewCatalog(
ItemDef{ID: 3001, Name: "铁剑", Type: TypeEquipment, MaxStack: 1, Slot: SlotWeapon},
ItemDef{ID: 4001, Name: "强化石", Type: TypeMaterial, MaxStack: 999},
)
b := NewBag(cat, 8)
_ = b.Do(0, func(tx *Tx) error {
if err := tx.Add(3001, 1); err != nil {
return err
}
return tx.Add(4001, 1) // 只有 1 颗,不够强化
})
inst := b.Snapshot()[0].Inst
table := EnhanceTable{{Rate: 1.0, CostID: 4001, CostCount: 5, SafeLevel: 0}}
rng := rand.New(rand.NewPCG(1, 2))
if _, err := b.Enhance(rng, table, inst); err != ErrNotEnough {
t.Fatalf("err=%v,期望 ErrNotEnough", err)
}
// 事务回滚:材料没被扣,等级没变。
if got := b.Count(4001); got != 1 {
t.Fatalf("材料 count=%d,期望 1(应回滚)", got)
}
if eq := b.Snapshot()[0]; eq.Enhance != 0 {
t.Fatalf("强化等级=%d,期望 0", eq.Enhance)
}
}
本机实跑 go test ./internal/inventory/ 全绿。三节合起来,internal/inventory 包共有 14 条用例,覆盖堆叠、事务、幂等、并发、装备与强化,go test -race ./internal/inventory/ 也无数据竞争。
本节小结
- 装备位用
map[EquipSlot]*ItemStack单独存,查询当前装备是 O(1),与背包槽位分开。 - 换装先给旧装备在背包里找好落点,放不下就整笔回滚——不能先占装备位再找地方放旧的。
- 强化规则逐级配置:成功率、材料消耗、保底等级都在表里,代码只按等级查一行。
Enhance先扣材料再掷随机;材料不足是确定性失败,不消耗随机数。- 随机源由调用方注入(
rand.New(rand.NewPCG(...))),保证同一输入可复现,禁用全局rand。 - 强化等级只是交给第 9 章的输入,换算成属性加成是下一章的事。
阅读导航:上一节:8.2 并发修改与事务 · 下一节:9.1 属性计算与加成来源 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。