From ab3ae942ecb50f6ba05aff1657a56a7bcf7ec8db Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 30 Sep 2026 19:03:00 -0600 Subject: [PATCH] Bots skip weapons they cannot reach SeekWeapon checks that its path ends within 150 u of the weapon, but Path::Compute appends the goal position to a path that failed to reach it, so the check always passes for a weapon lying off the nav mesh: the bot walks the last leg straight at the weapon, over whatever lies between. On ntre_saitama_ctg that leg runs off the fire pit's grate rim into its trigger_hurt, and each death drops another weapon for the next scavenger. Giving up, a failed path, OnStuck and OnMoveToFailure also left the weapon unmarked, so the next scavenge picked it again. FindNearestPrimaryWeapon now skips a weapon with no nav area within 40 u of it (brush line of sight), or more than 72 u above / 32 u below that area. A weapon the bot failed to reach goes into the ignore cache the behavior already keeps. Seek only weapons on the mesh with a full path The first change still let a weapon near the nav mesh draw a bot: the path to it could stop at the mesh's edge and end with a straight walk to the weapon, and a weapon on an area with no links in (a pit floor cut off from the mesh) gets that walk from the area beside it. FindNearestPrimaryWeapon now takes a weapon only if it lies over a nav area or at most half a player's width past its edge (generated areas stop that short of walls), at most 32 u above the area's surface or 8 u below it. SeekWeapon follows only a full path to that area, ending at the point on it nearest the weapon, so the bot never leaves the mesh for a weapon; a bot at the area's edge still touches one lying against the wall. This is checked when the behavior starts (before a ghost carrier drops the ghost), on every repath and whenever it picks a new weapon. A weapon without such a path goes into the ignore cache. CNEOBotPathCompute now does what its comment says: with includeGoalIfPathFails false it returns the full path or nothing. No caller passed false before. --- .../neo/bot/behavior/neo_bot_seek_weapon.cpp | 117 ++++++++++++++---- .../neo/bot/behavior/neo_bot_seek_weapon.h | 2 + .../server/neo/bot/neo_bot_path_compute.cpp | 16 ++- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.cpp b/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.cpp index 016153f399..586882078e 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.cpp @@ -9,6 +9,42 @@ static constexpr int BOT_WEP_PREF_RANK_UNPREFERRED = -1; static constexpr int BOT_WEP_PREF_RANK_EMPTY = -2; +// A weapon lies on a nav area when it is over the area, or at most half a player's width past its edge +// (nav_generate areas often stop short of wall boundaries), +static constexpr float BOT_WEP_MAX_OUTSIDE_AREA = HalfHumanWidth; +// and at most this far above the area's surface (such as a desk or crate surface) +static constexpr float BOT_WEP_MAX_HEIGHT_ABOVE_AREA = 32.0f; +// or this far below it (a nav area's plane is rarely an exact flush fit with a stairway's steps) +static constexpr float BOT_WEP_MAX_DEPTH_BELOW_AREA = 8.0f; +static constexpr float BOT_WEP_AREA_SEARCH_RANGE = 64.0f; + +//--------------------------------------------------------------------------------------------- +// Weapons can come to rest where no nav area exists: in a pit, behind a counter, out of a window +static CNavArea *GetWeaponNavArea( CBaseEntity *pWeapon ) +{ + const Vector vecWeapon = pWeapon->WorldSpaceCenter(); + CNavArea *pArea = TheNavMesh->GetNearestNavArea( vecWeapon, false, BOT_WEP_AREA_SEARCH_RANGE, true, false ); + if ( !pArea ) + { + return nullptr; + } + + Vector vecClosest; + pArea->GetClosestPointOnArea( vecWeapon, &vecClosest ); + if ( ( vecWeapon - vecClosest ).Length2D() > BOT_WEP_MAX_OUTSIDE_AREA ) + { + return nullptr; + } + + const float flHeightAboveArea = vecWeapon.z - vecClosest.z; + if ( flHeightAboveArea > BOT_WEP_MAX_HEIGHT_ABOVE_AREA || flHeightAboveArea < -BOT_WEP_MAX_DEPTH_BELOW_AREA ) + { + return nullptr; + } + + return pArea; +} + //--------------------------------------------------------------------------------------------- bool IsUndroppablePrimary( CBaseCombatWeapon *pPrimary ) { @@ -174,14 +210,17 @@ CBaseEntity *FindNearestPrimaryWeapon( const CNEOBot *me, bool bAllowDropGhost, if ( bBetterFound ) { + // A path to an off-mesh weapon ends with a straight walk to it, over whatever lies between + CNavArea *pWepArea = GetWeaponNavArea( pEntity ); + if ( !pWepArea ) + { + continue; + } + // Check if weapon candidate is in PVS of me - if ( pMyArea ) + if ( pMyArea && !pMyArea->IsPotentiallyVisible( pWepArea ) ) { - CNavArea *pWepArea = TheNavMesh->GetNavArea( pEntity->WorldSpaceCenter() ); - if ( pWepArea && !pMyArea->IsPotentiallyVisible( pWepArea ) ) - { - continue; - } + continue; } flClosestDistSq = flDistSq; @@ -201,6 +240,36 @@ CNEOBotSeekWeapon::CNEOBotSeekWeapon( CBaseEntity *pTargetWeapon, CNEOIgnoredWea m_pIgnoredWeapons = pIgnoredWeapons; } +//--------------------------------------------------------------------------------------------- +// Remember a weapon the bot failed to reach, so the next scavenge does not pick it again +void CNEOBotSeekWeapon::IgnoreTargetWeapon( void ) +{ + if ( !m_hTargetWeapon || !m_pIgnoredWeapons || m_pIgnoredWeapons->Has( m_hTargetWeapon ) ) + { + return; + } + + m_pIgnoredWeapons->Add( m_hTargetWeapon ); +} + +//--------------------------------------------------------------------------------------------- +// Only a full path to the weapon's nav area: a partial one ends with a straight walk from the edge of the mesh +bool CNEOBotSeekWeapon::PathToTargetWeapon( CNEOBot *me ) +{ + // The weapon may have been knocked off the mesh since it was chosen + const CNavArea *pWepArea = GetWeaponNavArea( m_hTargetWeapon ); + if ( !pWepArea ) + { + m_path.Invalidate(); + return false; + } + + // Stay on the area: from its edge a bot still touches a weapon lying against the wall beyond it + Vector vecGoal; + pWepArea->GetClosestPointOnArea( m_hTargetWeapon->WorldSpaceCenter(), &vecGoal ); + return CNEOBotPathCompute( me, m_path, vecGoal, FASTEST_ROUTE, PATH_NO_LENGTH_LIMIT, PATH_TRUNCATE_INCOMPLETE_PATH ); +} + //--------------------------------------------------------------------------------------------- CBaseEntity *CNEOBotSeekWeapon::FindAndPathToWeapon( CNEOBot *me ) { @@ -209,19 +278,16 @@ CBaseEntity *CNEOBotSeekWeapon::FindAndPathToWeapon( CNEOBot *me ) m_hTargetWeapon = FindNearestPrimaryWeapon( me, false, m_pIgnoredWeapons ); } - if ( m_hTargetWeapon ) - { - if ( !CNEOBotPathCompute( me, m_path, m_hTargetWeapon->GetAbsOrigin(), FASTEST_ROUTE ) || !m_path.IsValid() ) - { - m_hTargetWeapon = nullptr; - m_path.Invalidate(); - } - } - else + if ( !m_hTargetWeapon ) { // no weapon found m_path.Invalidate(); } + else if ( !PathToTargetWeapon( me ) ) + { + IgnoreTargetWeapon(); + m_hTargetWeapon = nullptr; + } return m_hTargetWeapon; } @@ -250,6 +316,14 @@ ActionResult< CNEOBot > CNEOBotSeekWeapon::OnStart( CNEOBot *me, Action< CNEOBot return Done("No valid replacement primary found"); } + // Check the path before a ghost carrier drops the ghost for this weapon + if ( !PathToTargetWeapon( me ) ) + { + IgnoreTargetWeapon(); + return Done( "No full path to the weapon" ); + } + m_repathTimer.Start( RandomFloat( 1.0f, 2.0f ) ); + auto *pNeoPrimary = assert_cast( pPrimary ); if ( pNeoPrimary && ( pNeoPrimary->GetNeoWepBits() & NEO_WEP_GHOST ) ) { @@ -271,14 +345,16 @@ ActionResult< CNEOBot > CNEOBotSeekWeapon::Update( CNEOBot *me, float interval ) if ( m_giveUpTimer.IsElapsed() ) { + IgnoreTargetWeapon(); return Done("Gave up seeking weapon"); } if ( !m_repathTimer.HasStarted() || m_repathTimer.IsElapsed() ) { - if ( !CNEOBotPathCompute( me, m_path, m_hTargetWeapon->GetAbsOrigin(), FASTEST_ROUTE ) ) + if ( !PathToTargetWeapon( me ) ) { - return Done("Unable to find a path to the nearest primary weapon"); + IgnoreTargetWeapon(); + return Done( "No full path to the weapon" ); } m_repathTimer.Start( RandomFloat( 1.0f, 2.0f ) ); } @@ -292,10 +368,7 @@ ActionResult< CNEOBot > CNEOBotSeekWeapon::Update( CNEOBot *me, float interval ) const float flMaxEndpointDistSqr = 150.0f * 150.0f; if ( m_path.GetEndPosition().DistToSqr( m_hTargetWeapon->GetAbsOrigin() ) > flMaxEndpointDistSqr ) { - if ( m_pIgnoredWeapons && !m_pIgnoredWeapons->Has( m_hTargetWeapon ) ) - { - m_pIgnoredWeapons->Add( m_hTargetWeapon ); - } + IgnoreTargetWeapon(); return Done("Weapon is unreachable (path doesn't get close enough)"); } @@ -354,6 +427,7 @@ ActionResult< CNEOBot > CNEOBotSeekWeapon::OnResume( CNEOBot *me, Action< CNEOBo //--------------------------------------------------------------------------------------------- EventDesiredResult< CNEOBot > CNEOBotSeekWeapon::OnStuck( CNEOBot *me ) { + IgnoreTargetWeapon(); m_hTargetWeapon = nullptr; m_repathTimer.Invalidate(); FindAndPathToWeapon(me); @@ -369,6 +443,7 @@ EventDesiredResult< CNEOBot > CNEOBotSeekWeapon::OnMoveToSuccess( CNEOBot *me, c //--------------------------------------------------------------------------------------------- EventDesiredResult< CNEOBot > CNEOBotSeekWeapon::OnMoveToFailure( CNEOBot *me, const Path *path, MoveToFailureType reason ) { + IgnoreTargetWeapon(); m_hTargetWeapon = nullptr; m_repathTimer.Invalidate(); FindAndPathToWeapon(me); diff --git a/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.h b/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.h index 47bba57055..0452ffd730 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.h +++ b/src/game/server/neo/bot/behavior/neo_bot_seek_weapon.h @@ -46,5 +46,7 @@ class CNEOBotSeekWeapon : public Action< CNEOBot > CountdownTimer m_repathTimer; CountdownTimer m_giveUpTimer; + bool PathToTargetWeapon( CNEOBot *me ); CBaseEntity *FindAndPathToWeapon( CNEOBot *me ); + void IgnoreTargetWeapon( void ); }; diff --git a/src/game/server/neo/bot/neo_bot_path_compute.cpp b/src/game/server/neo/bot/neo_bot_path_compute.cpp index 82b2f5ce39..ff4e1457c3 100644 --- a/src/game/server/neo/bot/neo_bot_path_compute.cpp +++ b/src/game/server/neo/bot/neo_bot_path_compute.cpp @@ -34,6 +34,12 @@ static void CNEOBotReservePath(CNEOBot* me, PathFollower& path) } } +// A path that stops short of the goal is only wanted if the caller asked for partial paths +static bool IsPathUsable(const PathFollower& path, bool reachedGoal, bool includeGoalIfPathFails) +{ + return path.IsValid() && (reachedGoal || includeGoalIfPathFails); +} + // By default, assumes that we would prefer to have partial paths even if we can't make a full path // to accomodate scenarios like getting as close as possible to a hazard area but not into it // Set includeGoalIfPathFails to false if you need to get the full path (or nothing on failure) @@ -42,8 +48,8 @@ bool CNEOBotPathCompute(CNEOBot* bot, PathFollower& path, const Vector& goal, Ro Assert(goal.IsValid()); CNEOBotPathCost cost_with_reservations(bot, route); - path.Compute(bot, goal, cost_with_reservations, maxPathLength, includeGoalIfPathFails, requireGoalArea); - if (path.IsValid()) + bool reachedGoal = path.Compute(bot, goal, cost_with_reservations, maxPathLength, includeGoalIfPathFails, requireGoalArea); + if (IsPathUsable(path, reachedGoal, includeGoalIfPathFails)) { CNEOBotReservePath(bot, path); return true; @@ -51,13 +57,15 @@ bool CNEOBotPathCompute(CNEOBot* bot, PathFollower& path, const Vector& goal, Ro CNEOBotPathCost cost_without_reservations(bot, FASTEST_ROUTE); cost_without_reservations.m_bIgnoreReservations = true; - path.Compute(bot, goal, cost_without_reservations, maxPathLength, includeGoalIfPathFails, requireGoalArea); - if (path.IsValid()) + reachedGoal = path.Compute(bot, goal, cost_without_reservations, maxPathLength, includeGoalIfPathFails, requireGoalArea); + if (IsPathUsable(path, reachedGoal, includeGoalIfPathFails)) { CNEOBotReservePath(bot, path); return true; } + // Path::Compute leaves a partial path valid even when told not to append the goal + path.Invalidate(); return false; }