From 94b411a32e82b6392f8c3b51e1e0fe4b15d81202 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 21:44:17 -0500 Subject: [PATCH] feat(combat): TacArc picks its targets in the client's order The client's TacArcBehavior::Cast (0x00fb2d10) sorts the targets in the arc nearest first, or by weight when distance_weight or angle_weight is set (sortWithWeights, 0x00f58cd0: distance_weight * (max range - distance) / max range + angle_weight * (180 - angle) / 180, heaviest first). With use_attack_priority, SortByAttackPriority (0x00f72900) then buckets them by GetAttackPriority, lowest first, keeping that order inside each bucket; only the DestroyableComponent answers it, and an object without one counts as 1. DoHit keeps the first max targets. Nothing else ranks targets: enemies are taken over nearer smashables only because their attack_priority (1) is lower than most smashables' (10). use_attack_priority is off when a behavior does not set it (TacArcBehavior:: Initialize, 0x00f9b980), as before. The server sorted by distance only and ignored the flag, so a one-target swing hit the nearest crate instead of the enemy behind it. OrderTargets now does the client's ordering, with equal targets in ascending id order (the order the client's id set hands them over in); the chosen targets are still written in ascending id order (issue 1045). Co-Authored-By: Claude Opus 5.5 --- dGame/dBehaviors/TacArcBehavior.cpp | 81 ++++++++++++-------- dGame/dBehaviors/TacArcBehavior.h | 22 +++++- tests/dGameTests/TacArcTests.cpp | 115 ++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 33 deletions(-) diff --git a/dGame/dBehaviors/TacArcBehavior.cpp b/dGame/dBehaviors/TacArcBehavior.cpp index 01a3b1df4..527dc8ffe 100644 --- a/dGame/dBehaviors/TacArcBehavior.cpp +++ b/dGame/dBehaviors/TacArcBehavior.cpp @@ -9,6 +9,9 @@ #include "QuickBuildComponent.h" #include "DestroyableComponent.h" +#include +#include +#include #include #include @@ -89,10 +92,10 @@ bool TacArcBehavior::ReadTargets(RakNet::BitStream& bitStream, const uint32_t ma return true; } -std::set TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, const std::vector& closestFirst, const uint32_t maxTargets) { - // TacArcBehavior::DoHit (0x00fb10c0): the closest maxTargets targets, written and acted on in ascending id order +std::set TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, const std::vector& ordered, const uint32_t maxTargets) { + // TacArcBehavior::DoHit (0x00fb10c0): the first maxTargets targets, written and acted on in ascending id order std::set targets; - for (const auto target : closestFirst) { + for (const auto target : ordered) { if (targets.size() >= maxTargets) break; if (target != LWOOBJID_EMPTY) targets.insert(target); } @@ -102,6 +105,34 @@ std::set TacArcBehavior::WriteTargets(RakNet::BitStream& bitStream, co return targets; } +std::vector TacArcBehavior::OrderTargets(std::vector candidates, const float distanceWeight, const float angleWeight, const float maxRange, const bool useAttackPriority) { + // GetObjectsInsideTacArc hands the targets over in a set, so they start in ascending id order + std::ranges::sort(candidates, std::less{}, &Candidate::id); + + // TacArcBehavior::Cast (0x00fb2d10) sorts by distance unless a weight is set + if (distanceWeight == 0.0f && angleWeight == 0.0f) { + std::ranges::stable_sort(candidates, std::less{}, &Candidate::distance); + } else { + // TacArcBehavior::sortWithWeights (0x00f58cd0): nearer and more straight ahead weighs more, heaviest first + const auto weight = [=](const Candidate& candidate) { + const auto distanceScore = maxRange > 0.0f ? (maxRange - candidate.distance) / maxRange : 0.0f; + const auto angleScore = std::abs(candidate.angle - 180.0f) / 180.0f; + return distanceWeight * distanceScore + angleWeight * angleScore; + }; + std::ranges::stable_sort(candidates, std::greater{}, weight); + } + + // TacArcBehavior::SortByAttackPriority (0x00f72900): buckets by GetAttackPriority, lowest first, each bucket in + // the order above. Nothing else ranks targets: enemies come before smashables because the enemies' + // attack_priority (1) is lower than most smashables' (10). + if (useAttackPriority) std::ranges::stable_sort(candidates, std::less{}, &Candidate::attackPriority); + + std::vector ordered; + ordered.reserve(candidates.size()); + for (const auto& candidate : candidates) ordered.push_back(candidate.id); + return ordered; +} + void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) { auto* self = Game::entityManager->GetEntity(context->originator); if (self == nullptr) { @@ -130,6 +161,7 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS auto reference = self->GetPosition() + m_offset; targets.clear(); + std::vector candidates; std::vector validTargets = Game::entityManager->GetEntitiesByProximity(reference, this->m_maxRange); @@ -165,32 +197,21 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS const float degreeAngle = std::abs(Vector3::Angle(forward, normalized) * (180 / 3.14) - 180); if (distance >= this->m_minRange && this->m_maxRange >= distance && degreeAngle <= 2 * this->m_angle) { targets.push_back(validTarget); + const auto* destroyable = validTarget->GetComponent(); + candidates.push_back({ + .id = validTarget->GetObjectID(), + .distance = distance, + .angle = degreeAngle, + // An object that does not answer GetAttackPriority keeps the message's default of 1 + .attackPriority = destroyable ? destroyable->GetAttackPriority() : 1, + }); } } - std::sort(targets.begin(), targets.end(), [this, reference, combatAi](Entity* a, Entity* b) { - const auto aDistance = Vector3::DistanceSquared(reference, a->GetPosition()); - const auto bDistance = Vector3::DistanceSquared(reference, b->GetPosition()); - - return aDistance < bDistance; - }); - - - if (m_useAttackPriority) { - // this should be using the attack priority column on the destroyable component - // We want targets with no threat level to remain the same order as above - // std::stable_sort(targets.begin(), targets.end(), [combatAi](Entity* a, Entity* b) { - // const auto aThreat = combatAi->GetThreat(a->GetObjectID()); - // const auto bThreat = combatAi->GetThreat(b->GetObjectID()); - - // If enabled for this behavior, prioritize threat over distance - // return aThreat > bThreat; - // }); - } - - // After we've sorted and found our closest targets, size the vector down in case there are too many - if (m_maxTargets > 0 && targets.size() > m_maxTargets) targets.resize(m_maxTargets); - const auto hit = !targets.empty(); + // The client keeps the first max targets of this order + auto ordered = OrderTargets(std::move(candidates), m_distanceWeight, m_angleWeight, m_maxRange, m_useAttackPriority); + if (m_maxTargets > 0 && ordered.size() > m_maxTargets) ordered.resize(m_maxTargets); + const auto hit = !ordered.empty(); bitStream.Write(hit); if (this->m_checkEnv) { @@ -199,13 +220,12 @@ void TacArcBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS } if (hit) { - if (combatAi) combatAi->LookAt(targets[0]->GetPosition()); + const auto* first = Game::entityManager->GetEntity(ordered.front()); + if (combatAi && first) combatAi->LookAt(first->GetPosition()); context->foundTarget = true; // We want to continue with this behavior - std::vector closestFirst; - for (const auto* target : targets) closestFirst.push_back(target->GetObjectID()); - for (const auto target : WriteTargets(bitStream, closestFirst, this->m_maxTargets)) { + for (const auto target : WriteTargets(bitStream, ordered, this->m_maxTargets)) { branch.target = target; this->m_action->Calculate(context, bitStream, branch); } @@ -236,6 +256,7 @@ void TacArcBehavior::Load() { this->m_usePickedTarget = GetBoolean("use_picked_target", false); this->m_useTargetPostion = GetBoolean("use_target_position", false); this->m_checkEnv = GetBoolean("check_env", false); + // TacArcBehavior::Initialize (0x00f9b980): off unless the behavior sets it this->m_useAttackPriority = GetBoolean("use_attack_priority", false); this->m_action = GetAction("action"); diff --git a/dGame/dBehaviors/TacArcBehavior.h b/dGame/dBehaviors/TacArcBehavior.h index f1dbd6050..19d453d15 100644 --- a/dGame/dBehaviors/TacArcBehavior.h +++ b/dGame/dBehaviors/TacArcBehavior.h @@ -13,13 +13,29 @@ public: void Calculate(BehaviorContext* context, RakNet::BitStream& bitStream, BehaviorBranchContext branch) override; void Load() override; + // use_attack_priority, off when the behavior does not set it + bool UsesAttackPriority() const { return m_useAttackPriority; } + // Reads the target count and ids the way the client writes them: at most maxTargets, returned ascending and // without empty ids. False when the data is cut short or lists too many targets. static bool ReadTargets(RakNet::BitStream& bitStream, uint32_t maxTargets, std::set& targets); - // Writes the closest maxTargets of closestFirst as the client does and returns them in the order their action - // data follows (ascending id) - static std::set WriteTargets(RakNet::BitStream& bitStream, const std::vector& closestFirst, uint32_t maxTargets); + // Writes the first maxTargets of ordered (see OrderTargets) as the client does and returns them in the order + // their action data follows (ascending id) + static std::set WriteTargets(RakNet::BitStream& bitStream, const std::vector& ordered, uint32_t maxTargets); + + // A target in the arc and what the client orders it by + struct Candidate { + LWOOBJID id = LWOOBJID_EMPTY; + float distance = 0.0f; + float angle = 0.0f; // degrees from the caster's forward, 0 straight ahead + int32_t attackPriority = 1; + }; + + // Orders candidates the way the client does before it keeps the first max targets: nearest first, or highest + // weight first when distance_weight or angle_weight is set; then, with use_attack_priority, lower attack priority + // first, keeping that order within each priority. Equal candidates stay in ascending id order. + static std::vector OrderTargets(std::vector candidates, float distanceWeight, float angleWeight, float maxRange, bool useAttackPriority); private: float m_maxRange; float m_height; diff --git a/tests/dGameTests/TacArcTests.cpp b/tests/dGameTests/TacArcTests.cpp index d9a4a1ad3..97bf38f2e 100644 --- a/tests/dGameTests/TacArcTests.cpp +++ b/tests/dGameTests/TacArcTests.cpp @@ -3,6 +3,10 @@ #include "BitStream.h" #include "GameDependencies.h" #include "TacArcBehavior.h" +#include "CDClientDatabase.h" +#include "CDClientManager.h" +#include "CDBehaviorTemplateTable.h" +#include "CDBehaviorParameterTable.h" // TacArc target lists as the client writes and reads them (TacArcBehavior::DoHit / DoUnserializeBS) class TacArcTests : public GameDependenciesTest { @@ -73,3 +77,114 @@ TEST_F(TacArcTests, WrittenTargetsReadBack) { ASSERT_TRUE(TacArcBehavior::ReadTargets(stream, 100, read)); EXPECT_EQ(read, written); } + +namespace { + using Candidate = TacArcBehavior::Candidate; + + std::vector Order(const std::vector& candidates, const bool useAttackPriority, const float distanceWeight = 0.0f, const float angleWeight = 0.0f, const float maxRange = 10.0f) { + return TacArcBehavior::OrderTargets(candidates, distanceWeight, angleWeight, maxRange, useAttackPriority); + } +} + +TEST_F(TacArcTests, NearestTargetsComeFirst) { + EXPECT_EQ(Order({ { 1, 5.0f }, { 2, 1.0f }, { 3, 3.0f } }, false), (std::vector{ 2, 3, 1 })); +} + +TEST_F(TacArcTests, LowerAttackPriorityComesFirst) { + // Priority 1 before 10 however far it is; within a priority the nearest first + const std::vector candidates = { + { .id = 1, .distance = 1.0f, .attackPriority = 10 }, + { .id = 2, .distance = 7.0f, .attackPriority = 1 }, + { .id = 3, .distance = 2.0f, .attackPriority = 10 }, + { .id = 4, .distance = 5.0f, .attackPriority = 1 }, + { .id = 5, .distance = 0.5f, .attackPriority = 5 }, + }; + EXPECT_EQ(Order(candidates, true), (std::vector{ 4, 2, 5, 1, 3 })); +} + +TEST_F(TacArcTests, EnemiesComeBeforeSmashablesThroughTheirPriority) { + // An enemy (attack_priority 1) behind a closer crate (10) takes a one-target swing + auto ordered = Order({ { .id = 100, .distance = 1.0f, .attackPriority = 10 }, { .id = 200, .distance = 6.0f, .attackPriority = 1 } }, true); + ordered.resize(1); + EXPECT_EQ(ordered, (std::vector{ 200 })); + + // A smashable that also has priority 1 competes with the enemy on distance only + EXPECT_EQ(Order({ { .id = 100, .distance = 1.0f, .attackPriority = 1 }, { .id = 200, .distance = 6.0f, .attackPriority = 1 } }, true), (std::vector{ 100, 200 })); +} + +TEST_F(TacArcTests, AttackPriorityIsIgnoredWithoutTheFlag) { + EXPECT_EQ(Order({ { .id = 100, .distance = 1.0f, .attackPriority = 10 }, { .id = 200, .distance = 6.0f, .attackPriority = 1 } }, false), (std::vector{ 100, 200 })); +} + +TEST_F(TacArcTests, AttackPriorityIsSigned) { + EXPECT_EQ(Order({ { .id = 1, .distance = 1.0f, .attackPriority = 1 }, { .id = 2, .distance = 2.0f, .attackPriority = -1 } }, true), (std::vector{ 2, 1 })); +} + +TEST_F(TacArcTests, TiesKeepAscendingIds) { + // Same distance and priority: the order the client's id set hands them over in + EXPECT_EQ(Order({ { 30, 2.0f }, { 10, 2.0f }, { 20, 2.0f } }, true), (std::vector{ 10, 20, 30 })); + EXPECT_EQ(Order({ { 30, 2.0f }, { 10, 2.0f }, { 20, 1.0f } }, false), (std::vector{ 20, 10, 30 })); +} + +TEST_F(TacArcTests, WeightsRankByDistanceAndAngle) { + // weight = distance_weight * (max range - distance) / max range + angle_weight * (180 - angle) / 180, heaviest first + const std::vector candidates = { + { .id = 1, .distance = 2.0f, .angle = 90.0f }, // 0.8 + 0.5 + { .id = 2, .distance = 8.0f, .angle = 0.0f }, // 0.2 + 1.0 + { .id = 3, .distance = 7.0f, .angle = 36.0f }, // 0.3 + 0.8 + }; + EXPECT_EQ(Order(candidates, false, 1.0f, 1.0f), (std::vector{ 1, 2, 3 })); + // Only the angle counts: straight ahead first + EXPECT_EQ(Order(candidates, false, 0.0f, 1.0f), (std::vector{ 2, 3, 1 })); + // The priority buckets keep the weighted order inside them + auto withPriority = candidates; + withPriority[1].attackPriority = 10; + EXPECT_EQ(Order(withPriority, true, 0.0f, 1.0f), (std::vector{ 3, 1, 2 })); +} + +TEST_F(TacArcTests, KeptTargetsAreTheFirstOrderedAndWrittenAscending) { + // Priority picks the enemies (ids 50, 60) over the nearer crates, then they are written in ascending id order + const auto ordered = Order({ + { .id = 60, .distance = 4.0f, .attackPriority = 1 }, + { .id = 10, .distance = 1.0f, .attackPriority = 10 }, + { .id = 50, .distance = 6.0f, .attackPriority = 1 }, + { .id = 20, .distance = 2.0f, .attackPriority = 10 }, + }, true); + RakNet::BitStream stream; + const auto written = TacArcBehavior::WriteTargets(stream, ordered, 2); + EXPECT_EQ(std::vector(written.begin(), written.end()), (std::vector{ 50, 60 })); +} + +class TacArcParameterTests : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); + for (const auto* sql : { + "CREATE TABLE BehaviorTemplate (behaviorID INTEGER, templateID INTEGER, effectID INTEGER, effectHandle TEXT);", + "CREATE TABLE BehaviorParameter (behaviorID INTEGER, parameterID TEXT, value REAL);", + // 990701 sets use_attack_priority, 990702 clears it, 990703 is an older TacArc without it + "INSERT INTO BehaviorTemplate VALUES (990701, 3, 0, ''), (990702, 3, 0, ''), (990703, 3, 0, '');", + "INSERT INTO BehaviorParameter VALUES (990701, 'use_attack_priority', 1), (990701, 'max range', 8), (990702, 'use_attack_priority', 0), (990702, 'max range', 8), (990703, 'max range', 8);", + }) { + CDClientDatabase::ExecuteDML(sql); + } + CDClientManager::GetTable()->LoadValuesFromDatabase(); + CDClientManager::GetTable()->LoadValuesFromDatabase(); + } + + void TearDown() override { TearDownDependencies(); } +}; + +TEST_F(TacArcParameterTests, UseAttackPriorityIsOffUnlessSet) { + // The client's TacArcBehavior::Initialize reads use_attack_priority with a default of 0 + TacArcBehavior set(990701); + set.Load(); + TacArcBehavior cleared(990702); + cleared.Load(); + TacArcBehavior missing(990703); + missing.Load(); + EXPECT_TRUE(set.UsesAttackPriority()); + EXPECT_FALSE(cleared.UsesAttackPriority()); + EXPECT_FALSE(missing.UsesAttackPriority()); +}