diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index d677de14d..67725479c 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -826,6 +826,13 @@ void InventoryComponent::UpdateXml(tinyxml2::XMLDocument& document) { items->DeleteChildren(); + // Saved while the equipped items are pushed (build mode, possibly carrying a model): the character's equipment is + // the pushed state, which PopEquippedItems puts back, not the temporary items equipped since. + const auto& savedEquipment = m_HasPushed ? m_Pushed : m_Equipped; + const auto isSavedEquipped = [&savedEquipment](const LWOOBJID id) { + return std::ranges::any_of(savedEquipment, [id](const auto& slot) { return slot.second.id == id; }); + }; + for (auto* inventory : inventoriesToSave) { if (inventory->GetSize() == 0) { continue; @@ -845,7 +852,7 @@ void InventoryComponent::UpdateXml(tinyxml2::XMLDocument& document) { itemElement->SetAttribute("s", static_cast(item->GetSlot())); itemElement->SetAttribute("c", static_cast(item->GetCount())); itemElement->SetAttribute("b", item->GetBound()); - itemElement->SetAttribute("eq", item->IsEquipped()); + itemElement->SetAttribute("eq", isSavedEquipped(item->GetId())); itemElement->SetAttribute("sk", item->GetSubKey()); // Begin custom xml @@ -1197,6 +1204,7 @@ void InventoryComponent::RemoveBuff(Item* item) const { void InventoryComponent::PushEquippedItems() { m_Pushed = m_Equipped; + m_HasPushed = true; m_Dirty = true; } @@ -1225,6 +1233,7 @@ void InventoryComponent::PopEquippedItems() { } m_Pushed.clear(); + m_HasPushed = false; auto destroyableComponent = m_Parent->GetComponent(); diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index c58c9c7fe..4ff55a512 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -517,6 +517,12 @@ private: */ EquipmentMap m_Pushed; + /** + * Whether the equipped items are pushed (between PushEquippedItems and PopEquippedItems, e.g. in build mode). + * What is equipped then is temporary (the thinking hat, a carried model) and m_Pushed is what gets saved. + */ + bool m_HasPushed{}; + /** * If the inventory has changed */ diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 13415b920..7772f5ecb 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -1,5 +1,6 @@ set(DCOMPONENTS_TESTS "DeletionRestrictionTests.cpp" + "InventorySaveTests.cpp" "VendorBuybackTests.cpp" "RacingWrongWayTests.cpp" "DestroyableComponentTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/InventorySaveTests.cpp b/tests/dGameTests/dComponentsTests/InventorySaveTests.cpp new file mode 100644 index 000000000..0046b70a0 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/InventorySaveTests.cpp @@ -0,0 +1,95 @@ +#include "GameDependencies.h" + +#include "CDClientManager.h" +#include "CDComponentsRegistryTable.h" +#include "CDItemComponentTable.h" +#include "Entity.h" +#include "InventoryComponent.h" +#include "Item.h" +#include "eReplicaComponentType.h" +#include "tinyxml2.h" + +#include + +// What the character save writes as equipped ("eq"), in and out of a pushed equipment state (build mode). +class InventorySaveTests : public GameDependenciesTest { +protected: + static constexpr LOT HAT = 1000; + static constexpr LOT MODEL = 6662; // a brick built model item, carried while building on a property + + std::unique_ptr entity; + InventoryComponent* inventory{}; + tinyxml2::XMLDocument doc; + LWOOBJID nextId = 0x7000; + + void SetUp() override { + SetUpDependencies(); + RegisterLot(HAT, "hair"); + RegisterLot(MODEL, "Extra_1"); + + // The entity's own LOT has no components, so InventoryComponent doesn't look it up in a database + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + entity = std::make_unique(1, info); + inventory = entity->AddComponent(-1); + + doc.Parse(""); + } + + void TearDown() override { + entity.reset(); + TearDownDependencies(); + } + + static void RegisterLot(const LOT lot, const std::string& equipLocation) { + const auto componentID = static_cast(96000 + lot); + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(lot), componentID); + registry.insert_or_assign(static_cast(eReplicaComponentType::ITEM) << 32 | static_cast(lot), componentID); + CDItemComponent component{}; + component.id = componentID; + component.equipLocation = equipLocation; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + // The inventory owns the item; equipped the way loading a save equips it + Item* GiveEquipped(const LOT lot, const eInventoryType type) { + auto* const bag = inventory->GetInventory(type); + auto* const item = new Item(nextId++, lot, bag, static_cast(bag->GetItems().size()), 1, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + inventory->UpdateSlot(item->GetInfo().equipLocation, { item->GetId(), item->GetLot(), item->GetCount(), item->GetSlot() }); + return item; + } + + // The "eq" attribute the save wrote for an item + bool SavedEquipped(const LWOOBJID id) { + inventory->UpdateXml(doc); + for (auto* bag = doc.FirstChildElement("obj")->FirstChildElement("inv")->FirstChildElement("items")->FirstChildElement("in"); bag; bag = bag->NextSiblingElement("in")) { + for (auto* element = bag->FirstChildElement("i"); element; element = element->NextSiblingElement("i")) { + if (element->Int64Attribute("id") == id) return element->BoolAttribute("eq"); + } + } + ADD_FAILURE() << "item " << id << " was not saved"; + return false; + } +}; + +TEST_F(InventorySaveTests, EquippedItemsAreSavedEquipped) { + const auto* const hat = GiveEquipped(HAT, eInventoryType::ITEMS); + EXPECT_TRUE(SavedEquipped(hat->GetId())); +} + +TEST_F(InventorySaveTests, ModelCarriedInBuildModeIsNotSavedEquipped) { + const auto* const hat = GiveEquipped(HAT, eInventoryType::ITEMS); + inventory->PushEquippedItems(); // entering build mode + const auto* const model = GiveEquipped(MODEL, eInventoryType::MODELS); // picked up off the property + + EXPECT_FALSE(SavedEquipped(model->GetId())); + EXPECT_TRUE(SavedEquipped(hat->GetId())); +} + +TEST_F(InventorySaveTests, ItemTakenOffInBuildModeIsStillSavedEquipped) { + const auto* const hat = GiveEquipped(HAT, eInventoryType::ITEMS); + inventory->PushEquippedItems(); + inventory->RemoveSlot("hair"); + + EXPECT_TRUE(SavedEquipped(hat->GetId())); +}