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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 03:13:17 -05:00
parent 6a438ac419
commit 08053a1b01
2 changed files with 52 additions and 0 deletions

View File

@@ -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) {

View File

@@ -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 <algorithm>
#include <cstring>
#include <optional>
@@ -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<BaseCombatAIComponent>(-1);
auto* destroyable = enemy.AddComponent<DestroyableComponent>(-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<GameMessages::SetStunned>(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<GameMessages::SetStunned>(sent).empty());
}