fix(inventory): nothing is used while stunned from using items, as the client

LWOInventoryComponent_Common::msgUseNonEquipmentItem (0x00cdeba0) asks
GetStunned first and uses nothing when canUseItem is false
(LWOControllablePhysComponent::msgGetStunned 0x00be0df0: stunned from
using items, or dead). The server now counts the use-item stun of every
SetStunned it sends for an entity as msgSetStunned (0x00bf1d10) does
(push/pop, never below 0, characters only, unless immune and not
bIgnoreImmunity), and checks it with IsDead before using an item. The
server never checked either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-02 10:54:35 -05:00
parent a538f32382
commit 02c96b08bd
6 changed files with 131 additions and 1 deletions

View File

@@ -38,6 +38,7 @@
#include "ModuleAssemblyComponent.h"
#include "HavokVehiclePhysicsComponent.h"
#include "CharacterComponent.h"
#include "ControllablePhysicsComponent.h"
#include "dZoneManager.h"
#include "PropertyManagementComponent.h"
#include "DestroyableComponent.h"
@@ -2407,8 +2408,17 @@ void InventoryComponent::OnClientItemConsumed(const GameMessages::ClientItemCons
void InventoryComponent::OnUseNonEquipmentItem(const GameMessages::UseNonEquipmentItem& msg) {
auto* item = FindItemById(msg.itemToUse);
if (!item) return;
if (item) item->UseNonEquip(item);
// As LWOInventoryComponent_Common::msgUseNonEquipmentItem (0x00cdeba0): nothing is used while GetStunned says
// canUseItem is false (LWOControllablePhysComponent::msgGetStunned 0x00be0df0: stunned from using items, or dead)
auto* const physics = m_Parent->GetComponent<ControllablePhysicsComponent>();
GameMessages::IsDead isDead{};
isDead.target = m_Parent->GetObjectID();
const bool dead = m_Parent->HandleMsg(isDead) && isDead.bDead;
if (physics && (physics->GetStunnedFromUsingItems() || dead)) return;
item->UseNonEquip(item);
}
void InventoryComponent::OnUpdateInventoryGroup(const GameMessages::UpdateInventoryGroup& msg) {

View File

@@ -370,6 +370,13 @@ void ControllablePhysicsComponent::DeactivateBubbleBuff() {
Game::entityManager->SerializeEntity(m_Parent);
};
void ControllablePhysicsComponent::ApplyStun(const GameMessages::SetStunned& stun) {
// Only characters are stunned from using items
if (!stun.bCantUseItem || !m_Parent->IsPlayer()) return;
if (!stun.bIgnoreImmunity && m_ImmuneToStunUseItemCount > 0) return;
m_StunnedUseItemCount = std::max(0, m_StunnedUseItemCount + (stun.StateChangeType == eStateChangeType::PUSH ? 1 : -1));
}
void ControllablePhysicsComponent::SetStunImmunity(
const eStateChangeType state,
const LWOOBJID originator,

View File

@@ -19,6 +19,9 @@ namespace tinyxml2 {
class Entity;
class dpEntity;
enum class eStateChangeType : uint32_t;
namespace GameMessages {
struct SetStunned;
}
/**
* Handles the movement of controllable Entities, e.g. enemies and players
@@ -289,6 +292,11 @@ public:
const bool GetImmuneToStunTurn() { return m_ImmuneToStunTurnCount > 0;};
const bool GetImmuneToStunUseItem() { return m_ImmuneToStunUseItemCount > 0;};
// A SetStunned sent for this entity, counted as LWOControllablePhysComponent::msgSetStunned (0x00bf1d10) counts it.
// Only the use-item stun is kept: the server checks it before using an item (msgGetStunned 0x00be0df0).
void ApplyStun(const GameMessages::SetStunned& stun);
bool GetStunnedFromUsingItems() const { return m_StunnedUseItemCount > 0; }
private:
// The save's physics state (LoadFromSaveData), then where the character comes into the world
void ReadSaveData(const tinyxml2::XMLDocument& doc);
@@ -421,6 +429,9 @@ private:
int32_t m_ImmuneToStunMoveCount;
int32_t m_ImmuneToStunTurnCount;
int32_t m_ImmuneToStunUseItemCount;
// Stuns from using items (ApplyStun)
int32_t m_StunnedUseItemCount = 0;
};
#endif // CONTROLLABLEPHYSICSCOMPONENT_H

View File

@@ -208,6 +208,13 @@ namespace GameMessages {
return true;
}
void SetStunned::Send(const SystemAddress& sysAddr) const {
auto* const entity = Game::entityManager->GetEntity(target);
auto* const physics = entity ? entity->GetComponent<ControllablePhysicsComponent>() : nullptr;
if (physics) physics->ApplyStun(*this);
NetGameMsg::Send(sysAddr);
}
void SetStunned::Serialize(RakNet::BitStream& bitStream) const {
BitStreamUtils::WriteOptional(bitStream, Originator, LWOOBJID_EMPTY);
bitStream.Write(StateChangeType);

View File

@@ -132,6 +132,8 @@ namespace GameMessages {
// Server -> client.
struct SetStunned : public NetGameMsg {
SetStunned() : NetGameMsg(MessageType::Game::SET_STUNNED) {}
// Also counts the stun on the target, as the client does (ControllablePhysicsComponent::ApplyStun), then sends it
void Send(const SystemAddress& sysAddr) const;
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;

View File

@@ -5,11 +5,17 @@
#include "CDItemComponentTable.h"
#include "CDLootMatrixTable.h"
#include "CDPackageComponentTable.h"
#include "Character.h"
#include "CharacterComponent.h"
#include "CombatMessages.h"
#include "ControllablePhysicsComponent.h"
#include "DestroyableComponent.h"
#include "Entity.h"
#include "InventoryComponent.h"
#include "InventoryMessages.h"
#include "Item.h"
#include "eReplicaComponentType.h"
#include "eStateChangeType.h"
#include "dGameMessagesTests/GameMessageTestUtils.h"
@@ -65,3 +71,90 @@ TEST_F(UseItemResultTests, AnOpenedPackageReportsSuccess) {
EXPECT_TRUE(results[0].useItemResult);
EXPECT_EQ(package->GetCount(), 1u);
}
// LWOInventoryComponent_Common::msgUseNonEquipmentItem (0x00cdeba0) uses nothing while GetStunned says the player can't
// use items (LWOControllablePhysComponent::msgGetStunned 0x00be0df0)
class UseItemStunTests : public UseItemResultTests {
protected:
std::unique_ptr<Character> character;
ControllablePhysicsComponent* physics{};
Item* package{};
void SetUp() override {
UseItemResultTests::SetUp();
// Again as a player
entity.reset();
info.lot = 1;
CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
entity = std::make_unique<Entity>(1, info);
inventoryComponent = entity->AddComponent<InventoryComponent>(-1);
character = std::make_unique<Character>(1, nullptr);
entity->SetCharacter(character.get());
entity->AddComponent<CharacterComponent>(-1, character.get(), ClientAddress())->InitializeStatisticsFromString("");
physics = entity->AddComponent<ControllablePhysicsComponent>(-1);
auto* const items = inventoryComponent->GetInventory(eInventoryType::ITEMS);
package = new Item(0x7000, PACKAGE, items, 0, 5, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE);
}
void TearDown() override {
entity->SetCharacter(nullptr);
entity.reset();
character.reset();
UseItemResultTests::TearDown();
}
void Use() {
GameMessages::UseNonEquipmentItem msg;
msg.itemToUse = package->GetId();
inventoryComponent->OnUseNonEquipmentItem(msg);
}
void Stun(const eStateChangeType type, const bool cantUseItem = true) {
GameMessages::SetStunned stun;
stun.target = entity->GetObjectID();
stun.StateChangeType = type;
stun.bCantUseItem = cantUseItem;
physics->ApplyStun(stun);
}
};
TEST_F(UseItemStunTests, NothingIsUsedWhileStunnedFromUsingItems) {
ASSERT_TRUE(entity->IsPlayer());
Stun(eStateChangeType::PUSH);
Stun(eStateChangeType::PUSH);
Use();
EXPECT_EQ(package->GetCount(), 5u);
Stun(eStateChangeType::POP);
Use();
EXPECT_EQ(package->GetCount(), 5u);
Stun(eStateChangeType::POP);
Use();
EXPECT_EQ(package->GetCount(), 4u);
// Other stuns don't stop it, and a pop with nothing pushed stays at 0
Stun(eStateChangeType::PUSH, false);
Stun(eStateChangeType::POP);
Use();
EXPECT_EQ(package->GetCount(), 3u);
}
TEST_F(UseItemStunTests, ImmunityKeepsTheStunOff) {
physics->SetStunImmunity(eStateChangeType::PUSH, LWOOBJID_EMPTY, false, false, false, false, false, false, true);
GameMessages::SetStunned stun;
stun.StateChangeType = eStateChangeType::PUSH;
stun.bCantUseItem = true;
stun.bIgnoreImmunity = false;
physics->ApplyStun(stun);
EXPECT_FALSE(physics->GetStunnedFromUsingItems());
stun.bIgnoreImmunity = true;
physics->ApplyStun(stun);
EXPECT_TRUE(physics->GetStunnedFromUsingItems());
}
TEST_F(UseItemStunTests, NothingIsUsedWhileDead) {
entity->AddComponent<DestroyableComponent>(-1)->SetIsDead(true);
Use();
EXPECT_EQ(package->GetCount(), 5u);
}