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()); +}