《游戏服务端编程实践》9.1.1 物品与堆叠模型

从物品定义与实例分离讲起,说明最大堆叠、不可堆叠、绑定隔离三条规则,对比槽位背包与无序背包在索引稳定性和空槽管理上的取舍,并用 Go 实现事务化 Add、稳定槽位、拆分合并与跨堆填充测试,最后给出背包协议与失败场景的边界建议,帮助读者建立可测试的物品模型。

一、物品定义与物品实例必须分开

背包系统最容易埋下的第一颗雷,是把“物品”当成一个字符串或一个数字。item_id 回答的是“这是什么”,而玩家背包里存的应该是“我有哪一件”。这两件事在只有一种药水时看起来一样,一旦出现强化、绑定、过期、来源追溯,就会立刻分叉。

定义(ItemDef)是策划配置:名称、最大堆叠、是否拾取绑定、有效期、能否交易。实例(Item)是玩家数据:instance_id、item_id、count、bind、expire_at。定义可以热更,实例必须落库;定义可以删除,实例不能因此消失。把两者混成一个结构,热更时会改到玩家资产,回滚时又会把新增字段丢掉。

instance_id 的唯一性范围要说清楚。单服自增、全局发号器、还是“角色 ID + 序号”都可以,但必须保证同一角色内不重复,并且跨服迁移后不碰撞。用 item_id 当唯一键是常见错误:两瓶药水会互相覆盖,装备强化后无法区分是哪一件。

count 的语义也要固定。实例上的 count 是“这一堆的数量”,不是“账号总持有量”。总数由所有堆求和得到,任何缓存的总数都必须能由堆重算,否则拆分、合并、邮件补发之后一定会对不上。

bind 不是布尔装饰,它决定物品能否交易、能否邮寄、能否上架。绑定状态应该在获得时确定,或在穿戴/使用时确定,不能由客户端上报。expire_at 用服务器时间,统一到秒或毫秒,并且要明确“到期后是删除、转为绑定、还是发邮件返还”。

定义和实例分离之后,测试就有了清晰边界:堆叠规则测定义与实例的组合,持久化测实例的往返,装备测实例如何从背包移动到装备槽。

二、堆叠的三条规则

堆叠不是“数量相加”这么简单。至少要定义三条规则:最大堆叠、不可堆叠、绑定隔离。

规则配置来源行为常见错误
最大堆叠ItemDef.MaxStack超过上限自动开新堆允许单堆超过上限
不可堆叠MaxStack = 1每件占一个槽位把装备当材料合并
绑定隔离实例 Bound绑定与未绑定不混堆合并后绑定状态丢失

最大堆叠要按“堆”而不是“次”判断。一次加 30 个、上限 20,正确结果是 20 + 10,而不是报错,也不是 30 放一堆。只有总空间不足时才失败,并且失败必须是事务性的:要么全加进去,要么一件都不加,不能留下“加了一半”的中间态。

不可堆叠物品的 MaxStack 设为 1,所有逻辑仍然走同一套加物品流程,不需要为装备写特殊分支。特殊分支越多,越容易在某条路径上漏掉绑定、过期或日志。

绑定隔离的键至少包含 item_id 与 bound。更严格的做法还要包含 expire_at:两批到期时间不同的食物不应该合并,否则到期时无法判断整堆何时消失。把“可堆叠键”抽成一个函数,所有 Add、Merge、Split 都调用它,规则才不会漂移。

堆叠还要考虑“堆的顺序”。新物品优先填旧堆还是优先占新槽?通常优先填未满的旧堆,减少槽位碎片;但绑定、过期不同则不能填。顺序本身不是正确性问题,却会影响客户端显示和玩家预期,应该写进协议说明。

三、槽位背包与无序背包

背包形态标识方式优点代价
槽位背包固定 slot 索引客户端布局稳定、拖拽直观需要处理空槽与扩容
无序背包instance_id 列表服务端实现简单客户端排序、分页复杂
混合槽位 + 实例 ID兼顾稳定与查询两套索引必须同步

槽位索引的稳定性是槽位背包的核心承诺。删除第 3 格物品后,第 4 格仍然是第 4 格,不能整体前移。整体前移会让客户端缓存的槽位引用全部错位,玩家拖拽、批量使用、快捷栏绑定都会指向错误物品。

槽位背包的代价是空槽管理。扩容、整理、堆叠合并都会产生空槽;服务端要能回答“第一个空槽是哪一格”“总空格数是多少”。这些查询应该由背包自己维护,而不是让调用方遍历。

无序背包适合材料仓库、邮件附件这类不要求固定布局的场景。它的 instance_id 是唯一键,添加删除都是 O(1),但客户端要自己排序。若产品同时要“固定格子”和“海量材料”,可以做成两层:前台槽位背包 + 后台仓库,仓库用无序结构。

无论哪种形态,协议里都不要直接暴露内存指针或数组下标以外的实现细节。槽位号是稳定标识,instance_id 是实例标识,两者用途不同:拖拽用槽位,交易与日志用实例。

四、拆分与合并的数量守恒

拆分是把一个堆变成两个堆,合并是把两个堆变成一个堆,二者都必须满足数量守恒:操作前后所有堆的 count 之和不变,instance_id 的归属要可解释。

拆分至少有三个约束:拆分数量必须大于 0、必须小于原堆数量、目标槽位必须为空。允许“拆出全部”会让原堆变成空堆,等于一次移动,应该由移动接口处理;允许拆出 0 会产生无意义事件。拆分产生的新堆应该分配新的 instance_id,因为它是新的实例,后续可能被单独交易或强化。

合并至少有两个约束:物品可堆叠键一致、目标堆未满。目标堆已满时是报错还是自动找下一堆,取决于产品规则;服务端实现应该显式返回结果,而不是静默丢弃。部分合并是正常情况:源堆剩下一部分,留在原槽位。

合并的 instance_id 归属要提前约定。通常保留目标堆的 instance_id,源堆被吸收;如果源堆有绑定或来源信息,不能简单丢弃,应该先校验可堆叠键一致,必要时记录日志。

数量守恒的测试不能只测“加进去”,要测“拆完再合”的往返:总数不变、槽位数不增、空槽被正确释放。再补一个边界:拆分后源堆剩 1、合并后源堆清空。很多计数错误只在“剩 1”时暴露。

五、失败场景:三次线上事故

第一个事故是“用 item_id 当唯一键”。两件同名装备强化到不同等级,服务端只存了一条记录,玩家切换装备时属性在两条强化等级之间跳变。根因是实例身份缺失,修复不是加判断,而是补 instance_id 并迁移旧数据。

第二个事故是“超堆叠”。运营补发了 999 个材料,单堆上限 99,服务端直接写成 999。客户端显示一格的 999,服务端按 99 计算可用数量,玩家使用后数量对不上。根因是 Add 没有按上限切堆,也没有在恢复时校验。

第三个事故是“拆分后总数不对”。拆分实现先复制整堆,再修改两边数量,但复制时把 count 写成了拆分数量,源堆没有减。玩家反复拆分可以复制物品。根因是缺少“操作前后总数相等”的断言和测试。

这三个事故的共同点是:它们都不是配置问题,而是模型边界不清。定义与实例、堆与槽位、源与目标,只要有一组边界没有显式化,就会出现“看起来能跑”的实现。

失败场景要写进测试,而不是只写在文档里。至少覆盖:空间不足时背包不变、绑定与未绑定不合并、拆分数量非法时不变、合并满堆时源堆不变。

六、Go 实现:事务化 Add 与稳定槽位

下面的 Bag 用固定槽位数组保存实例,Add 先计算总空间,空间不足直接返回 ErrNoSpace,不会留下半成品。fillable 是堆叠规则的唯一入口,绑定状态和到期时间都参与判断。

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

package bag

import (
	"errors"
	"fmt"
	"time"
)

// ItemDef 是物品定义:所有同类物品共享,不随实例变化。
// MaxStack 为 1 表示不可堆叠;Expire 为 0 表示永久。
type ItemDef struct {
	ID       string
	Name     string
	MaxStack int
	Bind     bool
	Expire   time.Duration
}

// Item 是物品实例:instanceID 唯一,itemID 指向定义。
type Item struct {
	InstanceID uint64
	ItemID     string
	Count      int
	Bound      bool
	ExpireAt   time.Time
}

var (
	ErrUnknownItem  = errors.New("bag: unknown item")
	ErrBadCount     = errors.New("bag: bad item count")
	ErrNoSpace      = errors.New("bag: not enough space")
	ErrBadSlot      = errors.New("bag: slot out of range")
	ErrEmptySlot    = errors.New("bag: slot is empty")
	ErrBadCapacity  = errors.New("bag: capacity must be positive")
	ErrNotMergeable = errors.New("bag: items cannot merge")
)

// Bag 是有序槽位背包。槽位索引稳定:删除物品不会移动其他槽位,
// 这样客户端可以用槽位号引用物品而不会错位。
type Bag struct {
	defs   map[string]ItemDef
	slots  []*Item
	nextID uint64
}

func NewBag(defs []ItemDef, capacity int) (*Bag, error) {
	if capacity <= 0 {
		return nil, ErrBadCapacity
	}
	byID := make(map[string]ItemDef, len(defs))
	for _, d := range defs {
		if d.MaxStack <= 0 {
			d.MaxStack = 1
		}
		byID[d.ID] = d
	}
	return &Bag{defs: byID, slots: make([]*Item, capacity)}, nil
}

func (b *Bag) Capacity() int { return len(b.slots) }

func (b *Bag) At(slot int) (Item, bool) {
	if slot < 0 || slot >= len(b.slots) || b.slots[slot] == nil {
		return Item{}, false
	}
	return *b.slots[slot], true
}

func (b *Bag) allocID() uint64 {
	b.nextID++
	return b.nextID
}

// fillable 判断已有堆能否继续容纳:物品相同、绑定状态相同、
// 到期时间相同且未达上限。绑定物品不与未绑定物品混堆。
func fillable(it *Item, itemID string, bound bool, expire time.Time, max int) bool {
	return it != nil && it.ItemID == itemID && it.Bound == bound &&
		it.Count < max && it.ExpireAt.Equal(expire)
}

// Add 以事务方式加入物品:放不下时返回 ErrNoSpace 且背包不变。
// 先填已有堆,再占用空槽,返回实际使用的槽位。
func (b *Bag) Add(itemID string, count int, bound bool, now time.Time) ([]int, error) {
	if count <= 0 {
		return nil, ErrBadCount
	}
	def, ok := b.defs[itemID]
	if !ok {
		return nil, fmt.Errorf("%w: %s", ErrUnknownItem, itemID)
	}
	bound = bound || def.Bind
	var expire time.Time
	if def.Expire > 0 {
		expire = now.Add(def.Expire)
	}

	free := 0
	for _, it := range b.slots {
		switch {
		case it == nil:
			free += def.MaxStack
		case fillable(it, itemID, bound, expire, def.MaxStack):
			free += def.MaxStack - it.Count
		}
	}
	if free < count {
		return nil, fmt.Errorf("%w: need %d, free %d", ErrNoSpace, count, free)
	}

	remaining := count
	var used []int
	for i, it := range b.slots {
		if remaining == 0 {
			break
		}
		if !fillable(it, itemID, bound, expire, def.MaxStack) {
			continue
		}
		n := min(def.MaxStack-it.Count, remaining)
		it.Count += n
		remaining -= n
		used = append(used, i)
	}
	for i, it := range b.slots {
		if remaining == 0 {
			break
		}
		if it != nil {
			continue
		}
		n := min(def.MaxStack, remaining)
		b.slots[i] = &Item{
			InstanceID: b.allocID(),
			ItemID:     itemID,
			Count:      n,
			Bound:      bound,
			ExpireAt:   expire,
		}
		remaining -= n
		used = append(used, i)
	}
	return used, nil
}

// Remove 从指定槽位取走 count 个,返回被取走的实例快照。
// 取空后槽位置空,其他槽位索引保持不变。
func (b *Bag) Remove(slot, count int) (Item, error) {
	if slot < 0 || slot >= len(b.slots) {
		return Item{}, ErrBadSlot
	}
	it := b.slots[slot]
	if it == nil {
		return Item{}, ErrEmptySlot
	}
	if count <= 0 || count > it.Count {
		return Item{}, ErrBadCount
	}
	removed := *it
	removed.Count = count
	it.Count -= count
	if it.Count == 0 {
		b.slots[slot] = nil
	}
	return removed, nil
}

// Split 从槽位拆出 count 个到第一个空槽,返回新槽位号。
// 拆分数必须小于原堆数量,避免产生空堆。
func (b *Bag) Split(slot, count int) (int, error) {
	if slot < 0 || slot >= len(b.slots) {
		return -1, ErrBadSlot
	}
	it := b.slots[slot]
	if it == nil {
		return -1, ErrEmptySlot
	}
	if count <= 0 || count >= it.Count {
		return -1, ErrBadCount
	}
	target := b.firstEmpty()
	if target < 0 {
		return -1, ErrNoSpace
	}
	moved := *it
	moved.InstanceID = b.allocID()
	moved.Count = count
	it.Count -= count
	b.slots[target] = &moved
	return target, nil
}

// Merge 把 from 槽合并进 to 槽,允许部分合并;from 为空后置空。
// 只有 itemID、绑定状态、到期时间都一致才允许合并。
func (b *Bag) Merge(from, to int) error {
	if from == to || from < 0 || to < 0 || from >= len(b.slots) || to >= len(b.slots) {
		return ErrBadSlot
	}
	src, dst := b.slots[from], b.slots[to]
	if src == nil || dst == nil {
		return ErrEmptySlot
	}
	def, ok := b.defs[src.ItemID]
	if !ok || src.ItemID != dst.ItemID || src.Bound != dst.Bound ||
		!src.ExpireAt.Equal(dst.ExpireAt) {
		return ErrNotMergeable
	}
	room := def.MaxStack - dst.Count
	if room <= 0 {
		return ErrNotMergeable
	}
	n := min(room, src.Count)
	dst.Count += n
	src.Count -= n
	if src.Count == 0 {
		b.slots[from] = nil
	}
	return nil
}

func (b *Bag) firstEmpty() int {
	for i, it := range b.slots {
		if it == nil {
			return i
		}
	}
	return -1
}

实现里有几个取舍。槽位用 []*Item,空槽是 nil,删除只置空不移动,保证索引稳定。allocID 只在创建新实例时调用,合并保留目标实例的 ID,拆分分配新 ID。Add 分两轮:先填已有堆,再占空槽;两轮都受 MaxStack 约束,因此不会出现超堆叠。

Remove 返回被取走部分的副本,调用方可以据此写日志或发邮件,而不需要再读一次背包。Split 要求拆分数严格小于原堆,避免制造空堆。Merge 允许部分合并,源堆清空后置空槽位;物品、绑定、到期任一不一致都返回 ErrNotMergeable。

测试覆盖跨堆填充(15 + 12 变成 20 + 7)、绑定隔离、溢出原子性、拆分合并守恒、删除后槽位稳定、不同到期时间不混堆。每个测试都直接检查槽位内容,而不是只检查总数,因为总数正确但槽位错位同样是 bug。

七、接入协议与后续章节

背包协议建议只暴露三个动作:按槽位使用、按槽位移除、查询背包快照。不要在协议里让客户端传 instance_id 去猜槽位,服务端应该用 instance_id 反查槽位,或者要求客户端同时带槽位与实例,服务端做双重校验。

每次变更产生事件:槽位、实例、物品、数量变化、原因(拾取、使用、交易、邮件)。事件是审计和回滚的基础,也是客户端增量更新的数据源。没有事件,只能整包下发,流量和一致性都更难控制。

扩容、持久化和数据修复放在下一篇。装备与强化涉及另一套实例状态,下一篇会把装备槽位、属性汇总和可审计随机数接上。

小结

物品定义与实例分离,instance_id 是唯一身份,count 是堆内数量。堆叠由最大堆叠、不可堆叠、绑定隔离三条规则决定,Add 必须事务化。槽位背包的索引稳定,拆分合并要满足数量守恒。把规则收拢到 fillable 与 Add,测试才能覆盖跨堆、绑定、溢出这些真实边界。

常见错误与边界

  • 用 item_id 当唯一键,导致同名物品互相覆盖。
  • 单堆数量超过 MaxStack,客户端与服务端各算一套。
  • 绑定与未绑定物品合并,合并后绑定状态丢失。
  • Add 空间不足时加了一半,背包进入中间态。
  • 删除后整体前移槽位,客户端引用全部错位。
  • 拆分数量为 0 或等于原堆,产生空堆或无意义事件。
  • 合并满堆时静默丢弃源物品。
  • 不同到期时间的物品混堆,到期时无法整堆处理。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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