一、物品定义与物品实例必须分开
背包系统最容易埋下的第一颗雷,是把“物品”当成一个字符串或一个数字。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 或等于原堆,产生空堆或无意义事件。
- 合并满堆时静默丢弃源物品。
- 不同到期时间的物品混堆,到期时无法整堆处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。