From d1314f03148ba3e67bae57ff99b2e291c1f43c6a Mon Sep 17 00:00:00 2001 From: sunzenshen Date: Sun, 20 Sep 2026 14:22:07 -0600 Subject: [PATCH 01/23] Fix bots bouncing on ladders and failing to descend --- .../Player/NextBotPlayerLocomotion.cpp | 206 ++++++++++++++++++ .../NextBot/Player/NextBotPlayerLocomotion.h | 14 ++ .../bot/behavior/neo_bot_ladder_approach.cpp | 77 ++++--- .../bot/behavior/neo_bot_ladder_approach.h | 1 + .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 87 +++++++- .../neo/bot/behavior/neo_bot_ladder_climb.h | 1 + 6 files changed, 350 insertions(+), 36 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index ed83abb3af..9f07f9048a 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -17,6 +17,32 @@ ConVar NextBotPlayerMoveDirect( "nb_player_move_direct", "0" ); +#ifdef NEO +// CGameMovement::LadderMove() puts a player on any ladder their wish direction points at, +// while TraverseLadder()'s NO_LADDER branch drops the move type straight back to walking whenever +// the bot's own state machine did not ask for a climb. A path that merely brushes past a ladder +// leaves the two toggling every tick: the bot hangs on the ladder making no progress, and because +// it never stops moving, no stuck event fires either. +// +// Letting go harder does not fix it - the wish direction still points at the brush. What does is +// giving up the argument and riding the ladder out by its nearer end, but only for the contacts +// that actually persist; most sort themselves out within a second and adopting those buys a lot of +// climbing nobody asked for. + +// How long a bot must be held against a ladder it did not ask for before taking it over. Short +// enough that a real snag is caught within half a second, long enough that the ordinary release +// still handles the many contacts that clear themselves immediately. +static const float LADDER_ADOPT_TIME = 0.5f; +// A ladder further than this from the bot is not the one it is stuck on. +static const float LADDER_TOUCH_RANGE = 64.0f; +// A gap longer than this means the bot walked away and came back, so the bout starts over. +static const float LADDER_CONTACT_RESET = 1.0f; + +// Going down a ladder, forward is pressed only once it moves the bot down at least this +// fraction of the climb speed. Slower than that, the view is still coming round. +static const float LADDER_MIN_DESCENT_RATE = 0.25f; +#endif // NEO + //----------------------------------------------------------------------------------------------------- PlayerLocomotion::PlayerLocomotion( INextBot *bot ) : ILocomotion( bot ) { @@ -45,6 +71,11 @@ void PlayerLocomotion::Reset( void ) m_ladderDismountGoal = NULL; m_ladderTimer.Invalidate(); +#ifdef NEO + m_unwantedLadderSince = 0.0f; + m_unwantedLadderLastTouch = 0.0f; +#endif + m_minSpeedLimit = 0.0f; m_maxSpeedLimit = 9999999.9f; @@ -52,6 +83,142 @@ void PlayerLocomotion::Reset( void ) } +#ifdef NEO +//----------------------------------------------------------------------------------------------------- +/** + * The nav ladder the bot is standing in, or NULL. The engine knows which brush it grabbed but + * keeps that normal private, so match against the nav record instead - close in xy, and inside the + * ladder's own height span. + */ +const CNavLadder *PlayerLocomotion::FindTouchedLadder( void ) const +{ + const Vector &feet = GetFeet(); + const CNavLadder *best = NULL; + float bestRangeSq = LADDER_TOUCH_RANGE * LADDER_TOUCH_RANGE; + + // The hull reaches a standing height above the feet, and the grab only needs the hull to touch + // the brush - so a bot standing on the floor well *below* a ladder whose foot hangs over a + // walkway is exactly the case to catch here, not one to filter out. + const float below = GetBot()->GetBodyInterface()->GetStandHullHeight(); + + for ( int i = 0; i < TheNavMesh->GetLadders().Count(); ++i ) + { + const CNavLadder *ladder = TheNavMesh->GetLadders()[i]; + + if ( feet.z < ladder->m_bottom.z - below || feet.z > ladder->m_top.z + GetStepHeight() ) + { + continue; + } + + const float rangeSq = ( ladder->GetPosAtHeight( feet.z ) - feet ).AsVector2D().LengthSqr(); + if ( rangeSq < bestRangeSq ) + { + bestRangeSq = rangeSq; + best = ladder; + } + } + + return best; +} + + +//----------------------------------------------------------------------------------------------------- +/** + * Called when the engine has us on a ladder our own path never asked for. Returns true if the + * ladder was taken over, in which case the caller leaves the move type alone and the normal ladder + * state machine drives from here. + */ +bool PlayerLocomotion::HandleUnwantedLadder( void ) +{ + const CNavLadder *ladder = FindTouchedLadder(); + if ( ladder == NULL ) + { + return false; + } + + // Most grabs sort themselves out within a second, and adopting every one of them buys a lot of + // climbing nobody asked for. Only take the ladder over once the bot has actually been held + // against it. + const float now = gpGlobals->curtime; + + if ( now - m_unwantedLadderLastTouch > LADDER_CONTACT_RESET ) + { + m_unwantedLadderSince = now; + } + + m_unwantedLadderLastTouch = now; + + if ( now - m_unwantedLadderSince < LADDER_ADOPT_TIME ) + { + return false; + } + + // Leave by the nearer end rather than argue about being here at all. Whichever end the bot is + // closer to is the one it can reach soonest, and the dismount goal has to be a real area or + // DismountLadderTop/Bottom has nothing to walk to. + // + // Climbing out upward only was measured and is worse: it sends the bot somewhere it then has to + // come back from, and costs captures even though it reads better on the ladder numbers. + const bool bGoUp = ( GetFeet().z - ladder->m_bottom.z ) > ( ladder->m_top.z - GetFeet().z ); + + // A ladder's top is recorded in whichever of the three slots the generator filled, and plenty + // of them leave m_topForwardArea empty - reading only that slot silently skips those ladders. + const CNavArea *dismount = ladder->m_bottomArea; + + if ( bGoUp ) + { + dismount = ladder->m_topForwardArea ? ladder->m_topForwardArea : + ( ladder->m_topLeftArea ? ladder->m_topLeftArea : ladder->m_topRightArea ); + } + + if ( dismount == NULL ) + { + return false; + } + + m_ladderInfo = ladder; + m_ladderDismountGoal = dismount; + m_ladderState = bGoUp ? ASCENDING_LADDER : DESCENDING_LADDER; + + return true; +} + + +//----------------------------------------------------------------------------------------------------- +// The horizontal direction into the face of the ladder brush we hold. LadderMove() takes whichever +// face we touched, so a bot that grabbed the ladder from behind holds its back face. +Vector PlayerLocomotion::GetIntoLadderFace( const CNavLadder *ladder ) const +{ + const Vector &normal = ladder->GetNormal(); + const bool bBehind = DotProduct( GetFeet() - ladder->m_top, normal ) < 0.0f; + + return bBehind ? normal : -normal; +} + + +//----------------------------------------------------------------------------------------------------- +// Would pressing forward move us down this ladder? LadderMove() turns a push into the face into +// climbing up, so forward descends only while the view points further down than into the face. +bool PlayerLocomotion::IsForwardDownLadder( const CNavLadder *ladder ) const +{ + Vector view; + m_player->EyeVectors( &view ); + + // Facing away from the face is no good either: standing on a floor, LadderMove() pushes us off for it + const float intoFace = DotProduct( view, GetIntoLadderFace( ladder ) ); + if ( intoFace < 0.0f ) + { + return false; + } + + // The vertical speed a forward press gives, as a fraction of the climb speed + const float climbRate = view.z + intoFace; + + return climbRate < -LADDER_MIN_DESCENT_RATE; +} +#endif // NEO + + //----------------------------------------------------------------------------------------------------- bool PlayerLocomotion::TraverseLadder( void ) { @@ -88,6 +255,14 @@ bool PlayerLocomotion::TraverseLadder( void ) if ( GetBot()->GetEntity()->GetMoveType() == MOVETYPE_LADDER ) { // on ladder and don't want to be +#ifdef NEO + // HandleUnwantedLadder() may take the ladder over instead, + // in which case the state machine drives from here and we are done + if ( HandleUnwantedLadder() ) + { + break; + } +#endif GetBot()->GetEntity()->SetMoveType( MOVETYPE_WALK ); } return false; @@ -115,6 +290,15 @@ PlayerLocomotion::LadderState PlayerLocomotion::ApproachAscendingLadder( void ) return DISMOUNTING_LADDER_TOP; } +#ifdef NEO + // A bot already on the ladder is not too far below it, whatever the nav ladder's bottom says - + // on a ladder whose foot hangs above the floor the check below would drop a climb the bot has begun + if ( GetBot()->GetEntity()->GetMoveType() == MOVETYPE_LADDER ) + { + return ASCENDING_LADDER; + } +#endif + // sanity check - are we too far below this ladder to reach it? if ( GetFeet().z <= m_ladderInfo->m_bottom.z - GetMaxJumpHeight() ) { @@ -149,6 +333,15 @@ PlayerLocomotion::LadderState PlayerLocomotion::ApproachDescendingLadder( void ) return NO_LADDER; } +#ifdef NEO + // A bot already on the ladder climbs down it. Steering on towards the mount point presses forward + // with the view still level, which climbs it up and off the top; letting go drops it from there. + if ( GetBot()->GetEntity()->GetMoveType() == MOVETYPE_LADDER ) + { + return DESCENDING_LADDER; + } +#endif + // sanity check - are we already at the end of this ladder? if ( GetFeet().z <= m_ladderInfo->m_bottom.z + GetMaxJumpHeight() ) { @@ -281,7 +474,12 @@ PlayerLocomotion::LadderState PlayerLocomotion::DescendLadder( void ) } // climb down this ladder - look down +#ifdef NEO + // into its face (facing away, a bot still on the top floor is pushed off the ladder the moment it presses forward) + Vector goal = GetFeet() + 100.0f * ( GetIntoLadderFace( m_ladderInfo ) + Vector( 0, 0, -2 ) ); +#else Vector goal = GetFeet() + 100.0f * ( m_ladderInfo->GetNormal() + Vector( 0, 0, -2 ) ); +#endif GetBot()->GetBodyInterface()->AimHeadTowards( goal, IBody::MANDATORY, 0.1f, NULL, "Ladder" ); @@ -489,6 +687,14 @@ void PlayerLocomotion::Approach( const Vector &pos, float goalWeight ) } #endif +#ifdef NEO + if ( m_player->IsOnLadder() && m_ladderState == DESCENDING_LADDER && m_ladderInfo && !IsForwardDownLadder( m_ladderInfo ) ) + { + // On the way down, press nothing until forward would move us down. With the view still + // coming round it climbs us up, and from the top of the ladder, off it. + } + else +#endif if ( m_player->IsOnLadder() && IsUsingLadder() && ( m_ladderState == ASCENDING_LADDER || m_ladderState == DESCENDING_LADDER ) ) { // we are on a ladder and WANT to be on a ladder. diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h index 6a947835ca..afbfcece23 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h @@ -61,6 +61,9 @@ class PlayerLocomotion : public ILocomotion virtual bool IsUsingLadder( void ) const; virtual bool IsAscendingOrDescendingLadder( void ) const; // we are actually on the ladder right now, either climbing up or down virtual bool IsAbleToAutoCenterOnLadder( void ) const; +#ifdef NEO + bool IsForwardDownLadder( const CNavLadder *ladder ) const; // would pressing forward move us down this ladder +#endif virtual void FaceTowards( const Vector &target ); // rotate body to face towards "target" @@ -127,6 +130,17 @@ class PlayerLocomotion : public ILocomotion const CNavArea *m_ladderDismountGoal; CountdownTimer m_ladderTimer; // a "give up" timer if things go awry +#ifdef NEO + // a ladder the engine put us on that our own path never asked for. Releasing it is not + // enough on its own - CGameMovement::LadderMove() re-grabs from the bot's wish direction, so + // the two toggle each other until something breaks the loop. See TraverseLadder(). + bool HandleUnwantedLadder( void ); + const CNavLadder *FindTouchedLadder( void ) const; + Vector GetIntoLadderFace( const CNavLadder *ladder ) const; // into the face of the ladder brush we hold + float m_unwantedLadderSince; // when the current bout of unwanted contact began + float m_unwantedLadderLastTouch; // last tick we were on a ladder we did not ask for +#endif + bool IsClimbPossible( INextBot *me, const CBaseEntity *obstacle ) const; }; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 27d9c05ee2..31259a19da 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -86,6 +86,12 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) // Are we climbing up or down the ladder? Vector targetPos = m_bGoingUp ? m_ladder->m_bottom : m_ladder->m_top; + // Going down, head for where the bot will hang on the ladder: in front of its face, over the drop + if ( !m_bGoingUp ) + { + targetPos += m_ladder->GetNormal() * ( body->GetHullWidth() * 0.5f + HANG_CLEARANCE ); + } + // Calculate 2D vector from bot to ladder mount point Vector2D to = ( targetPos - myPos ).AsVector2D(); float range = to.NormalizeInPlace(); @@ -99,6 +105,14 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) // If bot was looking up or down, sometimes the fast climb movement causes a detachment Vector lookTarget = m_ladderCenter; lookTarget.z = me->EyePosition().z; + + // Going down, look down the ladder instead: the forward press that grabs it stays held for a few + // ticks, and with a level view it climbs the bot up and off the top + if ( !m_bGoingUp ) + { + lookTarget = m_ladder->m_bottom; + } + body->AimHeadTowards( lookTarget, IBody::MANDATORY, 0.1f, nullptr, "Stare at ladder center" ); if ( me->IsDebugging( NEXTBOT_PATH ) ) @@ -107,8 +121,33 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) NDebugOverlay::Line( myPos, targetPos, 255, 255, 0, true, 0.1f ); } - // Are we aligned and close enough to mount the ladder? - if ( range >= MOUNT_RANGE ) + // Going down, mount only once off the top: grabbed while still up there, on the landing or on the + // ladder's own top, the bot cannot press its way down, so it keeps walking out over the drop + const float flTopZ = m_ladder->m_top.z; + const bool bOnTop = !m_bGoingUp + && ( myPos.z >= flTopZ || ( mover->IsOnGround() && myPos.z > flTopZ - mover->GetStepHeight() ) ); + + // Within mount range and on the ladder: start climbing + if ( range < MOUNT_RANGE && me->IsOnLadder() && !bOnTop ) + { + if ( me->IsDebugging( NEXTBOT_PATH ) ) + { + DevMsg( "%s: Starting ladder climb\n", me->GetDebugIdentifier() ); + } + + // Stop the bot before behavior transition to prevent falling off the ladder + // there can be a delay in the state change, so momentum can cause a fall + me->SetAbsVelocity( vec3_origin ); + // ChangeTo: if something goes wrong during climb, reevaluate situation + return ChangeTo( new CNEOBotLadderClimb( m_ladder, m_bGoingUp ), "Mounting ladder" ); + } + + // Are we aligned and close enough to mount the ladder? Going down, line up only from in front of the + // face (at an angle the bot ends up on the floor beside the drop); from elsewhere walk straight on + const bool bAligned = dot < ALIGN_DOT_THRESHOLD; + const bool bLineUp = m_bGoingUp ? range >= MOUNT_RANGE + : ( dot < 0.0f && ( range >= MOUNT_RANGE || !bAligned ) ); + if ( bLineUp ) { // Perpendicular alignment line Vector2D alignNormal = ladderNormal2D; @@ -132,34 +171,16 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) NDebugOverlay::Cross3D( goal, 5.0f, 255, 0, 255, true, 0.1f ); } } + else if ( m_bGoingUp ? bAligned : !bOnTop ) + { + // Aligned (or going down and off the top), push forward to attach to the ladder + me->PressForwardButton(); + mover->Approach( targetPos ); + } else { - // Within mount range - check if aligned to start climbing - bool onLadder = me->IsOnLadder(); - if ( onLadder ) - { - if ( me->IsDebugging( NEXTBOT_PATH ) ) - { - DevMsg( "%s: Starting ladder climb\n", me->GetDebugIdentifier() ); - } - - // Stop the bot before behavior transition to prevent falling off the ladder - // there can be a delay in the state change, so momentum can cause a fall - me->SetAbsVelocity( vec3_origin ); - // ChangeTo: if something goes wrong during climb, reevaluate situation - return ChangeTo( new CNEOBotLadderClimb( m_ladder, m_bGoingUp ), "Mounting ladder" ); - } - else if ( !m_bGoingUp || dot < ALIGN_DOT_THRESHOLD ) - { - // Aligned (or going down), push forward to attach to the ladder - me->PressForwardButton(); - mover->Approach( targetPos ); - } - else - { - // Close but not aligned - continue approaching to align - mover->Approach( targetPos ); - } + // Close but not aligned (or going down and still on the top) - continue approaching + mover->Approach( targetPos ); } return Continue(); diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h index aa52154b73..49fcd0a220 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h @@ -35,5 +35,6 @@ class CNEOBotLadderApproach : public Action CountdownTimer m_timeoutTimer; static constexpr float MOUNT_RANGE = 25.0f; // Distance to start climbing + static constexpr float HANG_CLEARANCE = 2.0f; // Gap between the face and a descending bot's hull as it mounts static constexpr float ALIGN_DOT_THRESHOLD = -0.9f; // cos(~25 degrees) alignment tolerance }; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 0ce5ad60ea..c67677f9cb 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -103,9 +103,51 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action // Try to resolve the exit area from the current path early ResolveExitArea( me ); + ClaimLadder( me ); + return Continue(); } +//--------------------------------------------------------------------------------------------- +// PlayerLocomotion lets go of a ladder it was never asked to use, and once the contact persists +// takes it over by the nearer end - at the foot, the bottom. Tell it this climb is wanted. +void CNEOBotLadderClimb::ClaimLadder( CNEOBot *me ) const +{ + ILocomotion *mover = me->GetLocomotionInterface(); + if ( mover->IsUsingLadder() ) + { + return; + } + + const CNavArea *pExitArea = m_pExitArea; + if ( !pExitArea ) + { + pExitArea = m_bGoingUp ? m_ladder->m_topForwardArea : m_ladder->m_bottomArea; + } + + // The generator fills whichever top slot the landing is in, often not the forward one + if ( !pExitArea && m_bGoingUp ) + { + pExitArea = m_ladder->m_topLeftArea ? m_ladder->m_topLeftArea : + ( m_ladder->m_topRightArea ? m_ladder->m_topRightArea : m_ladder->m_topBehindArea ); + } + + // The locomotion's dismount walks to this area, so there is nothing to claim with without one + if ( !pExitArea ) + { + return; + } + + if ( m_bGoingUp ) + { + mover->ClimbLadder( m_ladder, pExitArea ); + } + else + { + mover->DescendLadder( m_ladder, pExitArea ); + } +} + //--------------------------------------------------------------------------------------------- void CNEOBotLadderClimb::ResolveExitArea( CNEOBot *me ) { @@ -218,9 +260,23 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* //------------------------------------------------------------ if ( !m_bDismountPhase ) { + // The locomotion drops its claim whenever the engine lets go of the bot, even for a tick + if ( me->GetMoveType() == MOVETYPE_LADDER ) + { + ClaimLadder( me ); + } + float currentZ = myPos.z; float targetZ = m_bGoingUp ? m_ladder->m_top.z : m_ladder->m_bottom.z; + // Going down, the bot holds still until its view makes forward take it down: not a stall + const bool bHoldForView = !m_bGoingUp && !me->GetLocomotionInterface()->IsForwardDownLadder( m_ladder ); + if ( bHoldForView ) + { + m_flLastZ = currentZ; + m_stuckTimer.Start( STUCK_CHECK_INTERVAL ); + } + // Stuck detection: if we haven't made vertical progress, bail out gracefully if ( m_stuckTimer.IsElapsed() ) { @@ -251,13 +307,15 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* m_stuckTimer.Start( STUCK_CHECK_INTERVAL ); } - // Early jump-off + // Early jump-off. Going down, only once a standing height below the top: from any higher, the + // kick towards the exit lands the bot back on the floor the descent started from. bool bWantsDismount = false; if ( m_pExitArea ) { float zDistToExit = currentZ - m_exitAreaCenter.z; + const bool bBelowTopFloor = m_bGoingUp || currentZ < m_ladder->m_top.z - body->GetStandHullHeight(); - if ( zDistToExit > 0.0f && zDistToExit <= SAFE_FALL_DIST ) + if ( zDistToExit > 0.0f && zDistToExit <= SAFE_FALL_DIST && bBelowTopFloor ) { bWantsDismount = true; } @@ -329,12 +387,25 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* } - // Look at and move to the dismount height, slightly behind the ladder - Vector lookTarget = m_ladder->GetPosAtHeight( dismountZ ); - lookTarget -= m_ladder->GetNormal() * 50.0f; - body->AimHeadTowards( lookTarget, IBody::MANDATORY, 0.1f, nullptr, - m_bGoingUp ? "Climbing up (looking at dismount position)" : "Climbing down (looking at dismount position)" ); - me->PressForwardButton(0.1f); + // A descent the locomotion has claimed is aimed by its DescendLadder(); + // a second MANDATORY aim here would hold the view back from it + if ( m_bGoingUp || !mover->IsUsingLadder() ) + { + // Look at and move to the dismount height, slightly behind the ladder + Vector lookTarget = m_ladder->GetPosAtHeight( dismountZ ); + lookTarget -= m_ladder->GetNormal() * 50.0f; + body->AimHeadTowards( lookTarget, IBody::MANDATORY, 0.1f, nullptr, + m_bGoingUp ? "Climbing up (looking at dismount position)" : "Climbing down (looking at dismount position)" ); + } + + if ( bHoldForView ) + { + me->ReleaseForwardButton(); + } + else + { + me->PressForwardButton(0.1f); + } } //------------------------------------------------------------ diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index d1b34e2917..e60ff5e514 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -30,6 +30,7 @@ class CNEOBotLadderClimb : public Action void EnterDismountPhase( CNEOBot *me ); void ResolveExitArea( CNEOBot *me ); bool IsDismountBlocked( CNEOBot *me, const Vector &toExit ) const; + void ClaimLadder( CNEOBot *me ) const; const CNavLadder *m_ladder; const CNavArea *m_pExitArea; From 2d013b5dd68bc2777c249c8885e83792ee45ce02 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Fri, 2 Oct 2026 20:31:00 -0600 Subject: [PATCH 02/23] Mount ladder descents over a barrier at the top Some ladders top out on a barrier the bot reaches from behind the ladder's plane: a roof parapet level with the ladder's top, a catwalk handrail just above it. The descent approach heads for the hang point in front of the face, so from the floor behind the barrier it walks into the barrier until its timeout; from on top it walks off forward, falls past the face moving away from it, and LadderMove() never sees a move into the ladder. A descent that starts behind the ladder's plane with the top more than a step over its floor now goes over the top. The bot faces the face, just below level, lines up behind the ladder, hops onto the barrier from a standstill and backs out over the drop, crouched so it leaves the top slowly. PlayerLocomotion::CatchLadderBelowTop() then presses forward and down on the tick the bot drops past the ladder's top: bot updates come ten times a second, too seldom for the engine's 2 u reach, so the press is made in Upkeep(). Other descents are unchanged. --- .../Player/NextBotPlayerLocomotion.cpp | 90 +++++++++++ .../NextBot/Player/NextBotPlayerLocomotion.h | 7 + .../bot/behavior/neo_bot_ladder_approach.cpp | 144 +++++++++++++++++- .../bot/behavior/neo_bot_ladder_approach.h | 13 ++ 4 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 9f07f9048a..447c446ff3 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -41,6 +41,15 @@ static const float LADDER_CONTACT_RESET = 1.0f; // Going down a ladder, forward is pressed only once it moves the bot down at least this // fraction of the climb speed. Slower than that, the view is still coming round. static const float LADDER_MIN_DESCENT_RATE = 0.25f; + +// A catch over a ladder's top edge lapses this long after the descent last asked for it. +static const float LADDER_CATCH_TIME = 0.5f; +// How long the move down is held from a catch: past the grab, until the climb takes the ladder over. +static const float LADDER_CATCH_DESCENT_TIME = 0.2f; +// How far the hull may still reach back over the ladder's top: closer in, it could be standing on it. +static const float LADDER_CATCH_OVERLAP = 4.0f; +// The view must point at least this much into the face (cos 45 degrees) for forward to reach it. +static const float LADDER_CATCH_FACING = 0.707f; #endif // NEO //----------------------------------------------------------------------------------------------------- @@ -74,6 +83,8 @@ void PlayerLocomotion::Reset( void ) #ifdef NEO m_unwantedLadderSince = 0.0f; m_unwantedLadderLastTouch = 0.0f; + m_ladderToCatch = NULL; + m_ladderCatchTimer.Invalidate(); #endif m_minSpeedLimit = 0.0f; @@ -216,6 +227,85 @@ bool PlayerLocomotion::IsForwardDownLadder( const CNavLadder *ladder ) const return climbRate < -LADDER_MIN_DESCENT_RATE; } + + +//----------------------------------------------------------------------------------------------------- +/** + * A descent backing out over the ladder's top edge asks for this every update: Upkeep() presses into + * the face on the tick the bot drops past the top. + */ +void PlayerLocomotion::CatchLadderBelowTop( const CNavLadder *ladder ) +{ + m_ladderToCatch = ladder; + m_ladderCatchTimer.Start( LADDER_CATCH_TIME ); +} + + +//----------------------------------------------------------------------------------------------------- +void PlayerLocomotion::StopCatchingLadder( void ) +{ + m_ladderToCatch = NULL; + m_ladderCatchTimer.Invalidate(); +} + + +//----------------------------------------------------------------------------------------------------- +/** + * Bot updates come ten times a second, and a bot backing off the top is out of LadderMove()'s 2 u reach + * of the face within a tick or two: so the press into the face is made here, every tick, until it grabs. + */ +void PlayerLocomotion::Upkeep( void ) +{ + BaseClass::Upkeep(); + + const CNavLadder *ladder = m_ladderToCatch; + if ( ladder == NULL ) + { + return; + } + + if ( m_ladderCatchTimer.IsElapsed() || m_player->GetMoveType() == MOVETYPE_LADDER ) + { + StopCatchingLadder(); + return; + } + + INextBotPlayerInput *playerButtons = dynamic_cast< INextBotPlayerInput * >( GetBot() ); + if ( playerButtons == NULL ) + { + return; + } + + // Forward has to point into the face, or pressing it reaches nothing + const Vector &normal = ladder->GetNormal(); + Vector view; + m_player->EyeVectors( &view ); + if ( -DotProduct2D( view.AsVector2D(), normal.AsVector2D() ) < LADDER_CATCH_FACING ) + { + return; + } + + // In the air below the top, in front of the face with the hull clear of the ladder's own top, and not + // beside the ladder + const Vector &feet = GetFeet(); + if ( IsOnGround() || feet.z >= ladder->m_top.z ) + { + return; + } + + const Vector toFeet = feet - ladder->GetPosAtHeight( feet.z ); + const float side = toFeet.x * normal.y - toFeet.y * normal.x; + const float halfHull = GetBot()->GetBodyInterface()->GetHullWidth() * 0.5f; + if ( DotProduct( toFeet, normal ) < halfHull - LADDER_CATCH_OVERLAP || fabsf( side ) > ladder->m_width * 0.5f + halfHull ) + { + return; + } + + // LadderMove() grabs on the tick the hull is within reach, and the move down makes that first + // ladder move a descent whatever the view + playerButtons->PressForwardButton(); + playerButtons->PressMoveDownButton( LADDER_CATCH_DESCENT_TIME ); +} #endif // NEO diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h index afbfcece23..e3987d36f2 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h @@ -63,6 +63,9 @@ class PlayerLocomotion : public ILocomotion virtual bool IsAbleToAutoCenterOnLadder( void ) const; #ifdef NEO bool IsForwardDownLadder( const CNavLadder *ladder ) const; // would pressing forward move us down this ladder + void CatchLadderBelowTop( const CNavLadder *ladder ); // press into this ladder's face the tick we drop past its top + void StopCatchingLadder( void ); + virtual void Upkeep( void ) override; #endif virtual void FaceTowards( const Vector &target ); // rotate body to face towards "target" @@ -139,6 +142,10 @@ class PlayerLocomotion : public ILocomotion Vector GetIntoLadderFace( const CNavLadder *ladder ) const; // into the face of the ladder brush we hold float m_unwantedLadderSince; // when the current bout of unwanted contact began float m_unwantedLadderLastTouch; // last tick we were on a ladder we did not ask for + + // a descent backing out over the ladder's top edge: Upkeep() presses into the face the tick we drop past the top + const CNavLadder *m_ladderToCatch; + CountdownTimer m_ladderCatchTimer; #endif bool IsClimbPossible( INextBot *me, const CBaseEntity *obstacle ) const; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 31259a19da..9abd1be3fd 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -9,7 +9,7 @@ //--------------------------------------------------------------------------------------------- CNEOBotLadderApproach::CNEOBotLadderApproach( const CNavLadder *ladder, bool goingUp ) - : m_ladder( ladder ), m_bGoingUp( goingUp ) + : m_ladder( ladder ), m_bGoingUp( goingUp ), m_bOverTop( false ) { m_ladderCenter = ladder ? ( ladder->m_top + ladder->m_bottom ) * 0.5f : vec3_origin; } @@ -25,6 +25,23 @@ ActionResult CNEOBotLadderApproach::OnStart( CNEOBot *me, ActionGetLocomotionInterface(); + const Vector &feet = mover->GetFeet(); + const CNavArea *area = me->GetLastKnownArea(); + const float floorZ = area ? MIN( feet.z, area->GetZ( feet.x, feet.y ) ) : feet.z; + const bool bBehind = DotProduct2D( ( feet - m_ladder->m_top ).AsVector2D(), m_ladder->GetNormal().AsVector2D() ) < 0.0f; + + m_bOverTop = bBehind && floorZ < m_ladder->m_top.z - mover->GetStepHeight(); + if ( m_bOverTop ) + { + m_timeoutTimer.Start( OVER_TOP_TIMEOUT ); + } + } + if ( me->IsDebugging( NEXTBOT_PATH ) ) { DevMsg( "%s: Starting ladder approach (%s), length %.1f\n", @@ -100,6 +117,11 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) Vector2D ladderNormal2D = m_ladder->GetNormal().AsVector2D(); float dot = DotProduct2D( ladderNormal2D, to ); + if ( m_bOverTop ) + { + return UpdateOverTop( me, targetPos, range ); + } + // Aim at the ladder center at eye level to carefully attach to ladder // Eye level in order to not accidentally move and detach between behavior transition // If bot was looking up or down, sometimes the fast climb movement causes a detachment @@ -186,11 +208,131 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) return Continue(); } +//--------------------------------------------------------------------------------------------- +// Going down a ladder whose top stands on a barrier the bot reaches from behind the ladder's plane: +// +// hang point +// face -> x |#| <- parapet, handrail or wall cap, level with the ladder's top +// |#|_______________________ floor the descent starts from +// | <- bot backs out this way +// +// Walking off the top forward, the bot falls past the face moving away from it, which LadderMove() never grabs. +// So it faces the face, hops the barrier and backs out, and the locomotion presses into the face as it drops. +ActionResult CNEOBotLadderApproach::UpdateOverTop( CNEOBot *me, const Vector &hangPos, float range ) +{ + CNEOBotLocomotion *mover = me->GetLocomotionInterface(); + const Vector &feet = mover->GetFeet(); + + // Caught below the top, hanging in front of the face: climb down. Standing on the ladder's own top + // the engine may hold the bot too, but that is no mount: the locomotion lets go of it + if ( me->IsOnLadder() && !mover->IsOnGround() && feet.z < m_ladder->m_top.z && range < MOUNT_RANGE ) + { + mover->StopCatchingLadder(); + me->SetAbsVelocity( vec3_origin ); + return ChangeTo( new CNEOBotLadderClimb( m_ladder, m_bGoingUp ), "Mounting ladder over its top" ); + } + + // Face the ladder from far behind it, just below level: forward then points into the face and reaches furthest, + // and the back press points a little up, never into the ladder's own top + const Vector intoFace = -m_ladder->GetNormal(); + Vector lookAt = m_ladder->m_top + FACE_LOOK_RANGE * intoFace; + lookAt.z = me->EyePosition().z - FACE_LOOK_DROP; + me->GetBodyInterface()->AimHeadTowards( lookAt, IBody::MANDATORY, 0.1f, nullptr, "Facing the ladder to back onto it" ); + + mover->CatchLadderBelowTop( m_ladder ); + + // In the air, hopping onto the barrier or dropping past the face: keep backing out + if ( !mover->IsOnGround() ) + { + me->PressBackwardButton(); + return Continue(); + } + + // How far to the side of the ladder's middle the bot is, and how far out in front of its face + const Vector2D toFeet = ( feet - m_ladder->m_top ).AsVector2D(); + const float side = toFeet.x * intoFace.y - toFeet.y * intoFace.x; + const float out = -DotProduct2D( toFeet, intoFace.AsVector2D() ); + const float halfHull = me->GetBodyInterface()->GetHullWidth() * 0.5f; + const float rise = m_ladder->m_top.z - feet.z; + const bool bOnTop = rise <= mover->GetStepHeight(); + + if ( fabsf( side ) > MAX( m_ladder->m_width * 0.5f, LINEUP_TOLERANCE ) ) + { + // Line up behind the ladder first: hopping or dropping from beside it, the bot comes down beside it. Near + // the edge, sideways only: a press with any part into the face dips into the ladder's top, which grabs the bot + const bool bNearEdge = bOnTop && out > -( halfHull + EDGE_ZONE ); + const float depth = bNearEdge ? -out : halfHull + ( bOnTop ? 0.0f : LINEUP_DEPTH ); + Vector lineUp = m_ladder->m_top + intoFace * depth; + lineUp.z = feet.z; + mover->Approach( lineUp ); + return Continue(); + } + + if ( !bOnTop ) + { + // On the floor behind the barrier: hop onto it from a standstill, which goes straight up, and back out + // over it. No sidestep: square to the face, backing out heads straight for the hang point + if ( !mover->IsClimbingOrJumping() && rise <= mover->GetMaxJumpHeight() && IsBarrierAhead( me, hangPos ) ) + { + me->SetAbsVelocity( vec3_origin ); + mover->Jump(); + } + + me->PressBackwardButton(); + return Continue(); + } + + // On the barrier's top: back out crouched, so the bot leaves the top slowly and drops within reach of the face + me->PressCrouchButton( CROUCH_HOLD ); + + // Over the ladder's own top, a back press with the view still pointing up (coming down from a climb) + // points into that top: the engine grabs the bot there and slides it off at climbing speed + if ( out > -( halfHull + EDGE_ZONE ) && me->EyeAngles().x < 0.0f ) + { + return Continue(); + } + + // Sidesteps only while the bot's middle is behind the face: one still held as it drops would skew the + // press into the face + if ( out < 0.0f ) + { + mover->Approach( hangPos ); + } + else + { + me->PressBackwardButton(); + } + + return Continue(); +} + +//--------------------------------------------------------------------------------------------- +// Is the way to the hang point blocked just ahead, a step above the feet: a barrier, not a curb +bool CNEOBotLadderApproach::IsBarrierAhead( CNEOBot *me, const Vector &hangPos ) const +{ + const ILocomotion *mover = me->GetLocomotionInterface(); + Vector toHang = hangPos - mover->GetFeet(); + toHang.z = 0.0f; + if ( toHang.NormalizeInPlace() <= 0.0f ) + { + return false; + } + + const Vector start = mover->GetFeet() + Vector( 0.0f, 0.0f, mover->GetStepHeight() ); + const Vector end = start + BARRIER_PROBE * toHang; + + trace_t tr; + UTIL_TraceHull( start, end, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &tr ); + + return tr.DidHit(); +} + //--------------------------------------------------------------------------------------------- void CNEOBotLadderApproach::OnEnd( CNEOBot *me, Action *nextAction ) { me->StartLookingAroundForEnemies(); me->ClearAttribute( CNEOBot::IGNORE_ENEMIES ); + me->GetLocomotionInterface()->StopCatchingLadder(); if ( me->IsDebugging( NEXTBOT_PATH ) ) { diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h index 49fcd0a220..99a8cb0d47 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h @@ -29,12 +29,25 @@ class CNEOBotLadderApproach : public Action static constexpr float ALIGN_RANGE = 100.0f; // Distance to start alignment behavior private: + ActionResult UpdateOverTop( CNEOBot *me, const Vector &hangPos, float range ); + bool IsBarrierAhead( CNEOBot *me, const Vector &hangPos ) const; + const CNavLadder *m_ladder; bool m_bGoingUp; + bool m_bOverTop; // going down from behind a barrier at the ladder's top (UpdateOverTop) Vector m_ladderCenter; CountdownTimer m_timeoutTimer; static constexpr float MOUNT_RANGE = 25.0f; // Distance to start climbing static constexpr float HANG_CLEARANCE = 2.0f; // Gap between the face and a descending bot's hull as it mounts static constexpr float ALIGN_DOT_THRESHOLD = -0.9f; // cos(~25 degrees) alignment tolerance + + static constexpr float OVER_TOP_TIMEOUT = 5.0f; // approach timeout going over the top: line up, hop, back out + static constexpr float FACE_LOOK_RANGE = 1000.0f; // how far into the face the view is pointed going over the top + static constexpr float FACE_LOOK_DROP = 50.0f; // and how far below eye level there (3 degrees) + static constexpr float LINEUP_TOLERANCE = 6.0f; // least distance to the side of the ladder's middle a hop may start + static constexpr float LINEUP_DEPTH = 8.0f; // the line-up point on the floor, behind the face beyond the hull's half width + static constexpr float BARRIER_PROBE = 4.0f; // how close a barrier on the way to the hang point is hopped onto + static constexpr float EDGE_ZONE = 8.0f; // beyond the hull's half width behind the face: over the ladder's own top + static constexpr float CROUCH_HOLD = 0.3f; // crouch held from each update while backing out on the top }; From f7105108acb0942255dfc2b7eac73df8ae0b55ab Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Fri, 2 Oct 2026 20:32:00 -0600 Subject: [PATCH 03/23] Take a climb's exit from after its ladder CNEOBotLadderClimb::ResolveExitArea() took the first area from the path follower's current goal that is not a ladder. When that goal is still the area at the ladder's foot, a climb up resolves its exit below it, and the dismount's jump-off kicks the bot backwards off the top. The exit is now the first area after this ladder in the path. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index c67677f9cb..5c9ec8996e 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -164,6 +164,20 @@ void CNEOBotLadderClimb::ResolveExitArea( CNEOBot *me ) } constexpr int MAX_PATH_SEARCH_STEPS = 100; + + // The path's current goal can still be the area at the ladder's foot when the climb reaches the + // top, and that area is no exit: the exit is the first area after this ladder + const Path::Segment *ladderSeg = seg; + for ( int i = 0; ladderSeg && ladderSeg->ladder != m_ladder && i < MAX_PATH_SEARCH_STEPS; ++i ) + { + ladderSeg = path->NextSegment( ladderSeg ); + } + + if ( ladderSeg && ladderSeg->ladder == m_ladder ) + { + seg = ladderSeg; + } + int safetyCounter = 0; while ( seg && safetyCounter < MAX_PATH_SEARCH_STEPS ) { From a69bfa023d91cb8eae3f3ac2f6e31aeb597d303f Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Mon, 5 Oct 2026 22:34:30 -0600 Subject: [PATCH 04/23] Pick ladder top area with CNavLadder::GetTopArea across climb and locomotion --- .../NextBot/Player/NextBotPlayerLocomotion.cpp | 11 +---------- src/game/server/nav_ladder.h | 15 +++++++++++++++ .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 9 +-------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 447c446ff3..4794844d1c 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -172,16 +172,7 @@ bool PlayerLocomotion::HandleUnwantedLadder( void ) // come back from, and costs captures even though it reads better on the ladder numbers. const bool bGoUp = ( GetFeet().z - ladder->m_bottom.z ) > ( ladder->m_top.z - GetFeet().z ); - // A ladder's top is recorded in whichever of the three slots the generator filled, and plenty - // of them leave m_topForwardArea empty - reading only that slot silently skips those ladders. - const CNavArea *dismount = ladder->m_bottomArea; - - if ( bGoUp ) - { - dismount = ladder->m_topForwardArea ? ladder->m_topForwardArea : - ( ladder->m_topLeftArea ? ladder->m_topLeftArea : ladder->m_topRightArea ); - } - + const CNavArea *dismount = bGoUp ? ladder->GetTopArea() : ladder->m_bottomArea; if ( dismount == NULL ) { return false; diff --git a/src/game/server/nav_ladder.h b/src/game/server/nav_ladder.h index 91c456f94c..295dc90b9d 100644 --- a/src/game/server/nav_ladder.h +++ b/src/game/server/nav_ladder.h @@ -65,6 +65,9 @@ class CNavLadder CNavArea *m_topRightArea; CNavArea *m_topBehindArea; ///< area at top of ladder "behind" it - only useful for descending CNavArea *m_bottomArea; ///< the area at the bottom of the ladder +#ifdef NEO + const CNavArea *GetTopArea( void ) const; ///< the first top area set, trying the one behind last +#endif bool IsConnected( const CNavArea *area, LadderDirectionType dir ) const; ///< returns true if given area is connected in given direction @@ -150,6 +153,18 @@ inline const Vector &CNavLadder::GetNormal( void ) const return m_normal; } #ifdef NEO + +//-------------------------------------------------------------------------------------------------------------- +// nav_generate puts a ladder's top area in whichever slot its landing is in, often not the forward one, +// and some ladders only have the area behind their plane (such as one up a shaft) +inline const CNavArea *CNavLadder::GetTopArea( void ) const +{ + return m_topForwardArea ? m_topForwardArea + : m_topLeftArea ? m_topLeftArea + : m_topRightArea ? m_topRightArea + : m_topBehindArea; +} + class CInfoLadder : public CBaseEntity { public: diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 5c9ec8996e..c348fe3661 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -122,14 +122,7 @@ void CNEOBotLadderClimb::ClaimLadder( CNEOBot *me ) const const CNavArea *pExitArea = m_pExitArea; if ( !pExitArea ) { - pExitArea = m_bGoingUp ? m_ladder->m_topForwardArea : m_ladder->m_bottomArea; - } - - // The generator fills whichever top slot the landing is in, often not the forward one - if ( !pExitArea && m_bGoingUp ) - { - pExitArea = m_ladder->m_topLeftArea ? m_ladder->m_topLeftArea : - ( m_ladder->m_topRightArea ? m_ladder->m_topRightArea : m_ladder->m_topBehindArea ); + pExitArea = m_bGoingUp ? m_ladder->GetTopArea() : m_ladder->m_bottomArea; } // The locomotion's dismount walks to this area, so there is nothing to claim with without one From 0dd0f3f1fa0c0342953a786167538c9147e14b59 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 19:12:00 -0600 Subject: [PATCH 05/23] Tidy ladder comments, constants and the descent hold Review polish with no behavior change: the per-tick descent hold in Approach() becomes a named bool instead of an empty if straddling an #endif, the ladder constants are constexpr, long conditions wrap at their operators, and comments are cut to at most two lines each, split at clause boundaries. The feature's explanation stays at HandleUnwantedLadder() and its declaration. --- .../Player/NextBotPlayerLocomotion.cpp | 66 ++++++++----------- .../NextBot/Player/NextBotPlayerLocomotion.h | 5 +- .../bot/behavior/neo_bot_ladder_approach.cpp | 7 +- 3 files changed, 33 insertions(+), 45 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 4794844d1c..2da231b48d 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -18,38 +18,26 @@ ConVar NextBotPlayerMoveDirect( "nb_player_move_direct", "0" ); #ifdef NEO -// CGameMovement::LadderMove() puts a player on any ladder their wish direction points at, -// while TraverseLadder()'s NO_LADDER branch drops the move type straight back to walking whenever -// the bot's own state machine did not ask for a climb. A path that merely brushes past a ladder -// leaves the two toggling every tick: the bot hangs on the ladder making no progress, and because -// it never stops moving, no stuck event fires either. -// -// Letting go harder does not fix it - the wish direction still points at the brush. What does is -// giving up the argument and riding the ladder out by its nearer end, but only for the contacts -// that actually persist; most sort themselves out within a second and adopting those buys a lot of -// climbing nobody asked for. - -// How long a bot must be held against a ladder it did not ask for before taking it over. Short -// enough that a real snag is caught within half a second, long enough that the ordinary release -// still handles the many contacts that clear themselves immediately. -static const float LADDER_ADOPT_TIME = 0.5f; +// How long a bot must be held against a ladder it did not ask for before HandleUnwantedLadder() takes it over: +// a real snag is caught within half a second, and contacts that clear themselves are left to the ordinary release +static constexpr float LADDER_ADOPT_TIME = 0.5f; // A ladder further than this from the bot is not the one it is stuck on. -static const float LADDER_TOUCH_RANGE = 64.0f; +static constexpr float LADDER_TOUCH_RANGE = 64.0f; // A gap longer than this means the bot walked away and came back, so the bout starts over. -static const float LADDER_CONTACT_RESET = 1.0f; +static constexpr float LADDER_CONTACT_RESET = 1.0f; // Going down a ladder, forward is pressed only once it moves the bot down at least this // fraction of the climb speed. Slower than that, the view is still coming round. -static const float LADDER_MIN_DESCENT_RATE = 0.25f; +static constexpr float LADDER_MIN_DESCENT_RATE = 0.25f; // A catch over a ladder's top edge lapses this long after the descent last asked for it. -static const float LADDER_CATCH_TIME = 0.5f; +static constexpr float LADDER_CATCH_TIME = 0.5f; // How long the move down is held from a catch: past the grab, until the climb takes the ladder over. -static const float LADDER_CATCH_DESCENT_TIME = 0.2f; +static constexpr float LADDER_CATCH_DESCENT_TIME = 0.2f; // How far the hull may still reach back over the ladder's top: closer in, it could be standing on it. -static const float LADDER_CATCH_OVERLAP = 4.0f; +static constexpr float LADDER_CATCH_OVERLAP = 4.0f; // The view must point at least this much into the face (cos 45 degrees) for forward to reach it. -static const float LADDER_CATCH_FACING = 0.707f; +static constexpr float LADDER_CATCH_FACING = 0.707f; #endif // NEO //----------------------------------------------------------------------------------------------------- @@ -107,9 +95,8 @@ const CNavLadder *PlayerLocomotion::FindTouchedLadder( void ) const const CNavLadder *best = NULL; float bestRangeSq = LADDER_TOUCH_RANGE * LADDER_TOUCH_RANGE; - // The hull reaches a standing height above the feet, and the grab only needs the hull to touch - // the brush - so a bot standing on the floor well *below* a ladder whose foot hangs over a - // walkway is exactly the case to catch here, not one to filter out. + // The grab only needs the hull to touch the brush, so a bot standing well below + // a ladder whose foot hangs over a walkway is a case to catch, not to filter out const float below = GetBot()->GetBodyInterface()->GetStandHullHeight(); for ( int i = 0; i < TheNavMesh->GetLadders().Count(); ++i ) @@ -147,9 +134,8 @@ bool PlayerLocomotion::HandleUnwantedLadder( void ) return false; } - // Most grabs sort themselves out within a second, and adopting every one of them buys a lot of - // climbing nobody asked for. Only take the ladder over once the bot has actually been held - // against it. + // Most grabs clear themselves within a second, and adopting them buys climbing nobody asked for, + // so only take the ladder over once the bot has been held against it const float now = gpGlobals->curtime; if ( now - m_unwantedLadderLastTouch > LADDER_CONTACT_RESET ) @@ -164,12 +150,8 @@ bool PlayerLocomotion::HandleUnwantedLadder( void ) return false; } - // Leave by the nearer end rather than argue about being here at all. Whichever end the bot is - // closer to is the one it can reach soonest, and the dismount goal has to be a real area or - // DismountLadderTop/Bottom has nothing to walk to. - // - // Climbing out upward only was measured and is worse: it sends the bot somewhere it then has to - // come back from, and costs captures even though it reads better on the ladder numbers. + // Leave by the nearer end, the one the bot reaches soonest + // (always climbing out upward was measured: it sends bots where they must come back from, and costs captures) const bool bGoUp = ( GetFeet().z - ladder->m_bottom.z ) > ( ladder->m_top.z - GetFeet().z ); const CNavArea *dismount = bGoUp ? ladder->GetTopArea() : ladder->m_bottomArea; @@ -287,7 +269,8 @@ void PlayerLocomotion::Upkeep( void ) const Vector toFeet = feet - ladder->GetPosAtHeight( feet.z ); const float side = toFeet.x * normal.y - toFeet.y * normal.x; const float halfHull = GetBot()->GetBodyInterface()->GetHullWidth() * 0.5f; - if ( DotProduct( toFeet, normal ) < halfHull - LADDER_CATCH_OVERLAP || fabsf( side ) > ladder->m_width * 0.5f + halfHull ) + if ( DotProduct( toFeet, normal ) < halfHull - LADDER_CATCH_OVERLAP + || fabsf( side ) > ladder->m_width * 0.5f + halfHull ) { return; } @@ -769,13 +752,18 @@ void PlayerLocomotion::Approach( const Vector &pos, float goalWeight ) #endif #ifdef NEO - if ( m_player->IsOnLadder() && m_ladderState == DESCENDING_LADDER && m_ladderInfo && !IsForwardDownLadder( m_ladderInfo ) ) + // On the way down, press nothing until forward would move us down: + // with the view still coming round it climbs us up, and from the top of the ladder, off it + const bool bHoldForView = m_player->IsOnLadder() && m_ladderState == DESCENDING_LADDER + && m_ladderInfo && !IsForwardDownLadder( m_ladderInfo ); +#else + const bool bHoldForView = false; +#endif + if ( bHoldForView ) { - // On the way down, press nothing until forward would move us down. With the view still - // coming round it climbs us up, and from the top of the ladder, off it. + // hold still } else -#endif if ( m_player->IsOnLadder() && IsUsingLadder() && ( m_ladderState == ASCENDING_LADDER || m_ladderState == DESCENDING_LADDER ) ) { // we are on a ladder and WANT to be on a ladder. diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h index e3987d36f2..03e9b3f8de 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h @@ -134,9 +134,8 @@ class PlayerLocomotion : public ILocomotion CountdownTimer m_ladderTimer; // a "give up" timer if things go awry #ifdef NEO - // a ladder the engine put us on that our own path never asked for. Releasing it is not - // enough on its own - CGameMovement::LadderMove() re-grabs from the bot's wish direction, so - // the two toggle each other until something breaks the loop. See TraverseLadder(). + // a ladder the engine put us on that our own path never asked for: releasing it is not enough, + // since CGameMovement::LadderMove() re-grabs from the bot's wish direction (see TraverseLadder()) bool HandleUnwantedLadder( void ); const CNavLadder *FindTouchedLadder( void ) const; Vector GetIntoLadderFace( const CNavLadder *ladder ) const; // into the face of the ladder brush we hold diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 9abd1be3fd..d849796ef3 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -25,15 +25,16 @@ ActionResult CNEOBotLadderApproach::OnStart( CNEOBot *me, ActionGetLocomotionInterface(); const Vector &feet = mover->GetFeet(); const CNavArea *area = me->GetLastKnownArea(); const float floorZ = area ? MIN( feet.z, area->GetZ( feet.x, feet.y ) ) : feet.z; - const bool bBehind = DotProduct2D( ( feet - m_ladder->m_top ).AsVector2D(), m_ladder->GetNormal().AsVector2D() ) < 0.0f; + const Vector2D toFeet = ( feet - m_ladder->m_top ).AsVector2D(); + const bool bBehind = DotProduct2D( toFeet, m_ladder->GetNormal().AsVector2D() ) < 0.0f; m_bOverTop = bBehind && floorZ < m_ladder->m_top.z - mover->GetStepHeight(); if ( m_bOverTop ) From 677d96f37421433393c505abcc4bebff9de76f3e Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 19:58:00 -0600 Subject: [PATCH 06/23] Mount ladder descents over a level top Going down from behind a ladder whose top is level with the floor (the bot stands on the ladder brush's own top), the approach walked forward off the top and fell past the face moving away from it, so LadderMove never grabbed: on ntre_ridgeline_ctg ladder 1 bots never climbed down (0 mounts in 8 runs) and dropped 140 u instead. Take the over-top mount for a top level with the floor too. A bot held behind the face for 0.6 s goes back to the ordinary approach, and one wedged on the brush's top in front of the face hops off it. The descent catch now presses into the face from a step above the nav ladder's top, since a ladder brush often stands above it (13 u there): the bot used to drift past LadderMove's reach before its feet passed the nav top. Ridgeline, bots spawned on top, 8 runs an arm: descents mounted 0 -> 11, clean over-top descents 11 of 12 mounted, ladder approach timeouts 15 -> 0. Cross-map sample (rise, dawn, dusk, terminal, yard): no ladder worse. --- .../Player/NextBotPlayerLocomotion.cpp | 4 +- .../bot/behavior/neo_bot_ladder_approach.cpp | 38 +++++++++++++++++-- .../bot/behavior/neo_bot_ladder_approach.h | 5 +++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 2da231b48d..c9b5d3f0f9 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -259,9 +259,9 @@ void PlayerLocomotion::Upkeep( void ) } // In the air below the top, in front of the face with the hull clear of the ladder's own top, and not - // beside the ladder + // beside the ladder. Below the top by a step's margin: a ladder brush often stands above its nav ladder's top const Vector &feet = GetFeet(); - if ( IsOnGround() || feet.z >= ladder->m_top.z ) + if ( IsOnGround() || feet.z >= ladder->m_top.z + GetStepHeight() ) { return; } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index d849796ef3..39cd6937c2 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -9,7 +9,7 @@ //--------------------------------------------------------------------------------------------- CNEOBotLadderApproach::CNEOBotLadderApproach( const CNavLadder *ladder, bool goingUp ) - : m_ladder( ladder ), m_bGoingUp( goingUp ), m_bOverTop( false ) + : m_ladder( ladder ), m_bGoingUp( goingUp ), m_bOverTop( false ), m_bFlushTop( false ), m_vecLastProgressPos( vec3_origin ) { m_ladderCenter = ladder ? ( ladder->m_top + ladder->m_bottom ) * 0.5f : vec3_origin; } @@ -25,8 +25,9 @@ ActionResult CNEOBotLadderApproach::OnStart( CNEOBot *me, ActionGetLocomotionInterface(); @@ -36,13 +37,17 @@ ActionResult CNEOBotLadderApproach::OnStart( CNEOBot *me, Actionm_top ).AsVector2D(); const bool bBehind = DotProduct2D( toFeet, m_ladder->GetNormal().AsVector2D() ) < 0.0f; - m_bOverTop = bBehind && floorZ < m_ladder->m_top.z - mover->GetStepHeight(); + m_bOverTop = bBehind && floorZ < m_ladder->m_top.z + mover->GetStepHeight(); + m_bFlushTop = m_bOverTop && floorZ >= m_ladder->m_top.z - mover->GetStepHeight(); if ( m_bOverTop ) { m_timeoutTimer.Start( OVER_TOP_TIMEOUT ); } } + m_vecLastProgressPos = me->GetLocomotionInterface()->GetFeet(); + m_progressTimer.Start(); + if ( me->IsDebugging( NEXTBOT_PATH ) ) { DevMsg( "%s: Starting ladder approach (%s), length %.1f\n", @@ -73,6 +78,13 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) return Done( "Ladder approach timeout" ); } + const Vector &feetNow = me->GetLocomotionInterface()->GetFeet(); + if ( ( feetNow - m_vecLastProgressPos ).AsVector2D().IsLengthGreaterThan( PROGRESS_STEP ) ) + { + m_vecLastProgressPos = feetNow; + m_progressTimer.Start(); + } + const CKnownEntity *threat = me->GetVisionInterface()->GetPrimaryKnownThreat(true); if ( threat ) { @@ -253,6 +265,17 @@ ActionResult CNEOBotLadderApproach::UpdateOverTop( CNEOBot *me, const V const Vector2D toFeet = ( feet - m_ladder->m_top ).AsVector2D(); const float side = toFeet.x * intoFace.y - toFeet.y * intoFace.x; const float out = -DotProduct2D( toFeet, intoFace.AsVector2D() ); + + // A level top can still have something the bot cannot cross between the floor and the face, + // so a bot held behind the face goes back to the ordinary approach + if ( m_bFlushTop && out < 0.0f && m_progressTimer.IsGreaterThen( STALL_TIME ) ) + { + m_bOverTop = false; + mover->StopCatchingLadder(); + m_progressTimer.Start(); + return Continue(); + } + const float halfHull = me->GetBodyInterface()->GetHullWidth() * 0.5f; const float rise = m_ladder->m_top.z - feet.z; const bool bOnTop = rise <= mover->GetStepHeight(); @@ -302,6 +325,13 @@ ActionResult CNEOBotLadderApproach::UpdateOverTop( CNEOBot *me, const V else { me->PressBackwardButton(); + + // Backing out over a level top can wedge the bot on the ladder brush's top in front of the face, + // so hop back off it + if ( m_bFlushTop && !mover->IsClimbingOrJumping() && m_progressTimer.IsGreaterThen( STALL_TIME ) ) + { + mover->Jump(); + } } return Continue(); diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h index 99a8cb0d47..1dde6f8cc4 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h @@ -35,6 +35,9 @@ class CNEOBotLadderApproach : public Action const CNavLadder *m_ladder; bool m_bGoingUp; bool m_bOverTop; // going down from behind a barrier at the ladder's top (UpdateOverTop) + bool m_bFlushTop; // over the top with no barrier: the floor is level with it + Vector m_vecLastProgressPos; + IntervalTimer m_progressTimer; // since the bot last moved PROGRESS_STEP Vector m_ladderCenter; CountdownTimer m_timeoutTimer; @@ -50,4 +53,6 @@ class CNEOBotLadderApproach : public Action static constexpr float BARRIER_PROBE = 4.0f; // how close a barrier on the way to the hang point is hopped onto static constexpr float EDGE_ZONE = 8.0f; // beyond the hull's half width behind the face: over the ladder's own top static constexpr float CROUCH_HOLD = 0.3f; // crouch held from each update while backing out on the top + static constexpr float PROGRESS_STEP = 4.0f; // a move this far counts as progress + static constexpr float STALL_TIME = 0.6f; // and none for this long, over a level top, means blocked }; From ae73931bfac1caf66babc3b61666394830932bb1 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 20:31:00 -0600 Subject: [PATCH 07/23] Keep the ladder climb's teleport out of the brush When the climb starts with its way up blocked, it places the bot half a hull width off the ladder's face. On a ladder turned off the world axes that leaves a corner of the hull inside the brush: the bot is stuck in solid at the foot for the rest of the round (ntre_ridgeline ladder 1, 7 of 32 runs). Clear the hull's corners first, then slide in until the hull touches the face. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index c348fe3661..1f9cbb8d21 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -7,6 +7,11 @@ // memdbgon must be the last include file in a .cpp file!!! #include "tier0/memdbgon.h" +// The engine grabs a ladder within this distance of its face (CGameMovement::LadderDistance()) +static constexpr float LADDER_GRAB_DIST = 2.0f; +// and a hull placed off a ladder is slid at most this far towards the face to find it. +static constexpr float LADDER_PLACE_SEEK = 16.0f; + //--------------------------------------------------------------------------------------------- CNEOBotLadderClimb::CNEOBotLadderClimb( const CNavLadder *ladder, bool goingUp ) : m_ladder( ladder ), m_bGoingUp( goingUp ), m_flLastZ( 0.0f ), @@ -74,6 +79,21 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action idealPos += m_ladder->GetNormal() * offsetDist; idealPos.z = m_flLastZ; + // On a ladder turned off the world axes, half a hull width off the face leaves the hull's corner in the brush, + // so clear the corners first, then slide in until the hull touches the face + const Vector &normal = m_ladder->GetNormal(); + const float flReach = me->CollisionProp()->OBBSize().x / 2.0f * ( fabsf( normal.x ) + fabsf( normal.y ) ); + Vector clearPos = m_ladder->GetPosAtHeight( m_flLastZ ) + normal * ( flReach + 2.0f ); + clearPos.z = m_flLastZ; + + trace_t trFace; + UTIL_TraceHull( clearPos, clearPos - normal * LADDER_PLACE_SEEK, me->WorldAlignMins(), me->WorldAlignMaxs(), + MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trFace ); + if ( !trFace.startsolid ) + { + idealPos = trFace.DidHit() ? trFace.endpos + normal * ( LADDER_GRAB_DIST * 0.5f ) : clearPos; + } + // Face perpendicularly straight on to the ladder (-normal) Vector idealLookDir = -m_ladder->GetNormal(); QAngle idealAngles; From 05789f1e0136139eec59f210d73a50f7fd412468 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 20:49:00 -0600 Subject: [PATCH 08/23] Restart the unwanted-ladder wait on a climb's claim HandleUnwantedLadder() continues a bout of unwanted contact across gaps under a second, so a bout that ended just before a behavior climb mounted the same or a nearby ladder was still counted when the climb's claim dropped for a tick, and the locomotion took the ladder over by the nearer end at once: at the foot, the bottom, reversing the climb. ClimbLadder() and DescendLadder() now end the bout, so contact after a claim waits the full half second again. Syntax-checked only. --- .../NextBot/Player/NextBotPlayerLocomotion.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index c9b5d3f0f9..96f75c16ef 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -1070,6 +1070,12 @@ void PlayerLocomotion::ClimbLadder( const CNavLadder *ladder, const CNavArea *di // Approach( goal ); // FaceTowards( goal ); +#ifdef NEO + // A climb asked for ends any bout of unwanted contact, so the next one waits LADDER_ADOPT_TIME afresh + m_unwantedLadderSince = 0.0f; + m_unwantedLadderLastTouch = 0.0f; +#endif + m_ladderState = APPROACHING_ASCENDING_LADDER; m_ladderInfo = ladder; m_ladderDismountGoal = dismountGoal; @@ -1087,6 +1093,12 @@ void PlayerLocomotion::DescendLadder( const CNavLadder *ladder, const CNavArea * // Approach( goal ); // FaceTowards( goal ); +#ifdef NEO + // A climb asked for ends any bout of unwanted contact, so the next one waits LADDER_ADOPT_TIME afresh + m_unwantedLadderSince = 0.0f; + m_unwantedLadderLastTouch = 0.0f; +#endif + m_ladderState = APPROACHING_DESCENDING_LADDER; m_ladderInfo = ladder; m_ladderDismountGoal = dismountGoal; From 03d81ac67cf5e49aef02fa5cf72c389e97c80c02 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 21:06:00 -0600 Subject: [PATCH 09/23] Name the ladder teleport's clearance gap The placement that keeps a climb's teleport out of a turned ladder brush started its slide a bare 2 u clear of the face. Name the gap next to the other placement constants. No behavior change. --- src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 1f9cbb8d21..2a06179d31 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -9,7 +9,9 @@ // The engine grabs a ladder within this distance of its face (CGameMovement::LadderDistance()) static constexpr float LADDER_GRAB_DIST = 2.0f; -// and a hull placed off a ladder is slid at most this far towards the face to find it. +// A hull placed off a ladder starts this far clear of the face, +static constexpr float LADDER_PLACE_GAP = 2.0f; +// and is slid at most this far towards the face to find it. static constexpr float LADDER_PLACE_SEEK = 16.0f; //--------------------------------------------------------------------------------------------- @@ -83,7 +85,7 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action // so clear the corners first, then slide in until the hull touches the face const Vector &normal = m_ladder->GetNormal(); const float flReach = me->CollisionProp()->OBBSize().x / 2.0f * ( fabsf( normal.x ) + fabsf( normal.y ) ); - Vector clearPos = m_ladder->GetPosAtHeight( m_flLastZ ) + normal * ( flReach + 2.0f ); + Vector clearPos = m_ladder->GetPosAtHeight( m_flLastZ ) + normal * ( flReach + LADDER_PLACE_GAP ); clearPos.z = m_flLastZ; trace_t trFace; From fe52b95b33a745227782a557e57bafb4b212cc63 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Tue, 6 Oct 2026 22:41:00 -0600 Subject: [PATCH 10/23] Turn the view on a teleported ladder mount The climb's teleport fallback sets the body's angles but not the view, so the first forward press climbs with the view off the face's normal and the engine's ladder movement slides the bot off the side of a narrow ladder. On ridgeline ladder 1 every teleported first climber started at view yaw -86 against a face that wants -120 and fell back to the ground within a second. Research measurement (arm neo_bot_ladder_teleport_view): up climbs ending at the foot 11 of 97 -> 1 of 79; cross-map ladder screen climb success unchanged, rise L5 up near-ladder OnStuck equal over 12+12 runs (1514 v 1438). --- src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 2a06179d31..dc048458b5 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -104,6 +104,8 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action // Teleport the bot me->SetAbsOrigin( idealPos ); me->SetAbsAngles( idealAngles ); + // the view too: a forward press with the view off the face's normal slides the bot off the side of a narrow ladder + me->SnapEyeAngles( idealAngles ); // Update mover feet to new teleported position for stuck checking m_flLastZ = idealPos.z; From 5c0783b20a53e96389f09389a98c016d90eacbda Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:01:00 -0600 Subject: [PATCH 11/23] Dismount toward a ladder landing's near point A climb's exit is the path's first area after its ladder, and the dismount aimed at that area's center. On a long, narrow landing the center lies off to one side: on ntre_ridgeline_ctg ladder 1 it is the middle of the catwalk, and the kick toward it carried bots off the 30 u slab top onto the ground. The exit is now the area the ladder lands on: the path's first area after the ladder when the ladder connects to it, else a landing the path passes through, else the ladder's own end area. The dismount aims 16 u inside the landing's point nearest the ladder end and finishes once the bot stands on the landing. A landing shallower than half a hull is a wall cap the climb crosses (dawn and dusk ladder 1, 7 u deep), so it keeps the center aim and finishes on entering it. Research measurement (ridgeline, 16 runs an arm): up climbs ending on top 58 of 111 -> 69 of 94, falls from the top 11 -> 0, near-ladder OnStuck 67 -> 12. Without the wall-cap rule, dawn and dusk ladder 1 climbs that ended at the exit fell from 16/21 to 8/24 and 16/19 to 10/23; with it they match the base. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 68 +++++++++++++++++-- .../neo/bot/behavior/neo_bot_ladder_climb.h | 6 ++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index dc048458b5..ccb69fac9a 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -210,12 +210,70 @@ void CNEOBotLadderClimb::ResolveExitArea( CNEOBot *me ) if ( seg && !seg->ladder && seg->area ) { - m_pExitArea = seg->area; - m_exitAreaCenter = m_pExitArea->GetCenter(); + m_pExitArea = FindLanding( path, seg->area ); + m_exitAreaCenter = GetDismountPos( me ); } } } +//--------------------------------------------------------------------------------------------- +// By the dismount the path's goal can already be past the area this ladder lands on, +// so take a landing the path passes through, or else the ladder's own end area +const CNavArea *CNEOBotLadderClimb::FindLanding( const PathFollower *path, const CNavArea *pathExit ) const +{ + const CNavLadder::LadderDirectionType dir = m_bGoingUp ? CNavLadder::LADDER_UP : CNavLadder::LADDER_DOWN; + if ( m_ladder->IsConnected( pathExit, dir ) ) + { + return pathExit; + } + + for ( const Path::Segment *seg = path->FirstSegment(); seg; seg = path->NextSegment( seg ) ) + { + if ( seg->area && m_ladder->IsConnected( seg->area, dir ) ) + { + return seg->area; + } + } + + const CNavArea *ladderEnd = m_bGoingUp ? m_ladder->GetTopArea() : m_ladder->m_bottomArea; + return ladderEnd ? ladderEnd : pathExit; +} + +//--------------------------------------------------------------------------------------------- +// A landing shallower than half a hull is a wall cap the climb crosses, not a floor to stand on +bool CNEOBotLadderClimb::IsNarrowLanding( CNEOBot *me ) const +{ + const float flMinDepth = me->GetBodyInterface()->GetHullWidth() * NARROW_LANDING_HULLS; + return Min( m_pExitArea->GetSizeX(), m_pExitArea->GetSizeY() ) < flMinDepth; +} + +//--------------------------------------------------------------------------------------------- +// Step off toward where the landing meets this end of the ladder: a long, narrow landing's center +// can lie off to one side, and the dismount kick toward it carries the bot past the landing's edge +Vector CNEOBotLadderClimb::GetDismountPos( CNEOBot *me ) const +{ + if ( IsNarrowLanding( me ) ) + { + return m_pExitArea->GetCenter(); + } + + const Vector &ladderEnd = m_bGoingUp ? m_ladder->m_top : m_ladder->m_bottom; + Vector nearPoint; + m_pExitArea->GetClosestPointOnArea( ladderEnd, &nearPoint ); + + // into the wall at the top, where the landing is, and away from it at the bottom + Vector inward = m_bGoingUp ? m_ladderForward : -m_ladderForward; + inward.z = 0.0f; + if ( inward.NormalizeInPlace() > 0.0f ) + { + nearPoint += inward * LANDING_INSET; + } + + Vector dismountPos; + m_pExitArea->GetClosestPointOnArea( nearPoint, &dismountPos ); + return dismountPos; +} + //--------------------------------------------------------------------------------------------- // Implementation based on ladder climbing logic in https://github.com/Dragoteryx/drgbase/ @@ -444,8 +502,10 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* //------------------------------------------------------------ if ( m_bDismountPhase ) { - // Reached the target NavArea after the ladder - if ( m_pExitArea && me->GetLastKnownArea() == m_pExitArea ) + // The last known area becomes the landing while the bot is still in the air beside it, + // so finish once the bot stands on the landing, or enters a narrow one it crosses + if ( m_pExitArea && me->GetLastKnownArea() == m_pExitArea + && ( IsNarrowLanding( me ) || ( mover->IsOnGround() && m_pExitArea->IsOverlapping( myPos ) ) ) ) { return Done( "Reached next NavArea after dismount" ); } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index e60ff5e514..5aced1c8aa 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -4,6 +4,7 @@ #include "bot/neo_bot.h" class CNavLadder; +class PathFollower; //---------------------------------------------------------------------------------------------------------------- /** @@ -29,6 +30,9 @@ class CNEOBotLadderClimb : public Action private: void EnterDismountPhase( CNEOBot *me ); void ResolveExitArea( CNEOBot *me ); + const CNavArea *FindLanding( const PathFollower *path, const CNavArea *pathExit ) const; + bool IsNarrowLanding( CNEOBot *me ) const; + Vector GetDismountPos( CNEOBot *me ) const; bool IsDismountBlocked( CNEOBot *me, const Vector &toExit ) const; void ClaimLadder( CNEOBot *me ) const; @@ -53,4 +57,6 @@ class CNEOBotLadderClimb : public Action static constexpr float SAFE_FALL_DIST = 200.0f; // Max height to safely drop off a ladder static constexpr float MAX_DEVIATION_DIST = 100.0f; // Distance from ladder before canceling static constexpr float DISMOUNT_CLEARANCE_HULLS = 1.5f; // Hull widths toward the exit that must be clear to step off + static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths a landing must be deep to be stood on (dawn's 7 u wall cap is not) + static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims }; From d6224d6dd6973b5d09bc576cab249d338338dc54 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:01:20 -0600 Subject: [PATCH 12/23] Climb onto a ladder's wall-cap top by the mesh link A ladder that rises over a wall cap has its top area on the cap, shallower than a hull. The path follower's ledge search looks for the ledge's floor a full look-ahead past the wall, past a cap this thin, so a bot at the launch point of the climb onto it never jumped, and no bot descended that ladder (ntre_rise_ctg ladder 4). When the area after a climb is a ladder's top and is shallower than a hull along the approach, the follower now takes the mesh's climb link at the launch point, as it already does on an authoritative mesh. The rule meets 8 walk links across the ntre_ meshes: rise L4 and L5, dawn and dusk L2, saitama L11. Research measurement: rise L4 down, 6 runs on the edited mesh, 14 descents mounted and 14 reached the foot (0 before); dawn and dusk L2 and saitama L11, up and down, 3 runs each: every climb reached the ghost. --- .../server/NextBot/Path/NextBotPathFollow.cpp | 36 +++++++++++++++++++ .../server/NextBot/Path/NextBotPathFollow.h | 1 + 2 files changed, 37 insertions(+) diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.cpp b/src/game/server/NextBot/Path/NextBotPathFollow.cpp index da57ce9281..d931215f27 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.cpp +++ b/src/game/server/NextBot/Path/NextBotPathFollow.cpp @@ -1284,6 +1284,23 @@ bool PathFollower::FindClimbLedge( INextBot *bot, Vector startTracePos, Vector l #endif // _DEBUG +#ifdef NEO +//-------------------------------------------------------------------------------------------------------------- +// A climb onto a ladder's top area shallower than a hull, such as the wall cap a ladder rises over +bool PathFollower::IsLadderCapClimb( INextBot *bot ) const +{ + const Segment *afterClimb = NextSegment( m_goal ); + if ( !afterClimb || !afterClimb->area || afterClimb->area->GetLadders( CNavLadder::LADDER_DOWN )->Count() == 0 ) + { + return false; + } + + const Vector toLedge = afterClimb->area->GetCenter() - bot->GetLocomotionInterface()->GetFeet(); + const float flDepth = ( fabs( toLedge.x ) > fabs( toLedge.y ) ) ? afterClimb->area->GetSizeX() : afterClimb->area->GetSizeY(); + return flDepth < bot->GetBodyInterface()->GetHullWidth(); +} +#endif + //-------------------------------------------------------------------------------------------------------------- /** * Climb up ledges @@ -1349,6 +1366,25 @@ bool PathFollower::Climbing( INextBot *bot, const Path::Segment *goal, const Vec return false; } +#ifdef NEO + // The ledge search below looks for a ledge's floor a full look-ahead past its wall and misses a wall cap, + // so at the launch point of a climb onto one, take the mesh's climb link as the authoritative branch does + if ( m_goal->type == CLIMB_UP && IsLadderCapClimb( bot ) + && ( m_goal->pos - mover->GetFeet() ).AsVector2D().IsLengthLessThan( body->GetHullWidth() ) ) + { + Vector nearClimbGoal; + NextSegment( m_goal )->area->GetClosestPointOnArea( mover->GetFeet(), &nearClimbGoal ); + + climbDirection = nearClimbGoal - mover->GetFeet(); + climbDirection.z = 0.0f; + climbDirection.NormalizeInPlace(); + + if ( mover->ClimbUpToLedge( nearClimbGoal, climbDirection, NULL ) ) + { + return true; + } + } +#endif // If we're approaching a CLIMB_UP link, save off the height delta for it, and trust the nav *just* enough // to climb up to that ledge and only that ledge. We keep as large a tolerance as possible, to trust diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.h b/src/game/server/NextBot/Path/NextBotPathFollow.h index c421f82852..a0b6f89029 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.h +++ b/src/game/server/NextBot/Path/NextBotPathFollow.h @@ -89,6 +89,7 @@ class PathFollower : public Path #ifdef NEO bool HasFallenBelowPath( INextBot *bot ) const; // return true if we stand off the path, more than a step below it + bool IsLadderCapClimb( INextBot *bot ) const; // return true if the climb ahead is onto a ladder top shallower than a hull bool m_wasOnGround; CNEOBotPathObstacles m_pathObstacles; // looks ahead for props to steer around and breakables in the way #endif From 482f0dfbf2548be777f919dbeb436da351777656 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:01:40 -0600 Subject: [PATCH 13/23] Shimmy past a snag before abandoning a climb A climb that stops rising part-way is usually snagged on something at the ladder's edge rather than blocked outright. On ntre_subsurface_ctg ladder 1 the landing plate overlaps the ladder column by about 1 u, so a bot centered on the nav ladder heads into its underside at the same height every time, while one 2 u off center climbs past (169 of 198 mid-climb stall endings were there). On ntre_saitama_ctg ladder 2 bots catch the rim of the freight elevator hatch the same way. Before jumping off, a stalled climb now shimmies sideways, right then left, 0.3 s longer each time, up to three times, and gives up only if none of that restores the climb. Research measurement: subsurface ladder 1 up-climbs 34 -> 72 %; saitama ladder 2, 12 v 12 runs, rim snags 21 -> 0, start-solid traps in the slot below 207 -> 2, up-climbs 163 -> 177. Every-map screen, 4 ghost pins, 106 matches an arm: stuck events 6.22 -> 5.69 a match, falls off a ladder 1.25 -> 1.01, no new stuck spot. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 19 +++++++++++++++++++ .../neo/bot/behavior/neo_bot_ladder_climb.h | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index ccb69fac9a..ef82772a62 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -379,6 +379,13 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* EnterDismountPhase( me ); return Continue(); } + else if ( m_nNudges < STALL_NUDGES ) + { + // Usually snagged on something at the ladder's edge, not blocked outright: + // shimmy sideways, right then left, for longer each time, before giving up + ++m_nNudges; + m_nudgeTimer.Start( STALL_NUDGE_TIME * m_nNudges ); + } else { // We are stuck mid-climb. Reset scenario by jumping backwards and ending condition @@ -495,6 +502,18 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* { me->PressForwardButton(0.1f); } + + if ( onLadder && m_nudgeTimer.HasStarted() && !m_nudgeTimer.IsElapsed() ) + { + if ( m_nNudges % 2 ) + { + me->PressRightButton( 0.1f ); + } + else + { + me->PressLeftButton( 0.1f ); + } + } } //------------------------------------------------------------ diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index 5aced1c8aa..e30dcc0d51 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -50,6 +50,8 @@ class CNEOBotLadderClimb : public Action CountdownTimer m_timeoutTimer; CountdownTimer m_stuckTimer; CountdownTimer m_dismountTimer; + CountdownTimer m_nudgeTimer; // shimmying sideways past a snag + int m_nNudges = 0; static constexpr float STUCK_CHECK_INTERVAL = 0.4f; static constexpr float STUCK_Z_TOLERANCE = 2.0f; @@ -59,4 +61,6 @@ class CNEOBotLadderClimb : public Action static constexpr float DISMOUNT_CLEARANCE_HULLS = 1.5f; // Hull widths toward the exit that must be clear to step off static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths a landing must be deep to be stood on (dawn's 7 u wall cap is not) static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims + static constexpr int STALL_NUDGES = 3; // Sideways shimmies before a stalled climb jumps off (subsurface L1 up 34 -> 72 %, saitama L2 rim snags 21 -> 0) + static constexpr float STALL_NUDGE_TIME = 0.3f; // Length of the first shimmy, and the step each later one adds }; From 00bc1726b4ca4ffe5d376080ac89fb428cc39bac Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:02:00 -0600 Subject: [PATCH 14/23] Wait out the pre-round freeze in a ladder approach A bot that spawns within reach of a ladder starts its approach during the pre-round freeze, and the freeze holds it in place. A descent over a level top reads that as being blocked behind the ladder's face, drops back to the ordinary approach and walks off the top past the face; the freeze also runs out an approach's timeout before the bot can move. The approach now waits while the freeze lasts. Measured on ntre_ridgeline_ctg ladder 1 (descents from the catwalk, 12 bots, 2 s freeze, 8 wide): down approaches started in the freeze mounted over the top 0 of 125 times before, 125 of 125 after (those started after the freeze: 120 of 126, then 121 of 127). On the no-drop twin of the map, over-top mounts 49 of 108 -> 98 of 99. Up approaches timing out in the freeze 60 of 145 -> 1 of 136. No fall deaths either way. --- .../neo/bot/behavior/neo_bot_ladder_approach.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 39cd6937c2..8240d963f0 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -2,6 +2,7 @@ #include "bot/behavior/neo_bot_ladder_approach.h" #include "bot/behavior/neo_bot_ladder_climb.h" #include "bot/behavior/neo_bot_attack.h" +#include "neo_gamerules.h" #include "nav_ladder.h" // memdbgon must be the last include file in a .cpp file!!! @@ -73,6 +74,15 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) return Done( "Ladder is invalid" ); } + // The pre-round freeze holds the bot in place, so wait it out: counted as a stall, + // it drops a descent over a level top back to the ordinary approach, which walks off the top + if ( NEORules()->GetRemainingPreRoundFreezeTime( true ) > 0.0f ) + { + m_timeoutTimer.Reset(); + m_progressTimer.Start(); + return Continue(); + } + if ( m_timeoutTimer.IsElapsed() ) { return Done( "Ladder approach timeout" ); From 28734663eb3433f455c9cd49896b71d2444f303a Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:02:20 -0600 Subject: [PATCH 15/23] Keep a ladder climb's teleport out of solid When a climb's way up is blocked, the climb teleports the bot in front of the ladder's face. Two spots it could pick hold the bot there for the rest of the round, still on the ladder with no velocity: - When the hull does not fit even clear of the face, the fallback spot half a hull in front of the nav ladder can be inside the ladder brush itself (ntre_rise_ctg ladder 6, whose brush stands in front of its nav line). - The spot keeps the old feet height, and on a slope the floor there can be a few units higher. A hull sunk into a displacement does not start solid, and a ducked bot unducking on the ladder then drops its feet by the crouch height into the floor (ntre_ridgeline_ctg ladder 1, a bot 17 u under the floor, the Assault crouch height). The hull now stands on the floor under the spot, and a spot still in solid is skipped, so the bot climbs from where it is. Measured (12 bots, 8 wide): rise ladder 6 up, 22 of 51 teleports took the fallback into the brush and 2 of 16 runs lost a bot there for the round; after, 23 teleports skipped, 0 of 24 runs, up climbs reaching the top 17 of 23 -> 24 of 25. ridgeline: 0 traps in 80 runs, up climbs completed 161 of 168. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index ef82772a62..5010e2e049 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -96,19 +96,36 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action idealPos = trFace.DidHit() ? trFace.endpos + normal * ( LADDER_GRAB_DIST * 0.5f ) : clearPos; } + // The floor there can stand higher than the feet were, and a hull sunk in a displacement does not start solid, + // so stand the hull on that floor: unducking on the ladder lowers the feet, and from inside the floor wedges the bot + trace_t trFloor; + UTIL_TraceHull( idealPos + Vector( 0.0f, 0.0f, mover->GetStepHeight() ), idealPos, me->WorldAlignMins(), me->WorldAlignMaxs(), + MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trFloor ); + if ( !trFloor.startsolid && trFloor.DidHit() ) + { + idealPos.z = trFloor.endpos.z; + } + // Face perpendicularly straight on to the ladder (-normal) Vector idealLookDir = -m_ladder->GetNormal(); QAngle idealAngles; VectorAngles( idealLookDir, idealAngles ); - // Teleport the bot - me->SetAbsOrigin( idealPos ); - me->SetAbsAngles( idealAngles ); - // the view too: a forward press with the view off the face's normal slides the bot off the side of a narrow ladder - me->SnapEyeAngles( idealAngles ); - - // Update mover feet to new teleported position for stuck checking - m_flLastZ = idealPos.z; + // A spot still in solid, such as inside a ladder brush that stands in front of its nav line, would hold the bot + // there for good, so the bot climbs from where it is instead + trace_t trSpot; + UTIL_TraceHull( idealPos, idealPos, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trSpot ); + if ( !trSpot.startsolid ) + { + // Teleport the bot + me->SetAbsOrigin( idealPos ); + me->SetAbsAngles( idealAngles ); + // the view too: a forward press with the view off the face's normal slides the bot off the side of a narrow ladder + me->SnapEyeAngles( idealAngles ); + + // Update mover feet to new teleported position for stuck checking + m_flLastZ = idealPos.z; + } } else if ( me->IsDebugging( NEXTBOT_PATH ) ) { From 38209c64b2167c10e447ab5aeaf561ef785c5966 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:02:40 -0600 Subject: [PATCH 16/23] Wait out the freeze by the bot's freeze flag The ladder approach waited while the round's pre-round freeze time was left, but that time is also counted at warmup, at the ready-up countdown and while the server idles, when nobody is frozen: a bot in a ladder approach then stood still for the length of the freeze. Test the bot's own NEO_FL_FREEZETIME flag instead, as the locomotion and the breakable-clearing behavior do. It is set exactly while the bot cannot move, so the wait is unchanged in the pre-round freeze. --- src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 8240d963f0..447caf9038 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -2,7 +2,6 @@ #include "bot/behavior/neo_bot_ladder_approach.h" #include "bot/behavior/neo_bot_ladder_climb.h" #include "bot/behavior/neo_bot_attack.h" -#include "neo_gamerules.h" #include "nav_ladder.h" // memdbgon must be the last include file in a .cpp file!!! @@ -76,7 +75,7 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) // The pre-round freeze holds the bot in place, so wait it out: counted as a stall, // it drops a descent over a level top back to the ordinary approach, which walks off the top - if ( NEORules()->GetRemainingPreRoundFreezeTime( true ) > 0.0f ) + if ( me->GetNeoFlags() & NEO_FL_FREEZETIME ) { m_timeoutTimer.Reset(); m_progressTimer.Start(); From bbaa0d9eaef9d9cb9164e0c8d3c082a7fa87bb4a Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:03:00 -0600 Subject: [PATCH 17/23] Scan the ladders only when adopting one HandleUnwantedLadder() looped over every nav ladder on every locomotion update a bot spent on a ladder it did not ask for, though only the adoption at the end of a half-second bout needs to know which ladder it is. Run the scan after the bout test instead. The caller lets go of the ladder whenever this returns false, before and after. The one difference: a contact the scan matches to no nav ladder now counts toward the bout, so a bot that slides from such a spot to a recorded ladder within one bout is taken over sooner. --- .../NextBot/Player/NextBotPlayerLocomotion.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 96f75c16ef..14e3c9ca9e 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -128,12 +128,6 @@ const CNavLadder *PlayerLocomotion::FindTouchedLadder( void ) const */ bool PlayerLocomotion::HandleUnwantedLadder( void ) { - const CNavLadder *ladder = FindTouchedLadder(); - if ( ladder == NULL ) - { - return false; - } - // Most grabs clear themselves within a second, and adopting them buys climbing nobody asked for, // so only take the ladder over once the bot has been held against it const float now = gpGlobals->curtime; @@ -150,6 +144,13 @@ bool PlayerLocomotion::HandleUnwantedLadder( void ) return false; } + // The scan over every nav ladder is needed only to take one over, so it waits for the bout + const CNavLadder *ladder = FindTouchedLadder(); + if ( ladder == NULL ) + { + return false; + } + // Leave by the nearer end, the one the bot reaches soonest // (always climbing out upward was measured: it sends bots where they must come back from, and costs captures) const bool bGoUp = ( GetFeet().z - ladder->m_bottom.z ) > ( ladder->m_top.z - GetFeet().z ); From 534eb0eef63afc5b2c1f1470cc8b53d0303f5578 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:03:20 -0600 Subject: [PATCH 18/23] Return the wall cap from the climb-link test IsLadderCapClimb() answered yes or no, and its caller then took the cap area from the path again, relying on the test having checked it. LadderCapAhead() returns the area itself, or NULL, so the launch uses the area the test examined. Say what the test measures (an axis-aligned depth along the approach) and how it differs from the dismount's IsNarrowLanding(), and that the repeated launch lines are kept apart from the authoritative branch on purpose. No behavior change. --- .../server/NextBot/Path/NextBotPathFollow.cpp | 23 +++++++++++-------- .../server/NextBot/Path/NextBotPathFollow.h | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.cpp b/src/game/server/NextBot/Path/NextBotPathFollow.cpp index d931215f27..21ee09c41c 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.cpp +++ b/src/game/server/NextBot/Path/NextBotPathFollow.cpp @@ -1286,18 +1286,22 @@ bool PathFollower::FindClimbLedge( INextBot *bot, Vector startTracePos, Vector l #ifdef NEO //-------------------------------------------------------------------------------------------------------------- -// A climb onto a ladder's top area shallower than a hull, such as the wall cap a ladder rises over -bool PathFollower::IsLadderCapClimb( INextBot *bot ) const +// The area after the goal when it is a ladder top less than a hull deep along the approach, else NULL: +// the ledge search cannot find such an area (not the dismount's IsNarrowLanding(), which asks whether a bot can stand on it) +const CNavArea *PathFollower::LadderCapAhead( INextBot *bot ) const { + // an area a ladder goes down from is that ladder's top const Segment *afterClimb = NextSegment( m_goal ); if ( !afterClimb || !afterClimb->area || afterClimb->area->GetLadders( CNavLadder::LADDER_DOWN )->Count() == 0 ) { - return false; + return NULL; } - const Vector toLedge = afterClimb->area->GetCenter() - bot->GetLocomotionInterface()->GetFeet(); - const float flDepth = ( fabs( toLedge.x ) > fabs( toLedge.y ) ) ? afterClimb->area->GetSizeX() : afterClimb->area->GetSizeY(); - return flDepth < bot->GetBodyInterface()->GetHullWidth(); + // the depth is the area's axis-aligned size along the approach's dominant axis + const CNavArea *cap = afterClimb->area; + const Vector toLedge = cap->GetCenter() - bot->GetLocomotionInterface()->GetFeet(); + const float flDepth = ( fabsf( toLedge.x ) > fabsf( toLedge.y ) ) ? cap->GetSizeX() : cap->GetSizeY(); + return ( flDepth < bot->GetBodyInterface()->GetHullWidth() ) ? cap : NULL; } #endif @@ -1369,11 +1373,12 @@ bool PathFollower::Climbing( INextBot *bot, const Path::Segment *goal, const Vec #ifdef NEO // The ledge search below looks for a ledge's floor a full look-ahead past its wall and misses a wall cap, // so at the launch point of a climb onto one, take the mesh's climb link as the authoritative branch does - if ( m_goal->type == CLIMB_UP && IsLadderCapClimb( bot ) - && ( m_goal->pos - mover->GetFeet() ).AsVector2D().IsLengthLessThan( body->GetHullWidth() ) ) + const CNavArea *cap = ( m_goal->type == CLIMB_UP ) ? LadderCapAhead( bot ) : NULL; + if ( cap && ( m_goal->pos - mover->GetFeet() ).AsVector2D().IsLengthLessThan( body->GetHullWidth() ) ) { + // the authoritative branch's lines, repeated to leave that branch untouched Vector nearClimbGoal; - NextSegment( m_goal )->area->GetClosestPointOnArea( mover->GetFeet(), &nearClimbGoal ); + cap->GetClosestPointOnArea( mover->GetFeet(), &nearClimbGoal ); climbDirection = nearClimbGoal - mover->GetFeet(); climbDirection.z = 0.0f; diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.h b/src/game/server/NextBot/Path/NextBotPathFollow.h index a0b6f89029..e18a4a073a 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.h +++ b/src/game/server/NextBot/Path/NextBotPathFollow.h @@ -89,7 +89,7 @@ class PathFollower : public Path #ifdef NEO bool HasFallenBelowPath( INextBot *bot ) const; // return true if we stand off the path, more than a step below it - bool IsLadderCapClimb( INextBot *bot ) const; // return true if the climb ahead is onto a ladder top shallower than a hull + const CNavArea *LadderCapAhead( INextBot *bot ) const; // return the ladder top shallower than a hull the climb ahead is onto, or NULL bool m_wasOnGround; CNEOBotPathObstacles m_pathObstacles; // looks ahead for props to steer around and breakables in the way #endif From bfd4137ea1c05ea42b45778cfa2f09d20f177e06 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:03:40 -0600 Subject: [PATCH 19/23] Bound the launches onto a ladder's wall cap Climbing() runs on every path update, and the climb onto a wall cap by the mesh link is gated only by the goal and the bot's range to the launch point: a bot that launched and fell back to that point jumped again on every landing until the path was planned again. Count the launches for the current path segment and, after three, leave the climb to the ordinary ledge search. The count starts over on a new goal segment and on every new path. The ladders that take this link most (rise L4 down, saitama L11 down, dawn and dusk L2) are the re-measure set for this change. --- .../server/NextBot/Path/NextBotPathFollow.cpp | 19 ++++++++++++++++++- .../server/NextBot/Path/NextBotPathFollow.h | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.cpp b/src/game/server/NextBot/Path/NextBotPathFollow.cpp index 21ee09c41c..a8efd479dd 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.cpp +++ b/src/game/server/NextBot/Path/NextBotPathFollow.cpp @@ -34,6 +34,9 @@ ConVar NextBotDebugClimbing( "nb_debug_climbing", "0", FCVAR_CHEAT ); #ifdef NEO // How far from its feet a bot that landed looks for a nav area, the range UpdateLastKnownArea() searches static const float LANDING_NAV_AREA_RANGE = 50.0f; +// A climb onto a ladder's wall cap takes the mesh's link at most this many times a path segment, +// then is left to the ledge search: a bot that lands back at the launch point would otherwise jump on every landing +static const int LADDER_CAP_LAUNCHES = 3; #endif @@ -57,6 +60,8 @@ PathFollower::PathFollower( void ) #ifdef NEO m_wasOnGround = true; + m_capClimbGoal = NULL; + m_capLaunches = 0; #endif } @@ -121,6 +126,10 @@ void PathFollower::OnPathChanged( INextBot *bot, Path::ResultType result ) #ifdef NEO m_pathObstacles.Reset(); + + // a new path reuses the segments' storage, so a launch count kept by segment starts over + m_capClimbGoal = NULL; + m_capLaunches = 0; #endif } @@ -1374,7 +1383,14 @@ bool PathFollower::Climbing( INextBot *bot, const Path::Segment *goal, const Vec // The ledge search below looks for a ledge's floor a full look-ahead past its wall and misses a wall cap, // so at the launch point of a climb onto one, take the mesh's climb link as the authoritative branch does const CNavArea *cap = ( m_goal->type == CLIMB_UP ) ? LadderCapAhead( bot ) : NULL; - if ( cap && ( m_goal->pos - mover->GetFeet() ).AsVector2D().IsLengthLessThan( body->GetHullWidth() ) ) + if ( cap && m_goal != m_capClimbGoal ) + { + m_capClimbGoal = m_goal; + m_capLaunches = 0; + } + + if ( cap && m_capLaunches < LADDER_CAP_LAUNCHES + && ( m_goal->pos - mover->GetFeet() ).AsVector2D().IsLengthLessThan( body->GetHullWidth() ) ) { // the authoritative branch's lines, repeated to leave that branch untouched Vector nearClimbGoal; @@ -1386,6 +1402,7 @@ bool PathFollower::Climbing( INextBot *bot, const Path::Segment *goal, const Vec if ( mover->ClimbUpToLedge( nearClimbGoal, climbDirection, NULL ) ) { + ++m_capLaunches; return true; } } diff --git a/src/game/server/NextBot/Path/NextBotPathFollow.h b/src/game/server/NextBot/Path/NextBotPathFollow.h index e18a4a073a..9ea62157d0 100644 --- a/src/game/server/NextBot/Path/NextBotPathFollow.h +++ b/src/game/server/NextBot/Path/NextBotPathFollow.h @@ -92,6 +92,8 @@ class PathFollower : public Path const CNavArea *LadderCapAhead( INextBot *bot ) const; // return the ladder top shallower than a hull the climb ahead is onto, or NULL bool m_wasOnGround; CNEOBotPathObstacles m_pathObstacles; // looks ahead for props to steer around and breakables in the way + const Path::Segment *m_capClimbGoal; // the climb onto a ladder's wall cap m_capLaunches counts + int m_capLaunches; #endif float m_goalTolerance; From 082358eccec9add23d8ca3c7db61c5861f50c8bc Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:04:00 -0600 Subject: [PATCH 20/23] Rename the ladder climb's dismount point Since the dismount aims at a landing's near point, m_exitAreaCenter holds the exit area's center only for a narrow landing, and lines such as zDistToExit = currentZ - m_exitAreaCenter.z read as the center. Call it m_dismountPos. No behavior change. --- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 22 +++++++++---------- .../neo/bot/behavior/neo_bot_ladder_climb.h | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 5010e2e049..054d5eb73b 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -19,7 +19,7 @@ CNEOBotLadderClimb::CNEOBotLadderClimb( const CNavLadder *ladder, bool goingUp ) : m_ladder( ladder ), m_bGoingUp( goingUp ), m_flLastZ( 0.0f ), m_bDismountPhase( false ), m_bJumpedOffLadder( false ), m_pExitArea( nullptr ) { - m_exitAreaCenter = vec3_origin; + m_dismountPos = vec3_origin; m_ladderForward = ladder ? -ladder->GetNormal() : vec3_origin; } @@ -228,7 +228,7 @@ void CNEOBotLadderClimb::ResolveExitArea( CNEOBot *me ) if ( seg && !seg->ladder && seg->area ) { m_pExitArea = FindLanding( path, seg->area ); - m_exitAreaCenter = GetDismountPos( me ); + m_dismountPos = GetDismountPos( me ); } } } @@ -323,7 +323,7 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* if ( m_pExitArea ) { - toExit = m_exitAreaCenter - myPos; + toExit = m_dismountPos - myPos; toExit.z = 0.0f; if ( toExit.Length2DSqr() > 0.01f ) { @@ -425,7 +425,7 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* bool bWantsDismount = false; if ( m_pExitArea ) { - float zDistToExit = currentZ - m_exitAreaCenter.z; + float zDistToExit = currentZ - m_dismountPos.z; const bool bBelowTopFloor = m_bGoingUp || currentZ < m_ladder->m_top.z - body->GetStandHullHeight(); if ( zDistToExit > 0.0f && zDistToExit <= SAFE_FALL_DIST && bBelowTopFloor ) @@ -446,13 +446,13 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* { if ( bExitIsBehind ) { - dismountZ = Min( m_exitAreaCenter.z, targetZ ); + dismountZ = Min( m_dismountPos.z, targetZ ); } } else { // Allow early drop-off at intermediate floors - dismountZ = Max( m_exitAreaCenter.z, targetZ ); + dismountZ = Max( m_dismountPos.z, targetZ ); } } @@ -480,7 +480,7 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* bool bShouldGoUp = m_bGoingUp; if ( m_pExitArea ) { - bShouldGoUp = ( currentZ < m_exitAreaCenter.z ); + bShouldGoUp = ( currentZ < m_dismountPos.z ); } if ( bLipInTheWay ) @@ -555,11 +555,11 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* // Build look target toward exit area center with vertical bias preserved if ( m_pExitArea ) { - bool bDroppingEarly = ( myPos.z >= m_exitAreaCenter.z ); + bool bDroppingEarly = ( myPos.z >= m_dismountPos.z ); // Maintain Z-height while on the ladder in the dismount phase if ( onLadder ) { - if ( myPos.z < m_exitAreaCenter.z ) + if ( myPos.z < m_dismountPos.z ) { me->PressMoveUpButton(); } @@ -573,7 +573,7 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* bDroppingEarly = true; } - body->AimHeadTowards( m_exitAreaCenter, IBody::MANDATORY, 0.1f, nullptr, "Walking to exit area" ); + body->AimHeadTowards( m_dismountPos, IBody::MANDATORY, 0.1f, nullptr, "Walking to exit area" ); // Jump to detach from ladder if exit is not straight ahead, or if we have reached the exit height float dot = DotProduct( toExit, m_ladderForward ); @@ -606,7 +606,7 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* if ( me->IsDebugging( NEXTBOT_PATH ) ) { - NDebugOverlay::Line( myPos, m_exitAreaCenter, 0, 255, 255, true, 0.1f ); + NDebugOverlay::Line( myPos, m_dismountPos, 0, 255, 255, true, 0.1f ); } } else diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index e30dcc0d51..79fed211ad 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -44,7 +44,7 @@ class CNEOBotLadderClimb : public Action bool m_bJumpedOffLadder; float m_flLastZ; - Vector m_exitAreaCenter; + Vector m_dismountPos; Vector m_ladderForward; CountdownTimer m_timeoutTimer; From 49c9819a280d02fe4f5fdcb9bd9df736f802dcd4 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:04:20 -0600 Subject: [PATCH 21/23] Clarify ladder comments, constants and the hold Review edits, no behavior change: - The climb's IsNarrowLanding() and the path follower's LadderCapAhead() say what each measures: a landing under half a hull is crossed, not stood on, while a top up to a hull deep still holds the bot's center though the ledge search misses it. - The teleport's old spot is named as the unchecked fallback, with its gap as LADDER_PLACE_GAP, and the floor-trace comment reads in order. - Constants give their origin: NARROW_LANDING_HULLS (dawn and dusk L1, climbs ending at the exit 16 of 21 with it, 8 of 24 without), LANDING_INSET (half a hull), STALL_TIME (ridgeline L1, approach timeouts 15 -> 0), STALL_NUDGES (subsurface L1 up 34 -> 72 %, saitama L2 rim snags 21 -> 0), LADDER_MIN_DESCENT_RATE (a view 55 degrees below level) and LADDER_CATCH_OVERLAP (twice the grab reach); the descent test cites LadderMove(). - Approach() drops the empty hold branch: both of its moves now skip the hold, which presses nothing as before. - LadderEndArea() names the ladder end the climb leaves by, two traces wrap at the mask, and m_nNudges joins the constructor list. --- .../Player/NextBotPlayerLocomotion.cpp | 18 +++++------- .../bot/behavior/neo_bot_ladder_approach.cpp | 3 +- .../bot/behavior/neo_bot_ladder_approach.h | 2 +- .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 28 +++++++++++++------ .../neo/bot/behavior/neo_bot_ladder_climb.h | 9 +++--- 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 14e3c9ca9e..851ec08576 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -26,15 +26,15 @@ static constexpr float LADDER_TOUCH_RANGE = 64.0f; // A gap longer than this means the bot walked away and came back, so the bout starts over. static constexpr float LADDER_CONTACT_RESET = 1.0f; -// Going down a ladder, forward is pressed only once it moves the bot down at least this -// fraction of the climb speed. Slower than that, the view is still coming round. +// Going down a ladder, forward is pressed only once it moves the bot down at least this fraction of the climb speed +// (facing the face square on, a view more than 55 degrees below level): slower than that, the view is still coming round. static constexpr float LADDER_MIN_DESCENT_RATE = 0.25f; // A catch over a ladder's top edge lapses this long after the descent last asked for it. static constexpr float LADDER_CATCH_TIME = 0.5f; // How long the move down is held from a catch: past the grab, until the climb takes the ladder over. static constexpr float LADDER_CATCH_DESCENT_TIME = 0.2f; -// How far the hull may still reach back over the ladder's top: closer in, it could be standing on it. +// How far the hull may still reach back over the ladder's top, twice LadderMove()'s 2 u reach: closer in, it could be standing on it. static constexpr float LADDER_CATCH_OVERLAP = 4.0f; // The view must point at least this much into the face (cos 45 degrees) for forward to reach it. static constexpr float LADDER_CATCH_FACING = 0.707f; @@ -196,7 +196,8 @@ bool PlayerLocomotion::IsForwardDownLadder( const CNavLadder *ladder ) const return false; } - // The vertical speed a forward press gives, as a fraction of the climb speed + // As CGameMovement::LadderMove() turns a move on an upright ladder: the part into the face climbs, + // and the view's own vertical part adds to it, so this is a forward press's vertical speed as a fraction of the climb speed const float climbRate = view.z + intoFace; return climbRate < -LADDER_MIN_DESCENT_RATE; @@ -760,12 +761,7 @@ void PlayerLocomotion::Approach( const Vector &pos, float goalWeight ) #else const bool bHoldForView = false; #endif - if ( bHoldForView ) - { - // hold still - } - else - if ( m_player->IsOnLadder() && IsUsingLadder() && ( m_ladderState == ASCENDING_LADDER || m_ladderState == DESCENDING_LADDER ) ) + if ( !bHoldForView && m_player->IsOnLadder() && IsUsingLadder() && ( m_ladderState == ASCENDING_LADDER || m_ladderState == DESCENDING_LADDER ) ) { // we are on a ladder and WANT to be on a ladder. playerButtons->PressForwardButton(); @@ -801,7 +797,7 @@ void PlayerLocomotion::Approach( const Vector &pos, float goalWeight ) } } } - else + else if ( !bHoldForView ) { const float epsilon = 0.25f; if ( NextBotPlayerMoveDirect.GetBool() ) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 447caf9038..43dfbe7456 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -362,7 +362,8 @@ bool CNEOBotLadderApproach::IsBarrierAhead( CNEOBot *me, const Vector &hangPos ) const Vector end = start + BARRIER_PROBE * toHang; trace_t tr; - UTIL_TraceHull( start, end, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &tr ); + UTIL_TraceHull( start, end, me->WorldAlignMins(), me->WorldAlignMaxs(), + MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &tr ); return tr.DidHit(); } diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h index 1dde6f8cc4..a68b0c6e88 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h @@ -54,5 +54,5 @@ class CNEOBotLadderApproach : public Action static constexpr float EDGE_ZONE = 8.0f; // beyond the hull's half width behind the face: over the ladder's own top static constexpr float CROUCH_HOLD = 0.3f; // crouch held from each update while backing out on the top static constexpr float PROGRESS_STEP = 4.0f; // a move this far counts as progress - static constexpr float STALL_TIME = 0.6f; // and none for this long, over a level top, means blocked + static constexpr float STALL_TIME = 0.6f; // and none for this long, over a level top, means blocked (measured on ridgeline L1) }; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 054d5eb73b..04c659ef6d 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -17,7 +17,7 @@ static constexpr float LADDER_PLACE_SEEK = 16.0f; //--------------------------------------------------------------------------------------------- CNEOBotLadderClimb::CNEOBotLadderClimb( const CNavLadder *ladder, bool goingUp ) : m_ladder( ladder ), m_bGoingUp( goingUp ), m_flLastZ( 0.0f ), - m_bDismountPhase( false ), m_bJumpedOffLadder( false ), m_pExitArea( nullptr ) + m_bDismountPhase( false ), m_bJumpedOffLadder( false ), m_pExitArea( nullptr ), m_nNudges( 0 ) { m_dismountPos = vec3_origin; m_ladderForward = ladder ? -ladder->GetNormal() : vec3_origin; @@ -75,9 +75,10 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action mover->Reset(); // clear velocity cache in locomotion interface me->SetAbsVelocity( vec3_origin ); + // Half a hull off the nav line, unchecked: the fallback spot when the clear spot below starts in solid Vector idealPos = m_ladder->GetPosAtHeight( m_flLastZ ); // Offset slightly from the ladder surface based on the bot's collision box - float offsetDist = me->CollisionProp()->OBBSize().x / 2.0f + 2.0f; + float offsetDist = me->CollisionProp()->OBBSize().x / 2.0f + LADDER_PLACE_GAP; idealPos += m_ladder->GetNormal() * offsetDist; idealPos.z = m_flLastZ; @@ -96,8 +97,8 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action idealPos = trFace.DidHit() ? trFace.endpos + normal * ( LADDER_GRAB_DIST * 0.5f ) : clearPos; } - // The floor there can stand higher than the feet were, and a hull sunk in a displacement does not start solid, - // so stand the hull on that floor: unducking on the ladder lowers the feet, and from inside the floor wedges the bot + // The floor at the spot can stand higher than the feet were, and a hull sunk into a displacement does not start solid, + // so stand the hull on that floor: unducking later on the ladder lowers the feet by the crouch's lift, into the floor trace_t trFloor; UTIL_TraceHull( idealPos + Vector( 0.0f, 0.0f, mover->GetStepHeight() ), idealPos, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trFloor ); @@ -114,7 +115,8 @@ ActionResult CNEOBotLadderClimb::OnStart( CNEOBot *me, Action // A spot still in solid, such as inside a ladder brush that stands in front of its nav line, would hold the bot // there for good, so the bot climbs from where it is instead trace_t trSpot; - UTIL_TraceHull( idealPos, idealPos, me->WorldAlignMins(), me->WorldAlignMaxs(), MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trSpot ); + UTIL_TraceHull( idealPos, idealPos, me->WorldAlignMins(), me->WorldAlignMaxs(), + MASK_PLAYERSOLID, me, COLLISION_GROUP_PLAYER_MOVEMENT, &trSpot ); if ( !trSpot.startsolid ) { // Teleport the bot @@ -163,7 +165,7 @@ void CNEOBotLadderClimb::ClaimLadder( CNEOBot *me ) const const CNavArea *pExitArea = m_pExitArea; if ( !pExitArea ) { - pExitArea = m_bGoingUp ? m_ladder->GetTopArea() : m_ladder->m_bottomArea; + pExitArea = LadderEndArea(); } // The locomotion's dismount walks to this area, so there is nothing to claim with without one @@ -235,7 +237,7 @@ void CNEOBotLadderClimb::ResolveExitArea( CNEOBot *me ) //--------------------------------------------------------------------------------------------- // By the dismount the path's goal can already be past the area this ladder lands on, -// so take a landing the path passes through, or else the ladder's own end area +// so take a landing the path passes through, or else the ladder's own end area, or, with no end area, the path's const CNavArea *CNEOBotLadderClimb::FindLanding( const PathFollower *path, const CNavArea *pathExit ) const { const CNavLadder::LadderDirectionType dir = m_bGoingUp ? CNavLadder::LADDER_UP : CNavLadder::LADDER_DOWN; @@ -252,12 +254,20 @@ const CNavArea *CNEOBotLadderClimb::FindLanding( const PathFollower *path, const } } - const CNavArea *ladderEnd = m_bGoingUp ? m_ladder->GetTopArea() : m_ladder->m_bottomArea; + const CNavArea *ladderEnd = LadderEndArea(); return ladderEnd ? ladderEnd : pathExit; } //--------------------------------------------------------------------------------------------- -// A landing shallower than half a hull is a wall cap the climb crosses, not a floor to stand on +// The area at the end this climb leaves the ladder by +const CNavArea *CNEOBotLadderClimb::LadderEndArea() const +{ + return m_bGoingUp ? m_ladder->GetTopArea() : m_ladder->m_bottomArea; +} + +//--------------------------------------------------------------------------------------------- +// A landing whose short side is under half a hull is a wall cap the climb crosses, not a floor to stand on, +// and a deeper one holds the bot's center even where the path follower's LadderCapAhead() needs the mesh link to climb onto it bool CNEOBotLadderClimb::IsNarrowLanding( CNEOBot *me ) const { const float flMinDepth = me->GetBodyInterface()->GetHullWidth() * NARROW_LANDING_HULLS; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index 79fed211ad..19641632dc 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -31,6 +31,7 @@ class CNEOBotLadderClimb : public Action void EnterDismountPhase( CNEOBot *me ); void ResolveExitArea( CNEOBot *me ); const CNavArea *FindLanding( const PathFollower *path, const CNavArea *pathExit ) const; + const CNavArea *LadderEndArea() const; bool IsNarrowLanding( CNEOBot *me ) const; Vector GetDismountPos( CNEOBot *me ) const; bool IsDismountBlocked( CNEOBot *me, const Vector &toExit ) const; @@ -51,7 +52,7 @@ class CNEOBotLadderClimb : public Action CountdownTimer m_stuckTimer; CountdownTimer m_dismountTimer; CountdownTimer m_nudgeTimer; // shimmying sideways past a snag - int m_nNudges = 0; + int m_nNudges; static constexpr float STUCK_CHECK_INTERVAL = 0.4f; static constexpr float STUCK_Z_TOLERANCE = 2.0f; @@ -59,8 +60,8 @@ class CNEOBotLadderClimb : public Action static constexpr float SAFE_FALL_DIST = 200.0f; // Max height to safely drop off a ladder static constexpr float MAX_DEVIATION_DIST = 100.0f; // Distance from ladder before canceling static constexpr float DISMOUNT_CLEARANCE_HULLS = 1.5f; // Hull widths toward the exit that must be clear to step off - static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths a landing must be deep to be stood on (dawn's 7 u wall cap is not) - static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims - static constexpr int STALL_NUDGES = 3; // Sideways shimmies before a stalled climb jumps off (subsurface L1 up 34 -> 72 %, saitama L2 rim snags 21 -> 0) + static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths deep a landing must be to stand on (measured on dawn and dusk L1's 7 u wall cap) + static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims: half a hull, so the whole hull lands + static constexpr int STALL_NUDGES = 3; // Sideways shimmies before a stalled climb jumps off (measured on subsurface L1 and saitama L2) static constexpr float STALL_NUDGE_TIME = 0.3f; // Length of the first shimmy, and the step each later one adds }; From eab8ed8c1e33b0d26e50ca03eb2feb8c836647a9 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Thu, 8 Oct 2026 19:04:40 -0600 Subject: [PATCH 22/23] Stand up below a ladder's hanging foot to mount it A bot that climbs down a ladder whose foot hangs above the floor drops onto the floor crouched. If it then heads back up, the crouched hull fits under the ladder's bottom, so it never touches the ladder and the approach times out and restarts in place, for up to 40 s on sentinel_ctg's 75 u stub ladders. Going up such a ladder, release the crouch, and while still crouched step out to just in front of the face, where the bot stands and can press onto the ladder. --- .../neo/bot/behavior/neo_bot_ladder_approach.cpp | 14 ++++++++++++++ .../neo/bot/behavior/neo_bot_ladder_approach.h | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp index 43dfbe7456..347fb74cb4 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.cpp @@ -165,6 +165,20 @@ ActionResult CNEOBotLadderApproach::Update( CNEOBot *me, float ) NDebugOverlay::Line( myPos, targetPos, 255, 255, 0, true, 0.1f ); } + // Going up a ladder whose foot hangs above the floor, a crouched bot fits under the ladder and never touches it, + // so stand up, and step out from under it first (a bot drops onto the floor there crouched, from a climb down) + if ( m_bGoingUp && m_ladder->m_bottom.z > myPos.z + mover->GetStepHeight() ) + { + me->ReleaseCrouchButton(); + if ( me->GetFlags() & FL_DUCKING ) + { + Vector clear = m_ladder->m_bottom + m_ladder->GetNormal() * ( body->GetHullWidth() * 0.5f + HANG_CLEARANCE ); + clear.z = myPos.z; + mover->Approach( clear ); + return Continue(); + } + } + // Going down, mount only once off the top: grabbed while still up there, on the landing or on the // ladder's own top, the bot cannot press its way down, so it keeps walking out over the drop const float flTopZ = m_ladder->m_top.z; diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h index a68b0c6e88..14ef92d057 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_approach.h @@ -42,7 +42,7 @@ class CNEOBotLadderApproach : public Action CountdownTimer m_timeoutTimer; static constexpr float MOUNT_RANGE = 25.0f; // Distance to start climbing - static constexpr float HANG_CLEARANCE = 2.0f; // Gap between the face and a descending bot's hull as it mounts + static constexpr float HANG_CLEARANCE = 2.0f; // Gap between the face and the hull of a bot about to mount in front of it static constexpr float ALIGN_DOT_THRESHOLD = -0.9f; // cos(~25 degrees) alignment tolerance static constexpr float OVER_TOP_TIMEOUT = 5.0f; // approach timeout going over the top: line up, hop, back out From 9baa158a978a4857a3e4576e3e18b1ec41a1d855 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 11 Oct 2026 22:52:10 -0600 Subject: [PATCH 23/23] Give a stalled ladder climb three equal shimmies The stall branch started each shimmy's timer at 0.3 s times the shimmy count, but the progress check that starts the next shimmy or the jump-off comes every STUCK_CHECK_INTERVAL (0.4 s) regardless, so the shimmies ran 0.3, 0.4 and 0.4 s, not the 0.3, 0.6 and 0.9 s the comment described. sunmachine's review of this PR found the mismatch. Start every shimmy at the check interval instead, so the code says what it does, and drop STALL_NUDGE_TIME. The measured behavior is unchanged: the numbers cited for this change were taken with the 0.3 / 0.4 / 0.4 s shimmies. Measured against the growing version (the stuck check following the shimmy timer, 0.3 / 0.6 / 0.9 s) and the code as it was, Release builds on the rebased branch: subsurface ladder 1 up and saitama ladder 2 down, 8 runs an arm, and 20 ladder directions at the snag sites (dawn, dusk, rise, saitama, sentinel), 3 runs an arm, 312 matches. A second or third shimmy happened in under 5 % of stalls; where it happens most, the top of rise ladder 3, every arm ends in the jump-off. Climbs finished, near-ladder stuck events and falls did not differ between arms. --- src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp | 4 ++-- src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp index 04c659ef6d..09a5b22501 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp @@ -409,9 +409,9 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* else if ( m_nNudges < STALL_NUDGES ) { // Usually snagged on something at the ladder's edge, not blocked outright: - // shimmy sideways, right then left, for longer each time, before giving up + // shimmy sideways, right then left, until the next progress check, before giving up ++m_nNudges; - m_nudgeTimer.Start( STALL_NUDGE_TIME * m_nNudges ); + m_nudgeTimer.Start( STUCK_CHECK_INTERVAL ); } else { diff --git a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h index 19641632dc..f95510f443 100644 --- a/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h +++ b/src/game/server/neo/bot/behavior/neo_bot_ladder_climb.h @@ -63,5 +63,4 @@ class CNEOBotLadderClimb : public Action static constexpr float NARROW_LANDING_HULLS = 0.5f; // Hull widths deep a landing must be to stand on (measured on dawn and dusk L1's 7 u wall cap) static constexpr float LANDING_INSET = 16.0f; // How far inside a landing's near edge the dismount aims: half a hull, so the whole hull lands static constexpr int STALL_NUDGES = 3; // Sideways shimmies before a stalled climb jumps off (measured on subsurface L1 and saitama L2) - static constexpr float STALL_NUDGE_TIME = 0.3f; // Length of the first shimmy, and the step each later one adds };