From 3d148a045fcd593174bf5c8d7313e5cb45764034 Mon Sep 17 00:00:00 2001 From: sunzenshen Date: Sun, 20 Sep 2026 14:22:07 -0600 Subject: [PATCH 1/6] Stop bots fighting the engine over unwanted ladders CGameMovement::LadderMove() puts a player on any ladder their wish direction points at, while PlayerLocomotion::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 there making no progress, and because it never stops moving, no stuck event fires to notice it. Ladders whose foot hangs a chest height or more above the floor they serve are the worst, since they lie across a walkway rather than sitting at the foot of one. Rather than let go harder - which does not work, the wish direction still points at the brush - the bot gives up the argument and rides the ladder out by whichever end is nearer, but only once the contact has persisted for half a second. Most grabs sort themselves out sooner than that and adopting those costs a lot of climbing nobody asked for. Measured over 213 scenarios on all 29 maps, 213 matches an arm: time pinned against an unwanted ladder 10.2 -> 6.8 s a match, worst bout 61.7 -> 21.4 s, climbs finished 71 -> 79 %, stuck events 155 -> 141, capture rate level at 25 %, for 3.5 s a match of adopted climbing. Two held-out batches on five maps the behaviour was never tuned against, 156 matches an arm, agree: pinned time -37 %, worst bout -41 %, and the most finished climbs of any variant tried. Taking the ladder over only upward, and taking it over on contact without waiting, were both measured and are worse - the first strands bots somewhere they have to come back from, the second re-adopts the same ladder about six times a bout. This lands as a plain behaviour change with no convar to switch it off. The bug is not conditional: a reviewer running the branch with the fix disabled would simply hit the original defect and judge the branch by it, and offering the choice would ask them to review a switch as well as a change. The strategies that lost are in this branch's history and in the research notes rather than in the shipped code. --- .../Player/NextBotPlayerLocomotion.cpp | 131 +++++++++++++++++- .../NextBot/Player/NextBotPlayerLocomotion.h | 8 ++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 012a7afdbc..2c661ae955 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -17,6 +17,26 @@ ConVar NextBotPlayerMoveDirect( "nb_player_move_direct", "0" ); +// 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; + //----------------------------------------------------------------------------------------------------- PlayerLocomotion::PlayerLocomotion( INextBot *bot ) : ILocomotion( bot ) { @@ -45,6 +65,9 @@ void PlayerLocomotion::Reset( void ) m_ladderDismountGoal = NULL; m_ladderTimer.Invalidate(); + m_unwantedLadderSince = 0.0f; + m_unwantedLadderLastTouch = 0.0f; + m_minSpeedLimit = 0.0f; m_maxSpeedLimit = 9999999.9f; @@ -52,6 +75,106 @@ void PlayerLocomotion::Reset( void ) } +//----------------------------------------------------------------------------------------------------- +/** + * 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; +} + + +//----------------------------------------------------------------------------------------------------- +/** + * NEO: 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; +} + + //----------------------------------------------------------------------------------------------------- bool PlayerLocomotion::TraverseLadder( void ) { @@ -87,7 +210,13 @@ bool PlayerLocomotion::TraverseLadder( void ) if ( GetBot()->GetEntity()->GetMoveType() == MOVETYPE_LADDER ) { - // on ladder and don't want to be + // On a ladder and don't want to be. HandleUnwantedLadder() may take the ladder over + // instead, in which case the state machine drives from here and we are done. + if ( HandleUnwantedLadder() ) + { + break; + } + GetBot()->GetEntity()->SetMoveType( MOVETYPE_WALK ); } return false; diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h index 6a947835ca..e6dc989804 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h @@ -127,6 +127,14 @@ class PlayerLocomotion : public ILocomotion const CNavArea *m_ladderDismountGoal; CountdownTimer m_ladderTimer; // a "give up" timer if things go awry + // 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; + 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 + bool IsClimbPossible( INextBot *me, const CBaseEntity *obstacle ) const; }; From cd7d965691af7f53f3def594ea4d80a6005f9d3b Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 27 Sep 2026 19:10:00 -0600 Subject: [PATCH 2/6] Claim the ladder a climb behavior mounts CNEOBotLadderClimb puts the bot on a ladder without telling PlayerLocomotion, whose ladder state stays NO_LADDER. The locomotion then treats the climb as a ladder it never asked for: it forces the move type back to walking every update, and after half a second the unwanted-ladder takeover adopts the ladder by its nearer end. At the foot of a ladder that end is the bottom, so a bot mounted to climb up is driven back down. The climb now calls ClimbLadder() or DescendLadder() when it starts, so the locomotion drives the same way as the behavior. The dismount goal is the climb's exit area, else the ladder's own end area; without one there is nothing to walk to and the climb is left unclaimed. ApproachAscendingLadder() checked whether the bot is too far below the nav ladder's bottom before checking whether it is already on the ladder. On a ladder whose foot hangs above the floor that refused a claimed climb and handed it to the takeover again. A bot already on the ladder now goes straight to ascending. Research arm, 13 ladder-heavy maps: up-climbs finished 54 -> 66 % with the claim; the reordered check fixed 29 of 96 refused claims on a ladder with a 55 u foot gap. --- .../Player/NextBotPlayerLocomotion.cpp | 7 ++++ .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 42 +++++++++++++++++++ .../neo/bot/behavior/neo_bot_ladder_climb.h | 1 + 3 files changed, 50 insertions(+) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 2c661ae955..4a0dbb44bd 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -244,6 +244,13 @@ PlayerLocomotion::LadderState PlayerLocomotion::ApproachAscendingLadder( void ) return DISMOUNTING_LADDER_TOP; } + // 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; + } + // sanity check - are we too far below this ladder to reach it? if ( GetFeet().z <= m_ladderInfo->m_bottom.z - GetMaxJumpHeight() ) { 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..7386280634 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 ) { 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 9f0805fb7578394494745af7b0ce2e54a5efa46f Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Sun, 27 Sep 2026 19:15:00 -0600 Subject: [PATCH 3/6] Re-claim a ladder the climb is still on PlayerLocomotion's AscendLadder() and DescendLadder() give up the ladder the moment the engine reports the bot off it, even for a single tick. When CNEOBotLadderClimb carries on and the engine grabs the bot again, the locomotion sees a ladder it never asked for, forces walking, and after half a second adopts it by its nearer end - mid-climb often the bottom. While it climbs, the behavior now claims the ladder again whenever the bot is on it and the locomotion has let go. The dismount phase is left alone: there the bot is meant to be leaving the ladder. Research arm, measured together with a fix to the climb's teleport placement, on a 32 u squeeze ladder whose climbs lost their claim this way: finished up-climbs 59 -> 76 % by position. --- src/game/server/neo/bot/behavior/neo_bot_ladder_climb.cpp | 6 ++++++ 1 file changed, 6 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 7386280634..333a8778c8 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 @@ -260,6 +260,12 @@ 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; From e80c5459b7369b4cb814b52925ec903ba0f3bb7d Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 30 Sep 2026 19:11:00 -0600 Subject: [PATCH 4/6] Press forward down a ladder only when it descends CGameMovement::LadderMove() turns the part of a move that pushes into a ladder's face into climbing up. Going down, forward therefore only descends once the view points further down than into the face; with the view still level it climbs the bot up and, near the top, off the ladder. With the climb claimed (#2176), PlayerLocomotion's descend states steer every claimed descent, and they pressed forward at once: ApproachDescendingLadder() kept steering towards the mount point after the bot was already on the ladder, and Approach() presses forward on any ladder it is descending. - IsForwardDownLadder(): would a forward press move the bot down. A press facing away from the face is refused too: standing on a floor, LadderMove() pushes the bot off the ladder for it. - Approach() presses nothing on a descent until that holds. - DescendLadder() looks down into the face (the face of the ladder brush the bot holds, its back when grabbed from behind), not away. - ApproachDescendingLadder() hands a bot already on the ladder straight to DescendLadder(), before its "within a jump of the bottom" check lets go of it at the top of a short ladder. - CNEOBotLadderClimb holds forward the same way, does not count the wait for the view as a stall, and leaves the aim of a claimed descent to the locomotion: two MANDATORY aims at different points hold each other back, since PlayerBody rejects a same-priority aim until the head has been steady for 0.3 s. --- .../Player/NextBotPlayerLocomotion.cpp | 57 ++++++++++++++++++- .../NextBot/Player/NextBotPlayerLocomotion.h | 2 + .../neo/bot/behavior/neo_bot_ladder_climb.cpp | 33 +++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp index 4a0dbb44bd..3ef0fb1f8d 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.cpp @@ -37,6 +37,10 @@ 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; +// NEO: 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; + //----------------------------------------------------------------------------------------------------- PlayerLocomotion::PlayerLocomotion( INextBot *bot ) : ILocomotion( bot ) { @@ -175,6 +179,40 @@ bool PlayerLocomotion::HandleUnwantedLadder( void ) } +//----------------------------------------------------------------------------------------------------- +// NEO: 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; +} + + +//----------------------------------------------------------------------------------------------------- +// NEO: 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; +} + + //----------------------------------------------------------------------------------------------------- bool PlayerLocomotion::TraverseLadder( void ) { @@ -285,6 +323,13 @@ PlayerLocomotion::LadderState PlayerLocomotion::ApproachDescendingLadder( void ) return NO_LADDER; } + // 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; + } + // sanity check - are we already at the end of this ladder? if ( GetFeet().z <= m_ladderInfo->m_bottom.z + GetMaxJumpHeight() ) { @@ -416,8 +461,9 @@ PlayerLocomotion::LadderState PlayerLocomotion::DescendLadder( void ) return DISMOUNTING_LADDER_BOTTOM; } - // climb down this ladder - look down - Vector goal = GetFeet() + 100.0f * ( m_ladderInfo->GetNormal() + Vector( 0, 0, -2 ) ); + // NEO: climb down this ladder - look down 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 ) ); GetBot()->GetBodyInterface()->AimHeadTowards( goal, IBody::MANDATORY, 0.1f, NULL, "Ladder" ); @@ -625,7 +671,12 @@ void PlayerLocomotion::Approach( const Vector &pos, float goalWeight ) } #endif - if ( m_player->IsOnLadder() && IsUsingLadder() && ( m_ladderState == ASCENDING_LADDER || m_ladderState == DESCENDING_LADDER ) ) + if ( m_player->IsOnLadder() && m_ladderState == DESCENDING_LADDER && m_ladderInfo && !IsForwardDownLadder( m_ladderInfo ) ) + { + // NEO: 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 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. playerButtons->PressForwardButton(); diff --git a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h index e6dc989804..92235864bb 100644 --- a/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h +++ b/src/game/server/NextBot/Player/NextBotPlayerLocomotion.h @@ -61,6 +61,7 @@ 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; + bool IsForwardDownLadder( const CNavLadder *ladder ) const; // NEO: would pressing forward move us down this ladder virtual void FaceTowards( const Vector &target ); // rotate body to face towards "target" @@ -132,6 +133,7 @@ class PlayerLocomotion : public ILocomotion // 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; // NEO: 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 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 333a8778c8..b92b578dbf 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 @@ -269,6 +269,14 @@ ActionResult CNEOBotLadderClimb::Update( CNEOBot *me, float /*interval* 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() ) { @@ -377,12 +385,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); + } } //------------------------------------------------------------ From acfd7bcc71cff4c73cbf61bedb54e972acc1f002 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 30 Sep 2026 19:12:00 -0600 Subject: [PATCH 5/6] Mount a descent from in front of the ladder A bot the engine puts on a ladder while it still stands at the top - on the landing, or on top of the ladder brush where that stands a step proud of the floor - cannot press its way down: the floor or the brush is under its feet. The approach used to mount it there anyway. - The approach heads for where a descending bot hangs, half a hull plus 2 u in front of the ladder's face at its top, and mounts only once the bot is off the top: below the ladder's top and no longer standing within a step of it. - It lines up on the ladder's normal line only from in front of the face. Coming in at an angle, the bot stays on the floor beside the drop and steps up onto the ladder's top; from beside or behind the ladder the line-up would aim into the wall, so it walks straight on. - Going down it looks down the ladder, not level at its center: the forward press that grabs the ladder stays held until the next bot update, and with a level view it climbs the bot back up. --- .../bot/behavior/neo_bot_ladder_approach.cpp | 77 ++++++++++++------- .../bot/behavior/neo_bot_ladder_approach.h | 1 + 2 files changed, 50 insertions(+), 28 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 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 }; From 5c9d184982573b95b4d02a32d619d933539eb4e5 Mon Sep 17 00:00:00 2001 From: Alan Shen Date: Wed, 30 Sep 2026 19:13:00 -0600 Subject: [PATCH 6/6] Keep a descent's early jump-off below the top CNEOBotLadderClimb jumps off early once the exit area is within a safe drop below. Going down a short ladder that is at once, on the mount, and the kick towards the exit area's center lands the bot back on the floor it started from wherever that floor overhangs the exit (a hatch in a roof, a landing over the room below). Going down, the jump-off now waits until the bot is a standing height below the ladder's top. --- 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 b92b578dbf..5a9fd3ffb8 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 @@ -307,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; }