Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/game/server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,8 @@ set(UNITY_SOURCE_NEO_BOT
neo/bot/behavior/neo_bot_ctg_capture.cpp
neo/bot/behavior/neo_bot_ctg_carrier.cpp
neo/bot/behavior/neo_bot_ctg_enemy.cpp
neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp
neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp
neo/bot/behavior/neo_bot_ctg_escort.cpp
neo/bot/behavior/neo_bot_ctg_lone_wolf.cpp
neo/bot/behavior/neo_bot_ctg_seek.cpp
Expand Down Expand Up @@ -1600,6 +1602,8 @@ target_sources_grouped(
neo/bot/behavior/neo_bot_ctg_capture.h
neo/bot/behavior/neo_bot_ctg_carrier.h
neo/bot/behavior/neo_bot_ctg_enemy.h
neo/bot/behavior/neo_bot_ctg_enemy_chase.h
neo/bot/behavior/neo_bot_ctg_enemy_cutoff.h
neo/bot/behavior/neo_bot_ctg_escort.h
neo/bot/behavior/neo_bot_ctg_lone_wolf.h
neo/bot/behavior/neo_bot_ctg_lone_wolf_ambush.cpp
Expand Down
212 changes: 167 additions & 45 deletions src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,212 @@
#include "neo_player.h"
#include "bot/neo_bot.h"
#include "bot/behavior/neo_bot_ctg_enemy.h"
#include "bot/behavior/neo_bot_attack.h"
#include "bot/neo_bot_path_compute.h"
#include "bot/behavior/neo_bot_ctg_enemy_chase.h"
#include "bot/behavior/neo_bot_ctg_enemy_cutoff.h"
#include "neo_gamerules.h"

#include "nav_mesh.h"
#include "nav_pathfind.h"

//---------------------------------------------------------------------------------------------
CNEOBotCtgEnemy::CNEOBotCtgEnemy( void )
// A route start-first with the travel distance to each area, summed between area centers: only
// comparable with the same measure, not with a PathFollower's length
struct CNEOBotPredictedRoute
{
}
CUtlVector< CNavArea * > areas;
CUtlVector< float > travel;
};

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemy::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction )
// Shortest route from pStartArea to vecGoal. NavAreaBuildPath leaves it in the areas' parent
// pointers, which the next search overwrites, so it is copied out here.
static bool PredictRoute( CNavArea *pStartArea, const Vector &vecGoal, CNEOBotPredictedRoute &route )
{
m_chasePath.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() );
CNavArea *pGoalArea = TheNavMesh->GetNearestNavArea( vecGoal );
if ( !pStartArea || !pGoalArea )
{
return false;
}

ShortestPathCost cost;
if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) )
{
return false;
}

// The parent chain runs goal -> start
for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() )
Comment on lines +31 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rare one, but cheap to close: if the cap's nearest area is blocked, NavAreaBuildPath drops its copy of the goal (nav_pathfind.h) and can still succeed by reaching another area that contains vecGoal (here). This loop then walks parent links from pGoalArea, which that search never reached, so the links are left over from an earlier search and the route isn't the carrier's. The search hands back the area it actually reached through closestArea, so walking from that fixes it:

Suggested change
ShortestPathCost cost;
if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) )
{
return false;
}
// The parent chain runs goal -> start
for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() )
ShortestPathCost cost;
CNavArea *pReachedArea = nullptr;
if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost, &pReachedArea ) )
{
return false;
}
// The parent chain runs goal -> start, from the area the search reached: a blocked pGoalArea is
// passed over for another area containing vecGoal, and its own parent links are stale
for ( CNavArea *pArea = pReachedArea; pArea; pArea = pArea->GetParent() )

{
route.areas.AddToTail( pArea );
}

route.areas.Reverse();

route.travel.AddToTail( 0.0f );
for ( int i = 1; i < route.areas.Count(); ++i )
{
route.travel.AddToTail( route.travel[ i - 1 ]
+ ( route.areas[i]->GetCenter() - route.areas[ i - 1 ]->GetCenter() ).Length() );
}

return Continue();
return true;
}

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval )
// Floods outward over the areas this bot can traverse, leaving each reached area marked with its
// shortest travel distance (IsMarked, GetCostSoFar) until the next nav search. Ladders are not followed.
class CNEOBotTraversableFlood : public ISearchSurroundingAreasFunctor
{
if ( !NEORules()->GhostExists() )
public:
CNEOBotTraversableFlood( CNEOBot *me ) : m_me( me ) {}

virtual bool operator()( CNavArea *area, CNavArea *priorArea, float travelDistanceSoFar ) override
{
return Done( "Ghost does not exist" );
return true;
}

if ( NEORules()->GetGhosterPlayer() <= 0 )
virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override
{
return Done( "No ghost carrier" );
return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea );
}
Comment on lines +68 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want both sides exact, the flood can do Dijkstra by overriding IterateAdjacentAreas: keep the open list sorted by distance and lower an area's distance when a shorter way in turns up. Untested sketch. It keeps your ShouldSearch and still skips ladders like the base version:

Suggested change
virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override
{
return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea );
}
virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override
{
return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea );
}
// Dijkstra in place of the base breadth-first flood: the open list is ordered by distance and an
// area's distance drops when a shorter way in turns up, so GetCostSoFar is the shortest travel
virtual void IterateAdjacentAreas( CNavArea *area, CNavArea *priorArea, float travelDistanceSoFar ) override
{
for ( int dir = 0; dir < NUM_DIRECTIONS; ++dir )
{
const int count = area->GetAdjacentCount( (NavDirType)dir );
for ( int i = 0; i < count; ++i )
{
CNavArea *adjArea = area->GetAdjacentArea( (NavDirType)dir, i );
if ( !ShouldSearch( adjArea, area, travelDistanceSoFar ) )
{
continue;
}
const float flCost = travelDistanceSoFar + ( adjArea->GetCenter() - area->GetCenter() ).Length();
if ( !adjArea->IsMarked() )
{
adjArea->Mark();
adjArea->SetParent( area );
adjArea->SetCostSoFar( flCost );
adjArea->SetTotalCost( flCost );
adjArea->AddToOpenList();
}
else if ( adjArea->IsOpen() && flCost < adjArea->GetCostSoFar() )
{
adjArea->SetParent( area );
adjArea->SetCostSoFar( flCost );
adjArea->SetTotalCost( flCost );
adjArea->UpdateOnOpenList();
}
}
}
}

With the list sorted by distance, the travelDistanceLimit cut in SearchSurroundingAreas becomes exact too.


CNEO_Player* pGhostCarrier = ToNEOPlayer( UTIL_PlayerByIndex( NEORules()->GetGhosterPlayer() ) );
if ( !pGhostCarrier || pGhostCarrier->GetTeamNumber() == me->GetTeamNumber() )
// Dijkstra in place of the base breadth-first flood: the open list is ordered by distance and an
// area's distance drops when a shorter way in turns up, so GetCostSoFar is the shortest travel
virtual void IterateAdjacentAreas( CNavArea *area, CNavArea *priorArea, float travelDistanceSoFar ) override
{
return Done( "Ghost carrier is friendly" );
for ( int dir = 0; dir < NUM_DIRECTIONS; ++dir )
{
const int count = area->GetAdjacentCount( (NavDirType)dir );
for ( int i = 0; i < count; ++i )
{
CNavArea *adjArea = area->GetAdjacentArea( (NavDirType)dir, i );
if ( !ShouldSearch( adjArea, area, travelDistanceSoFar ) )
{
continue;
}

const float flCost = travelDistanceSoFar + ( adjArea->GetCenter() - area->GetCenter() ).Length();
if ( !adjArea->IsMarked() )
{
adjArea->Mark();
adjArea->SetParent( area );
adjArea->SetCostSoFar( flCost );
adjArea->SetTotalCost( flCost );
adjArea->AddToOpenList();
}
else if ( adjArea->IsOpen() && flCost < adjArea->GetCostSoFar() )
{
adjArea->SetParent( area );
adjArea->SetCostSoFar( flCost );
adjArea->SetTotalCost( flCost );
adjArea->UpdateOnOpenList();
}
}
}
}

const CKnownEntity *threat = me->GetVisionInterface()->GetPrimaryKnownThreat(true);
if ( threat && !threat->IsObsolete() && me->GetIntentionInterface()->ShouldAttack( me, threat ) )
private:
CNEOBot *m_me;
};

//---------------------------------------------------------------------------------------------
CNEO_Player *CNEOBotCtgEnemy::EnemyGhostCarrier( const CNEOBot *me )
{
if ( !NEORules()->GhostExists() )
{
return SuspendFor( new CNEOBotAttack(pGhostCarrier->GetAbsOrigin()), "Attacking ghoster team" );
return nullptr;
}

// Investigate the ghost carrier's position
CNEOBotPathUpdateChase( me, m_chasePath, pGhostCarrier, DEFAULT_ROUTE );

return Continue();
}
const int iGhoster = NEORules()->GetGhosterPlayer();
if ( iGhoster <= 0 || iGhoster > gpGlobals->maxClients )
{
return nullptr;
}

CNEO_Player *pCarrier = ToNEOPlayer( UTIL_PlayerByIndex( iGhoster ) );
if ( !pCarrier || !pCarrier->IsAlive() || pCarrier->GetTeamNumber() == me->GetTeamNumber() )
{
return nullptr;
}

return pCarrier;
}

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemy::OnResume( CNEOBot *me, Action< CNEOBot > *interruptingAction )
CNavArea *CNEOBotCtgEnemy::FindCutOff( CNEOBot *me, CNEO_Player *pGhostCarrier )
{
m_chasePath.Invalidate();
CNavArea *pMyArea = me->GetLastKnownArea();
if ( !pGhostCarrier || !pMyArea )
{
return nullptr;
}

return Continue();
}
// The carrier's goal as a defender reads it off the ghost marker: the nearest zone it can score in
const Vector vecGoal = NEORules()->GetNearestGhostCapPoint( pGhostCarrier->GetTeamNumber(), pGhostCarrier->GetAbsOrigin() );
if ( vecGoal == CNEO_Player::VECTOR_INVALID_WAYPOINT )
{
return nullptr;
}

//---------------------------------------------------------------------------------------------
EventDesiredResult< CNEOBot > CNEOBotCtgEnemy::OnStuck( CNEOBot *me )
{
return TryContinue();
}
// A naive shortest route: the carrier's class, loadout and intent are not observable
CNEOBotPredictedRoute carrierRoute;
if ( !PredictRoute( pGhostCarrier->GetLastKnownArea(), vecGoal, carrierRoute ) )
{
return nullptr;
}

//---------------------------------------------------------------------------------------------
EventDesiredResult< CNEOBot > CNEOBotCtgEnemy::OnMoveToSuccess( CNEOBot *me, const Path *path )
{
return TryContinue();
// Already in its cap's area: nothing to cut off, and a zero search range would mean no limit
if ( carrierRoute.areas.Count() < 2 )
{
return nullptr;
}

// One flood prices every area of the route by our own travel. It must come after the route is
// copied out: both searches use the nav areas' shared search state.
CNEOBotTraversableFlood flood( me );
SearchSurroundingAreas( pMyArea, flood, carrierRoute.travel.Tail() );
Comment on lines +164 to +167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question on the two sides of this race: the carrier's distance comes from NavAreaBuildPath with ShortestPathCost, but ours comes from SearchSurroundingAreas, which is breadth-first. IncludeInSearch sets every total cost to 0, so the open list runs first in, first out, and it writes CostSoFar only the first time it reaches an area and never lowers it. So each area ends up with the length of the route with the fewest hops, which can be longer than the shortest one.

Say the bot's short way onto the carrier's route goes through a stairway cut into lots of small areas, and a longer detour crosses a few big ones. The flood reaches the route through the detour first and keeps that longer distance, so the bot thinks it loses a race it would win, and it chases or takes a later cut-off.

It only ever errs toward the bot looking slower, so it never picks a cut-off it can't make. Is that bias something you want here, or should both sides use the same shortest-distance measure? (From reading the code, haven't run it.)


// The earliest area we reach no later than the carrier is the furthest from its cap we can stand in its way
for ( int i = 0; i < carrierRoute.areas.Count(); ++i )
{
CNavArea *pArea = carrierRoute.areas[i];
if ( pArea->IsMarked() && pArea->GetCostSoFar() <= carrierRoute.travel[i] )
{
return pArea;
}
}

return nullptr;
}

//---------------------------------------------------------------------------------------------
EventDesiredResult< CNEOBot > CNEOBotCtgEnemy::OnMoveToFailure( CNEOBot *me, const Path *path, MoveToFailureType reason )
ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval )
{
return TryContinue();
CNEO_Player *pGhostCarrier = EnemyGhostCarrier( me );
if ( !pGhostCarrier )
{
return Done( "No enemy ghost carrier" );
}

// No cut-off means the carrier is ahead of us, and a detour would only give up more ground
CNavArea *pCutOff = FindCutOff( me, pGhostCarrier );
if ( !pCutOff )
{
return ChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), "Chasing the ghost carrier down" );
}

if ( pCutOff == me->GetLastKnownArea() )
{
return ChangeTo( new CNEOBotCtgEnemyChase( DEFAULT_ROUTE ), "Standing on the cut-off - chasing" );
}

return ChangeTo( new CNEOBotCtgEnemyCutOff( pCutOff ), "Cutting the carrier off short of its cap" );
}

//---------------------------------------------------------------------------------------------
QueryResultType CNEOBotCtgEnemy::ShouldHurry( const INextBot *me ) const
QueryResultType CNEOBotCtgEnemy::CarrierUrgency( const CNEOBot *me )
{
const CNEOBot *meBot = static_cast<const CNEOBot *>( me );

CNEO_Player *pCarrier = ToNEOPlayer( UTIL_PlayerByIndex( NEORules()->GetGhosterPlayer() ) );
if ( !pCarrier || !pCarrier->IsAlive() || pCarrier->GetTeamNumber() == meBot->GetTeamNumber() )
CNEO_Player *pCarrier = EnemyGhostCarrier( me );
if ( !pCarrier )
{
return ANSWER_UNDEFINED;
}
Expand All @@ -97,7 +219,7 @@ QueryResultType CNEOBotCtgEnemy::ShouldHurry( const INextBot *me ) const
}

const float flCarrierToGoalSq = pCarrier->GetAbsOrigin().DistToSqr( vecCarrierGoal );
const float flMeToGoalSq = meBot->GetAbsOrigin().DistToSqr( vecCarrierGoal );
const float flMeToGoalSq = me->GetAbsOrigin().DistToSqr( vecCarrierGoal );

return ( flMeToGoalSq > flCarrierToGoalSq ) ? ANSWER_YES : ANSWER_UNDEFINED;
}
25 changes: 13 additions & 12 deletions src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@
#define NEO_BOT_CTG_ENEMY_H

#include "bot/neo_bot.h"
#include "Path/NextBotChasePath.h"

class CNEO_Player;
class CNavArea;

//--------------------------------------------------------------------------------------------------------
// Decides whether to cut the enemy ghost carrier off or chase it, hands over, and never stays on the
// stack. Plans only from what any opponent sees: the ghost marker and the fixed cap zones.
class CNEOBotCtgEnemy : public Action< CNEOBot >
{
public:
CNEOBotCtgEnemy( void );

virtual ActionResult< CNEOBot > OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) override;
virtual ActionResult< CNEOBot > Update( CNEOBot *me, float interval ) override;
virtual ActionResult< CNEOBot > OnResume( CNEOBot *me, Action< CNEOBot > *interruptingAction ) override;

virtual EventDesiredResult< CNEOBot > OnStuck( CNEOBot *me ) override;
virtual EventDesiredResult< CNEOBot > OnMoveToSuccess( CNEOBot *me, const Path *path ) override;
virtual EventDesiredResult< CNEOBot > OnMoveToFailure( CNEOBot *me, const Path *path, MoveToFailureType reason ) override;
virtual const char *GetName( void ) const override { return "ctgEnemy"; }

virtual QueryResultType ShouldHurry( const INextBot *me ) const override;
// The living enemy player carrying the ghost, or nullptr if there is none
static CNEO_Player *EnemyGhostCarrier( const CNEOBot *me );

virtual const char *GetName( void ) const override { return "ctgEnemy"; }
// The earliest area on the carrier's predicted route that this bot reaches first; nullptr when
// there is none, which means the carrier is ahead of this bot
static CNavArea *FindCutOff( CNEOBot *me, CNEO_Player *pGhostCarrier );

private:
ChasePath m_chasePath;
// ShouldHurry for the behaviors this one hands over to
static QueryResultType CarrierUrgency( const CNEOBot *me );
};

#endif // NEO_BOT_CTG_ENEMY_H
62 changes: 62 additions & 0 deletions src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#include "cbase.h"
#include "neo_player.h"
#include "bot/neo_bot.h"
#include "bot/behavior/neo_bot_ctg_enemy_chase.h"
#include "bot/behavior/neo_bot_ctg_enemy.h"
#include "bot/behavior/neo_bot_attack.h"
#include "bot/neo_bot_path_compute.h"
#include "neo_gamerules.h"

// Seconds between a chasing bot re-checking whether it is ahead of the carrier,
// which picks the fastest or the default route
static constexpr float CTG_ENEMY_CHASE_REPLAN_SECONDS = 20.0f;

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemyChase::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction )
{
m_chasePath.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() );
m_routeTypeTimer.Start( CTG_ENEMY_CHASE_REPLAN_SECONDS );

return Continue();
}

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemyChase::Update( CNEOBot *me, float interval )
{
CNEO_Player *pGhostCarrier = CNEOBotCtgEnemy::EnemyGhostCarrier( me );
if ( !pGhostCarrier )
{
return Done( "No enemy ghost carrier" );
}

// We are ahead of the carrier exactly when a cut-off still exists
if ( m_routeTypeTimer.IsElapsed() )
{
m_routeTypeTimer.Start( CTG_ENEMY_CHASE_REPLAN_SECONDS );
m_routeType = CNEOBotCtgEnemy::FindCutOff( me, pGhostCarrier ) ? DEFAULT_ROUTE : FASTEST_ROUTE;
}

const CKnownEntity *threat = me->GetVisionInterface()->GetPrimaryKnownThreat( true );
if ( threat && !threat->IsObsolete() && me->GetIntentionInterface()->ShouldAttack( me, threat ) )
{
return SuspendFor( new CNEOBotAttack( pGhostCarrier->GetAbsOrigin() ), "Attacking ghoster team" );
}

CNEOBotPathUpdateChase( me, m_chasePath, pGhostCarrier, m_routeType );

return Continue();
}

//---------------------------------------------------------------------------------------------
ActionResult< CNEOBot > CNEOBotCtgEnemyChase::OnResume( CNEOBot *me, Action< CNEOBot > *interruptingAction )
{
m_chasePath.Invalidate();

return Continue();
}

//---------------------------------------------------------------------------------------------
QueryResultType CNEOBotCtgEnemyChase::ShouldHurry( const INextBot *me ) const
{
return CNEOBotCtgEnemy::CarrierUrgency( static_cast< const CNEOBot * >( me ) );
}
Loading
Loading