From a46ccc39c74c53b68235b5489e3ed07dd5366493 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 27 Sep 2026 15:22:10 -0600 Subject: [PATCH 1/4] Bots cut off the enemy ghost carrier When an enemy picks up the ghost, CNEOBotCtgEnemy now decides once whether this bot can get in front of the carrier and hands over: CNEOBotCtgEnemyCutOff paths to the earliest area on the carrier's predicted route that the bot reaches first, then chases from there; CNEOBotCtgEnemyChase runs the carrier down, by the fastest route from behind and the default route from ahead. The route is a naive shortest path to the nearest cap the carrier can score in, from what any player sees: the ghost marker and the cap zones. One SearchSurroundingAreas flood prices the whole route by the bot's own travel. CNEOBotCtgSeek makes the enemy-carrier decision before UpdateCommon's combat suspend, so a defender that sees an escort still goes for the carrier. The ShouldHurry rule moves to the two children, since the decider no longer stays on the stack. --- src/game/server/CMakeLists.txt | 4 + .../neo/bot/behavior/neo_bot_ctg_enemy.cpp | 183 +++++++++++++----- .../neo/bot/behavior/neo_bot_ctg_enemy.h | 25 +-- .../bot/behavior/neo_bot_ctg_enemy_chase.cpp | 62 ++++++ .../bot/behavior/neo_bot_ctg_enemy_chase.h | 27 +++ .../bot/behavior/neo_bot_ctg_enemy_cutoff.cpp | 116 +++++++++++ .../bot/behavior/neo_bot_ctg_enemy_cutoff.h | 33 ++++ .../neo/bot/behavior/neo_bot_ctg_seek.cpp | 21 +- 8 files changed, 405 insertions(+), 66 deletions(-) create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.h create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp create mode 100644 src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.h diff --git a/src/game/server/CMakeLists.txt b/src/game/server/CMakeLists.txt index fec1fd9998..179decfe5f 100644 --- a/src/game/server/CMakeLists.txt +++ b/src/game/server/CMakeLists.txt @@ -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 @@ -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 diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp index 1c8b2c6269..00b92e96b8 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp @@ -2,90 +2,183 @@ #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" +ConVar sv_neo_bot_ctg_enemy_cutoff_lead( "sv_neo_bot_ctg_enemy_cutoff_lead", "1.0", FCVAR_CHEAT, + "CTG: a bot claims a point on the enemy ghost carrier's route only when its own travel there is at " + "most this fraction of the carrier's. Below 1 it needs a head start; above 1 it will try marginal cut-offs.", + true, 0.1f, true, 2.0f ); //--------------------------------------------------------------------------------------------- -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() ); - - return Continue(); -} - -//--------------------------------------------------------------------------------------------- -ActionResult< CNEOBot > CNEOBotCtgEnemy::Update( CNEOBot *me, float interval ) -{ - if ( !NEORules()->GhostExists() ) + CNavArea *pGoalArea = TheNavMesh->GetNearestNavArea( vecGoal ); + if ( !pStartArea || !pGoalArea ) { - return Done( "Ghost does not exist" ); + return false; } - if ( NEORules()->GetGhosterPlayer() <= 0 ) + ShortestPathCost cost; + if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) ) { - return Done( "No ghost carrier" ); + return false; } - CNEO_Player* pGhostCarrier = ToNEOPlayer( UTIL_PlayerByIndex( NEORules()->GetGhosterPlayer() ) ); - if ( !pGhostCarrier || pGhostCarrier->GetTeamNumber() == me->GetTeamNumber() ) + // The parent chain runs goal -> start + for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() ) { - return Done( "Ghost carrier is friendly" ); + route.areas.AddToTail( pArea ); } - const CKnownEntity *threat = me->GetVisionInterface()->GetPrimaryKnownThreat(true); - if ( threat && !threat->IsObsolete() && me->GetIntentionInterface()->ShouldAttack( me, threat ) ) + route.areas.Reverse(); + + route.travel.AddToTail( 0.0f ); + for ( int i = 1; i < route.areas.Count(); ++i ) { - return SuspendFor( new CNEOBotAttack(pGhostCarrier->GetAbsOrigin()), "Attacking ghoster team" ); + route.travel.AddToTail( route.travel[ i - 1 ] + + ( route.areas[i]->GetCenter() - route.areas[ i - 1 ]->GetCenter() ).Length() ); } - // Investigate the ghost carrier's position - CNEOBotPathUpdateChase( me, m_chasePath, pGhostCarrier, DEFAULT_ROUTE ); - - return Continue(); + return true; } - - //--------------------------------------------------------------------------------------------- -ActionResult< CNEOBot > CNEOBotCtgEnemy::OnResume( CNEOBot *me, Action< CNEOBot > *interruptingAction ) +// Floods outward over the areas this bot can traverse, leaving each reached area marked with its +// travel distance (IsMarked, GetCostSoFar) until the next nav search. Ladders are not followed. +class CNEOBotTraversableFlood : public ISearchSurroundingAreasFunctor { - m_chasePath.Invalidate(); +public: + CNEOBotTraversableFlood( CNEOBot *me ) : m_me( me ) {} - return Continue(); -} + virtual bool operator()( CNavArea *area, CNavArea *priorArea, float travelDistanceSoFar ) override + { + return true; + } + + virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override + { + return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea ); + } + +private: + CNEOBot *m_me; +}; //--------------------------------------------------------------------------------------------- -EventDesiredResult< CNEOBot > CNEOBotCtgEnemy::OnStuck( CNEOBot *me ) +CNEO_Player *CNEOBotCtgEnemy::EnemyGhostCarrier( const CNEOBot *me ) { - return TryContinue(); + if ( !NEORules()->GhostExists() ) + { + return nullptr; + } + + 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; } //--------------------------------------------------------------------------------------------- -EventDesiredResult< CNEOBot > CNEOBotCtgEnemy::OnMoveToSuccess( CNEOBot *me, const Path *path ) +CNavArea *CNEOBotCtgEnemy::FindCutOff( CNEOBot *me, CNEO_Player *pGhostCarrier ) { - return TryContinue(); + CNavArea *pMyArea = me->GetLastKnownArea(); + if ( !pGhostCarrier || !pMyArea ) + { + return nullptr; + } + + // 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; + } + + // A naive shortest route: the carrier's class, loadout and intent are not observable + CNEOBotPredictedRoute carrierRoute; + if ( !PredictRoute( pGhostCarrier->GetLastKnownArea(), vecGoal, carrierRoute ) ) + { + return nullptr; + } + + // 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. + const float flLead = sv_neo_bot_ctg_enemy_cutoff_lead.GetFloat(); + CNEOBotTraversableFlood flood( me ); + SearchSurroundingAreas( pMyArea, flood, carrierRoute.travel.Tail() * flLead ); + + // The earliest area we beat the carrier to 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] * flLead ) + { + 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( 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; } @@ -97,7 +190,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; } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h index ff616a1772..16f0f82bd0 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.h @@ -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 diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp new file mode 100644 index 0000000000..e99c917daf --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp @@ -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" + +ConVar sv_neo_bot_ctg_enemy_chase_replan_seconds( "sv_neo_bot_ctg_enemy_chase_replan_seconds", "20", FCVAR_CHEAT, + "CTG: seconds between a chasing bot re-checking whether to take the fastest or the default route to the enemy ghost carrier.", + true, 1.0f, false, 0.0f ); + +//--------------------------------------------------------------------------------------------- +ActionResult< CNEOBot > CNEOBotCtgEnemyChase::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) +{ + m_chasePath.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() ); + m_routeTypeTimer.Start( sv_neo_bot_ctg_enemy_chase_replan_seconds.GetFloat() ); + + 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( sv_neo_bot_ctg_enemy_chase_replan_seconds.GetFloat() ); + 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 ) ); +} diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.h new file mode 100644 index 0000000000..aaf1c47533 --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.h @@ -0,0 +1,27 @@ +#pragma once + +#include "bot/neo_bot.h" +#include "Path/NextBotChasePath.h" +#include "nav_pathfind.h" + +//-------------------------------------------------------------------------------------------------------- +// Runs the enemy ghost carrier down until there is no longer a living enemy carrier. FASTEST_ROUTE +// from behind it; DEFAULT_ROUTE from ahead, where the reservation penalty spreads defenders out. +class CNEOBotCtgEnemyChase : public Action< CNEOBot > +{ +public: + CNEOBotCtgEnemyChase( RouteType route ) : m_routeType( route ) {} + + 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 QueryResultType ShouldHurry( const INextBot *me ) const override; + + virtual const char *GetName( void ) const override { return "ctgEnemyChase"; } + +private: + ChasePath m_chasePath; + CountdownTimer m_routeTypeTimer; // throttles re-checking whether we are ahead of the carrier + RouteType m_routeType; +}; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp new file mode 100644 index 0000000000..e32e38d428 --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp @@ -0,0 +1,116 @@ +#include "cbase.h" +#include "neo_player.h" +#include "bot/neo_bot.h" +#include "bot/behavior/neo_bot_ctg_enemy_cutoff.h" +#include "bot/behavior/neo_bot_ctg_enemy.h" +#include "bot/behavior/neo_bot_ctg_enemy_chase.h" +#include "bot/neo_bot_path_compute.h" +#include "neo_gamerules.h" + +ConVar sv_neo_bot_ctg_enemy_cutoff_replan_seconds( "sv_neo_bot_ctg_enemy_cutoff_replan_seconds", "5", FCVAR_CHEAT, + "CTG: seconds between a bot heading for a cut-off re-picking it on the enemy ghost carrier's route, to catch the carrier taking a line it did not predict.", + true, 0.5f, false, 0.0f ); + +//--------------------------------------------------------------------------------------------- +// A partial path would walk the bot to wherever the pathfinder gave up, short of the cut-off +bool CNEOBotCtgEnemyCutOff::RepathToCutOff( CNEOBot *me ) +{ + return CNEOBotPathCompute( me, m_path, m_pCutOff->GetCenter(), FASTEST_ROUTE, PATH_NO_LENGTH_LIMIT, false ); +} + +//--------------------------------------------------------------------------------------------- +// Re-pick the cut-off. False when there is none left to head for, so the caller drops to the chase. +bool CNEOBotCtgEnemyCutOff::Replan( CNEOBot *me, CNEO_Player *pGhostCarrier ) +{ + CNavArea *pCutOff = CNEOBotCtgEnemy::FindCutOff( me, pGhostCarrier ); + if ( !pCutOff ) + { + return false; + } + + if ( pCutOff == m_pCutOff ) + { + return true; + } + + m_pCutOff = pCutOff; + return RepathToCutOff( me ); +} + +//--------------------------------------------------------------------------------------------- +ActionResult< CNEOBot > CNEOBotCtgEnemyCutOff::OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) +{ + m_path.SetMinLookAheadDistance( me->GetDesiredPathLookAheadRange() ); + + if ( !RepathToCutOff( me ) ) + { + // Every hand-over but arrival chases by FASTEST_ROUTE: an unreachable cut-off is no sign we are ahead + return ChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), "No path to the cut-off - chasing" ); + } + + m_replanTimer.Start( sv_neo_bot_ctg_enemy_cutoff_replan_seconds.GetFloat() ); + + return Continue(); +} + +//--------------------------------------------------------------------------------------------- +ActionResult< CNEOBot > CNEOBotCtgEnemyCutOff::Update( CNEOBot *me, float interval ) +{ + CNEO_Player *pGhostCarrier = CNEOBotCtgEnemy::EnemyGhostCarrier( me ); + if ( !pGhostCarrier ) + { + return Done( "No enemy ghost carrier" ); + } + + if ( m_replanTimer.IsElapsed() ) + { + m_replanTimer.Start( sv_neo_bot_ctg_enemy_cutoff_replan_seconds.GetFloat() ); + + if ( !Replan( me, pGhostCarrier ) ) + { + return ChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), "Lost the cut-off - chasing" ); + } + } + + // The cut-off's value is the ground it wins, not an ambush: once on it, chase from here + if ( me->GetLastKnownArea() == m_pCutOff ) + { + return ChangeTo( new CNEOBotCtgEnemyChase( DEFAULT_ROUTE ), "Arrived at the cut-off - chasing" ); + } + + m_path.Update( me ); + if ( !m_path.IsValid() ) + { + return ChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), "Lost the path to the cut-off - chasing" ); + } + + return Continue(); +} + +//--------------------------------------------------------------------------------------------- +EventDesiredResult< CNEOBot > CNEOBotCtgEnemyCutOff::OnStuck( CNEOBot *me ) +{ + if ( !RepathToCutOff( me ) ) + { + return TryChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), RESULT_TRY, "Stuck with no way to the cut-off - chasing" ); + } + + return TryContinue(); +} + +//--------------------------------------------------------------------------------------------- +EventDesiredResult< CNEOBot > CNEOBotCtgEnemyCutOff::OnMoveToFailure( CNEOBot *me, const Path *path, MoveToFailureType reason ) +{ + if ( !RepathToCutOff( me ) ) + { + return TryChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), RESULT_TRY, "Cannot reach the cut-off - chasing" ); + } + + return TryContinue(); +} + +//--------------------------------------------------------------------------------------------- +QueryResultType CNEOBotCtgEnemyCutOff::ShouldHurry( const INextBot *me ) const +{ + return CNEOBotCtgEnemy::CarrierUrgency( static_cast< const CNEOBot * >( me ) ); +} diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.h b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.h new file mode 100644 index 0000000000..178bc4efdd --- /dev/null +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.h @@ -0,0 +1,33 @@ +#pragma once + +#include "bot/neo_bot.h" + +class CNEO_Player; +class CNavArea; + +//-------------------------------------------------------------------------------------------------------- +// Runs to the earliest area on the enemy ghost carrier's predicted route that this bot reaches first, +// then chases from there. Re-picks the area on a timer, because the carrier's route is only a guess. +class CNEOBotCtgEnemyCutOff : public Action< CNEOBot > +{ +public: + CNEOBotCtgEnemyCutOff( CNavArea *pCutOff ) : m_pCutOff( pCutOff ) {} + + virtual ActionResult< CNEOBot > OnStart( CNEOBot *me, Action< CNEOBot > *priorAction ) override; + virtual ActionResult< CNEOBot > Update( CNEOBot *me, float interval ) override; + + virtual EventDesiredResult< CNEOBot > OnStuck( CNEOBot *me ) override; + virtual EventDesiredResult< CNEOBot > OnMoveToFailure( CNEOBot *me, const Path *path, MoveToFailureType reason ) override; + + virtual QueryResultType ShouldHurry( const INextBot *me ) const override; + + virtual const char *GetName( void ) const override { return "ctgEnemyCutOff"; } + +private: + bool Replan( CNEOBot *me, CNEO_Player *pGhostCarrier ); + bool RepathToCutOff( CNEOBot *me ); + + CNavArea *m_pCutOff; + PathFollower m_path; + CountdownTimer m_replanTimer; // throttles re-picking the cut-off +}; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp index 6283a84836..cda7f3c6b5 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_seek.cpp @@ -24,6 +24,13 @@ ActionResult< CNEOBot > CNEOBotCtgSeek::Update( CNEOBot *me, float interval ) return Done( "Round Over: CTG objective no longer relevant" ); } + // Before UpdateCommon's combat suspend, or a defender that sees an escort never gets here and + // fights the screen while the ghost is walked in; the chase still fights, anchored to the carrier + if ( CNEOBotCtgEnemy::EnemyGhostCarrier( me ) ) + { + return SuspendFor( new CNEOBotCtgEnemy, "Stopping the ghost carrier!" ); + } + ActionResult< CNEOBot > result = UpdateCommon( me, interval ); if ( result.IsRequestingChange() || result.IsDone() ) { @@ -45,22 +52,18 @@ ActionResult< CNEOBot > CNEOBotCtgSeek::Update( CNEOBot *me, float interval ) return SuspendFor( new CNEOBotCtgLoneWolf, "I'm the last one on my team!" ); } + // Our own side's ghost only: the enemy carrier is handled above, and the escort stays below the + // combat suspend so the attacking team does not get the same head start if (NEORules()->GhostExists()) { int iGhosterPlayer = NEORules()->GetGhosterPlayer(); if (iGhosterPlayer > 0 && iGhosterPlayer <= gpGlobals->maxClients) { CNEO_Player* pGhostCarrier = ToNEOPlayer(UTIL_PlayerByIndex(iGhosterPlayer)); - if (pGhostCarrier && pGhostCarrier != me) + if (pGhostCarrier && pGhostCarrier != me + && pGhostCarrier->GetTeamNumber() == me->GetTeamNumber()) { - if (pGhostCarrier->GetTeamNumber() == me->GetTeamNumber()) - { - return SuspendFor(new CNEOBotCtgEscort, "Protecting the ghost carrier!"); - } - else - { - return SuspendFor(new CNEOBotCtgEnemy, "Stopping the ghost carrier!"); - } + return SuspendFor(new CNEOBotCtgEscort, "Protecting the ghost carrier!"); } // If I have the ghost, switch to ghost behavior From 20c9f527c81652ba709a434bd7f3fb68696f9a9b Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:08:14 -0600 Subject: [PATCH 2/4] Replace the cut-off tuning cvars with constants The cut-off and chase re-plan intervals (5 s and 20 s) are settled, so they become named constants. The cut-off lead stayed at 1.0 after a looser lead made bots commit to cut-offs they lost; at 1.0 it is no factor at all, so the race is now plainly "reach it no later than the carrier". --- .../server/neo/bot/behavior/neo_bot_ctg_enemy.cpp | 12 +++--------- .../neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp | 10 +++++----- .../neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp | 10 +++++----- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp index 00b92e96b8..d6b054c264 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp @@ -8,11 +8,6 @@ #include "nav_mesh.h" #include "nav_pathfind.h" -ConVar sv_neo_bot_ctg_enemy_cutoff_lead( "sv_neo_bot_ctg_enemy_cutoff_lead", "1.0", FCVAR_CHEAT, - "CTG: a bot claims a point on the enemy ghost carrier's route only when its own travel there is at " - "most this fraction of the carrier's. Below 1 it needs a head start; above 1 it will try marginal cut-offs.", - true, 0.1f, true, 2.0f ); - //--------------------------------------------------------------------------------------------- // 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 @@ -133,15 +128,14 @@ CNavArea *CNEOBotCtgEnemy::FindCutOff( CNEOBot *me, CNEO_Player *pGhostCarrier ) // 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. - const float flLead = sv_neo_bot_ctg_enemy_cutoff_lead.GetFloat(); CNEOBotTraversableFlood flood( me ); - SearchSurroundingAreas( pMyArea, flood, carrierRoute.travel.Tail() * flLead ); + SearchSurroundingAreas( pMyArea, flood, carrierRoute.travel.Tail() ); - // The earliest area we beat the carrier to is the furthest from its cap we can stand in its way + // 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] * flLead ) + if ( pArea->IsMarked() && pArea->GetCostSoFar() <= carrierRoute.travel[i] ) { return pArea; } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp index e99c917daf..f6685de5c3 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_chase.cpp @@ -7,15 +7,15 @@ #include "bot/neo_bot_path_compute.h" #include "neo_gamerules.h" -ConVar sv_neo_bot_ctg_enemy_chase_replan_seconds( "sv_neo_bot_ctg_enemy_chase_replan_seconds", "20", FCVAR_CHEAT, - "CTG: seconds between a chasing bot re-checking whether to take the fastest or the default route to the enemy ghost carrier.", - true, 1.0f, false, 0.0f ); +// 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( sv_neo_bot_ctg_enemy_chase_replan_seconds.GetFloat() ); + m_routeTypeTimer.Start( CTG_ENEMY_CHASE_REPLAN_SECONDS ); return Continue(); } @@ -32,7 +32,7 @@ ActionResult< CNEOBot > CNEOBotCtgEnemyChase::Update( CNEOBot *me, float interva // We are ahead of the carrier exactly when a cut-off still exists if ( m_routeTypeTimer.IsElapsed() ) { - m_routeTypeTimer.Start( sv_neo_bot_ctg_enemy_chase_replan_seconds.GetFloat() ); + m_routeTypeTimer.Start( CTG_ENEMY_CHASE_REPLAN_SECONDS ); m_routeType = CNEOBotCtgEnemy::FindCutOff( me, pGhostCarrier ) ? DEFAULT_ROUTE : FASTEST_ROUTE; } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp index e32e38d428..c171151e0a 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp @@ -7,9 +7,9 @@ #include "bot/neo_bot_path_compute.h" #include "neo_gamerules.h" -ConVar sv_neo_bot_ctg_enemy_cutoff_replan_seconds( "sv_neo_bot_ctg_enemy_cutoff_replan_seconds", "5", FCVAR_CHEAT, - "CTG: seconds between a bot heading for a cut-off re-picking it on the enemy ghost carrier's route, to catch the carrier taking a line it did not predict.", - true, 0.5f, false, 0.0f ); +// Seconds between a bot heading for a cut-off re-picking it, +// since the carrier may take a line other than the predicted one +static constexpr float CTG_ENEMY_CUTOFF_REPLAN_SECONDS = 5.0f; //--------------------------------------------------------------------------------------------- // A partial path would walk the bot to wherever the pathfinder gave up, short of the cut-off @@ -48,7 +48,7 @@ ActionResult< CNEOBot > CNEOBotCtgEnemyCutOff::OnStart( CNEOBot *me, Action< CNE return ChangeTo( new CNEOBotCtgEnemyChase( FASTEST_ROUTE ), "No path to the cut-off - chasing" ); } - m_replanTimer.Start( sv_neo_bot_ctg_enemy_cutoff_replan_seconds.GetFloat() ); + m_replanTimer.Start( CTG_ENEMY_CUTOFF_REPLAN_SECONDS ); return Continue(); } @@ -64,7 +64,7 @@ ActionResult< CNEOBot > CNEOBotCtgEnemyCutOff::Update( CNEOBot *me, float interv if ( m_replanTimer.IsElapsed() ) { - m_replanTimer.Start( sv_neo_bot_ctg_enemy_cutoff_replan_seconds.GetFloat() ); + m_replanTimer.Start( CTG_ENEMY_CUTOFF_REPLAN_SECONDS ); if ( !Replan( me, pGhostCarrier ) ) { From 0c37f00b606fdbb52c29c517977a984ee4f38707 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 14 Oct 2026 19:09:14 -0600 Subject: [PATCH 3/4] Name the cut-off path's truncate argument RepathToCutOff passed a bare false for Path::Compute's includeGoalIfPathFails argument. Spell it PATH_TRUNCATE_INCOMPLETE_PATH, as CNEOBotSeekWeapon does, so the call says what it asks for. --- src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp index c171151e0a..9f38d36f08 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy_cutoff.cpp @@ -15,7 +15,7 @@ static constexpr float CTG_ENEMY_CUTOFF_REPLAN_SECONDS = 5.0f; // A partial path would walk the bot to wherever the pathfinder gave up, short of the cut-off bool CNEOBotCtgEnemyCutOff::RepathToCutOff( CNEOBot *me ) { - return CNEOBotPathCompute( me, m_path, m_pCutOff->GetCenter(), FASTEST_ROUTE, PATH_NO_LENGTH_LIMIT, false ); + return CNEOBotPathCompute( me, m_path, m_pCutOff->GetCenter(), FASTEST_ROUTE, PATH_NO_LENGTH_LIMIT, PATH_TRUNCATE_INCOMPLETE_PATH ); } //--------------------------------------------------------------------------------------------- From f94ac127c3986d0feb92a2791b84616fc3d38aca Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Fri, 16 Oct 2026 23:40:00 -0600 Subject: [PATCH 4/4] Measure the cut-off race by shortest distance The bot's side of the race used SearchSurroundingAreas, a breadth-first flood that keeps the fewest-hops distance to each area, while the carrier's side uses a shortest path. Override IterateAdjacentAreas so the flood runs Dijkstra and both sides use the same measure. --- .../neo/bot/behavior/neo_bot_ctg_enemy.cpp | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp index d6b054c264..a52a177d00 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp @@ -54,7 +54,7 @@ static bool PredictRoute( CNavArea *pStartArea, const Vector &vecGoal, CNEOBotPr //--------------------------------------------------------------------------------------------- // Floods outward over the areas this bot can traverse, leaving each reached area marked with its -// travel distance (IsMarked, GetCostSoFar) until the next nav search. Ladders are not followed. +// shortest travel distance (IsMarked, GetCostSoFar) until the next nav search. Ladders are not followed. class CNEOBotTraversableFlood : public ISearchSurroundingAreasFunctor { public: @@ -70,6 +70,41 @@ class CNEOBotTraversableFlood : public ISearchSurroundingAreasFunctor 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(); + } + } + } + } + private: CNEOBot *m_me; };