《MMO 服务器开发》8.3 装备与强化

装备是背包里唯一同时带「位置」和「个体属性」的东西。本节实现装备位与换装(旧装备换下回背包),给出按等级配置成功率、材料消耗与保底等级的强化表;强化在同一事务里扣材料、失败掉级、材料不足整笔回滚,随机源由调用方注入以保证可复现,产出的强化等级作为第 9 章属性计算的输入。

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 属性计算与加成来源 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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