mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-06 11:57:13 +00:00
feat: deletion restrictions
Deleting an item now follows the item's DeletionRestrictions row (ItemComponent.delResIndex), as the client's LWOInventoryComponent_Common::CanRemoveFromInventory (0x00ce0d20) and CheckDeletionRestrictionIndex (0x00c94c20) do: an item the player is driving or riding can't be deleted, an operator can delete anything, and otherwise the row decides: - LOTS_INCLUDED: only while the player has another item of any of the LOTs (the last rocket can't be deleted) - LOTS_EXCLUDED: only while the player has other items of all of the LOTs - ANY_RESTRICTION / ALL_RESTRICTIONS: any / all of the listed rows allow it (a row that names itself again allows it) - ZONE: only in the listed zones - ALWAYS_RESTRICTED: never A missing, unrestricted or bad row (no valid ids, unknown check type) allows it. The client checks first and shows the row's failure reason; the server checks again and ignores a client's forceDeletion. Refs issue 960 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
17
dCommon/dEnums/eDeletionRestrictionsCheckType.h
Normal file
17
dCommon/dEnums/eDeletionRestrictionsCheckType.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#ifndef __EDELETIONRESTRICTIONSCHECKTYPE__H__
|
||||||
|
#define __EDELETIONRESTRICTIONSCHECKTYPE__H__
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// 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__
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
#include "CDRailActivatorComponent.h"
|
#include "CDRailActivatorComponent.h"
|
||||||
#include "CDRewardCodesTable.h"
|
#include "CDRewardCodesTable.h"
|
||||||
#include "CDPetComponentTable.h"
|
#include "CDPetComponentTable.h"
|
||||||
|
#include "CDDeletionRestrictionsTable.h"
|
||||||
|
|
||||||
#ifndef CDCLIENT_CACHE_ALL
|
#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.
|
// 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(CDSkillBehaviorTable);
|
||||||
DEFINE_TABLE_STORAGE(CDTamingBuildPuzzleTable);
|
DEFINE_TABLE_STORAGE(CDTamingBuildPuzzleTable);
|
||||||
DEFINE_TABLE_STORAGE(CDVendorComponentTable);
|
DEFINE_TABLE_STORAGE(CDVendorComponentTable);
|
||||||
|
DEFINE_TABLE_STORAGE(CDDeletionRestrictionsTable);
|
||||||
|
|
||||||
void CDClientManager::LoadValuesFromDatabase() {
|
void CDClientManager::LoadValuesFromDatabase() {
|
||||||
if (!CDClientDatabase::isConnected) {
|
if (!CDClientDatabase::isConnected) {
|
||||||
@@ -149,6 +151,7 @@ void CDClientManager::LoadValuesFromDatabase() {
|
|||||||
CDTamingBuildPuzzleTable::Instance().LoadValuesFromDatabase();
|
CDTamingBuildPuzzleTable::Instance().LoadValuesFromDatabase();
|
||||||
CDVendorComponentTable::Instance().LoadValuesFromDatabase();
|
CDVendorComponentTable::Instance().LoadValuesFromDatabase();
|
||||||
CDZoneTableTable::LoadValuesFromDatabase();
|
CDZoneTableTable::LoadValuesFromDatabase();
|
||||||
|
CDDeletionRestrictionsTable::Instance().LoadValuesFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CDClientManager::LoadValuesFromDefaults() {
|
void CDClientManager::LoadValuesFromDefaults() {
|
||||||
|
|||||||
@@ -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<uint32_t>(id);
|
||||||
|
if (parsed) entry.ids.push_back(*parsed);
|
||||||
|
}
|
||||||
|
entry.checkType = static_cast<eDeletionRestrictionsCheckType>(tableData.getIntField("checkType", static_cast<int>(eDeletionRestrictionsCheckType::MAX)));
|
||||||
|
|
||||||
|
entries.insert_or_assign(entry.id, entry);
|
||||||
|
tableData.nextRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<CDDeletionRestriction> 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CDTable.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
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<uint32_t> ids;
|
||||||
|
eDeletionRestrictionsCheckType checkType{};
|
||||||
|
};
|
||||||
|
|
||||||
|
class CDDeletionRestrictionsTable : public CDTable<CDDeletionRestrictionsTable, std::map<uint32_t, CDDeletionRestriction>> {
|
||||||
|
public:
|
||||||
|
void LoadValuesFromDatabase();
|
||||||
|
std::optional<CDDeletionRestriction> GetByID(uint32_t id) const;
|
||||||
|
};
|
||||||
@@ -33,7 +33,7 @@ struct CDItemComponent {
|
|||||||
uint32_t itemRating; //!< ???
|
uint32_t itemRating; //!< ???
|
||||||
bool isTwoHanded; //!< Whether or not the item is double handed
|
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 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 currencyLOT; //!< ???
|
||||||
uint32_t altCurrencyCost; //!< ???
|
uint32_t altCurrencyCost; //!< ???
|
||||||
std::string subItems; //!< A comma seperate string of sub items (maybe for multi-itemed things like faction test gear set)
|
std::string subItems; //!< A comma seperate string of sub items (maybe for multi-itemed things like faction test gear set)
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ set(DDATABASE_CDCLIENTDATABASE_CDCLIENTTABLES_SOURCES "CDActivitiesTable.cpp"
|
|||||||
"CDSkillBehaviorTable.cpp"
|
"CDSkillBehaviorTable.cpp"
|
||||||
"CDTamingBuildPuzzleTable.cpp"
|
"CDTamingBuildPuzzleTable.cpp"
|
||||||
"CDVendorComponentTable.cpp"
|
"CDVendorComponentTable.cpp"
|
||||||
"CDZoneTableTable.cpp" PARENT_SCOPE)
|
"CDZoneTableTable.cpp"
|
||||||
|
"CDDeletionRestrictionsTable.cpp" PARENT_SCOPE)
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
#include "CDSkillBehaviorTable.h"
|
#include "CDSkillBehaviorTable.h"
|
||||||
#include "StringifiedEnum.h"
|
#include "StringifiedEnum.h"
|
||||||
#include "Amf3.h"
|
#include "Amf3.h"
|
||||||
|
#include "CDDeletionRestrictionsTable.h"
|
||||||
|
#include "eDeletionRestrictionsCheckType.h"
|
||||||
|
#include "eGameMasterLevel.h"
|
||||||
|
#include "PossessorComponent.h"
|
||||||
|
#include "dZoneManager.h"
|
||||||
|
|
||||||
#include <ranges>
|
#include <ranges>
|
||||||
|
|
||||||
@@ -1893,3 +1898,60 @@ bool InventoryComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo
|
|||||||
|
|
||||||
return true;
|
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<PossessorComponent>();
|
||||||
|
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<int32_t>(restrictionId) < 0) return true;
|
||||||
|
|
||||||
|
std::set<uint32_t> checked;
|
||||||
|
return CheckDeletionRestriction(itemId, restrictionId, checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InventoryComponent::CheckDeletionRestriction(const LWOOBJID itemId, const uint32_t restrictionId, std::set<uint32_t>& checked) const {
|
||||||
|
const auto restriction = CDClientManager::GetTable<CDDeletionRestrictionsTable>()->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<uint32_t>& lots, const LWOOBJID excludedItemId, const bool any) const {
|
||||||
|
std::set<uint32_t> 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*>(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#define INVENTORYCOMPONENT_H
|
#define INVENTORYCOMPONENT_H
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <set>
|
||||||
#include <stack>
|
#include <stack>
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +183,32 @@ public:
|
|||||||
*/
|
*/
|
||||||
Item* FindItemById(LWOOBJID id) const;
|
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<uint32_t>& 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<uint32_t>& lots, LWOOBJID excludedItemId, bool any) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an item in the inventory that matches the specified LOT
|
* Returns an item in the inventory that matches the specified LOT
|
||||||
* @param lot the lot of the item to find
|
* @param lot the lot of the item to find
|
||||||
|
|||||||
@@ -5342,6 +5342,12 @@ void GameMessages::HandleRemoveItemFromInventory(RakNet::BitStream& inStream, En
|
|||||||
|
|
||||||
iStackCount = std::min<uint32_t>(item->GetCount(), iStackCount);
|
iStackCount = std::min<uint32_t>(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) {
|
if (bConfirmed) {
|
||||||
const auto itemType = static_cast<eItemType>(item->GetInfo().itemType);
|
const auto itemType = static_cast<eItemType>(item->GetInfo().itemType);
|
||||||
if (itemType == eItemType::MODEL || itemType == eItemType::LOOT_MODEL) {
|
if (itemType == eItemType::MODEL || itemType == eItemType::LOOT_MODEL) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
set(DCOMPONENTS_TESTS
|
set(DCOMPONENTS_TESTS
|
||||||
|
"DeletionRestrictionTests.cpp"
|
||||||
"DestroyableComponentTests.cpp"
|
"DestroyableComponentTests.cpp"
|
||||||
"PetComponentTests.cpp"
|
"PetComponentTests.cpp"
|
||||||
"SimplePhysicsComponentTests.cpp"
|
"SimplePhysicsComponentTests.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 <gtest/gtest.h>
|
||||||
|
|
||||||
|
// 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> entity;
|
||||||
|
InventoryComponent* inventory{};
|
||||||
|
|
||||||
|
void SetUp() override {
|
||||||
|
SetUpDependencies();
|
||||||
|
CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
|
||||||
|
entity = std::make_unique<Entity>(1, info);
|
||||||
|
inventory = entity->AddComponent<InventoryComponent>(-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<CDDeletionRestrictionsTable>().clear();
|
||||||
|
entity.reset();
|
||||||
|
TearDownDependencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Row(const uint32_t id, const bool restricted, const eDeletionRestrictionsCheckType checkType, const std::vector<uint32_t>& ids) {
|
||||||
|
CDClientManager::GetEntriesMutable<CDDeletionRestrictionsTable>().insert_or_assign(id, CDDeletionRestriction{ id, restricted, ids, checkType });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Allows(const uint32_t row) const {
|
||||||
|
std::set<uint32_t> 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user