* feat: add extensible inventory/backpack module Introduce eve.Inventory with data-driven items, Bag containers, EquipmentSet, pluggable accept/capacity/stack rules, change events, script bindings, tests, docs, and a playable example. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * fix: inventory tests for CHECK_EQ double-eval and never-stack slots Store side-effecting call results before CHECK_EQ (zeroerr evaluates LHS twice), and treat non-merging stack rules as per-slot capacity 1. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
315 lines
8.9 KiB
C++
315 lines
8.9 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "inventory/Item.h"
|
|
#include "inventory/Bag.h"
|
|
#include "inventory/InventorySystem.h"
|
|
#include "inventory/Equipment.h"
|
|
#include "inventory/Inventory.h"
|
|
|
|
#include <cmath>
|
|
#include <string>
|
|
|
|
using namespace eve::inventory;
|
|
|
|
namespace {
|
|
bool approxEq(float a, float b, float eps = 1e-5f) { return std::fabs(a - b) < eps; }
|
|
} // namespace
|
|
|
|
TEST_CASE("inventory.itemRegistry.registerAndJson") {
|
|
ItemRegistry::clear();
|
|
|
|
ItemDefinition potion;
|
|
potion.id = "potion.hp";
|
|
potion.displayName = "Heal Potion";
|
|
potion.maxStack = 20;
|
|
potion.weight = 0.2f;
|
|
potion.tags = {"consumable", "potion"};
|
|
potion.extra["icon"] = "ui/potion.png";
|
|
ItemRegistry::registerItem(potion);
|
|
|
|
CHECK(ItemRegistry::count() == 1);
|
|
CHECK(ItemRegistry::find("potion.hp") != nullptr);
|
|
CHECK_EQ(ItemRegistry::find("potion.hp")->maxStack, 20);
|
|
CHECK(ItemRegistry::find("potion.hp")->hasTag("potion"));
|
|
CHECK_EQ(ItemRegistry::find("potion.hp")->getExtra("icon"), "ui/potion.png");
|
|
|
|
int n = ItemRegistry::loadFromJson(R"([
|
|
{"id":"ore.iron","displayName":"Iron Ore","maxStack":50,"weight":1.0,"tags":["material"]},
|
|
{"id":"sword.iron","displayName":"Iron Sword","maxStack":1,"weight":3.5,
|
|
"equipSlot":"weapon","tags":["weapon","melee"],"extra":{"damage":"10"}}
|
|
])");
|
|
CHECK_EQ(n, 2);
|
|
CHECK_EQ(ItemRegistry::count(), 3);
|
|
CHECK_EQ(ItemRegistry::find("sword.iron")->equipSlot, "weapon");
|
|
|
|
ItemRegistry::clear();
|
|
CHECK_EQ(ItemRegistry::count(), 0);
|
|
}
|
|
|
|
TEST_CASE("inventory.bag.addStackRemove") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::clearEvents();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition coin;
|
|
coin.id = "coin";
|
|
coin.maxStack = 99;
|
|
coin.weight = 0.01f;
|
|
ItemRegistry::registerItem(coin);
|
|
|
|
Bag bag(5);
|
|
bag.setId("player");
|
|
bag.setMaxWeight(100.f);
|
|
|
|
CHECK(bag.canAddItem("coin", 10));
|
|
// NOTE: zeroerr CHECK_EQ evaluates the LHS twice (compare + print), so
|
|
// side-effecting calls must be stored in a local first.
|
|
int added = bag.addItem("coin", 150);
|
|
CHECK_EQ(added, 150); // 99 + 51 across two slots
|
|
CHECK_EQ(bag.countItem("coin"), 150);
|
|
CHECK_EQ(bag.getUsedSlotCount(), 2);
|
|
CHECK_EQ(bag.getSlotQuantity(0), 99);
|
|
CHECK_EQ(bag.getSlotQuantity(1), 51);
|
|
|
|
int removed = bag.removeItem("coin", 60);
|
|
CHECK_EQ(removed, 60);
|
|
CHECK_EQ(bag.countItem("coin"), 90);
|
|
CHECK_EQ(bag.getSlotQuantity(0), 39); // removed from first stack first
|
|
CHECK_EQ(bag.getSlotQuantity(1), 51);
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.bag.acceptTagsAndReject") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition quest;
|
|
quest.id = "quest.letter";
|
|
quest.tags = {"quest"};
|
|
ItemRegistry::registerItem(quest);
|
|
|
|
ItemDefinition junk;
|
|
junk.id = "junk";
|
|
junk.tags = {"trash"};
|
|
ItemRegistry::registerItem(junk);
|
|
|
|
Bag questBag(4);
|
|
questBag.addAcceptTag("quest");
|
|
CHECK(questBag.canAddItem("quest.letter", 1));
|
|
CHECK(!questBag.canAddItem("junk", 1));
|
|
CHECK_EQ(questBag.canAddItemReason("junk", 1), "accept_tag_mismatch");
|
|
|
|
Bag general(4);
|
|
general.addRejectTag("quest");
|
|
CHECK(!general.canAddItem("quest.letter", 1));
|
|
CHECK_EQ(general.canAddItemReason("quest.letter", 1), "rejected_tag");
|
|
CHECK(general.canAddItem("junk", 1));
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.bag.weightCapacity") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition ore;
|
|
ore.id = "ore";
|
|
ore.maxStack = 10;
|
|
ore.weight = 2.f;
|
|
ItemRegistry::registerItem(ore);
|
|
|
|
Bag bag(10);
|
|
bag.setMaxWeight(5.f);
|
|
bag.setCapacityPolicy("slotsAndWeight");
|
|
|
|
int added = bag.addItem("ore", 3);
|
|
CHECK_EQ(added, 2); // 2*2=4 <=5, third would be 6
|
|
CHECK(approxEq(bag.getUsedWeight(), 4.f));
|
|
CHECK(!bag.canAddItem("ore", 1));
|
|
CHECK_EQ(bag.canAddItemReason("ore", 1), "over_weight");
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.bag.moveSwapSplit") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition gem;
|
|
gem.id = "gem";
|
|
gem.maxStack = 10;
|
|
ItemRegistry::registerItem(gem);
|
|
|
|
Bag bag(4);
|
|
bag.addItem("gem", 8);
|
|
CHECK(bag.splitStack(0, 3, 1));
|
|
CHECK_EQ(bag.getSlotQuantity(0), 5);
|
|
CHECK_EQ(bag.getSlotQuantity(1), 3);
|
|
|
|
CHECK(bag.moveSlot(1, 2));
|
|
CHECK(bag.isSlotEmpty(1));
|
|
CHECK_EQ(bag.getSlotQuantity(2), 3);
|
|
|
|
CHECK(bag.swapSlots(0, 2));
|
|
CHECK_EQ(bag.getSlotQuantity(0), 3);
|
|
CHECK_EQ(bag.getSlotQuantity(2), 5);
|
|
|
|
// merge via move
|
|
CHECK(bag.moveSlot(0, 2));
|
|
CHECK(bag.isSlotEmpty(0));
|
|
CHECK_EQ(bag.getSlotQuantity(2), 8);
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.bag.transferBetweenBags") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::clearEvents();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition herb;
|
|
herb.id = "herb";
|
|
herb.maxStack = 20;
|
|
herb.weight = 0.1f;
|
|
ItemRegistry::registerItem(herb);
|
|
|
|
Bag a(3);
|
|
a.setId("a");
|
|
Bag b(3);
|
|
b.setId("b");
|
|
a.addItem("herb", 15);
|
|
|
|
int moved = InventorySystem::transfer(&a, &b, "herb", 10);
|
|
CHECK_EQ(moved, 10);
|
|
CHECK_EQ(a.countItem("herb"), 5);
|
|
CHECK_EQ(b.countItem("herb"), 10);
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.extension.customAcceptAndStack") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition a;
|
|
a.id = "scroll.a";
|
|
a.maxStack = 5;
|
|
a.extra["level"] = "3";
|
|
ItemRegistry::registerItem(a);
|
|
|
|
InventorySystem::registerAcceptRule("minLevel3", [](const Bag &, const ItemDefinition &def,
|
|
int, std::string *reason) {
|
|
int level = 0;
|
|
try {
|
|
level = std::stoi(def.getExtra("level", "0"));
|
|
} catch (...) {
|
|
}
|
|
if (level < 3) {
|
|
if (reason) *reason = "level_too_low";
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
InventorySystem::registerStackRule("neverSame",
|
|
[](const ItemStack &, const ItemStack &,
|
|
const ItemDefinition &) { return false; });
|
|
|
|
Bag bag(4);
|
|
bag.setAcceptRule("minLevel3");
|
|
bag.setStackRule("neverSame");
|
|
CHECK(bag.canAddItem("scroll.a", 1));
|
|
int added = bag.addItem("scroll.a", 3);
|
|
CHECK_EQ(added, 3);
|
|
// never stack -> each unit occupies its own slot
|
|
CHECK_EQ(bag.getUsedSlotCount(), 3);
|
|
|
|
InventorySystem::unregisterAcceptRule("minLevel3");
|
|
InventorySystem::unregisterStackRule("neverSame");
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.equipment.equipUnequip") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::clearEvents();
|
|
InventorySystem::ensureBuiltins();
|
|
|
|
ItemDefinition sword;
|
|
sword.id = "sword.iron";
|
|
sword.maxStack = 1;
|
|
sword.equipSlot = "weapon";
|
|
sword.tags = {"weapon"};
|
|
ItemRegistry::registerItem(sword);
|
|
|
|
ItemDefinition helm;
|
|
helm.id = "helm.iron";
|
|
helm.maxStack = 1;
|
|
helm.equipSlot = "head";
|
|
helm.tags = {"armor"};
|
|
ItemRegistry::registerItem(helm);
|
|
|
|
Bag bag(4);
|
|
bag.setId("player");
|
|
bag.addItem("sword.iron", 1);
|
|
bag.addItem("helm.iron", 1);
|
|
|
|
EquipmentSet eq;
|
|
eq.setId("eq");
|
|
eq.defineSlot("weapon");
|
|
eq.addSlotAllowedTag("weapon", "weapon");
|
|
eq.defineSlot("head");
|
|
|
|
int swordSlot = bag.findItem("sword.iron");
|
|
CHECK(swordSlot >= 0);
|
|
CHECK(eq.equipFromBag("weapon", &bag, swordSlot));
|
|
CHECK_EQ(eq.getSlotItemId("weapon"), "sword.iron");
|
|
CHECK_EQ(bag.countItem("sword.iron"), 0);
|
|
|
|
// wrong slot
|
|
int helmSlot = bag.findItem("helm.iron");
|
|
CHECK(!eq.equipFromBag("weapon", &bag, helmSlot));
|
|
|
|
CHECK(eq.unequipToBag("weapon", &bag));
|
|
CHECK(eq.isSlotEmpty("weapon"));
|
|
CHECK_EQ(bag.countItem("sword.iron"), 1);
|
|
|
|
ItemRegistry::clear();
|
|
}
|
|
|
|
TEST_CASE("inventory.events.andFacade") {
|
|
ItemRegistry::clear();
|
|
InventorySystem::clearEvents();
|
|
|
|
Inventory *inv = Inventory::create();
|
|
CHECK(inv != nullptr);
|
|
CHECK_EQ(inv->getName(), "Inventory");
|
|
|
|
int n = inv->registerItemsFromJson(
|
|
R"([{"id":"apple","maxStack":10,"weight":0.2,"tags":["food"]}])");
|
|
CHECK_EQ(n, 1);
|
|
CHECK(inv->hasItemDefinition("apple"));
|
|
CHECK(inv->hasAcceptRule("default"));
|
|
CHECK(inv->hasCapacityPolicy("slotsAndWeight"));
|
|
CHECK(inv->hasStackRule("sameItem"));
|
|
|
|
Bag *bag = inv->newBag(4);
|
|
bag->setId("pack");
|
|
int added = bag->addItem("apple", 4);
|
|
CHECK_EQ(added, 4);
|
|
CHECK(inv->getChangeEventCount() >= 1);
|
|
bool sawAdd = false;
|
|
for (int i = 0; i < inv->getChangeEventCount(); ++i) {
|
|
if (inv->getChangeEventAction(i) == "add" && inv->getChangeEventItemId(i) == "apple")
|
|
sawAdd = true;
|
|
}
|
|
CHECK(sawAdd);
|
|
|
|
inv->clearChangeEvents();
|
|
CHECK_EQ(inv->getChangeEventCount(), 0);
|
|
|
|
bag->destroy();
|
|
inv->clearItemDefinitions();
|
|
}
|