From dd116b9794785aebbbcb061722e5f0fb5501d085 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:13:56 -0500 Subject: [PATCH] feat(skills): end a dead caster's skill when a client reports it (CasterDead) When a client gets EchoStartSkill (or SyncSkill) from a caster it sees as dead, aimed at another object, it logs "msgEchoStartSkill msgCasterDead" and sends CasterDead (120: optional i64Caster, optional uiSkillHandle) through that target (LWOSkillComponent::msgEchoStartSkill 0x00d5dc90). 303 live packets, the target nearly always the attacked player, the caster a spawned enemy; the next messages were mostly Die and SetStunned for the player. DLU dropped it. The server now ends that skill on the caster: its behaviors with that handle are dropped (end entries run, pending timers and syncs discarded, its projectiles removed), so a hit an enemy scheduled before dying does not land later. It only does so when the server also sees the caster as dead, so a client can't cancel a living enemy's attack. What live did with the message is inferred from the name and when it was sent. Check in game: kill an enemy in the middle of a slow or charged attack (e.g. a Maelstrom horseman or a spider queen add): its attack does not hit after it has died, and other enemies' attacks still hit normally. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/SkillComponent.cpp | 17 ++++++++ dGame/dComponents/SkillComponent.h | 12 ++++++ dGame/dGameMessages/GameMessageHandler.cpp | 1 + dGame/dGameMessages/SkillMessages.cpp | 22 +++++++++++ dGame/dGameMessages/SkillMessages.h | 13 +++++++ .../ClientMessagesTests.cpp | 39 +++++++++++++++++++ 6 files changed, 104 insertions(+) diff --git a/dGame/dComponents/SkillComponent.cpp b/dGame/dComponents/SkillComponent.cpp index e745f81b9..a39ecc188 100644 --- a/dGame/dComponents/SkillComponent.cpp +++ b/dGame/dComponents/SkillComponent.cpp @@ -202,6 +202,23 @@ void SkillComponent::Reset() { this->m_managedBehaviors.clear(); } +void SkillComponent::EndSkill(const uint32_t skillUid) { + const auto [begin, end] = m_managedBehaviors.equal_range(skillUid); + for (auto it = begin; it != end; ++it) { + auto* const context = it->second; + if (!context) continue; + std::erase_if(m_managedProjectiles, [context](const ProjectileSyncEntry& projectile) { return projectile.context == context; }); + // End what the skill started, but drop its pending timers (Reset would run them now: a late hit) + for (const auto& entry : context->endEntries) entry.behavior->End(context, entry.branchContext, entry.second); + context->endEntries.clear(); + context->timerEntries.clear(); + context->syncEntries.clear(); + context->scheduledUpdates.clear(); + delete context; + } + m_managedBehaviors.erase(begin, end); +} + void SkillComponent::Interrupt() { // TODO: need to check immunities on the destroyable component, but they aren't implemented auto* combat = m_Parent->GetComponent(); diff --git a/dGame/dComponents/SkillComponent.h b/dGame/dComponents/SkillComponent.h index 6cea05929..2fbf95952 100644 --- a/dGame/dComponents/SkillComponent.h +++ b/dGame/dComponents/SkillComponent.h @@ -88,6 +88,18 @@ public: */ void Interrupt(); + /** + * Ends a skill this entity is running: drops its behaviors (and their pending hits and projectiles) with that + * skill handle + * @param skillUid the skill handle + */ + void EndSkill(uint32_t skillUid); + + /** + * @return whether this entity runs a skill with that handle + */ + bool HasSkill(uint32_t skillUid) const { return m_managedBehaviors.contains(skillUid); } + /** * Starts a player skill. Should only be called when the server receives a start skill message from the client. * @param behaviorId the root behavior ID of the skill diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index b9be5e22e..9e4767855 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -169,6 +169,7 @@ namespace { { SELECT_SKILL, []() { return std::make_unique(); } }, { START_SKILL, []() { return std::make_unique(); } }, { SYNC_SKILL, []() { return std::make_unique(); } }, + { CASTER_DEAD, []() { return std::make_unique(); } }, { REQUEST_SERVER_PROJECTILE_IMPACT, []() { return std::make_unique(); } }, // Combat diff --git a/dGame/dGameMessages/SkillMessages.cpp b/dGame/dGameMessages/SkillMessages.cpp index b2a9b61c3..5d05623ec 100644 --- a/dGame/dGameMessages/SkillMessages.cpp +++ b/dGame/dGameMessages/SkillMessages.cpp @@ -8,6 +8,7 @@ #include "dServer.h" #include "eMissionTaskType.h" #include "Entity.h" +#include "EntityManager.h" #include "Game.h" #include "InventoryComponent.h" #include "MissionComponent.h" @@ -57,6 +58,27 @@ namespace GameMessages { return true; } + void CasterDead::Serialize(RakNet::BitStream& bitStream) const { + BitStreamUtils::WriteOptional(bitStream, i64Caster, LWOOBJID_EMPTY); + BitStreamUtils::WriteOptional(bitStream, uiSkillHandle, 0u); + } + + bool CasterDead::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, i64Caster, LWOOBJID_EMPTY)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, uiSkillHandle, 0u)); + return true; + } + + void CasterDead::Handle(Entity& entity, const SystemAddress& sysAddr) { + // Only for a caster the server also sees as dead: a client can't cancel a living enemy's attack + auto* const caster = Game::entityManager->GetEntity(i64Caster); + if (!caster) return; + const auto* const destroyable = caster->GetComponent(); + if (!destroyable || !destroyable->GetIsDead()) return; + auto* const skillComponent = caster->GetComponent(); + if (skillComponent) skillComponent->EndSkill(uiSkillHandle); + } + void SelectSkill::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(bFromSkillSet); bitStream.Write(skillID); diff --git a/dGame/dGameMessages/SkillMessages.h b/dGame/dGameMessages/SkillMessages.h index 0be118386..5804615c0 100644 --- a/dGame/dGameMessages/SkillMessages.h +++ b/dGame/dGameMessages/SkillMessages.h @@ -49,6 +49,19 @@ namespace GameMessages { int32_t skillID{}; }; + // Client -> server. A client got EchoStartSkill (or SyncSkill) from a caster it sees as dead, aimed at another + // object (LWOSkillComponent::msgEchoStartSkill 0x00d5dc90), and tells the server through that target object. + // The server ends the caster's skill with that handle if the caster is dead there too. + struct CasterDead : public NetGameMsg { + CasterDead() : NetGameMsg(MessageType::Game::CASTER_DEAD) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + LWOOBJID i64Caster{ LWOOBJID_EMPTY }; // optional + uint32_t uiSkillHandle{}; // optional + }; + // Client -> server. The player picked a skill; DLU only uses it to fix invisible items on first load. struct SelectSkill : public NetGameMsg { SelectSkill() : NetGameMsg(MessageType::Game::SELECT_SKILL) {} diff --git a/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp index dd4017b86..0317725a0 100644 --- a/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp @@ -3,7 +3,12 @@ #include "GameDependencies.h" #include "GameMessageTestUtils.h" +#include "CDClientDatabase.h" #include "CharacterComponent.h" +#include "DestroyableComponent.h" +#include "EntityManager.h" +#include "SkillComponent.h" +#include "SkillMessages.h" #include "Entity.h" #include "PlayerMessages.h" @@ -82,3 +87,37 @@ TEST_F(ClientMessagesTests, SetLastCustomBuildMatchesLiveCapture) { EXPECT_EQ(characterComponent->GetLastRocketConfig(), u"1:14454;1:4714;1:4715;"); EXPECT_FALSE(characterComponent->GetIsLanding()); } + +// CasterDead (120): an optional caster, then an optional skill handle. 303 live packets, sent through the targeted +// player when an enemy's skill arrived after the enemy died. +TEST_F(ClientMessagesTests, CasterDeadMatchesLiveCapture) { + auto msg = FromLiveClientCapture(std::string(PLAYER_HEADER) + "7800" + "dade8000002000024040000000"); + EXPECT_EQ(msg.i64Caster, 0x40040000000bdb5LL); + EXPECT_EQ(msg.uiSkillHandle, 1u); + EXPECT_EQ(RoundTrip(msg).i64Caster, msg.i64Caster); + ExpectTruncatedFails(msg); +} + +// The caster's skill with that handle ends only when the server also sees the caster as dead. +TEST_F(ClientMessagesTests, CasterDeadEndsTheDeadCastersSkill) { + CDClientDatabase::Connect(":memory:"); // the entity manager looks the new entity up + CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);"); + auto* const caster = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, 0x40040000000bdb5LL); + ASSERT_NE(caster, nullptr); + auto* const destroyable = caster->AddComponent(-1); + auto* const skills = caster->AddComponent(-1); + RakNet::BitStream empty; + skills->CastPlayerSkill(0, 1, empty, player->GetObjectID()); + skills->CastPlayerSkill(0, 2, empty, player->GetObjectID()); + + GameMessages::CasterDead msg; + msg.i64Caster = caster->GetObjectID(); + msg.uiSkillHandle = 1; + msg.Handle(*player, UNASSIGNED_SYSTEM_ADDRESS); + EXPECT_TRUE(skills->HasSkill(1)); // alive: a client can't cancel it + + destroyable->SetIsDead(true); + msg.Handle(*player, UNASSIGNED_SYSTEM_ADDRESS); + EXPECT_FALSE(skills->HasSkill(1)); + EXPECT_TRUE(skills->HasSkill(2)); +}