《MMO 服务器开发》8.1 物品与堆叠模型

背包最容易被低估的是「一个物品到底是什么」。本节把配置表物品 ID 与背包内实例 ID 拆开,用一套结构同时表达可堆叠与不可堆叠物品,并把堆叠合并、容量预检与「失败即整体不动」的原子性写成一份能跑过的 Go 实现,为 8.2 的并发事务打底。

8.1 物品与堆叠模型

背包是每个 MMO 都要写、又几乎人人写过一遍的东西。真正难的不是「把物品塞进格子」,而是把「物品」这个概念本身切清楚:同一件「小血瓶」在配置表里只有一条记录,但在背包里可能同时存在十几个互不相同的堆叠,各自带着数量、绑定状态、强化等级。分不清这两层,后面每一次发奖、扣道具、交易都会踩坑。

这一节先解决建模问题——把配置表 ID 与物品实例 ID 分开,把可堆叠与不可堆叠物品统一到一套结构里,再给出堆叠合并、容量预检与原子性的实现。它是第 8 章的地基:8.2 的并发事务、8.3 的装备强化,全部建立在这份模型之上。

第 7 章的掉落表产出的是「要发什么」,本节处理的是「发到哪、怎么叠」。掉落与背包的分界线,正是 ItemID 与 ItemInstID 的分界线。

与站内既有文章的分工

本书不是讨论背包架构的唯一来源。站内已经有一批讨论背包与道具「架构方案与取舍」的文章,本节与它们的分工如下:

既有文章主题分工
/inventory-service-handle-concurrent-modifications/背包并发修改既有文章负责方案选型与取舍
/game-server-item-expiration-and-temporary-assets/道具过期与临时资产既有文章负责过期策略与资产分类

一句话概括:既有文章回答「有哪几种做法、各自代价是什么」,本书回答「选定的这条怎么做出来、能不能真跑、端到端是否对得上」。下面不再重复方案对比,直接进入实现。

两套 ID:配置表与实例

新手最常见的第一处错误,是用一个 ItemID 同时表示「这是什么」和「这一份」。策划表里「小血瓶」的 ID 是 1001,全服所有玩家的小血瓶都指向同一条定义;但玩家 A 背包里的第 3 格和第 7 格可能各是一个小血瓶堆叠,它们的数量不同、绑定状态不同。这两层必须拆开:

  • ItemID uint32:配置表物品 ID。它回答「这是什么」,由策划表唯一确定,进程内只读、可被所有角色共享。
  • ItemInstID uint64:背包内物品实例 ID。它回答「这一份是哪一份」,在放入背包时分配、全服单调递增。

只有配置表 ID 而没有实例 ID,就没法给「这一份」挂任何个体属性;强化等级、绑定、过期时间这些恰恰都是个体属性。反过来,只有实例 ID 而没有配置表 ID,每次要用物品都得回表查它是什么,既慢又容易在配置热更时对不上。所以两者的关系是:配置表 ID 描述共性,实例 ID 承载个性。

ItemDef 里还有一个决定堆叠行为的关键字段 MaxStack:它规定一格最多放多少个。消耗品与材料给一个大于 1 的上限,装备固定为 1——因为装备是不可堆叠的,每一件都必须独立成格,否则强化等级、附魔词条就没有地方挂。Stackable() 直接由 MaxStack > 1 推出,不需要单独再存一个布尔。

堆叠、容量与原子性

加入物品要回答两个问题:往哪叠、放不下怎么办。

先看「往哪叠」。可堆叠物品的策略是:先把同种、未绑定、未满的已有堆叠从低槽位到高槽位依次补满,还有剩余再开新格。这条规则有两个后果——同样的输入永远得到同样的槽位分布(可预期、可复现),而且会尽量把整格腾出来而不是把新物品塞进零散空格。不可堆叠的装备则跳过合并,每件直接占一格。

再看「放不下怎么办」。这里藏着一个容易忽略的正确性问题:如果一边遍历一边放,放到第 3 格才发现不够,前面两格已经改动了,函数返回错误时背包却处于半成品状态。所以 add 先做容量预检——先算已有堆叠还能补多少、剩余的要开几格、空格够不够——预检通过才真正落盘。这样即使调用方没包事务,add 本身也是「要么全进、要么全不动」。

remove 同理:数量不足时先返回 ErrNotEnough、一个字节都不改。这种「先校验后执行」的习惯看起来啰嗦,但它是后面事务能成立的前提——一个会留下中间态的基础函数,任何外层事务都救不回来。

可运行产物:item.go

下面这段是 L1,go test 可以直接跑。它包含完整的物品模型、配置表、背包状态与堆叠算法:

// file: internal/inventory/item.go
// tier: L1
// verify: go test ./internal/inventory/
// Package inventory 实现 aegis 单服《晨曦大陆》的背包与道具系统:
// 物品堆叠模型、并发事务化修改、装备与强化。
package inventory

import "errors"

// ItemID 是配置表物品 ID(不是实例 ID);同一件「小血瓶」全服共用一个 ItemID。
type ItemID uint32

// ItemInstID 是背包内物品实例 ID。同种物品的每个堆叠各有独立实例号,
// 强化等级、绑定状态这类「个体差异」都挂在实例上。
type ItemInstID uint64

// ItemType 物品大类。
type ItemType uint8

const (
	TypeMaterial   ItemType = iota // 材料:可堆叠,不直接使用
	TypeConsumable                 // 消耗品:可堆叠,使用后减少数量
	TypeEquipment                  // 装备:不可堆叠,每件占一格
)

// EquipSlot 装备位。
type EquipSlot uint8

const (
	SlotNone   EquipSlot = iota
	SlotWeapon           // 武器
	SlotHelmet           // 头盔
	SlotArmor            // 护甲
	SlotBoots            // 靴子
)

// ItemDef 是配置表里一条物品定义,进程内只读、被所有角色共享。
type ItemDef struct {
	ID       ItemID
	Name     string
	Type     ItemType
	MaxStack int       // 每格最大堆叠数;装备固定为 1
	Slot     EquipSlot // 可穿戴槽位;非装备为 SlotNone
}

// Stackable 表示该物品能否堆叠。
func (d ItemDef) Stackable() bool { return d.MaxStack > 1 }

// ItemStack 是背包里的一格:一个物品实例。
type ItemStack struct {
	Inst    ItemInstID // 实例 ID
	ID      ItemID     // 配置表 ID
	Count   int        // 本格数量
	Bound   bool       // 绑定后不可交易
	Enhance int        // 强化等级(0 表示未强化)
}

// Catalog 是只读的物品配置表。
type Catalog struct{ defs map[ItemID]ItemDef }

// NewCatalog 用给定定义构造配置表。
func NewCatalog(defs ...ItemDef) *Catalog {
	m := make(map[ItemID]ItemDef, len(defs))
	for _, d := range defs {
		m[d.ID] = d
	}
	return &Catalog{defs: m}
}

// Get 取一条定义。
func (c *Catalog) Get(id ItemID) (ItemDef, bool) { d, ok := c.defs[id]; return d, ok }

// 背包与道具相关的错误。调用方据错误值分流,不靠字符串匹配。
var (
	ErrUnknown    = errors.New("inventory: unknown item id")
	ErrNoSpace    = errors.New("inventory: bag has no space")
	ErrNotEnough  = errors.New("inventory: not enough items")
	ErrNotEquip   = errors.New("inventory: item is not equipment")
	ErrNotInBag   = errors.New("inventory: instance not in bag")
	ErrMaxEnhance = errors.New("inventory: already at max enhance level")
)

// bagState 是背包的全部可变状态。事务通过整体替换它获得原子性:
// 改动发生在副本上,提交时一次性换指针,失败则丢弃副本。
type bagState struct {
	slots    []*ItemStack             // 定长背包槽位,nil 为空格
	equip    map[EquipSlot]*ItemStack // 已穿戴装备
	index    map[ItemInstID]int       // 背包内实例 ID → 槽位下标
	nextInst ItemInstID               // 下一个可分配的实例号
}

func newBagState(slots int) *bagState {
	return &bagState{
		slots: make([]*ItemStack, slots),
		equip: make(map[EquipSlot]*ItemStack),
		index: make(map[ItemInstID]int),
	}
}

// add 把 count 个 id 放进背包:可堆叠的先补已有堆叠,再开新格;
// 不可堆叠的每件占一格。先做容量预检,放不下就整体不动并返回 ErrNoSpace。
func (s *bagState) add(cat *Catalog, id ItemID, count int) error {
	def, ok := cat.Get(id)
	if !ok {
		return ErrUnknown
	}
	if count <= 0 {
		return nil
	}
	// 预检:先算已有堆叠能补多少,再算要开几格。
	room := 0
	if def.Stackable() {
		for _, st := range s.slots {
			if st != nil && st.ID == id && !st.Bound && st.Count < def.MaxStack {
				room += def.MaxStack - st.Count
			}
		}
	}
	if need := count - room; need > 0 {
		per := 1
		if def.Stackable() {
			per = def.MaxStack
		}
		if needSlots := (need + per - 1) / per; needSlots > s.emptyCount() {
			return ErrNoSpace
		}
	}
	// 预检通过才落盘:先补已有堆叠,从低槽位开始,保证结果可预期。
	if def.Stackable() {
		for _, st := range s.slots {
			if count == 0 {
				break
			}
			if st != nil && st.ID == id && !st.Bound && st.Count < def.MaxStack {
				n := min(def.MaxStack-st.Count, count)
				st.Count += n
				count -= n
			}
		}
	}
	for count > 0 {
		slot := s.firstEmpty()
		n := 1
		if def.Stackable() {
			n = min(def.MaxStack, count)
		}
		s.put(slot, def, n)
		count -= n
	}
	return nil
}

// remove 按配置表 ID 扣减 count 个。数量不足时先返回错误、不做任何改动。
func (s *bagState) remove(cat *Catalog, id ItemID, count int) error {
	if count <= 0 {
		return nil
	}
	if s.Count(id) < count {
		return ErrNotEnough
	}
	for i, st := range s.slots {
		if count == 0 {
			break
		}
		if st == nil || st.ID != id {
			continue
		}
		if st.Count <= count {
			count -= st.Count
			s.clearSlot(i)
		} else {
			st.Count -= count
			count = 0
		}
	}
	return nil
}

// Count 返回背包内某配置表物品的总数量(不含已穿戴装备)。
func (s *bagState) Count(id ItemID) int {
	n := 0
	for _, st := range s.slots {
		if st != nil && st.ID == id {
			n += st.Count
		}
	}
	return n
}

// Snapshot 返回背包槽位的值拷贝,供只读展示使用。
func (s *bagState) Snapshot() []ItemStack {
	out := make([]ItemStack, 0, len(s.slots))
	for _, st := range s.slots {
		if st != nil {
			out = append(out, *st)
		}
	}
	return out
}

func (s *bagState) clearSlot(i int) {
	if st := s.slots[i]; st != nil {
		delete(s.index, st.Inst)
		s.slots[i] = nil
	}
}

func (s *bagState) firstEmpty() int {
	for i, st := range s.slots {
		if st == nil {
			return i
		}
	}
	return -1
}

func (s *bagState) emptyCount() int {
	n := 0
	for _, st := range s.slots {
		if st == nil {
			n++
		}
	}
	return n
}

func (s *bagState) put(slot int, def ItemDef, count int) {
	s.nextInst++
	s.slots[slot] = &ItemStack{Inst: s.nextInst, ID: def.ID, Count: count}
	s.index[s.nextInst] = slot
}

配套的 internal/inventory/item_test.go 覆盖了六条用例:TestStackMerge 验证 120 个小血瓶被切成 99 + 21 两格且实例号不同;TestAddMergeExisting 验证未满时并堆而不占新格;TestEquipmentNoStack 验证装备每件一格;TestNoSpaceIsAtomic 验证 2 格背包塞 250 个时返回 ErrNoSpace 且一物未进;TestRemove 验证扣减与数量不足;TestUnknownItem 验证未知 ID 报 ErrUnknown。本机实跑 go test ./internal/inventory/ 全绿。

本节小结

  • 配置表 ID(ItemID)描述「这是什么」,实例 ID(ItemInstID)承载「这一份的个体属性」,两者不可混用。
  • MaxStack 一个字段同时决定能否堆叠与每格上限;Stackable() 由它推出,不另存布尔。
  • 加物品先做容量预检,预检通过才落盘,让 add 本身满足「要么全进、要么全不动」。
  • 本节是第 8 章地基:8.2 在这份状态上加并发事务,8.3 在它上面实现装备与强化。

阅读导航:上一节:目录 · 下一节:8.2 并发修改与事务 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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