diff --git a/dCommon/dEnums/eDeletionRestrictionsCheckType.h b/dCommon/dEnums/eDeletionRestrictionsCheckType.h new file mode 100644 index 000000000..f9e20646e --- /dev/null +++ b/dCommon/dEnums/eDeletionRestrictionsCheckType.h @@ -0,0 +1,17 @@ +#ifndef __EDELETIONRESTRICTIONSCHECKTYPE__H__ +#define __EDELETIONRESTRICTIONSCHECKTYPE__H__ + +#include + +// DeletionRestrictions.checkType, as the client's eDeletionRestrictionCheckType +enum class eDeletionRestrictionsCheckType : uint32_t { + LOTS_INCLUDED, // can delete while the player has another item of any of these LOTs + LOTS_EXCLUDED, // can delete while the player has other items of all of these LOTs + ANY_RESTRICTION, // can delete if any of these restrictions (rows) allows it + ALL_RESTRICTIONS, // can delete if all of these restrictions (rows) allow it + ZONE, // can delete only in one of these zones + ALWAYS_RESTRICTED, // can never delete + MAX +}; + +#endif //!__EDELETIONRESTRICTIONSCHECKTYPE__H__ diff --git a/dDatabase/CDClientDatabase/CDClientManager.cpp b/dDatabase/CDClientDatabase/CDClientManager.cpp index 9aea07110..b20f0f773 100644 --- a/dDatabase/CDClientDatabase/CDClientManager.cpp +++ b/dDatabase/CDClientDatabase/CDClientManager.cpp @@ -41,6 +41,7 @@ #include "CDRailActivatorComponent.h" #include "CDRewardCodesTable.h" #include "CDPetComponentTable.h" +#include "CDDeletionRestrictionsTable.h" #ifndef CDCLIENT_CACHE_ALL // Uncomment this to cache the full cdclient database into memory. This will make the server load faster, but will use more memory. @@ -102,6 +103,7 @@ DEFINE_TABLE_STORAGE(CDScriptComponentTable); DEFINE_TABLE_STORAGE(CDSkillBehaviorTable); DEFINE_TABLE_STORAGE(CDTamingBuildPuzzleTable); DEFINE_TABLE_STORAGE(CDVendorComponentTable); +DEFINE_TABLE_STORAGE(CDDeletionRestrictionsTable); void CDClientManager::LoadValuesFromDatabase() { if (!CDClientDatabase::isConnected) { @@ -149,6 +151,7 @@ void CDClientManager::LoadValuesFromDatabase() { CDTamingBuildPuzzleTable::Instance().LoadValuesFromDatabase(); CDVendorComponentTable::Instance().LoadValuesFromDatabase(); CDZoneTableTable::LoadValuesFromDatabase(); + CDDeletionRestrictionsTable::Instance().LoadValuesFromDatabase(); } void CDClientManager::LoadValuesFromDefaults() { diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp new file mode 100644 index 000000000..ddbc76baf --- /dev/null +++ b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.cpp @@ -0,0 +1,32 @@ +#include "CDDeletionRestrictionsTable.h" +#include "GeneralUtils.h" +#include "eDeletionRestrictionsCheckType.h" + +void CDDeletionRestrictionsTable::LoadValuesFromDatabase() { + auto& entries = GetEntriesMutable(); + + auto tableData = CDClientDatabase::ExecuteQuery("SELECT * FROM DeletionRestrictions"); + while (!tableData.eof()) { + CDDeletionRestriction entry; + entry.id = tableData.getIntField("id", 0); + entry.restricted = tableData.getIntField("restricted", 0) != 0; + for (auto id : GeneralUtils::SplitString(tableData.getStringField("ids", ""), ',')) { + // The ids are written with spaces around them + id.erase(0, id.find_first_not_of(" \t")); + id.erase(id.find_last_not_of(" \t") + 1); + const auto parsed = GeneralUtils::TryParse(id); + if (parsed) entry.ids.push_back(*parsed); + } + entry.checkType = static_cast(tableData.getIntField("checkType", static_cast(eDeletionRestrictionsCheckType::MAX))); + + entries.insert_or_assign(entry.id, entry); + tableData.nextRow(); + } +} + +std::optional CDDeletionRestrictionsTable::GetByID(const uint32_t id) const { + const auto& entries = GetEntries(); + const auto it = entries.find(id); + if (it == entries.end()) return std::nullopt; + return it->second; +} diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h new file mode 100644 index 000000000..c1647fd8d --- /dev/null +++ b/dDatabase/CDClientDatabase/CDClientTables/CDDeletionRestrictionsTable.h @@ -0,0 +1,21 @@ +#pragma once + +#include "CDTable.h" + +#include + +enum class eDeletionRestrictionsCheckType : uint32_t; + +struct CDDeletionRestriction { + uint32_t id{}; + bool restricted{}; + // LOTs, zone ids or other rows of this table, depending on checkType; ids that aren't numbers are left out + std::vector ids; + eDeletionRestrictionsCheckType checkType{}; +}; + +class CDDeletionRestrictionsTable : public CDTable> { +public: + void LoadValuesFromDatabase(); + std::optional GetByID(uint32_t id) const; +}; diff --git a/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h b/dDatabase/CDClientDatabase/CDClientTables/CDItemComponentTable.h index 60a3e4129..58bee024a 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; //!< Relates to DeletionRestrictions Table 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 57731ad93..801007f98 100644 --- a/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt +++ b/dDatabase/CDClientDatabase/CDClientTables/CMakeLists.txt @@ -39,4 +39,5 @@ set(DDATABASE_CDCLIENTDATABASE_CDCLIENTTABLES_SOURCES "CDActivitiesTable.cpp" "CDSkillBehaviorTable.cpp" "CDTamingBuildPuzzleTable.cpp" "CDVendorComponentTable.cpp" - "CDZoneTableTable.cpp" PARENT_SCOPE) + "CDZoneTableTable.cpp" + "CDDeletionRestrictionsTable.cpp" PARENT_SCOPE) diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 076625164..c62baf2ea 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -40,6 +40,11 @@ #include "CDSkillBehaviorTable.h" #include "StringifiedEnum.h" #include "Amf3.h" +#include "CDDeletionRestrictionsTable.h" +#include "eDeletionRestrictionsCheckType.h" +#include "eGameMasterLevel.h" +#include "PossessorComponent.h" +#include "dZoneManager.h" #include @@ -1893,3 +1898,60 @@ bool InventoryComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo return true; } + +bool InventoryComponent::CanRemoveFromInventory(const LWOOBJID itemId, const LOT lot) const { + // Not what the player is driving or riding + const auto* possessor = m_Parent->GetComponent(); + if (possessor && itemId != LWOOBJID_EMPTY && (possessor->GetPossessable() == itemId || possessor->GetMountItemID() == itemId)) return false; + + if (m_Parent->GetGMLevel() == eGameMasterLevel::OPERATOR) return true; + + const auto restrictionId = Inventory::FindItemComponent(lot).delResIndex; + if (static_cast(restrictionId) < 0) return true; + + std::set checked; + return CheckDeletionRestriction(itemId, restrictionId, checked); +} + +bool InventoryComponent::CheckDeletionRestriction(const LWOOBJID itemId, const uint32_t restrictionId, std::set& checked) const { + const auto restriction = CDClientManager::GetTable()->GetByID(restrictionId); + bool allowed = true; + if (!restriction || !restriction->restricted || restriction->checkType >= eDeletionRestrictionsCheckType::MAX) { + // No row, not restricted or an unknown check type: the item can be removed + } else if (restriction->checkType == eDeletionRestrictionsCheckType::ALWAYS_RESTRICTED) { + allowed = false; + } else if (restriction->ids.empty()) { + LOG("No valid ids for row %u in DeletionRestrictions", restrictionId); + } else if (restriction->checkType == eDeletionRestrictionsCheckType::LOTS_INCLUDED || restriction->checkType == eDeletionRestrictionsCheckType::LOTS_EXCLUDED) { + allowed = HasOtherItemsOfLots(restriction->ids, itemId, restriction->checkType == eDeletionRestrictionsCheckType::LOTS_INCLUDED); + } else if (restriction->checkType == eDeletionRestrictionsCheckType::ZONE) { + allowed = std::ranges::find(restriction->ids, Game::zoneManager->GetZoneID().GetMapID()) != restriction->ids.end(); + } else if (!checked.insert(restrictionId).second) { + LOG("Row %u in DeletionRestrictions refers back to itself", restrictionId); + } else { + // ANY_RESTRICTION: any of the rows allows it; ALL_RESTRICTIONS: every row does + const bool any = restriction->checkType == eDeletionRestrictionsCheckType::ANY_RESTRICTION; + allowed = !any; + for (const auto row : restriction->ids) { + const bool rowAllows = CheckDeletionRestriction(itemId, row, checked); + allowed = any ? (allowed || rowAllows) : (allowed && rowAllows); + } + checked.erase(restrictionId); + } + + return allowed; +} + +bool InventoryComponent::HasOtherItemsOfLots(const std::vector& lots, const LWOOBJID excludedItemId, const bool any) const { + std::set missing(lots.begin(), lots.end()); + for (const auto* inventory : m_Inventories | std::views::values) { + if (!inventory) continue; + for (const auto& [id, item] : const_cast(inventory)->GetItems()) { + if (id == excludedItemId || !item || !missing.contains(item->GetLot())) continue; + if (any) return true; + missing.erase(item->GetLot()); + if (missing.empty()) return true; + } + } + return false; +} diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index d7b67cfc2..daf3c6397 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -4,6 +4,7 @@ #define INVENTORYCOMPONENT_H #include +#include #include @@ -182,6 +183,32 @@ public: */ Item* FindItemById(LWOOBJID id) const; + /** + * Whether the player may remove (delete) an item, as the client's LWOInventoryComponent_Common::CanRemoveFromInventory + * (0x00ce0d20): not what they are driving or riding, always for an operator, otherwise as the item's + * DeletionRestrictions row says (ItemComponent.delResIndex) + * @param itemId the item's id + * @param lot the item's LOT + */ + bool CanRemoveFromInventory(LWOOBJID itemId, LOT lot) const; + + /** + * Whether a DeletionRestrictions row allows removing an item (CheckDeletionRestrictionIndex 0x00c94c20) + * @param itemId the item being removed + * @param restrictionId the row + * @param checked the rows being checked further up (ANY_RESTRICTION / ALL_RESTRICTIONS), so a row that names + * itself again does not loop + */ + bool CheckDeletionRestriction(LWOOBJID itemId, uint32_t restrictionId, std::set& checked) const; + + /** + * Whether the player has items of the given LOTs other than one item (HasOtherItemsOfLots 0x00c02db0) + * @param lots the LOTs + * @param excludedItemId the item not to count + * @param any true: an item of any of the LOTs is enough; false: one of each LOT is needed + */ + bool HasOtherItemsOfLots(const std::vector& lots, LWOOBJID excludedItemId, bool any) const; + /** * Returns an item in the inventory that matches the specified LOT * @param lot the lot of the item to find diff --git a/dGame/dGameMessages/GameMessages.cpp b/dGame/dGameMessages/GameMessages.cpp index 19d5859d6..b259ad774 100644 --- a/dGame/dGameMessages/GameMessages.cpp +++ b/dGame/dGameMessages/GameMessages.cpp @@ -5342,6 +5342,12 @@ void GameMessages::HandleRemoveItemFromInventory(RakNet::BitStream& inStream, En iStackCount = std::min(item->GetCount(), iStackCount); + // The client checks this before asking (and says why it can't); forceDeletion from a client isn't trusted + if (!inv->CanRemoveFromInventory(item->GetId(), item->GetLot())) { + LOG("Player %llu may not delete item %llu (LOT %i)", entity->GetObjectID(), item->GetId(), item->GetLot()); + return; + } + if (bConfirmed) { const auto itemType = static_cast(item->GetInfo().itemType); if (itemType == eItemType::MODEL || itemType == eItemType::LOOT_MODEL) { diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index f73f1214c..5cdd5c86b 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..54bd544b0 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/DeletionRestrictionTests.cpp @@ -0,0 +1,84 @@ +#include "GameDependencies.h" + +#include "CDClientManager.h" +#include "CDComponentsRegistryTable.h" +#include "CDDeletionRestrictionsTable.h" +#include "Entity.h" +#include "eDeletionRestrictionsCheckType.h" +#include "InventoryComponent.h" + +#include + +// DeletionRestrictions rows as the client's LWOInventoryComponent_Common::CheckDeletionRestrictionIndex (0x00c94c20) +// reads them. The test zone is map 1. +class DeletionRestrictionTests : public GameDependenciesTest { +protected: + std::unique_ptr entity; + InventoryComponent* inventory{}; + + void SetUp() override { + SetUpDependencies(); + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + entity = std::make_unique(1, info); + inventory = entity->AddComponent(-1); + + using enum eDeletionRestrictionsCheckType; + Row(1, true, ALWAYS_RESTRICTED, {}); + Row(2, false, ALWAYS_RESTRICTED, {}); + Row(3, true, ZONE, { 1, 1100 }); + Row(4, true, ZONE, { 1000 }); + Row(5, true, ANY_RESTRICTION, { 1, 3 }); + Row(6, true, ALL_RESTRICTIONS, { 1, 3 }); + Row(7, true, ALL_RESTRICTIONS, { 3, 7 }); + Row(8, true, ZONE, {}); + Row(9, true, LOTS_INCLUDED, { 6416 }); + Row(10, true, MAX, { 1 }); + } + + void TearDown() override { + CDClientManager::GetEntriesMutable().clear(); + entity.reset(); + TearDownDependencies(); + } + + static void Row(const uint32_t id, const bool restricted, const eDeletionRestrictionsCheckType checkType, const std::vector& ids) { + CDClientManager::GetEntriesMutable().insert_or_assign(id, CDDeletionRestriction{ id, restricted, ids, checkType }); + } + + bool Allows(const uint32_t row) const { + std::set checked; + return inventory->CheckDeletionRestriction(1234, row, checked); + } +}; + +TEST_F(DeletionRestrictionTests, AlwaysRestrictedOnlyWhenRestricted) { + EXPECT_FALSE(Allows(1)); + EXPECT_TRUE(Allows(2)); +} + +TEST_F(DeletionRestrictionTests, ZoneAllowsOnlyInTheListedZones) { + EXPECT_TRUE(Allows(3)); + EXPECT_FALSE(Allows(4)); +} + +TEST_F(DeletionRestrictionTests, AnyAndAllCombineOtherRows) { + EXPECT_TRUE(Allows(5)); // always restricted OR in zone 1 + EXPECT_FALSE(Allows(6)); // always restricted AND in zone 1 +} + +TEST_F(DeletionRestrictionTests, ARowThatNamesItselfDoesNotLoop) { + EXPECT_TRUE(Allows(7)); +} + +TEST_F(DeletionRestrictionTests, MissingRowsAndBadRowsAllowIt) { + EXPECT_TRUE(Allows(99)); + EXPECT_TRUE(Allows(8)); // no valid ids + EXPECT_TRUE(Allows(10)); // unknown check type +} + +TEST_F(DeletionRestrictionTests, LotsIncludedNeedsAnotherItemOfThoseLots) { + // The player has no other rocket + EXPECT_FALSE(Allows(9)); + EXPECT_FALSE(inventory->HasOtherItemsOfLots({ 6416 }, 1234, true)); + EXPECT_FALSE(inventory->HasOtherItemsOfLots({ 6416, 4713 }, 1234, false)); +}