diff --git a/dDatabase/CDClientDatabase/CDClientManager.cpp b/dDatabase/CDClientDatabase/CDClientManager.cpp index 680a792f5..b5990a4f9 100644 --- a/dDatabase/CDClientDatabase/CDClientManager.cpp +++ b/dDatabase/CDClientDatabase/CDClientManager.cpp @@ -36,6 +36,7 @@ #include "CDMissionEmailTable.h" #include "CDRewardsTable.h" #include "CDPropertyEntranceComponentTable.h" +#include "CDDeletionRestrictionsTable.h" #include "CDPropertyTemplateTable.h" #include "CDModularBuildComponentTable.h" #include "CDFeatureGatingTable.h" @@ -93,6 +94,7 @@ DEFINE_TABLE_STORAGE(CDPackageComponentTable); DEFINE_TABLE_STORAGE(CDPetComponentTable); DEFINE_TABLE_STORAGE(CDProximityMonitorComponentTable); DEFINE_TABLE_STORAGE(CDPropertyEntranceComponentTable); +DEFINE_TABLE_STORAGE(CDDeletionRestrictionsTable); DEFINE_TABLE_STORAGE(CDPropertyTemplateTable); DEFINE_TABLE_STORAGE(CDModularBuildComponentTable); DEFINE_TABLE_STORAGE(CDRailActivatorComponentTable); @@ -118,6 +120,7 @@ void CDClientManager::LoadValuesFromDatabase() { CDBrickIDTableTable::Instance().LoadValuesFromDatabase(); CDCLIENT_DONT_CACHE_TABLE(CDComponentsRegistryTable::Instance().LoadValuesFromDatabase()); CDCurrencyTableTable::Instance().LoadValuesFromDatabase(); + CDDeletionRestrictionsTable::Instance().LoadValuesFromDatabase(); CDDestructibleComponentTable::Instance().LoadValuesFromDatabase(); CDEmoteTableTable::Instance().LoadValuesFromDatabase(); CDFeatureGatingTable::Instance().LoadValuesFromDatabase(); diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp new file mode 100644 index 000000000..8f3fd20d3 --- /dev/null +++ b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp @@ -0,0 +1,22 @@ +#include "CDDeletionRestrictionsTable.h" + +void CDDeletionRestrictionsTable::LoadValuesFromDatabase() { + auto& entries = GetEntriesMutable(); + auto tableData = CDClientDatabase::ExecuteQuery("SELECT id, restricted, ids, checkType FROM DeletionRestrictions;"); + while (!tableData.eof()) { + CDDeletionRestriction entry; + entry.restricted = tableData.getIntField("restricted", 0) != 0; + entry.ids = tableData.getStringField("ids", ""); + entry.checkType = tableData.getIntField("checkType", 0); + entries.insert_or_assign(tableData.getIntField("id", -1), std::move(entry)); + tableData.nextRow(); + } + + tableData.finalize(); +} + +const CDDeletionRestriction* CDDeletionRestrictionsTable::Get(const int32_t id) const { + const auto& entries = GetEntries(); + const auto it = entries.find(id); + return it == entries.end() ? nullptr : &it->second; +} diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h new file mode 100644 index 000000000..e8e0fbdfe --- /dev/null +++ b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h @@ -0,0 +1,29 @@ +#pragma once +#include "CDTable.h" + +#include +#include +#include + +// How a DeletionRestrictions row decides whether an item may be deleted (eDeletionRestrictionCheckType in the client) +enum class eDeletionRestrictionCheckType : int32_t { + LOTS_INCLUDED = 0, // allowed if another item of one of the LOTs is owned + LOTS_EXCLUDED = 1, // allowed if other items of every one of the LOTs are owned + ANY_RESTRICTION = 2, // allowed if any of the listed rows allows it + ALL_RESTRICTIONS = 3, // allowed if all of the listed rows allow it + ZONE = 4, // allowed in the listed map IDs + ALWAYS_RESTRICTED = 5, // never allowed +}; + +struct CDDeletionRestriction { + bool restricted{}; + std::string ids; // comma separated LOTs, row IDs or map IDs, depending on checkType + int32_t checkType{}; +}; + +class CDDeletionRestrictionsTable : public CDTable> { +public: + void LoadValuesFromDatabase(); + // The row with this ID, or nullptr + const CDDeletionRestriction* Get(int32_t id) const; +}; diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h b/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h index 60a3e4129..73683d66f 100644 --- a/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h +++ b/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h @@ -33,7 +33,7 @@ struct CDItemComponent { uint32_t itemRating; //!< ??? bool isTwoHanded; //!< Whether or not the item is double handed uint32_t minNumRequired; //!< Maybe the minimum number required for a mission, or to own this object? - uint32_t delResIndex; //!< ??? + uint32_t delResIndex; //!< Row of DeletionRestrictions, -1 (as unsigned) for none uint32_t currencyLOT; //!< ??? uint32_t altCurrencyCost; //!< ??? std::string subItems; //!< A comma seperate string of sub items (maybe for multi-itemed things like faction test gear set) diff --git a/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt b/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt index a48ee30f8..81810c2a1 100644 --- a/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt +++ b/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt @@ -28,6 +28,7 @@ set(DDATABASE_CDCLIENTDATABASE_CDCLIENTTABLES_SOURCES "CDActivitiesTable.cpp" "CDPlayerFlagsTable.cpp" "CDPhysicsComponentTable.cpp" "CDPropertyEntranceComponentTable.cpp" + "CDDeletionRestrictionsTable.cpp" "CDPropertyTemplateTable.cpp" "CDModularBuildComponentTable.cpp" "CDProximityMonitorComponentTable.cpp" diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index ed4d60cb3..a60613194 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -11,6 +11,9 @@ #include "dServer.h" #include "Logger.h" #include "CDClientManager.h" +#include "CDDeletionRestrictionsTable.h" +#include "eGameMasterLevel.h" +#include #include "ObjectIDManager.h" #include "MissionComponent.h" #include "GameMessages.h" @@ -2063,6 +2066,11 @@ void InventoryComponent::OnRemoveItemFromInventory(const GameMessages::RemoveIte const auto iStackCount = std::min(item->GetCount(), msg.iStackCount); + if (msg.bConfirmed && !CanDelete(*item)) { + LOG("%llu tried to delete %llu (LOT %i), which its deletion restriction does not allow", m_Parent->GetObjectID(), item->GetId(), item->GetLot()); + return; + } + if (msg.bConfirmed) { const auto itemType = static_cast(item->GetInfo().itemType); if (itemType == eItemType::MODEL || itemType == eItemType::LOOT_MODEL) { @@ -2213,3 +2221,76 @@ void InventoryComponent::OnUpdateInventoryGroupContents(const GameMessages::Upda UpdateGroup(groupUpdate); } + +namespace { + // A comma separated list of numbers; anything that is not a number is skipped + std::vector ParseDeletionRestrictionIds(const std::string& ids) { + std::vector parsed; + for (auto part : GeneralUtils::SplitString(ids, ',')) { + const auto first = part.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) continue; + part = part.substr(first, part.find_last_not_of(" \t\r\n") - first + 1); + const auto value = GeneralUtils::TryParse(part); + if (value) parsed.push_back(*value); + } + return parsed; + } +} + +bool InventoryComponent::CanDelete(const Item& item) const { + if (m_Parent->GetGMLevel() == eGameMasterLevel::OPERATOR) return true; + + const auto row = static_cast(item.GetInfo().delResIndex); + if (row < 0) return true; + + std::set visited; + return CheckDeletionRestriction(item.GetId(), row, visited); +} + +bool InventoryComponent::CheckDeletionRestriction(const LWOOBJID itemId, const int32_t row, std::set& visited) const { + // Rows that are missing, not restricted, of an unknown type or without IDs don't restrict anything + const auto* const restriction = CDClientManager::GetTable()->Get(row); + if (!restriction || !restriction->restricted) return true; + + const auto checkType = static_cast(restriction->checkType); + if (checkType == eDeletionRestrictionCheckType::ALWAYS_RESTRICTED) return false; + if (restriction->checkType < 0 || restriction->checkType > GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::ALWAYS_RESTRICTED)) return true; + + const auto ids = ParseDeletionRestrictionIds(restriction->ids); + if (ids.empty()) return true; + + switch (checkType) { + case eDeletionRestrictionCheckType::ANY_RESTRICTION: + case eDeletionRestrictionCheckType::ALL_RESTRICTIONS: { + if (!visited.insert(row).second) return true; + const bool any = checkType == eDeletionRestrictionCheckType::ANY_RESTRICTION; + bool result = !any; + for (const auto id : ids) { + const bool allowed = CheckDeletionRestriction(itemId, static_cast(id), visited); + result = any ? (result || allowed) : (result && allowed); + } + visited.erase(row); + return result; + } + case eDeletionRestrictionCheckType::ZONE: { + const auto mapId = Game::zoneManager->GetZoneID().GetMapID(); + return std::ranges::find(ids, static_cast(mapId)) != ids.end(); + } + case eDeletionRestrictionCheckType::LOTS_INCLUDED: + case eDeletionRestrictionCheckType::LOTS_EXCLUDED: { + // The other items the player has, in any inventory, not counting the one being deleted + std::set missing(ids.begin(), ids.end()); + for (const auto& [type, inventory] : m_Inventories) { + for (const auto& [id, other] : inventory->GetItems()) { + if (id == itemId || !missing.contains(other->GetLot())) continue; + if (checkType == eDeletionRestrictionCheckType::LOTS_INCLUDED) return true; + missing.erase(other->GetLot()); + if (missing.empty()) return true; + } + } + return false; + } + default: + return true; + } +} diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index c943e5d77..c58c9c7fe 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -4,6 +4,7 @@ #define INVENTORYCOMPONENT_H #include +#include #include @@ -333,6 +334,12 @@ public: */ std::vector FindBuffs(Item* item, bool castOnEquip) const; + /** + * One DeletionRestrictions row (CheckDeletionRestrictionIndex @ 00c94c20). visited stops rows that refer back to + * themselves; the client lets such rows through. + */ + bool CheckDeletionRestriction(LWOOBJID itemId, int32_t row, std::set& visited) const; + /** * Initializes the equipped items with a list of items * @param items the items to equip @@ -451,6 +458,12 @@ public: void OnEquipInventory(const GameMessages::EquipInventory& msg); void OnUnEquipInventory(const GameMessages::UnEquipInventory& msg); void OnRemoveItemFromInventory(const GameMessages::RemoveItemFromInventory& msg); + + /** + * Whether the player may delete this item: its ItemComponent's DeletionRestrictions row, as the client checks it + * (LWOInventoryComponent_Common::CanRemoveFromInventory @ 00ce0d20 in 1.10.64). Operators may delete anything. + */ + bool CanDelete(const Item& item) const; void OnMoveItemInInventory(const GameMessages::MoveItemInInventory& msg); void OnMoveItemBetweenInventoryTypes(const GameMessages::MoveItemBetweenInventoryTypes& msg); // Answers the client at sysAddr with ResponseMoveItemBetweenInventoryTypes. diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 5a55650ae..33186193c 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -1,4 +1,5 @@ set(DCOMPONENTS_TESTS + "DeletionRestrictionTests.cpp" "DestroyableComponentTests.cpp" "PetComponentTests.cpp" "SimplePhysicsComponentTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/DeletionRestrictionTests.cpp b/tests/dGameTests/dComponentsTests/DeletionRestrictionTests.cpp new file mode 100644 index 000000000..c4faced69 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/DeletionRestrictionTests.cpp @@ -0,0 +1,125 @@ +#include "GameDependencies.h" + +#include "CDClientManager.h" +#include "CDComponentsRegistryTable.h" +#include "CDDeletionRestrictionsTable.h" +#include "CDItemComponentTable.h" +#include "Entity.h" +#include "InventoryComponent.h" +#include "Item.h" +#include "eGameMasterLevel.h" +#include "eReplicaComponentType.h" + +#include + +// The deletion checks follow the client's LWOInventoryComponent_Common::CheckDeletionRestrictionIndex (0x00c94c20). +class DeletionRestrictionTests : public GameDependenciesTest { +protected: + std::unique_ptr entity; + InventoryComponent* inventory{}; + LWOOBJID nextId = 0x6000; + + void SetUp() override { + SetUpDependencies(); + // Rows as the CDClient has them + auto& rows = CDClientManager::GetEntriesMutable(); + rows.clear(); + rows[0] = { false, "", 0 }; + rows[500] = { true, "2001, 2002", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::LOTS_INCLUDED) }; + rows[501] = { true, "2001,2002", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::LOTS_EXCLUDED) }; + rows[502] = { true, "", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::ALWAYS_RESTRICTED) }; + rows[503] = { true, "500, 502", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::ANY_RESTRICTION) }; + rows[504] = { true, "500, 502", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::ALL_RESTRICTIONS) }; + rows[505] = { true, "505", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::ALL_RESTRICTIONS) }; + rows[506] = { true, "", GeneralUtils::ToUnderlying(eDeletionRestrictionCheckType::LOTS_INCLUDED) }; + + RegisterLot(1000, 0); + RegisterLot(1500, 500); + RegisterLot(1501, 501); + RegisterLot(1502, 502); + RegisterLot(1503, 503); + RegisterLot(1504, 504); + RegisterLot(1505, 505); + RegisterLot(1506, 506); + RegisterLot(1599, 599); // no such row + RegisterLot(2001, -1); + RegisterLot(2002, -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); + } + + void TearDown() override { + entity.reset(); + TearDownDependencies(); + } + + static void RegisterLot(const LOT lot, const int32_t delResIndex) { + const auto componentID = static_cast(95000 + 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.delResIndex = static_cast(delResIndex); + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + // The inventory owns the item + Item* Give(const LOT lot) { + auto* const items = inventory->GetInventory(eInventoryType::ITEMS); + return new Item(nextId++, lot, items, static_cast(items->GetItems().size()), 1, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + } +}; + +TEST_F(DeletionRestrictionTests, UnrestrictedRowsAllowDeleting) { + EXPECT_TRUE(inventory->CanDelete(*Give(1000))); + EXPECT_TRUE(inventory->CanDelete(*Give(1599))); + EXPECT_TRUE(inventory->CanDelete(*Give(2001))); + EXPECT_TRUE(inventory->CanDelete(*Give(1506))); // restricted row without IDs + EXPECT_TRUE(inventory->CanDelete(*Give(1505))); // refers to itself +} + +TEST_F(DeletionRestrictionTests, AlwaysRestricted) { + EXPECT_FALSE(inventory->CanDelete(*Give(1502))); +} + +TEST_F(DeletionRestrictionTests, OperatorsDeleteAnything) { + entity->SetGMLevel(eGameMasterLevel::OPERATOR); + EXPECT_TRUE(inventory->CanDelete(*Give(1502))); +} + +TEST_F(DeletionRestrictionTests, AnyOfTheLotsMustRemain) { + auto* const item = Give(1500); + EXPECT_FALSE(inventory->CanDelete(*item)); + Give(2002); + EXPECT_TRUE(inventory->CanDelete(*item)); +} + +TEST_F(DeletionRestrictionTests, AllOfTheLotsMustRemain) { + auto* const item = Give(1501); + Give(2001); + EXPECT_FALSE(inventory->CanDelete(*item)); + Give(2002); + EXPECT_TRUE(inventory->CanDelete(*item)); +} + +TEST_F(DeletionRestrictionTests, TheDeletedItemDoesNotCount) { + auto* const item = Give(2001); + RegisterLot(2001, 500); + EXPECT_FALSE(inventory->CanDelete(*item)); + Give(2001); + EXPECT_TRUE(inventory->CanDelete(*item)); +} + +TEST_F(DeletionRestrictionTests, AnyAndAllOfOtherRows) { + auto* const any = Give(1503); + auto* const all = Give(1504); + EXPECT_FALSE(inventory->CanDelete(*any)); + EXPECT_FALSE(inventory->CanDelete(*all)); + Give(2001); // row 500 now allows it, row 502 never does + EXPECT_TRUE(inventory->CanDelete(*any)); + EXPECT_FALSE(inventory->CanDelete(*all)); +}