feat(inventory): enforce DeletionRestrictions when deleting items

Deleting an item now follows its ItemComponent delResIndex row in the
DeletionRestrictions table, the way the client's shared inventory code
decides it (LWOInventoryComponent_Common::CanRemoveFromInventory @
0x00ce0d20, CheckDeletionRestrictionIndex @ 0x00c94c20):
- missing, unrestricted, unknown-type or empty rows allow it;
- LOTS_INCLUDED: another item of any listed LOT must remain;
- LOTS_EXCLUDED: other items of every listed LOT must remain;
- ANY_RESTRICTION / ALL_RESTRICTIONS: any / all listed rows allow it;
- ZONE: only in the listed maps; ALWAYS_RESTRICTED: never.
Operators (GM level 9) may delete anything, as in the client. A refused
delete is logged and the item stays.

ItemComponent.minNumRequired is not used: the client never reads it, so
its meaning can't be verified.

Issue 960: the rocket (6416, row 8) and the classic rocket parts (rows 1-3)
have rows that keep at least one rocket or part, so the last rocket can
no longer be deleted and strand the player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:49:03 -05:00
parent 4399daad4c
commit 02108055e7
9 changed files with 276 additions and 1 deletions

View File

@@ -11,6 +11,9 @@
#include "dServer.h"
#include "Logger.h"
#include "CDClientManager.h"
#include "CDDeletionRestrictionsTable.h"
#include "eGameMasterLevel.h"
#include <set>
#include "ObjectIDManager.h"
#include "MissionComponent.h"
#include "GameMessages.h"
@@ -2063,6 +2066,11 @@ void InventoryComponent::OnRemoveItemFromInventory(const GameMessages::RemoveIte
const auto iStackCount = std::min<uint32_t>(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<eItemType>(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<int64_t> ParseDeletionRestrictionIds(const std::string& ids) {
std::vector<int64_t> 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<int64_t>(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<int32_t>(item.GetInfo().delResIndex);
if (row < 0) return true;
std::set<int32_t> visited;
return CheckDeletionRestriction(item.GetId(), row, visited);
}
bool InventoryComponent::CheckDeletionRestriction(const LWOOBJID itemId, const int32_t row, std::set<int32_t>& 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<CDDeletionRestrictionsTable>()->Get(row);
if (!restriction || !restriction->restricted) return true;
const auto checkType = static_cast<eDeletionRestrictionCheckType>(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<int32_t>(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<int64_t>(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<int64_t> 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;
}
}