diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 87b57727d..2df5d371f 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -21,6 +21,11 @@ #include "Database.h" #include "CDModularBuildComponentTable.h" #include "SkillMessages.h" +#include +#include +#include "eObjectWorldState.h" +#include "EffectsMessages.h" +#include "ZoneMessages.h" #include "MovementMessages.h" #include "SkillComponent.h" #include "Character.h" @@ -1052,7 +1057,9 @@ void InventoryComponent::EquipItem(Item* item, const bool skipChecks) { if (item->GetInfo().isBOE) item->SetBound(true); - GenerateProxies(item); + // Live: the item is attached (and its effect played) first, then the item it replaces is unequipped, then the + // skills change and the item's proxies are added + SendEquipState(*item, true); UpdateSlot(item->GetInfo().equipLocation, { item->GetId(), item->GetLot(), item->GetCount(), item->GetSlot(), item->GetConfig() }); @@ -1060,6 +1067,8 @@ void InventoryComponent::EquipItem(Item* item, const bool skipChecks) { AddItemSkills(item->GetLot()); + GenerateProxies(item); + EquipScripts(item); Game::entityManager->SerializeEntity(m_Parent); @@ -1082,12 +1091,21 @@ void InventoryComponent::UnEquipItem(Item* item) { set->OnUnEquip(lot); } + // Taken out of its slot first, so its proxies (purged below) don't unequip it again + RemoveSlot(item->GetInfo().equipLocation); + + // Live: back to the inventory (and its effect), its proxies taken away, its equip skills uncast, then its skill + // removed + SendEquipState(*item, false); + + PurgeProxies(item); + + SendUncastEquipSkills(*item); + RemoveBuff(item); RemoveItemSkills(item->GetLot()); - RemoveSlot(item->GetInfo().equipLocation); - UnequipScripts(item); Game::entityManager->SerializeEntity(m_Parent); @@ -1746,12 +1764,75 @@ void InventoryComponent::PurgeProxies(Item* item) { auto proxies = FindProxies(item->GetId()); for (auto* proxy : proxies) { - proxy->UnEquip(); + // Live told the client each proxy left the inventory (then UnEquipInventory and ChangeObjectWorldState, + // from RemoveFromInventory) + GameMessages::RemoveItemFromInventory removeItem; + removeItem.target = m_Parent->GetObjectID(); + removeItem.eInvType = proxy->GetInventory()->GetType(); + removeItem.eLootTypeSource = static_cast(eLootSourceType::INVENTORY); + removeItem.iObjID = proxy->GetId(); + removeItem.iObjTemplate = proxy->GetLot(); + removeItem.iRequestingObjID = item->GetId(); + removeItem.iStackCount = proxy->GetCount(); + removeItem.iStackRemaining = 0; + if (m_Parent->IsPlayer()) removeItem.SendToClient(m_Parent->GetSystemAddress()); proxy->RemoveFromInventory(); } } +namespace { + // The effect an item plays on its wearer when it is equipped or unequipped, as LWOItemComponent::SendMessage + // (0x00cdafb0) picks it from the equip location; none for the other locations + std::u16string EquipEffectType(const std::string& equipLocation, const bool equip) { + static const std::array, 6> SLOTS = { { + { "special_l", u"left" }, { "special_r", u"right" }, { "hair", u"head" }, + { "clavicle", u"clavicle" }, { "chest", u"chest" }, { "legs", u"legs" }, + } }; + for (const auto& [location, slot] : SLOTS) { + if (equipLocation == location) return std::u16string(equip ? u"equip-" : u"unequip-") + std::u16string(slot); + } + return u""; + } +} + +void InventoryComponent::SendEquipState(const Item& item, const bool equipped) const { + // Live sent every equip and unequip to everyone, proxies included + GameMessages::ChangeObjectWorldState worldState; + worldState.target = item.GetId(); + worldState.newState = equipped ? eObjectWorldState::ATTACHED : eObjectWorldState::INVENTORY; + worldState.Send(UNASSIGNED_SYSTEM_ADDRESS); + + // The client plays no equip effect for noEquipAnimation items; live played no unequip effect for proxies (they + // go with their item) + const auto& info = item.GetInfo(); + if (equipped ? info.noEquipAnimation : item.GetParent() != LWOOBJID_EMPTY) return; + + GameMessages::PlayFXEffect effect; + effect.target = m_Parent->GetObjectID(); + if (info.equipEffects != 0) { + effect.effectID = static_cast(info.equipEffects); + effect.effectType = equipped ? u"equip" : u"unequip"; + } else { + effect.effectType = EquipEffectType(info.equipLocation, equipped); + } + if (effect.effectType.empty()) return; + effect.priority = 1.07f; + effect.Send(UNASSIGNED_SYSTEM_ADDRESS); +} + +void InventoryComponent::SendUncastEquipSkills(const Item& item) const { + // Proxies' equip skills aren't cast (FindBuffs), so there is nothing to uncast + if (item.GetParent() != LWOOBJID_EMPTY || !m_Parent->IsPlayer()) return; + for (const auto& skill : CDClientManager::GetTable()->Get(item.GetLot())) { + if (skill.castOnType != 1) continue; + GameMessages::UncastSkill uncast; + uncast.target = m_Parent->GetObjectID(); + uncast.skillID = static_cast(skill.skillID); + uncast.SendToClient(m_Parent->GetSystemAddress()); + } +} + void InventoryComponent::LoadPetXml(const tinyxml2::XMLDocument& document) { auto* petInventoryElement = document.FirstChildElement("obj")->FirstChildElement("pet"); diff --git a/dGame/dComponents/InventoryComponent.h b/dGame/dComponents/InventoryComponent.h index 4ff55a512..66305108c 100644 --- a/dGame/dComponents/InventoryComponent.h +++ b/dGame/dComponents/InventoryComponent.h @@ -579,6 +579,17 @@ private: */ void PurgeProxies(Item* item); + /** + * Tells everyone an item was equipped or unequipped: ChangeObjectWorldState (ATTACHED / INVENTORY) on the item and + * the equip- / unequip- effect on the wearer, as live did + */ + void SendEquipState(const Item& item, bool equipped) const; + + /** + * Tells the player the item's equip skills (castOnType 1) ended: UncastSkill for each, as live did on unequip + */ + void SendUncastEquipSkills(const Item& item) const; + /** * Saves all the pet information stored in inventory items to the database * @param document the xml doc to save to diff --git a/dGame/dComponents/RocketLaunchpadControlComponent.cpp b/dGame/dComponents/RocketLaunchpadControlComponent.cpp index 6a0beec72..686e51dde 100644 --- a/dGame/dComponents/RocketLaunchpadControlComponent.cpp +++ b/dGame/dComponents/RocketLaunchpadControlComponent.cpp @@ -78,13 +78,9 @@ void RocketLaunchpadControlComponent::Launch(Entity* originator, LWOMAPID mapId, SetSelectedMapId(originator->GetObjectID(), zone); + // Equipping the rocket (RocketEquip) sent ChangeObjectWorldState(ATTACHED); live sent it before this event GameMessages::FireEventClientSide(m_Parent->GetObjectID(), u"RocketEquipped", rocket->GetId(), originator->GetObjectID()).SendToClient(originator->GetSystemAddress()); - GameMessages::ChangeObjectWorldState worldState; - worldState.target = rocket->GetId(); - worldState.newState = eObjectWorldState::ATTACHED; - worldState.Send(UNASSIGNED_SYSTEM_ADDRESS); - Game::entityManager->SerializeEntity(originator); } diff --git a/dGame/dInventory/Item.cpp b/dGame/dInventory/Item.cpp index 45c493a10..ec0a67de4 100644 --- a/dGame/dInventory/Item.cpp +++ b/dGame/dInventory/Item.cpp @@ -564,6 +564,16 @@ void Item::DisassembleModel(uint32_t numToDismantle) { } void Item::RemoveFromInventory() { + // Live told the client an equipped item taken out of the inventory was unequipped (after RemoveItemFromInventory) + if (IsEquipped()) { + auto* const entity = inventory->GetComponent()->GetParent(); + GameMessages::UnEquipInventory unequip; + unequip.target = entity->GetObjectID(); + unequip.bIgnoreCooldown = true; + unequip.itemToUnequip = id; + if (entity->IsPlayer()) unequip.SendToClient(entity->GetSystemAddress()); + } + UnEquip(); count = 0; diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 2b96405e9..7e06f77cb 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -1,6 +1,7 @@ set(DCOMPONENTS_TESTS "DeletionRestrictionTests.cpp" "InventorySaveTests.cpp" + "EquipMessagesTests.cpp" "DestroyableLevelConfigTests.cpp" "RailActivatorComponentTests.cpp" "VendorBuybackTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/EquipMessagesTests.cpp b/tests/dGameTests/dComponentsTests/EquipMessagesTests.cpp new file mode 100644 index 000000000..773038b5a --- /dev/null +++ b/tests/dGameTests/dComponentsTests/EquipMessagesTests.cpp @@ -0,0 +1,212 @@ +#include "GameDependencies.h" +#include + +#include + +#include "CDClientDatabase.h" +#include "CDComponentsRegistryTable.h" +#include "CDItemComponentTable.h" +#include "CDObjectSkillsTable.h" +#include "Character.h" +#include "CharacterComponent.h" +#include "EffectsMessages.h" +#include "Entity.h" +#include "InventoryComponent.h" +#include "InventoryMessages.h" +#include "Item.h" +#include "SkillMessages.h" +#include "ZoneMessages.h" +#include "eReplicaComponentType.h" + +#include "dGameMessagesTests/GameMessageTestUtils.h" + +// Equipping and unequipping as live did (captures, e.g. equipping a head item over a ninja hood): ChangeObjectWorldState +// on the item and the equip- / unequip- effect on the wearer to everyone; the replaced item's proxies taken +// away (RemoveItemFromInventory, UnEquipInventory, ChangeObjectWorldState) and its equip skills uncast, to the player. + +using namespace GameMessageTestUtils; + +class EquipMessagesTests : public GameDependenciesTest { +protected: + static constexpr LOT HOOD = 2641; // hair, with the proxy below and equip skills 362 and 371 + static constexpr LOT HOOD_PROXY = 10482; // clavicle, no equip animation + static constexpr LOT HAT = 2632; // hair + static constexpr LOT SWORD = 7000; // special_r + static constexpr LOT ROCKET = 6416; // Extra_1: no effect + + std::unique_ptr entity; + std::unique_ptr character; + InventoryComponent* inventory{}; + LWOOBJID nextId = 0x1000000000007000LL; + + void SetUp() override { + SetUpDependencies(); + // Equipping looks the item's set up in the CDClient; none of these items is in one + CDClientDatabase::Connect(":memory:"); + CDClientDatabase::ExecuteDML("CREATE TABLE ItemSets (setID INTEGER, itemIDs TEXT);"); + RegisterLot(HOOD, "hair", std::to_string(HOOD_PROXY)); + RegisterLot(HOOD_PROXY, "clavicle", "", true); + RegisterLot(HAT, "hair"); + RegisterLot(SWORD, "special_r"); + RegisterLot(ROCKET, "Extra_1"); + auto& skills = CDClientManager::GetEntriesMutable(); + skills.push_back({ HOOD, 362, 1, 0 }); + skills.push_back({ HOOD, 371, 1, 0 }); + skills.push_back({ HOOD_PROXY, 372, 1, 0 }); + + info.lot = 1; // a player + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + entity = std::make_unique(0x1000000000000001LL, info); + inventory = entity->AddComponent(-1); // before the character, so it doesn't load a save + character = std::make_unique(1, nullptr); + entity->SetCharacter(character.get()); + entity->AddComponent(-1, character.get(), ClientAddress())->InitializeStatisticsFromString(""); + } + + void TearDown() override { + entity->SetCharacter(nullptr); + entity.reset(); + character.reset(); + TearDownDependencies(); + } + + static void RegisterLot(const LOT lot, const std::string& equipLocation, const std::string& subItems = "", const bool noEquipAnimation = false) { + const auto componentID = static_cast(96000 + 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.equipLocation = equipLocation; + component.subItems = subItems; + component.noEquipAnimation = noEquipAnimation; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + // The proxies (sub-items) equipped with an item + std::vector Proxies(const Item* item) { + std::vector proxies; + for (auto* candidate : inventory->GetInventory(eInventoryType::ITEM_SETS)->GetItems() | std::views::values) { + if (candidate->GetParent() == item->GetId()) proxies.push_back(candidate); + } + return proxies; + } + + Item* Give(const LOT lot) { + auto* const bag = inventory->GetInventory(eInventoryType::ITEMS); + return new Item(nextId++, lot, bag, static_cast(bag->GetItems().size()), 1, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + } + + // Each game message sent: what it is, about which object, to whom, and the fields the tests look at + struct Sent { + MessageType::Game id{}; + LWOOBJID target{}; + bool toEveryone{}; + std::string detail; + bool operator==(const Sent&) const = default; + }; + + std::vector Messages(const std::vector& packets) const { + std::vector out; + for (const auto& packet : packets) { + RakNet::BitStream bitStream(const_cast(packet.bytes.data()), packet.bytes.size(), false); + Sent sent; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, sent.target, sent.id)) continue; + sent.toEveryone = packet.broadcast && packet.sysAddr == UNASSIGNED_SYSTEM_ADDRESS; + if (!sent.toEveryone) EXPECT_EQ(packet.sysAddr, ClientAddress()); + switch (sent.id) { + case MessageType::Game::CHANGE_OBJECT_WORLD_STATE: { + GameMessages::ChangeObjectWorldState msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + sent.detail = msg.newState == eObjectWorldState::ATTACHED ? "ATTACHED" : "INVENTORY"; + break; + } + case MessageType::Game::PLAY_FX_EFFECT: { + GameMessages::PlayFXEffect msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + EXPECT_EQ(msg.effectID, -1); + EXPECT_FLOAT_EQ(msg.priority, 1.07f); + sent.detail = GeneralUtils::UTF16ToWTF8(msg.effectType); + break; + } + case MessageType::Game::UN_EQUIP_INVENTORY: { + GameMessages::UnEquipInventory msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + EXPECT_TRUE(msg.bIgnoreCooldown); + sent.detail = std::to_string(msg.itemToUnequip); + break; + } + case MessageType::Game::REMOVE_ITEM_FROM_INVENTORY: { + GameMessages::RemoveItemFromInventory msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + sent.detail = std::to_string(msg.iObjID); + break; + } + case MessageType::Game::UNCAST_SKILL: { + GameMessages::UncastSkill msg; + EXPECT_TRUE(msg.Deserialize(bitStream)); + sent.detail = std::to_string(msg.skillID); + break; + } + case MessageType::Game::ADD_ITEM_TO_INVENTORY_CLIENT_SYNC: + break; + default: + continue; // not part of equipping + } + out.push_back(sent); + } + return out; + } +}; + +TEST_F(EquipMessagesTests, EquippingAndReplacingAsLiveDid) { + using enum MessageType::Game; + const auto player = entity->GetObjectID(); + auto* const hood = Give(HOOD); + auto* const hat = Give(HAT); + + // The hood: attached with its effect, then its proxy added and attached (no effect: no equip animation) + auto sent = Messages(Capture([&] { hood->Equip(); })); + ASSERT_EQ(sent.size(), 4); + EXPECT_EQ(sent[0], (Sent{ CHANGE_OBJECT_WORLD_STATE, hood->GetId(), true, "ATTACHED" })); + EXPECT_EQ(sent[1], (Sent{ PLAY_FX_EFFECT, player, true, "equip-head" })); + EXPECT_EQ(sent[2].id, ADD_ITEM_TO_INVENTORY_CLIENT_SYNC); + const auto proxies = Proxies(hood); + ASSERT_EQ(proxies.size(), 1); + const auto proxy = proxies[0]->GetId(); + EXPECT_EQ(sent[3], (Sent{ CHANGE_OBJECT_WORLD_STATE, proxy, true, "ATTACHED" })); + + // The hat replaces it: the hat first, then the hood goes back with its effect, its proxy is taken away and its + // equip skills are uncast (the proxy's aren't: they were never cast) + sent = Messages(Capture([&] { hat->Equip(); })); + const std::vector expected = { + { CHANGE_OBJECT_WORLD_STATE, hat->GetId(), true, "ATTACHED" }, + { PLAY_FX_EFFECT, player, true, "equip-head" }, + { CHANGE_OBJECT_WORLD_STATE, hood->GetId(), true, "INVENTORY" }, + { PLAY_FX_EFFECT, player, true, "unequip-head" }, + { REMOVE_ITEM_FROM_INVENTORY, player, false, std::to_string(proxy) }, + { UN_EQUIP_INVENTORY, player, false, std::to_string(proxy) }, + { CHANGE_OBJECT_WORLD_STATE, proxy, true, "INVENTORY" }, + { UNCAST_SKILL, player, false, "362" }, + { UNCAST_SKILL, player, false, "371" }, + }; + EXPECT_EQ(sent, expected); + EXPECT_TRUE(Proxies(hood).empty()); + EXPECT_TRUE(hat->IsEquipped()); + EXPECT_FALSE(hood->IsEquipped()); +} + +TEST_F(EquipMessagesTests, UnequippingAndSlotsWithoutAnEffect) { + using enum MessageType::Game; + const auto player = entity->GetObjectID(); + auto* const sword = Give(SWORD); + auto* const rocket = Give(ROCKET); + + sword->Equip(); + auto sent = Messages(Capture([&] { sword->UnEquip(); })); + EXPECT_EQ(sent, (std::vector{ { CHANGE_OBJECT_WORLD_STATE, sword->GetId(), true, "INVENTORY" }, { PLAY_FX_EFFECT, player, true, "unequip-right" } })); + + // Extra_1 (a rocket) has no effect; the world state still goes out + sent = Messages(Capture([&] { rocket->Equip(true); })); + EXPECT_EQ(sent, (std::vector{ { CHANGE_OBJECT_WORLD_STATE, rocket->GetId(), true, "ATTACHED" } })); +}