From 08053a1b012b4633ed68bd1c5f8ff31787f48006 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:13:17 -0500 Subject: [PATCH] feat(combat): dead enemies are stunned right after Die Live sent SetStunned to every dead object with a combat AI in the packet right after its Die: 7,098 of 7,105 such deaths (7,424 decoded stuns, all the same: no originator, push, can't attack, move or turn, ignore immunity). Objects without combat AI (smashables, 9,393 deaths) never got one. DLU sent none, so a dying enemy could keep turning or start an attack while its death plays. Check: kill enemies mid-attack and while they chase you; they stop moving, turning and attacking the moment they die, and the death and loot still play normally. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/DestroyableComponent.cpp | 12 ++++++ .../DestroyableComponentTests.cpp | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/dGame/dComponents/DestroyableComponent.cpp b/dGame/dComponents/DestroyableComponent.cpp index 7f675eba9..1a1e36d04 100644 --- a/dGame/dComponents/DestroyableComponent.cpp +++ b/dGame/dComponents/DestroyableComponent.cpp @@ -764,6 +764,18 @@ void DestroyableComponent::Smash(const LWOOBJID source, const eKillType killType die.lootOwnerID = source; die.Send(UNASSIGNED_SYSTEM_ADDRESS); + // Live stunned every dead enemy right after its Die (7,098 of 7,105 deaths of objects with combat AI): no + // originator, push, can't attack, move or turn, ignoring immunity. Nothing else that dies gets it. + if (m_Parent->HasComponent(eReplicaComponentType::BASE_COMBAT_AI)) { + GameMessages::SetStunned stun; + stun.target = m_Parent->GetObjectID(); + stun.StateChangeType = eStateChangeType::PUSH; + stun.bCantAttack = true; + stun.bCantMove = true; + stun.bCantTurn = true; + stun.Send(UNASSIGNED_SYSTEM_ADDRESS); + } + // Live told the killer right after Die, before the loot: EnemiesSmashed for an NPC, SmashablesSmashed for another // smashable. Racing counts its own (RacingSmashablesSmashed). if (owner && owner != m_Parent) { diff --git a/tests/dGameTests/dComponentsTests/DestroyableComponentTests.cpp b/tests/dGameTests/dComponentsTests/DestroyableComponentTests.cpp index 30f4dea77..5587ccfd0 100644 --- a/tests/dGameTests/dComponentsTests/DestroyableComponentTests.cpp +++ b/tests/dGameTests/dComponentsTests/DestroyableComponentTests.cpp @@ -8,10 +8,14 @@ #include "eStateChangeType.h" #include "Character.h" #include "CharacterComponent.h" +#include "BaseCombatAIComponent.h" +#include "CDClientDatabase.h" #include "CombatMessages.h" +#include "dGameMessagesTests/GameMessageTestUtils.h" #include "PacketTestUtils.h" #include "dCommonVars.h" +#include #include #include @@ -666,3 +670,39 @@ TEST_F(DestroyableTest, SmashWithoutAttackHasNoDirection) { EXPECT_EQ(die->directionRelative_AngleY, 0.0f); EXPECT_EQ(die->directionRelative_Force, 0.0f); } + +// Live stunned every dead enemy in the packet right after its Die; bytes are a live SetStunned with the ID replaced. +TEST_F(DestroyableTest, DeadEnemyIsStunnedAfterDie) { + if (!CDClientDatabase::isConnected) { + CDClientDatabase::Connect(":memory:"); + for (const auto* table : { + "ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER)", + "BaseCombatAIComponent (id INTEGER, aggroRadius REAL, tetherSpeed REAL, pursuitSpeed REAL, softTetherRadius REAL, hardTetherRadius REAL, minRoundLength REAL, maxRoundLength REAL, combatRoundLength REAL)", + "ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER)", + "SkillBehavior (skillID INTEGER, behaviorID INTEGER)", + }) { + CDClientDatabase::ExecuteDML(std::string("CREATE TABLE ") + table + ";"); + } + } + Entity enemy(0x0102030405060708LL, info); + enemy.AddComponent(-1); + auto* destroyable = enemy.AddComponent(-1); + destroyable->SetMaxHealth(4.0f); + destroyable->SetHealth(4); + + const auto sent = PacketTestUtils::Capture([&] { destroyable->Smash(LWOOBJID_EMPTY); }); + const auto ids = GameMessageTestUtils::SentGameMessageIds(sent); + const auto die = std::find(ids.begin(), ids.end(), MessageType::Game::DIE); + ASSERT_NE(die, ids.end()); + ASSERT_NE(die + 1, ids.end()); + EXPECT_EQ(*(die + 1), MessageType::Game::SET_STUNNED); + + const auto stuns = GameMessageTestUtils::SentGameMessages(sent); + ASSERT_EQ(stuns.size(), 1u); + EXPECT_PACKET_EQ(PacketTestUtils::FromHex("53 05 00 0c 00 00 00 00 08 07 06 05 04 03 02 01 c6 00 00 00 00 00 40 50 80", 193), GameMessageTestUtils::StructPacket(stuns[0])); +} + +TEST_F(DestroyableTest, DeadSmashableIsNotStunned) { + const auto sent = PacketTestUtils::Capture([&] { destroyableComponent->Smash(LWOOBJID_EMPTY); }); + EXPECT_TRUE(GameMessageTestUtils::SentGameMessages(sent).empty()); +}