diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 96e12c98a..a16ee8aa5 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -5,6 +5,9 @@ #include "EconomyLedger.h" #include +#include +#include +#include #include "Entity.h" #include "Item.h" @@ -157,6 +160,17 @@ void InventoryComponent::LoadDataFromTemplate() { } } +uint32_t InventoryComponent::GetDefaultInventorySize(const eInventoryType type) { + switch (type) { + case eInventoryType::ITEMS: return 20u; + case eInventoryType::VAULT_MODELS: + case eInventoryType::VAULT_ITEMS: return 40u; + case eInventoryType::VENDOR_BUYBACK: return 27u; + case eInventoryType::DONATION: return 24u; + default: return 240u; + } +} + Inventory* InventoryComponent::GetInventory(const eInventoryType type) { const auto index = m_Inventories.find(type); @@ -165,25 +179,7 @@ Inventory* InventoryComponent::GetInventory(const eInventoryType type) { } // Create new empty inventory - uint32_t size = 240u; - - switch (type) { - case eInventoryType::ITEMS: - size = 20u; - break; - case eInventoryType::VAULT_MODELS: - case eInventoryType::VAULT_ITEMS: - size = 40u; - break; - case eInventoryType::VENDOR_BUYBACK: - size = 27u; - break; - case eInventoryType::DONATION: - size = 24u; - break; - default: - break; - } + const auto size = GetDefaultInventorySize(type); auto* inventory = new Inventory(type, size, {}, this); @@ -646,6 +642,104 @@ bool InventoryComponent::HasSpaceForLoot(const Loot::Return& loot) { return true; } +bool InventoryComponent::CanAddItems(const std::vector>& items, const std::vector>& itemsToTurnIn, + const std::map& sizeIncrease) const { + // A copy of the inventories: per inventory its items in ID order (new ones last, as the client's new IDs) + struct SimItem { + LOT lot; + uint32_t count; + uint32_t slot; + LWOOBJID subKey; + }; + std::map> inventories; + std::map sizes; + for (const auto& [type, inventory] : m_Inventories) { + if (!inventory) continue; + auto& simItems = inventories[type]; + for (const auto& [id, item] : inventory->GetItems()) { + if (item) simItems.push_back({ item->GetLot(), item->GetCount(), item->GetSlot(), item->GetSubKey() }); + } + sizes[type] = inventory->GetSize(); + } + for (const auto& [type, increase] : sizeIncrease) sizes[type] += increase; + + // Turned in items leave first; an item of 1 settles the whole count, as the client + for (auto [lot, count] : itemsToTurnIn) { + for (auto& [type, simItems] : inventories) { + for (auto it = simItems.begin(); count != 0 && it != simItems.end();) { + if (it->lot != lot) { + ++it; + continue; + } + if (it->count < 2) { + it = simItems.erase(it); + count = 0; + continue; + } + const auto taken = std::min(count, it->count); + it->count -= taken; + count -= taken; + it = it->count == 0 ? simItems.erase(it) : it + 1; + } + if (count == 0) break; + } + } + + // LWOInventoryComponent_Common::GetInvItemCount over every inventory + const auto heldCount = [this](const LOT lot, const std::optional only) { + uint32_t total = 0; + for (const auto& [type, inventory] : m_Inventories) { + if (!inventory || (only && type != *only)) continue; + for (const auto& [id, item] : inventory->GetItems()) { + if (item && item->GetLot() == lot) total += item->GetCount(); + } + } + return total; + }; + + for (const auto& [lot, wanted] : items) { + const auto type = Inventory::FindInventoryTypeForLot(lot); + // An inventory the server has not made yet is empty (the client refuses one it does not have, but has every + // one the server sent) + if (!inventories.contains(type)) sizes[type] += GetDefaultInventorySize(type); + auto& simItems = inventories[type]; + + const auto& info = Inventory::FindItemComponent(lot); + // LookupItemInfo (0x00c1c190): a stack size of 0 is no limit + const uint32_t stack = info.stackSize == 0 ? std::numeric_limits::max() : info.stackSize; + auto left = wanted; + if (info.isUnique) { + if (heldCount(lot, std::nullopt) != heldCount(lot, eInventoryType::VENDOR_BUYBACK)) return false; // UniqueItemStillExistsInAnInventory + left = 1; + } + + for (auto& simItem : simItems) { + if (left == 0) break; + if (simItem.lot != lot || simItem.subKey != LWOOBJID_EMPTY || simItem.count >= stack) continue; + const auto added = std::min(left, stack - simItem.count); + simItem.count += added; + left -= added; + } + + if (left == 0) continue; + std::set takenSlots; + for (const auto& simItem : simItems) takenSlots.insert(simItem.slot); + const auto size = sizes[type]; + while (left != 0) { + if (takenSlots.size() >= size) return false; // TargetInventoryOutOfRoom + uint32_t slot = 0; + while (slot < size && takenSlots.contains(slot)) slot++; + if (slot >= size) return false; // items sit beyond the size; the client would look for a slot forever + takenSlots.insert(slot); + const auto added = std::min(left, stack); + left -= added; + simItems.push_back({ lot, added, slot, LWOOBJID_EMPTY }); + } + } + + return true; +} + namespace { /** * Cars and rockets built before builds were recorded have no subkey, so the client has no blueprint id to ask for diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index a51990659..f99d2c581 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -126,6 +126,9 @@ public: */ Inventory* GetInventory(eInventoryType type); + // The size an inventory starts with when the server makes it + static uint32_t GetDefaultInventorySize(eInventoryType type); + /** * Returns all the inventories this entity has, indexed by type * @return all the inventories this entity has, indexed by type @@ -255,6 +258,18 @@ public: */ bool HasSpaceForLoot(const Loot::Return& loot); + /** + * Whether all of these items would fit, as the client works it out before turning in a mission + * (LWOInventoryComponent_Common::CheckCanAddItems 0x00d53de0): the items to turn in are taken out first, the bag + * sizes grow by sizeIncrease, a unique item fails while one is held outside the buyback, then each item tops up + * stacks of its LOT and takes free slots. Nothing changes. + * @param items LOTs and counts to add + * @param itemsToTurnIn LOTs and counts taken from any inventory first + * @param sizeIncrease extra slots per inventory + */ + bool CanAddItems(const std::vector>& items, const std::vector>& itemsToTurnIn, + const std::map& sizeIncrease) const; + /** * Equips an item in the specified slot * @param location the location to store the item (e.g. chest, left hand, etc.) diff --git a/dGame/dGameMessages/MissionMessages.cpp b/dGame/dGameMessages/MissionMessages.cpp index 7daaa8c35..23d047929 100644 --- a/dGame/dGameMessages/MissionMessages.cpp +++ b/dGame/dGameMessages/MissionMessages.cpp @@ -160,6 +160,14 @@ namespace GameMessages { if (iMissionState == eMissionState::AVAILABLE || iMissionState == eMissionState::COMPLETE_AVAILABLE) { missionComponent->AcceptMission(missionID); } else if (iMissionState == eMissionState::READY_TO_COMPLETE || iMissionState == eMissionState::COMPLETE_READY_TO_COMPLETE) { + // LWOMissionComponent::MissionWindowOK (0x00d501f0) only turns a mission in when every reward fits + // (CanReceiveAllRewards, with the reward the player picked) + const auto* const mission = missionComponent->GetMission(missionID); + const auto chosen = mission && mission->GetReward() > 0 ? mission->GetReward() : -1; + if (!mission || !mission->CanReceiveAllRewards(chosen)) { + LOG("Player %llu cannot receive all the rewards of mission %i, not turning it in", player->GetObjectID(), missionID); + return; + } missionComponent->CompleteMission(missionID); } diff --git a/dGame/dMission/Mission.cpp b/dGame/dMission/Mission.cpp index 6f4c77833..d73822b5c 100644 --- a/dGame/dMission/Mission.cpp +++ b/dGame/dMission/Mission.cpp @@ -2,6 +2,9 @@ #include "ChatServerLink.h" #include +#include +#include +#include #include #include #include @@ -464,6 +467,56 @@ void Mission::Catchup() { } } +bool Mission::CanReceiveAllRewards(const LOT chosenReward) const { + if (IsComplete()) return false; + + auto* const entity = GetAssociate(); + auto* const inventoryComponent = entity ? entity->GetComponent() : nullptr; + if (!inventoryComponent) return false; + + // The repeat rewards once ever completed; a reward item counts at least 1 (LWOMission 0x00fa35d0) + const bool repeat = m_Completions > 0; + const std::array, 4> rewards = repeat + ? std::array, 4>{ { { info.reward_item1_repeatable, info.reward_item1_repeat_count }, { info.reward_item2_repeatable, info.reward_item2_repeat_count }, + { info.reward_item3_repeatable, info.reward_item3_repeat_count }, { info.reward_item4_repeatable, info.reward_item4_repeat_count } } } + : std::array, 4>{ { { info.reward_item1, info.reward_item1_count }, { info.reward_item2, info.reward_item2_count }, + { info.reward_item3, info.reward_item3_count }, { info.reward_item4, info.reward_item4_count } } }; + std::vector> items; + for (const auto& [lot, count] : rewards) { + if (lot == LOT_NULL || (chosenReward != -1 && lot != chosenReward)) continue; + items.emplace_back(lot, static_cast(std::max(count, 1))); + } + + // The bag rewards come with the first completion only + std::map sizeIncrease; + if (!repeat) { + if (info.reward_maxinventory > 0) sizeIncrease[eInventoryType::ITEMS] += info.reward_maxinventory; + if (info.reward_maxmodel > 0) sizeIncrease[eInventoryType::MODELS] += info.reward_maxmodel; + } + + // The items the tasks take (sub_00fa2f20): a gather task's target unless it keeps them (LWOGatherTask 0x00f919c0), + // one of a delivery task's target + const bool noGiver = (info.offer_objectID == -1 || info.offer_objectID == 0) && (info.target_objectID == -1 || info.target_objectID == 0); + std::vector> itemsToTurnIn; + for (const auto* const task : m_Tasks) { + const auto& taskInfo = task->GetClientInfo(); + const auto target = static_cast(taskInfo.target); + if (task->GetType() == eMissionTaskType::GATHER) { + const auto& parameters = task->GetParameters(); + int32_t param = parameters.empty() ? 2 : static_cast(parameters[0]); + if (noGiver && (param & 2) != 0) param = 1; // an achievement never takes items + const bool keep = (param & 1) != 0 && (param & 2) == 0; + if (!keep && target != LOT_NULL) itemsToTurnIn.emplace_back(target, static_cast(taskInfo.targetValue)); + } else if (task->GetType() == eMissionTaskType::DELIVERY) { + if (target != LOT_NULL) itemsToTurnIn.emplace_back(target, 1); + } + } + + // The client also refuses when the coin reward would pass its max currency (the save's cm, no limit without it); + // the server writes no cm, so there is no limit to check + return inventoryComponent->CanAddItems(items, itemsToTurnIn, sizeIncrease); +} + void Mission::YieldRewards() { auto* entity = GetAssociate(); diff --git a/dGame/dMission/Mission.h b/dGame/dMission/Mission.h index f3149ad84..3cd640e29 100644 --- a/dGame/dMission/Mission.h +++ b/dGame/dMission/Mission.h @@ -250,6 +250,14 @@ public: * Whether the player has looked at this mission in progress (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0), * saved as v on the mission's entry */ + /** + * Whether the player can take every reward of this mission in progress, as the client checks before turning it in + * (LWOMissionComponent::msgCanReceiveAllRewards 0x00d17e20, sub_00d17750): the reward items (all, or only the + * chosen one) must fit in the inventory after the items the tasks take, with the bag rewards added. + * @param chosenReward the reward the player picked, -1 for all + */ + bool CanReceiveAllRewards(LOT chosenReward = -1) const; + bool GetViewed() const { return m_Viewed; } void SetViewed(const bool viewed) { m_Viewed = viewed; } diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 9375d59da..dec176192 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -16,6 +16,7 @@ set(DCOMPONENTS_TESTS "CollectibleComponentTests.cpp" "MissionOfferTests.cpp" "MissionClientRulesTests.cpp" + "MissionRewardSpaceTests.cpp" "ReplicaComponentOrderTests.cpp" "QuickBuildCompleteTests.cpp" "RocketLaunchTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/MissionRewardSpaceTests.cpp b/tests/dGameTests/dComponentsTests/MissionRewardSpaceTests.cpp new file mode 100644 index 000000000..a709f3619 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/MissionRewardSpaceTests.cpp @@ -0,0 +1,203 @@ +// Whether a mission's rewards fit before it is turned in, as the 1.10.64 client checks +// (LWOMissionComponent::msgCanReceiveAllRewards 0x00d17e20, LWOInventoryComponent_Common::CheckCanAddItems 0x00d53de0). +#include "GameDependencies.h" + +#include "CDClientDatabase.h" +#include "CDClientManager.h" +#include "CDComponentsRegistryTable.h" +#include "CDItemComponentTable.h" +#include "CDMissionTasksTable.h" +#include "CDMissionsTable.h" +#include "Entity.h" +#include "GameMessages.h" +#include "InventoryComponent.h" +#include "Item.h" +#include "Mission.h" +#include "MissionComponent.h" +#include "eItemType.h" +#include "eMissionTaskType.h" +#include "eReplicaComponentType.h" + +#include "tinyxml2.h" + +#include +#include +#include + +class MissionRewardSpaceTests : public GameDependenciesTest { +protected: + static constexpr LOT HAT = 8100; // stack 1 + static constexpr LOT GEM = 8101; // stack 10 + static constexpr LOT UNIQUE = 8102; // unique + static constexpr LOT DEED = 8103; // stack 0: no limit + static constexpr uint32_t PLAIN = 993001; // rewards a hat; the repeat rewards 2 hats + static constexpr uint32_t GATHER = 993002; // takes 2 gems, rewards a hat + static constexpr uint32_t KEEP = 993003; // gathers 2 gems but keeps them, rewards a hat + static constexpr uint32_t BAG = 993004; // rewards a hat and one more slot + static constexpr uint32_t GEMS = 993005; // rewards 5 gems + static constexpr uint32_t ONE = 993006; // rewards the unique item + static constexpr uint32_t CHOICE = 993007; // rewards a hat or 5 gems + + std::unique_ptr player; + InventoryComponent* inventory{}; + MissionComponent* missions{}; + LWOOBJID nextId = 0x9000; + + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + RegisterLot(HAT, 1, false); + RegisterLot(GEM, 10, false); + RegisterLot(UNIQUE, 1, true); + RegisterLot(DEED, 0, false); + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + + auto& rows = CDClientManager::GetEntriesMutable(); + rows.push_back(Rewards(PLAIN, HAT, 1)); + rows.back().repeatable = true; + rows.back().reward_item1_repeatable = HAT; + rows.back().reward_item1_repeat_count = 2; + rows.push_back(Rewards(GATHER, HAT, 1)); + rows.push_back(Rewards(KEEP, HAT, 1)); + rows.push_back(Rewards(BAG, HAT, 1)); + rows.back().reward_maxinventory = 1; + rows.push_back(Rewards(GEMS, GEM, 5)); + rows.push_back(Rewards(ONE, UNIQUE, 1)); + rows.push_back(Rewards(CHOICE, HAT, 1)); + rows.back().reward_item2 = GEM; + rows.back().reward_item2_count = 5; + + auto& tasks = CDClientManager::GetEntriesMutable(); + tasks.push_back(GatherTask(GATHER, "")); + tasks.push_back(GatherTask(KEEP, "1")); + + player = std::make_unique(48, info); + inventory = player->AddComponent(-1); + missions = player->AddComponent(-1); + } + + void TearDown() override { + player.reset(); + CDClientManager::GetEntriesMutable().clear(); + CDClientManager::GetEntriesMutable().clear(); + TearDownDependencies(); + } + + static CDMissions Rewards(const uint32_t id, const LOT lot, const int32_t count) { + CDMissions row{}; + row.id = id; + row.isMission = true; + row.cooldownTime = -1; + row.offer_objectID = 1000; + row.target_objectID = 1000; + for (auto* const item : { &row.reward_item1, &row.reward_item2, &row.reward_item3, &row.reward_item4, + &row.reward_item1_repeatable, &row.reward_item2_repeatable, &row.reward_item3_repeatable, &row.reward_item4_repeatable }) *item = LOT_NULL; + row.reward_item1 = lot; + row.reward_item1_count = count; + return row; + } + + static CDMissionTasks GatherTask(const uint32_t missionId, const std::string& param) { + CDMissionTasks task{}; + task.id = missionId; + task.taskType = static_cast(eMissionTaskType::GATHER); + task.target = GEM; + task.targetValue = 2; + task.taskParam1 = param; + return task; + } + + static void RegisterLot(const LOT lot, const uint32_t stack, const bool unique) { + const auto componentID = static_cast(97000 + 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.itemType = static_cast(eItemType::HAT); + component.stackSize = stack; + component.isUnique = unique; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + void Give(const LOT lot, const uint32_t count, const eInventoryType type = eInventoryType::ITEMS) { + auto* const bag = inventory->GetInventory(type); + new Item(nextId++, lot, bag, bag->FindEmptySlot(), count, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + } + + // Fills the items bag with hats up to all but `free` slots + void Fill(const int32_t free) { + auto* const bag = inventory->GetInventory(eInventoryType::ITEMS); + while (bag->GetEmptySlots() > free) Give(HAT, 1); + } + + // The mission ready to turn in (the first time, or a repeat) + const Mission& Ready(const uint32_t id, const bool repeat = false) { + tinyxml2::XMLDocument doc; + const auto ids = std::to_string(id); + const auto done = repeat ? "" : std::string(); + const auto xml = "" + done + ""; + doc.Parse(xml.c_str()); + missions->LoadFromSaveData(doc); + return *missions->GetMission(id); + } +}; + +TEST_F(MissionRewardSpaceTests, RewardNeedsAFreeSlot) { + const auto& mission = Ready(PLAIN); + Fill(1); + EXPECT_TRUE(mission.CanReceiveAllRewards()); + Fill(0); + EXPECT_FALSE(mission.CanReceiveAllRewards()); +} + +TEST_F(MissionRewardSpaceTests, RepeatRewardsAndTheBagReward) { + Fill(1); + EXPECT_FALSE(Ready(PLAIN, true).CanReceiveAllRewards()); // 2 hats the second time + Fill(0); + EXPECT_TRUE(Ready(BAG).CanReceiveAllRewards()); // the reward's own extra slot +} + +TEST_F(MissionRewardSpaceTests, TakenItemsMakeRoom) { + Fill(1); + Give(GEM, 1); // the last slot; taking one gem settles the whole count, as the client + EXPECT_TRUE(Ready(GATHER).CanReceiveAllRewards()); + EXPECT_FALSE(Ready(KEEP).CanReceiveAllRewards()); +} + +TEST_F(MissionRewardSpaceTests, StacksAreToppedUp) { + Fill(1); + Give(GEM, 5); + EXPECT_TRUE(Ready(GEMS).CanReceiveAllRewards()); + EXPECT_TRUE(inventory->CanAddItems({ { GEM, 5 } }, {}, {})); + EXPECT_FALSE(inventory->CanAddItems({ { GEM, 6 } }, {}, {})); + EXPECT_FALSE(inventory->CanAddItems({ { GEM, 5 }, { GEM, 1 } }, {}, {})); + EXPECT_FALSE(inventory->CanAddItems({ { DEED, 1000 } }, { { GEM, 4 } }, {})); // the gems keep their slot + EXPECT_TRUE(inventory->CanAddItems({ { DEED, 1000 } }, { { GEM, 5 } }, {})); // a stack size of 0 is no limit + EXPECT_TRUE(inventory->CanAddItems({ { HAT, 1 } }, {}, { { eInventoryType::ITEMS, 1 } })); +} + +TEST_F(MissionRewardSpaceTests, OneUniqueItem) { + const auto& mission = Ready(ONE); + EXPECT_TRUE(mission.CanReceiveAllRewards()); + Give(UNIQUE, 1, eInventoryType::VENDOR_BUYBACK); + EXPECT_TRUE(mission.CanReceiveAllRewards()); // only sold back + Give(UNIQUE, 1, eInventoryType::VAULT_ITEMS); + EXPECT_FALSE(mission.CanReceiveAllRewards()); +} + +TEST_F(MissionRewardSpaceTests, OnlyTheChosenReward) { + const auto& mission = Ready(CHOICE); + Fill(1); + EXPECT_FALSE(mission.CanReceiveAllRewards()); // a hat and the gems need two slots + EXPECT_TRUE(mission.CanReceiveAllRewards(HAT)); + EXPECT_TRUE(mission.CanReceiveAllRewards(GEM)); +} + +TEST_F(MissionRewardSpaceTests, NotInProgress) { + tinyxml2::XMLDocument doc; + doc.Parse(R"()"); + missions->LoadFromSaveData(doc); + EXPECT_FALSE(missions->GetMission(PLAIN)->CanReceiveAllRewards()); +}