From 4e4f66055c068e61b0d4be13c3563d963de6a6b9 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 2 Sep 2026 21:43:31 -0600 Subject: [PATCH] Fix bot thrashing when attack destination has no upcoming cover --- .../neo/bot/behavior/neo_bot_attack.cpp | 124 ++++++++++-------- .../server/neo/bot/behavior/neo_bot_attack.h | 4 + 2 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_attack.cpp b/src/game/server/neo/bot/behavior/neo_bot_attack.cpp index 0d0dab527..525b2916b 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_attack.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_attack.cpp @@ -11,6 +11,9 @@ #include "nav_mesh.h" #include "debugoverlay_shared.h" +ConVar sv_neo_bot_attack_cover_search_interval("sv_neo_bot_attack_cover_search_interval", "1.0", FCVAR_CHEAT, + "Timer throttle (in seconds) for attempts between cover searches", true, 0, false, 0); + ConVar sv_neo_bot_attack_debug_cover("sv_neo_bot_attack_debug_cover", "0", FCVAR_CHEAT, "Draw debug overlays for bot attack/cover behavior", true, 0, true, 1); @@ -58,12 +61,50 @@ class CSearchForAttackCover : public ISearchSurroundingAreasFunctor CSearchForAttackCover( CNEOBot *me, const CKnownEntity *threat, const CNavArea *goalArea = nullptr ) : m_me( me ), m_threat( threat ) { m_attackCoverArea = nullptr; + m_coverAvoidPenalty = FLT_MAX; m_myArea = m_me->GetLastKnownArea(); m_threatArea = threat->GetLastKnownArea(); m_goalArea = goalArea ? goalArea : m_threatArea; // prioritize movement towards input goal area or threat m_myDistToGoalSq = m_goalArea ? ( m_goalArea->GetCenter() - m_me->GetAbsOrigin() ).LengthSqr() : 0; } + bool IsBetterCandidate( CNavArea *area, float avoidPenalty ) const + { + if ( !m_attackCoverArea || avoidPenalty < m_coverAvoidPenalty ) + { + return true; + } + if ( avoidPenalty > m_coverAvoidPenalty ) + { + return false; + } + + // If we already have a previous candidate cover area, + // only consider this new candidate area if it's an improvement + // as we assume earlier breadth first search nodes are closer to bot + // and thus faster to reach for safety. + if ( neo_bot_path_reservation_enable.GetBool() ) + { + // prefer areas that friendly bots have reserved relatively less + return CNEOBotPathReservations()->GetPredictedFriendlyPathCount( area->GetID(), m_me->GetTeamNumber() ) + < CNEOBotPathReservations()->GetPredictedFriendlyPathCount( m_attackCoverArea->GetID(), m_me->GetTeamNumber() ); + } + // Fallback when path reservation is disabled: potentially visible area + // count is a rough proxy for how exposed an area is. It ignores whether + // the area is actually reachable and does nothing to keep friendlies + // from bunching up in the same area. + return area->GetPotentiallyVisibleAreaCount() < m_attackCoverArea->GetPotentiallyVisibleAreaCount(); + } + + // Record area as the current cover candidate. + // Returns whether the search should keep going + bool TakeCandidate( CNavArea *area, float avoidPenalty ) + { + m_attackCoverArea = area; + m_coverAvoidPenalty = avoidPenalty; + return avoidPenalty != 0.0f; // negative value might indicate overflow + } + virtual bool operator() ( CNavArea *baseArea, CNavArea *priorArea, float travelDistanceSoFar ) { // return true to keep searching, false ends search usually implying that suitable cover was found @@ -79,36 +120,12 @@ class CSearchForAttackCover : public ISearchSurroundingAreasFunctor return true; // skip our starting area } - if ( neo_bot_path_reservation_enable.GetBool() && - ( CNEOBotPathReservations()->GetAreaAvoidPenalty(area->GetID()) > 0 ) ) + float avoidPenalty = neo_bot_path_reservation_enable.GetBool() + ? CNEOBotPathReservations()->GetAreaAvoidPenalty( area->GetID() ) + : 0.0f; + if ( !IsBetterCandidate( area, avoidPenalty ) ) { - return true; // skip areas that have had navigation hiccups - } - - if ( m_attackCoverArea ) - { - // If we already have a previous candidate cover area, - // only consider this new candidate area if it's an improvement - // as we assume earlier breadth first search nodes are closer to bot - // and thus faster to reach for safety. - if ( neo_bot_path_reservation_enable.GetBool() ) - { - int candidateReservations = CNEOBotPathReservations()->GetPredictedFriendlyPathCount(area->GetID(), m_me->GetTeamNumber()); - int previousReservations = CNEOBotPathReservations()->GetPredictedFriendlyPathCount(m_attackCoverArea->GetID(), m_me->GetTeamNumber()); - if (candidateReservations >= previousReservations) - { - return true; // skip areas that have been reserved relatively more or equal by friendly bots - } - } - // Fallback in case the path reservation system is disabled - else if (area->GetPotentiallyVisibleAreaCount() >= m_attackCoverArea->GetPotentiallyVisibleAreaCount()) - { - // Use potentially visible area count as a rough proxy for how exposed the area is - // The downsides of this approach are: - // * It doesn't consider whether the nav area is actually reachable - // * It doesn't do anything to discourage friendlies from bunching up in the same area - return true; // skip areas that are relatively more exposed - } + return true; // the cover candidate we already have is at least as good } float goalAreaDistanceSq = ( m_goalArea->GetCenter() - area->GetCenter() ).LengthSqr(); @@ -139,22 +156,20 @@ class CSearchForAttackCover : public ISearchSurroundingAreasFunctor if ( trSmoke.fraction < trNormal.fraction ) { - m_attackCoverArea = area; - return false; // found smoke as concealment + return TakeCandidate( area, avoidPenalty ); // found smoke as concealment } } } else if (!m_threatArea->IsCompletelyVisible(area)) { - m_attackCoverArea = area; + TakeCandidate( area, avoidPenalty ); // partial cover, keep looking for something better } return true; // search for potentially better cover } // found hard cover - m_attackCoverArea = area; - return false; // found suitable cover + return TakeCandidate( area, avoidPenalty ); } virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) @@ -194,7 +209,8 @@ class CSearchForAttackCover : public ISearchSurroundingAreasFunctor CNEOBot *m_me; const CKnownEntity *m_threat; - const CNavArea *m_attackCoverArea; + const CNavArea *m_attackCoverArea; // best cover candidate found so far + float m_coverAvoidPenalty; // avoid penalty of m_attackCoverArea; 0 means no navigation hiccups const CNavArea *m_goalArea; // reference point of the optional goal direction const CNavArea *m_myArea; // reference point of myself const CNavArea *m_threatArea; // reference point of the threat @@ -202,6 +218,22 @@ class CSearchForAttackCover : public ISearchSurroundingAreasFunctor }; +//--------------------------------------------------------------------------------------------- +// Pick the next cover area to leapfrog towards, or nullptr if no better area found +const CNavArea *CNEOBotAttack::FindAttackCover( CNEOBot *me, const CKnownEntity *threat ) +{ + CNavArea *pStartArea = me->GetLastKnownArea(); + if ( !pStartArea ) + { + return nullptr; + } + + CSearchForAttackCover search( me, threat, m_goalArea ); + SearchSurroundingAreas( pStartArea, search ); + return search.m_attackCoverArea; +} + + //--------------------------------------------------------------------------------------------- // head aiming and weapon firing is handled elsewhere - we just need to get into position to fight ActionResult< CNEOBot > CNEOBotAttack::Update( CNEOBot *me, float interval ) @@ -329,27 +361,17 @@ ActionResult< CNEOBot > CNEOBotAttack::Update( CNEOBot *me, float interval ) } // Consider if there is cover between me and goal to leapfrog to - if ( m_bSawEnemySinceLastPathCompute && + if ( m_bSawEnemySinceLastPathCompute && m_coverSearchTimer.IsElapsed() && (!m_attackCoverArea || (me->GetLastKnownArea() == m_attackCoverArea)) ) { + m_coverSearchTimer.Start( sv_neo_bot_attack_cover_search_interval.GetFloat() ); m_bSawEnemySinceLastPathCompute = false; - CSearchForAttackCover search( me, threat, m_goalArea ); - SearchSurroundingAreas( me->GetLastKnownArea(), search ); - if ( search.m_attackCoverArea ) + m_attackCoverArea = FindAttackCover( me, threat ); + if ( m_attackCoverArea ) { - m_attackCoverArea = search.m_attackCoverArea; m_chasePath.Invalidate(); } - else if (m_goalArea) - { - // Even if we bounce back to Attack, goal position may get refreshed in prior behavior - return Done( "Reconsidering goal: Failed to find cover towards goal." ); - } - else - { - m_attackCoverArea = nullptr; - } m_path.Invalidate(); } @@ -378,10 +400,6 @@ ActionResult< CNEOBot > CNEOBotAttack::Update( CNEOBot *me, float interval ) m_attackCoverArea = nullptr; } } - else if (m_goalArea) - { - return Done( "Reconsidering goal: No path to cover found." ); - } else { // Directly chase threat if no cover was found or haven't seen enemy since last path compute diff --git a/src/game/server/neo/bot/behavior/neo_bot_attack.h b/src/game/server/neo/bot/behavior/neo_bot_attack.h index 52ec61eeb..6455404cf 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_attack.h +++ b/src/game/server/neo/bot/behavior/neo_bot_attack.h @@ -4,6 +4,7 @@ #include "Path/NextBotChasePath.h" class CNEOBot; +class CKnownEntity; //------------------------------------------------------------------------------- @@ -27,10 +28,13 @@ class CNEOBotAttack : public Action< CNEOBot > virtual const char *GetName( void ) const { return "Attack"; }; private: + const CNavArea *FindAttackCover( CNEOBot *me, const CKnownEntity *threat ); + bool m_bSawEnemySinceLastPathCompute; // throttles m_attackCoverArea search const CNavArea *m_attackCoverArea; // attempting to advance towards this cover area const CNavArea *m_goalArea; // if set, engage enemies while moving towards this destination PathFollower m_path; ChasePath m_chasePath; + CountdownTimer m_coverSearchTimer; CountdownTimer m_grenadeThrowCooldownTimer; };