feat(inventory): equip and unequip like live

Live, on every equip and unequip (captures: 22,422 ChangeObjectWorldState,
4,269 equip effects, 245 UncastSkill):

- ChangeObjectWorldState on the item to everyone: ATTACHED when equipped,
  INVENTORY when unequipped, proxies (sub-items) included.
- The equip-<slot> / unequip-<slot> effect on the wearer to everyone
  (effect -1, priority 1.07), the slot picked from the item's equip location
  as the client's ItemComponent does (0x00cdafb0): special_l left,
  special_r right, hair head, clavicle, chest, legs; none for others (e.g. a
  rocket's Extra_1); the item's equipEffects with "equip"/"unequip" when it
  has one; no equip effect for noEquipAnimation items, no unequip effect for
  proxies.
- Order when an item replaces another: the new item's state and effect,
  then the old item's, then the old item's proxies taken away
  (RemoveItemFromInventory, UnEquipInventory with ignoreCooldown,
  ChangeObjectWorldState INVENTORY, to the player), then UncastSkill for
  the old item's equip skills (castOnType 1, not its proxies'), then its
  skill removed and the new one added, then the new item's proxies.
- An equipped item taken out of the inventory is followed by
  UnEquipInventory (ignoreCooldown) to the player.

DLU sent none of these (only the rocket's ChangeObjectWorldState, which
now comes from equipping it, before RocketEquipped as live).

Check in game (with a second player watching): equip and unequip a hat, a
sword, a shield, a shirt, pants and a backpack; both players see the equip
animation/effect. Put a ninja hood on, then another hat over it: the hood's
back piece disappears and its passive effects end (no lingering aura or
stat). Launch a rocket: it is still shown during the launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 02:41:12 -05:00
parent b845faee72
commit eaba3d8459
6 changed files with 320 additions and 9 deletions

View File

@@ -21,6 +21,11 @@
#include "Database.h"
#include "CDModularBuildComponentTable.h"
#include "SkillMessages.h"
#include <string_view>
#include <array>
#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<int32_t>(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<std::pair<std::string_view, std::u16string_view>, 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<int32_t>(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<CDObjectSkillsTable>()->Get(item.GetLot())) {
if (skill.castOnType != 1) continue;
GameMessages::UncastSkill uncast;
uncast.target = m_Parent->GetObjectID();
uncast.skillID = static_cast<int32_t>(skill.skillID);
uncast.SendToClient(m_Parent->GetSystemAddress());
}
}
void InventoryComponent::LoadPetXml(const tinyxml2::XMLDocument& document) {
auto* petInventoryElement = document.FirstChildElement("obj")->FirstChildElement("pet");

View File

@@ -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-<slot> / unequip-<slot> 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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
set(DCOMPONENTS_TESTS
"DeletionRestrictionTests.cpp"
"InventorySaveTests.cpp"
"EquipMessagesTests.cpp"
"DestroyableLevelConfigTests.cpp"
"RailActivatorComponentTests.cpp"
"VendorBuybackTests.cpp"

View File

@@ -0,0 +1,212 @@
#include "GameDependencies.h"
#include <gtest/gtest.h>
#include <ranges>
#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-<slot> / unequip-<slot> 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> entity;
std::unique_ptr<Character> 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<CDObjectSkillsTable>();
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<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
entity = std::make_unique<Entity>(0x1000000000000001LL, info);
inventory = entity->AddComponent<InventoryComponent>(-1); // before the character, so it doesn't load a save
character = std::make_unique<Character>(1, nullptr);
entity->SetCharacter(character.get());
entity->AddComponent<CharacterComponent>(-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<uint32_t>(96000 + lot);
auto& registry = CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>();
registry.insert_or_assign(static_cast<uint64_t>(lot), componentID);
registry.insert_or_assign(static_cast<uint64_t>(eReplicaComponentType::ITEM) << 32 | static_cast<uint64_t>(lot), componentID);
CDItemComponent component{};
component.id = componentID;
component.equipLocation = equipLocation;
component.subItems = subItems;
component.noEquipAnimation = noEquipAnimation;
CDClientManager::GetEntriesMutable<CDItemComponentTable>().insert_or_assign(componentID, component);
}
// The proxies (sub-items) equipped with an item
std::vector<Item*> Proxies(const Item* item) {
std::vector<Item*> 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<uint32_t>(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<Sent> Messages(const std::vector<CapturedPacket>& packets) const {
std::vector<Sent> out;
for (const auto& packet : packets) {
RakNet::BitStream bitStream(const_cast<uint8_t*>(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<Sent> 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<Sent>{ { 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<Sent>{ { CHANGE_OBJECT_WORLD_STATE, rocket->GetId(), true, "ATTACHED" } }));
}