Files
WoWee/tests/test_inventory_sort.cpp
Kelsi 8b2f003bc6 feat(inventory): sorting merges partial stacks before it orders them
Sorting arranged what was there without consolidating it, so two half stacks
of linen stayed two half stacks — just adjacent. Pour them together first,
then sort, so the result is one stack of twenty rather than two of ten.

On the wire a merge is a swap: dropping a stack onto another of the same item
makes the server move what fits and leave the rest behind. So the merges go
through the same queue as the sort, ahead of it, and the arithmetic mirrors
what the server will do — pour into the earliest partial, stop when the
destination is full, drop the source slot when it empties.

Merging plans and applies in one pass rather than splitting into a compute
and an apply the way the sort does. Those two have to be kept in agreement by
hand, and there is no reason to add a third pair.

Skips what the sort skips: full stacks, items that do not stack, and special
containers. The bank's Sort All merges too.
2026-07-31 19:03:07 -07:00

283 lines
10 KiB
C++

#include "catch_amalgamated.hpp"
#include "game/inventory.hpp"
using namespace wowee::game;
namespace {
ItemDef makeItem(uint32_t id, ItemQuality quality, uint32_t stack = 1) {
ItemDef def;
def.itemId = id;
def.quality = quality;
def.stackCount = stack;
def.maxStack = 20;
return def;
}
} // namespace
TEST_CASE("sortBags orders backpack by quality then itemId", "[inventory]") {
Inventory inv;
inv.setBackpackSlot(0, makeItem(500, ItemQuality::COMMON));
inv.setBackpackSlot(3, makeItem(100, ItemQuality::RARE));
inv.setBackpackSlot(7, makeItem(200, ItemQuality::UNCOMMON));
inv.sortBags();
CHECK(inv.getBackpackSlot(0).item.itemId == 100); // rare first
CHECK(inv.getBackpackSlot(1).item.itemId == 200); // then uncommon
CHECK(inv.getBackpackSlot(2).item.itemId == 500); // then common
CHECK(inv.getBackpackSlot(3).empty());
}
TEST_CASE("sortBags skips special containers", "[inventory]") {
Inventory inv;
// Bag 0: quiver with arrows in slots 0 and 2
inv.setBagSize(0, 6);
inv.setBagSpecial(0, true);
inv.setBagSlot(0, 0, makeItem(2512, ItemQuality::COMMON, 200)); // arrows
inv.setBagSlot(0, 2, makeItem(2512, ItemQuality::COMMON, 50));
// Bag 1: normal bag with one item
inv.setBagSize(1, 4);
inv.setBagSlot(1, 3, makeItem(300, ItemQuality::EPIC));
// Backpack: one item so bag contents have room to move forward
inv.setBackpackSlot(5, makeItem(400, ItemQuality::COMMON));
inv.sortBags();
// Quiver contents untouched, including the gap between them
CHECK(inv.getBagSlot(0, 0).item.itemId == 2512);
CHECK(inv.getBagSlot(0, 0).item.stackCount == 200);
CHECK(inv.getBagSlot(0, 1).empty());
CHECK(inv.getBagSlot(0, 2).item.itemId == 2512);
CHECK(inv.getBagSlot(0, 2).item.stackCount == 50);
// Normal bag + backpack items pooled and sorted into the backpack
CHECK(inv.getBackpackSlot(0).item.itemId == 300); // epic first
CHECK(inv.getBackpackSlot(1).item.itemId == 400);
CHECK(inv.getBagSlot(1, 3).empty());
}
TEST_CASE("computeSortSwaps never addresses special containers", "[inventory]") {
Inventory inv;
inv.setBagSize(0, 6);
inv.setBagSpecial(0, true);
inv.setBagSlot(0, 0, makeItem(2512, ItemQuality::COMMON, 200));
inv.setBagSize(1, 4);
inv.setBagSlot(1, 0, makeItem(100, ItemQuality::RARE));
inv.setBackpackSlot(0, makeItem(500, ItemQuality::COMMON));
auto swaps = inv.computeSortSwaps();
// Wire address of bag 0 is FIRST_BAG_EQUIP_SLOT + 0 = 19
const uint8_t quiverBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT);
for (const auto& op : swaps) {
CHECK(op.srcBag != quiverBag);
CHECK(op.dstBag != quiverBag);
}
// The rare item from the normal bag should still be moved (sort not a no-op)
CHECK(!swaps.empty());
}
TEST_CASE("sortBank orders main bank slots by quality then itemId", "[inventory]") {
Inventory inv;
inv.setBankSlot(0, makeItem(500, ItemQuality::COMMON));
inv.setBankSlot(4, makeItem(100, ItemQuality::RARE));
inv.setBankSlot(9, makeItem(200, ItemQuality::UNCOMMON));
inv.sortBank(28);
CHECK(inv.getBankSlot(0).item.itemId == 100); // rare first
CHECK(inv.getBankSlot(1).item.itemId == 200); // then uncommon
CHECK(inv.getBankSlot(2).item.itemId == 500); // then common
CHECK(inv.getBankSlot(3).empty());
}
TEST_CASE("sortBank pools bank bag contents into the main bank", "[inventory]") {
Inventory inv;
// Main bank has one low-quality item; a bank bag holds a higher-quality one.
inv.setBankSlot(2, makeItem(400, ItemQuality::COMMON));
inv.setBankBagSize(0, 6);
inv.setBankBagSlot(0, 3, makeItem(100, ItemQuality::EPIC));
inv.sortBank(28);
// Everything pools and refills main bank first: epic, then common.
CHECK(inv.getBankSlot(0).item.itemId == 100);
CHECK(inv.getBankSlot(1).item.itemId == 400);
CHECK(inv.getBankBagSlot(0, 3).empty());
}
TEST_CASE("sortBank respects the main slot count for Classic", "[inventory]") {
Inventory inv;
// Only 24 main slots exist on Classic — item in slot 25 must not seed the sort,
// and the sort must never write past slot 23.
inv.setBankSlot(0, makeItem(500, ItemQuality::COMMON));
inv.setBankSlot(1, makeItem(100, ItemQuality::RARE));
inv.sortBank(24);
CHECK(inv.getBankSlot(0).item.itemId == 100);
CHECK(inv.getBankSlot(1).item.itemId == 500);
CHECK(inv.getBankSlot(24).empty());
CHECK(inv.getBankSlot(27).empty());
}
TEST_CASE("computeBankSortSwaps addresses bank slots and bags correctly", "[inventory]") {
Inventory inv;
inv.setBankSlot(0, makeItem(500, ItemQuality::COMMON));
inv.setBankSlot(1, makeItem(100, ItemQuality::RARE));
inv.setBankBagSize(0, 4);
inv.setBankBagSlot(0, 0, makeItem(300, ItemQuality::EPIC));
auto swaps = inv.computeBankSortSwaps(28);
CHECK(!swaps.empty());
// Every address is either the main bank (0xFF, slot >= BANK_SLOT_START) or a bank
// bag container (>= BANK_BAG_CONTAINER_START).
for (const auto& op : swaps) {
bool srcOk = (op.srcBag == 0xFF && op.srcSlot >= Inventory::BANK_SLOT_START) ||
(op.srcBag >= Inventory::BANK_BAG_CONTAINER_START);
bool dstOk = (op.dstBag == 0xFF && op.dstSlot >= Inventory::BANK_SLOT_START) ||
(op.dstBag >= Inventory::BANK_BAG_CONTAINER_START);
CHECK(srcOk);
CHECK(dstOk);
}
}
TEST_CASE("sortBankBag orders one bag without touching the rest of the bank",
"[inventory]") {
Inventory inv;
inv.setBankSlot(0, makeItem(900, ItemQuality::POOR));
inv.setBankBagSize(0, 6);
inv.setBankBagSize(1, 6);
inv.setBankBagSlot(0, 0, makeItem(500, ItemQuality::COMMON));
inv.setBankBagSlot(0, 4, makeItem(100, ItemQuality::EPIC));
inv.setBankBagSlot(1, 2, makeItem(700, ItemQuality::RARE));
inv.sortBankBag(0);
// Sorted and compacted to the front of its own bag.
CHECK(inv.getBankBagSlot(0, 0).item.itemId == 100);
CHECK(inv.getBankBagSlot(0, 1).item.itemId == 500);
CHECK(inv.getBankBagSlot(0, 4).empty());
// Nothing pooled into the main bank, and the other bag is untouched — which
// is the whole point of sorting one bag rather than the whole bank.
CHECK(inv.getBankSlot(0).item.itemId == 900);
CHECK(inv.getBankSlot(1).empty());
CHECK(inv.getBankBagSlot(1, 2).item.itemId == 700);
}
TEST_CASE("computeBankBagSortSwaps stays inside the one bag", "[inventory]") {
Inventory inv;
inv.setBankSlot(0, makeItem(900, ItemQuality::POOR));
inv.setBankBagSize(2, 5);
inv.setBankBagSlot(2, 0, makeItem(500, ItemQuality::COMMON));
inv.setBankBagSlot(2, 3, makeItem(100, ItemQuality::EPIC));
const auto swaps = inv.computeBankBagSortSwaps(2);
CHECK(!swaps.empty());
const uint8_t expected =
static_cast<uint8_t>(Inventory::BANK_BAG_CONTAINER_START + 2);
for (const auto& op : swaps) {
CHECK(op.srcBag == expected);
CHECK(op.dstBag == expected);
}
}
TEST_CASE("an out-of-range bank bag index is a no-op", "[inventory]") {
Inventory inv;
inv.sortBankBag(-1);
inv.sortBankBag(Inventory::BANK_BAG_SLOTS);
CHECK(inv.computeBankBagSortSwaps(-1).empty());
CHECK(inv.computeBankBagSortSwaps(Inventory::BANK_BAG_SLOTS).empty());
}
TEST_CASE("merging pours partial stacks together", "[inventory]") {
Inventory inv;
auto stack = [](uint32_t id, uint32_t count, uint32_t max) {
ItemDef d = makeItem(id, ItemQuality::COMMON, count);
d.maxStack = max;
return d;
};
SECTION("two half stacks become one") {
inv.setBackpackSlot(0, stack(2589, 10, 20)); // Linen Cloth
inv.setBackpackSlot(3, stack(2589, 10, 20));
const auto ops = inv.mergePartialStacks();
CHECK(inv.getBackpackSlot(0).item.stackCount == 20);
CHECK(inv.getBackpackSlot(3).empty());
REQUIRE(ops.size() == 1);
// The later slot pours into the earlier one.
CHECK(ops[0].srcSlot == Inventory::NUM_EQUIP_SLOTS + 3);
CHECK(ops[0].dstSlot == Inventory::NUM_EQUIP_SLOTS + 0);
}
SECTION("an overflowing pour leaves the remainder behind") {
inv.setBackpackSlot(0, stack(2589, 15, 20));
inv.setBackpackSlot(1, stack(2589, 12, 20));
const auto ops = inv.mergePartialStacks();
CHECK(inv.getBackpackSlot(0).item.stackCount == 20);
CHECK(inv.getBackpackSlot(1).item.stackCount == 7);
CHECK(ops.size() == 1);
}
SECTION("three partials collapse to as few stacks as fit") {
inv.setBackpackSlot(0, stack(2589, 8, 20));
inv.setBackpackSlot(1, stack(2589, 8, 20));
inv.setBackpackSlot(2, stack(2589, 8, 20));
inv.mergePartialStacks();
CHECK(inv.getBackpackSlot(0).item.stackCount == 20);
CHECK(inv.getBackpackSlot(1).item.stackCount == 4);
CHECK(inv.getBackpackSlot(2).empty());
}
SECTION("different items and full stacks are left alone") {
inv.setBackpackSlot(0, stack(2589, 20, 20)); // already full
inv.setBackpackSlot(1, stack(2592, 5, 20)); // Wool Cloth
inv.setBackpackSlot(2, stack(2589, 5, 20));
ItemDef sword = makeItem(1234, ItemQuality::RARE); // maxStack 1 by default
sword.maxStack = 1;
inv.setBackpackSlot(3, sword);
const auto ops = inv.mergePartialStacks();
CHECK(ops.empty());
CHECK(inv.getBackpackSlot(0).item.stackCount == 20);
CHECK(inv.getBackpackSlot(2).item.stackCount == 5);
CHECK(inv.getBackpackSlot(3).item.itemId == 1234);
}
SECTION("special containers are skipped, as the sort skips them") {
inv.setBagSize(0, 6);
inv.setBagSpecial(0, true);
inv.setBagSlot(0, 0, stack(2512, 100, 200)); // arrows in a quiver
inv.setBagSlot(0, 2, stack(2512, 50, 200));
CHECK(inv.mergePartialStacks().empty());
CHECK(inv.getBagSlot(0, 0).item.stackCount == 100);
CHECK(inv.getBagSlot(0, 2).item.stackCount == 50);
}
SECTION("the bank merges the same way") {
inv.setBankSlot(0, stack(2589, 6, 20));
inv.setBankBagSize(0, 6);
inv.setBankBagSlot(0, 1, stack(2589, 9, 20));
const auto ops = inv.mergeBankPartialStacks(28);
CHECK(inv.getBankSlot(0).item.stackCount == 15);
CHECK(inv.getBankBagSlot(0, 1).empty());
REQUIRE(ops.size() == 1);
CHECK(ops[0].srcBag == Inventory::BANK_BAG_CONTAINER_START);
CHECK(ops[0].dstBag == 0xFF);
}
}