前两天看到有人发现了《塞尔达传说 王国之泪》里的材料复制 bug。

在试用后我发现这个 bug 的规律,同时也窥得些许王国之泪的背包管理逻辑。于是用代码复现了出来。

现象

游戏中 bug 的触发步骤:

  1. 假设在背包中所有材料如下: A 材料有1 个,B 材料有 4 个C 材料有 5 个,且排列顺序是 A, B, C
  2. 在滑翔状态打开背包,同时取出 A 材料 1 个,B 材料 4 个并丢弃,关闭背包
  3. 落地后发现背包中剩下的材料有:B 材料 4 个,C 材料 1 个
  4. 捡起刚才丢弃的 A 材料 1 个和 B 材料 4 个,于是此时背包内容:A 材料 1 个,B 材料 8 个C 材料 1 个

通过上述步骤,无中生有了 4 个 B 材料,但是却少了 4 个 C 材料。

通过多次试验,发现某材料增加后,其后面一种材料会减少相应的数量,直到减少到 0 为止

如果增加的材料本身就位于最后一个位置,则没有任何其它材料损失

剖析

这个情况其实是典型的数据一致性问题。

由于我没有开发过类似游戏,所以我仅仅从我熟悉的角度去简单解释这个问题。

从上面的步骤可以看出,所有操作没有在一个事务里(也许它没有事务的概念,又或者为了性能或者游戏表现形式没有采用事务而是手动控制)。并且逻辑操作可以拆分为下面几步:

  1. 记录要取的材料所在背包中的位置
  2. 根据背包位置取材料(这个取其实只是在手上凭空增加材料,还没有涉及到扣减逻辑)
  3. 根据要取的材料顺序扣减材料,如果扣减后有位置的材料数量是 0,则所有后续材料都向前挪一格
  4. 继续根据背包位置扣减材料数量

在简化逻辑以后会发现,这其实是个很明显的bug,不过可能由于游戏中的逻辑判断十分复杂,还和角色滞空状态,游戏前端表现步骤等等相关联,导致没能发现。

重现

先定义一个简单的材料实体

    /**
     * 材料物品实体
     * @param id 编号
     * @param name 名称
     * @param attackPoint 余料建造攻击力
     * @param healPoint 回复点数
     * @param description 说明
     */
    public record Material(int id, String name, int attackPoint, int healPoint, String description) {}

然后材料图鉴和初始化也搞一搞

    // 材料图鉴
    public static final HashMap<Integer, Material> materialHandBook;
    static {
        materialHandBook = new HashMap<>();
        materialHandBook.put(1, new Material(1, "金苹果", 1, 6,""));
        materialHandBook.put(2, new Material(2, "椰子", 1, 4,""));
        materialHandBook.put(3, new Material(3, "海拉鲁番茄", 1, 4,""));
        materialHandBook.put(4, new Material(4, "钻石", 25, 0,""));
        materialHandBook.put(5, new Material(5, "古代之刃", 50, 0,""));
        materialHandBook.put(6, new Material(6, "巨大光亮花的种子", 1, 0,""));
        materialHandBook.put(7, new Material(7, "木柴捆", 1, 0,""));
        materialHandBook.put(8, new Material(8, "左纳乌能源", 2, 0,""));
    }

重点来了,背包类,为了简化逻辑,假设林克都是单手掏背包(单线程)

/**
 * 背包类
 */
public static class Bag {

  // 背包容量单位(格子)
  @Data
  @AllArgsConstructor
  private static class Slot {
    private int materialId;
    private int count;
  }

  // 背包容量
  private static final Slot[] slots = new Slot[128];

  // 放东西入背包
  public void put(int materialId) {
    int i = 0;
    // 有则直接数量加1
    for (; i < slots.length && slots[i] != null; i++) {
      if (slots[i].materialId == materialId) {
        slots[i].count++;
        return;
      }
    }
    // 没有则放到最后一位,数量初始化为1
    slots[i] = new Slot(materialId, 1);
  }

  private int getIndexFromSlot(int materialId) {
    for (int i = 0; i < slots.length; i++) {
      if (slots[i].materialId == materialId) {
        return i;
      }
    }
    return -1;
  }

  // 取物品出背包 (1 <= indexes.length <= 5,由外部保证)
  public List<Material> multiGet(int[] materialIds) {
    // 转化为 index - count 键值对
    Map<Integer, Integer> indexCountMap = new HashMap<>();
    for (int materialId : materialIds) {
      indexCountMap.compute(getIndexFromSlot(materialId), (k, v) -> (v == null) ? 1 : v + 1);
    }

    // 1. 物品取出
    List<Material> materials = new ArrayList<>();
    for (Map.Entry<Integer, Integer> entry : indexCountMap.entrySet()) {
      int idx = entry.getKey();
      int count = entry.getValue();
      if (slots[idx] != null) {
        for (int i = 0; i < count; i++) {
          materials.add(materialHandBook.get(slots[idx].materialId));
        }
      }
    }
    // 2. 物品扣减
    for (Map.Entry<Integer, Integer> entry : indexCountMap.entrySet()) {
      int idx = entry.getKey();
      int count = entry.getValue();
      if (slots[idx] != null) {
        slots[idx].count -= count;
        int i = idx;
        if (slots[i] != null && slots[i].count <= 0) {
          while (i + 1 <= slots.length) {
            if (i + 1 < slots.length) {
              slots[i] = slots[i + 1];
            } else {
              slots[i] = null;
            }
            i++;
          }
        }
      }
    }
    return materials;
  }


  // 显示背包内容
  public void show() {
    System.out.println("==========背包==========");
    for (Slot slot : slots) {
      if (slot != null) {
        Material material = materialHandBook.get(slot.materialId);
        System.out.println(material.name + ": " + slot.count);
      }
    }
    System.out.println("=======================");
  }
}

写个 main 测试一下:

public static void main(String[] args) {
  Bag bag = new Bag();
  bag.put(1);
  bag.put(1);
  bag.put(7);
  bag.put(8);
  bag.put(8);
  bag.put(8);
  bag.put(8);
  System.out.println("初始背包");
  bag.show();
  List<Material> materials = bag.multiGet(new int[]{7,8,8,8,8});
  for (Material material : materials) {
    System.out.println("取出的物品:" + material.name);
  }
  bag.show();
  for (Material material : materials) {
    System.out.println("捡起物品:" + material.name);
    bag.put(material.id);
  }
  System.out.println("复制材料后的背包");
  bag.show();
}

结果:

初始背包
==========背包==========
金苹果: 2
木柴捆: 1
左纳乌能源: 4
=======================
取出的物品:木柴捆
取出的物品:左纳乌能源
取出的物品:左纳乌能源
取出的物品:左纳乌能源
取出的物品:左纳乌能源
==========背包==========
金苹果: 2
左纳乌能源: 4
=======================
捡起物品:木柴捆
捡起物品:左纳乌能源
捡起物品:左纳乌能源
捡起物品:左纳乌能源
捡起物品:左纳乌能源
复制材料后的背包
==========背包==========
金苹果: 2
左纳乌能源: 8
木柴捆: 1
=======================

总结

这个 bug 的问题在于把背包容量改了,却没有更新要扣减的位置信息。

有点像 Java 在循环里删除数组元素,不同的是 Java 会报错,而游戏里显然是做了一定的健壮性处理,不过这个健壮性处理又会令问题在出现时难以被发现。

说明一下,我用 Java 重现出来的 bug 很好修,但游戏里的就不一定了,复杂程度可能高出好几个量级,不要想当然用简单逻辑去替代复杂问题(这种行为往往是懂一点但又不多的人容易犯的错误)。

最后,合理用 bug 可以护肝,过度用 bug 加速游戏生命周期,好游戏不多,且玩且珍惜。