From 24dc3ca7e39ef268329b0994fa2d40aeb7f714d9 Mon Sep 17 00:00:00 2001 From: sunzenshen Date: Wed, 23 Sep 2026 02:59:26 -0600 Subject: [PATCH 1/5] Chase a partial path that already arrives instead of failing ChasePath::RefreshPath treats every failed Path::Compute as FAIL_NO_PATH_EXISTS: it fires OnMoveToFailure, invalidates the path and blocks repathing for 0.005 s per unit of range. But a failed Compute still leaves a valid PARTIAL_PATH ending in the reachable area closest to the target, and the bot's own path cost asks for exactly that: neo_bot_path_cost refuses hazard areas under DEFAULT_ROUTE "to partially path up to boundary of hazard area". Over 584 measured chase failures every one carried such a path, the median ended beside the target (141 u short) and the blackout ran a median 6.3 s with the bot standing still. Count a partial path that already covers 90 % of the straight-line range to the target as a path. One that ends well short still fails, so the range-scaled throttle keeps guarding the genuinely unreachable case. Measured on six maps (oilstain, transit, bullet, yard, apparatus, saitama), 49 pinned matches per arm, paired per pin: FAIL_NO_PATH_EXISTS 11.63 -> 0.08 a match x0.12 [0.10, 0.14], 47 down / 0 up other move failures 2.92 -> 1.71 OnStuck 69.7 -> 68.1 x1.12 [0.92, 1.37] (no change) kills 13.1 -> 13.6 x1.05 [0.95, 1.15] rounds won by capture 0.39 -> 0.47 It lowered FAIL_NO_PATH_EXISTS on every map and moved nothing else. --- .../server/NextBot/Path/NextBotChasePath.h | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/game/server/NextBot/Path/NextBotChasePath.h b/src/game/server/NextBot/Path/NextBotChasePath.h index 1929648b63..6e1258ffca 100644 --- a/src/game/server/NextBot/Path/NextBotChasePath.h +++ b/src/game/server/NextBot/Path/NextBotChasePath.h @@ -218,6 +218,27 @@ inline void ChasePath::RefreshPath( INextBot *bot, CBaseEntity *subject, const I isPath = Compute( bot, pathTarget, cost, GetMaxPathLength() ); } +#ifdef NEO + // A failed Compute still leaves a valid PARTIAL_PATH, ending in the reachable area closest + // to the target. When that end is already most of the way there, the search arrived and + // stopped short (the target stands just off the mesh, on a prop, over a ledge) and the bot + // can walk it. Calling it FAIL_NO_PATH_EXISTS instead invalidates the path and blocks + // repathing for a range-scaled time, so the bot stands still where it could be closing in. + // A partial path that ends well short of that still fails, and the throttle keeps guarding + // the genuinely unreachable case. + if ( !isPath && IsValid() ) + { + // share of the straight-line range to the target the partial path must already cover + constexpr float minPartialPathCoverage = 0.9f; + const float rangeToTarget = bot->GetPosition().DistTo( pathTarget ); + if ( rangeToTarget > 0.0f ) + { + const float covered = bot->GetPosition().DistTo( GetEndPosition() ); + isPath = ( covered / rangeToTarget ) >= minPartialPathCoverage; + } + } +#endif + if ( isPath ) { if ( bot->IsDebugging( NEXTBOT_PATH ) ) From 2352949626efa080f69a3b80ad737a7638117916 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 27 Sep 2026 09:59:16 -0600 Subject: [PATCH 2/5] Follow a partial chase path as far as its areas reach A partial chase path runs through the areas the search reached and then straight to the subject, through whatever stopped the search: a hazard area refused under DEFAULT_ROUTE, a blocked area, a climb the class cannot make. The previous commit's 90 % gate measured the distance to that appended subject position, so it passed practically every partial path, and the bot walked the unreachable last leg into walls and back into the smoke the path had avoided. Follow a partial chase path regardless of distance, with two bounds: - refuse one that crosses no area before its last leg (the bot already stands in the last area it can reach), as a failed search does; - walk the last leg only while the ground a hull width ahead is still the last reachable area, then wait there on the range-scaled retry. If the follower drops the path on the leg, wait the same way instead of rebuilding it on the next tick. sv_neo_bot_path_partial_chase_enable 0 restores the stock behaviour. Measured with bots on six maps, 24 matches an arm, against following the whole partial path: hazard re-entries 2.17 -> 0.42 a match, stuck on the last leg 0.62 -> 0, stuck on a partial path 0.42 a match against ~8 on full chase paths. Against stock on all 29 ntre maps: OnStuck, falls and kills unchanged, FAIL_NO_PATH_EXISTS down on 25 maps. --- .../server/NextBot/Path/NextBotChasePath.h | 105 +++++++++++++++--- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/src/game/server/NextBot/Path/NextBotChasePath.h b/src/game/server/NextBot/Path/NextBotChasePath.h index 6e1258ffca..9edba14a95 100644 --- a/src/game/server/NextBot/Path/NextBotChasePath.h +++ b/src/game/server/NextBot/Path/NextBotChasePath.h @@ -14,6 +14,14 @@ #include "NextBotPathFollow.h" #include "tier0/vprof.h" +#ifdef NEO +#include "convar.h" + +inline ConVar sv_neo_bot_path_partial_chase_enable( "sv_neo_bot_path_partial_chase_enable", "1", FCVAR_CHEAT, + "Follow a partial chase path as far as the nav areas it reaches", true, 0.0f, true, 1.0f ); +#endif + + //---------------------------------------------------------------------------------------------- /** @@ -45,6 +53,13 @@ class ChasePath : public PathFollower private: void RefreshPath( INextBot *bot, CBaseEntity *subject, const IPathCost &cost, Vector *pPredictedSubjectPos ); +#ifdef NEO + bool HasReachableArea( void ) const; + bool IsLastLegWalkable( INextBot *bot ) const; + void BackOff( INextBot *bot, CBaseEntity *subject ); + + bool m_isPartialPath = false; // following a path the search could not complete +#endif CountdownTimer m_failTimer; // throttle re-pathing if last path attempt failed CountdownTimer m_throttleTimer; // require a minimum time between re-paths @@ -103,11 +118,79 @@ inline void ChasePath::Update( INextBot *bot, CBaseEntity *subject, const IPathC // maintain the path to the subject RefreshPath( bot, subject, cost, pPredictedSubjectPos ); +#ifdef NEO + // A partial path ends in a straight leg from the last area the search reached to the subject, + // through whatever stopped the search. Walk it only as far as that area goes, then wait there. + const bool onLastLeg = subject && IsValid() && m_isPartialPath && GetCurrentGoal() == LastSegment(); + if ( onLastLeg && !IsLastLegWalkable( bot ) ) + { + bot->OnMoveToFailure( this, FAIL_NO_PATH_EXISTS ); + BackOff( bot, subject ); + Invalidate(); + return; + } +#endif + // move along the path towards the subject PathFollower::Update( bot ); + +#ifdef NEO + // the follower dropped the path on the leg (fell off, stuck): wait instead of rebuilding it next tick + if ( onLastLeg && !IsValid() ) + { + BackOff( bot, subject ); + } +#endif } +#ifdef NEO +//---------------------------------------------------------------------------------------------- +/** + * True if a partial path crosses an area before its last leg. Without one the bot already stands + * in the last area the search reached, and the path is only the unreachable leg. + */ +inline bool ChasePath::HasReachableArea( void ) const +{ + return PriorSegment( LastSegment() ) != FirstSegment(); +} + + +//---------------------------------------------------------------------------------------------- +/** + * True while the bot on the last leg of a partial path still has the last area the search reached + * just ahead of it, so it stops at that area's edge instead of walking into what stopped the search + */ +inline bool ChasePath::IsLastLegWalkable( INextBot *bot ) const +{ + const CNavArea *lastArea = LastSegment()->area; + if ( !lastArea || bot->GetLocomotionInterface()->IsStuck() ) + { + return false; + } + + // about a hull width, so the bot stops before its body crosses the edge + const float edgeLookAhead = 24.0f; + + Vector toEnd = LastSegment()->pos - bot->GetPosition(); + toEnd.z = 0.0f; + toEnd.NormalizeInPlace(); + + return lastArea->IsOverlapping( bot->GetPosition() + edgeLookAhead * toEnd ); +} + + +//---------------------------------------------------------------------------------------------- +/** + * Throttle the next repath by range to the subject, as a failed path computation does + */ +inline void ChasePath::BackOff( INextBot *bot, CBaseEntity *subject ) +{ + m_failTimer.Start( 0.005f * ( bot->GetRangeTo( subject ) ) ); +} +#endif + + //---------------------------------------------------------------------------------------------- /** * Return true if situation has changed enough to warrant recomputing the current path @@ -219,24 +302,14 @@ inline void ChasePath::RefreshPath( INextBot *bot, CBaseEntity *subject, const I } #ifdef NEO - // A failed Compute still leaves a valid PARTIAL_PATH, ending in the reachable area closest - // to the target. When that end is already most of the way there, the search arrived and - // stopped short (the target stands just off the mesh, on a prop, over a ledge) and the bot - // can walk it. Calling it FAIL_NO_PATH_EXISTS instead invalidates the path and blocks - // repathing for a range-scaled time, so the bot stands still where it could be closing in. - // A partial path that ends well short of that still fails, and the throttle keeps guarding - // the genuinely unreachable case. - if ( !isPath && IsValid() ) + // Even if the chase path only has a partial path, get as close to the target as possible + const bool isCompletePath = isPath; + if ( sv_neo_bot_path_partial_chase_enable.GetBool() && !isPath && IsValid() ) { - // share of the straight-line range to the target the partial path must already cover - constexpr float minPartialPathCoverage = 0.9f; - const float rangeToTarget = bot->GetPosition().DistTo( pathTarget ); - if ( rangeToTarget > 0.0f ) - { - const float covered = bot->GetPosition().DistTo( GetEndPosition() ); - isPath = ( covered / rangeToTarget ) >= minPartialPathCoverage; - } + isPath = HasReachableArea(); } + + m_isPartialPath = isPath && !isCompletePath; #endif if ( isPath ) From 23f764be666fc6ce5d330e23a24a212ba6968d6a Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:07:49 -0600 Subject: [PATCH 3/5] Remove the partial chase path enable cvar The partial chase path is the behavior the change proposes, so it is implemented directly instead of behind sv_neo_bot_path_partial_chase_enable. The stock behavior stays in history for comparison. --- src/game/server/NextBot/Path/NextBotChasePath.h | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/game/server/NextBot/Path/NextBotChasePath.h b/src/game/server/NextBot/Path/NextBotChasePath.h index 9edba14a95..4bde526e42 100644 --- a/src/game/server/NextBot/Path/NextBotChasePath.h +++ b/src/game/server/NextBot/Path/NextBotChasePath.h @@ -14,14 +14,6 @@ #include "NextBotPathFollow.h" #include "tier0/vprof.h" -#ifdef NEO -#include "convar.h" - -inline ConVar sv_neo_bot_path_partial_chase_enable( "sv_neo_bot_path_partial_chase_enable", "1", FCVAR_CHEAT, - "Follow a partial chase path as far as the nav areas it reaches", true, 0.0f, true, 1.0f ); -#endif - - //---------------------------------------------------------------------------------------------- /** @@ -304,7 +296,7 @@ inline void ChasePath::RefreshPath( INextBot *bot, CBaseEntity *subject, const I #ifdef NEO // Even if the chase path only has a partial path, get as close to the target as possible const bool isCompletePath = isPath; - if ( sv_neo_bot_path_partial_chase_enable.GetBool() && !isPath && IsValid() ) + if ( !isPath && IsValid() ) { isPath = HasReachableArea(); } From a3598e1aec7c23c93ed5cdf5e360ac10a58db1bd Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:08:49 -0600 Subject: [PATCH 4/5] Name the chase back-off rate BackOff repeated RefreshPath's 0.005 s per unit of range as a bare literal. Name it CHASE_RETRY_SECONDS_PER_UNIT and say where the rate comes from, so a reader can keep the two in step without touching the stock failure branch. --- src/game/server/NextBot/Path/NextBotChasePath.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/game/server/NextBot/Path/NextBotChasePath.h b/src/game/server/NextBot/Path/NextBotChasePath.h index 4bde526e42..f9fda1f570 100644 --- a/src/game/server/NextBot/Path/NextBotChasePath.h +++ b/src/game/server/NextBot/Path/NextBotChasePath.h @@ -173,12 +173,16 @@ inline bool ChasePath::IsLastLegWalkable( INextBot *bot ) const //---------------------------------------------------------------------------------------------- +// Seconds of re-path back-off per unit of range to the subject, +// the rate RefreshPath's failure branch uses +static constexpr float CHASE_RETRY_SECONDS_PER_UNIT = 0.005f; + /** * Throttle the next repath by range to the subject, as a failed path computation does */ inline void ChasePath::BackOff( INextBot *bot, CBaseEntity *subject ) { - m_failTimer.Start( 0.005f * ( bot->GetRangeTo( subject ) ) ); + m_failTimer.Start( CHASE_RETRY_SECONDS_PER_UNIT * ( bot->GetRangeTo( subject ) ) ); } #endif From 1b243711e4e31a6cacae89c0cfa282752d4a3c4c Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:09:49 -0600 Subject: [PATCH 5/5] Correct the edge look-ahead comment The look-ahead is 24 u and a hull is 32 u wide (HalfHumanWidth 16), so it is three quarters of a hull width, not about one. --- src/game/server/NextBot/Path/NextBotChasePath.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/server/NextBot/Path/NextBotChasePath.h b/src/game/server/NextBot/Path/NextBotChasePath.h index f9fda1f570..9aff9e49c7 100644 --- a/src/game/server/NextBot/Path/NextBotChasePath.h +++ b/src/game/server/NextBot/Path/NextBotChasePath.h @@ -161,7 +161,7 @@ inline bool ChasePath::IsLastLegWalkable( INextBot *bot ) const return false; } - // about a hull width, so the bot stops before its body crosses the edge + // three quarters of a hull width, so the bot stops before its body crosses the edge const float edgeLookAhead = 24.0f; Vector toEnd = LastSegment()->pos - bot->GetPosition();