diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs index 7f620ec9..07bd9ee0 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs @@ -49,6 +49,45 @@ public sealed class PathSegmentBuilderTests Assert.True(segments[0].PreserveSprint); } + [Fact] + public void FromPath_DescendIntoTurningDescend_UsesLandingRecoveryHints() + { + // Regression: a Descend that lands and immediately steps into a + // perpendicular Descend (different heading) used to receive the + // turning-branch hints with RequireStableFooting=true. That gate forces + // GroundedSegmentController to wait for IsSettledOnTargetBlock, which + // takes ~3 seconds while residual jump momentum decays. The + // LandingRecovery branch (RequireStableFooting=false) lets the + // ShouldComplete shortcut fire as soon as the bot's footprint is + // inside the landing block. + var nodes = BuildNodes( + (255, 137, 220, MoveType.Traverse), + (256, 134, 219, MoveType.Descend), + (256, 132, 217, MoveType.Descend)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.LandingRecovery, segments[0].ExitTransition); + Assert.False(segments[0].ExitHints.RequireStableFooting); + } + + [Fact] + public void FromPath_TraverseIntoTurningTraverse_StillUsesTurnHints() + { + // Sanity guard: ordinary Traverse → turning-Traverse must still use the + // turning branch (StableFooting=true) — only Descend/Parkour/Fall + // sources should bypass it. + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.True(segments[0].ExitHints.RequireStableFooting); + } + [Fact] public void FromPath_CopiesParkourProfile_ToRuntimeSegment() { diff --git a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs index d7cba809..5e655a99 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs @@ -94,20 +94,21 @@ namespace MinecraftClient.Pathing.Execution bool nextImmediatelyJumps = nextNext is not null && nextNext.MoveType is (MoveType.Parkour or MoveType.Ascend); - if (turning) - { - return new PathTransitionHints( - DesiredHeadingX: next.HeadingX, - DesiredHeadingZ: next.HeadingZ, - MinExitSpeed: nextImmediatelyJumps ? 0.05 : 0.0, - MaxExitSpeed: nextImmediatelyJumps ? 0.16 : 0.05, - RequireStableFooting: !nextImmediatelyJumps, - RequireGrounded: true, - RequireJumpReady: nextImmediatelyJumps, - AllowAirBrake: true, - HorizonTicks: 12); - } - + // LandingRecovery (current is Descend/Parkour/Fall) takes precedence + // over the turning branch even when heading changes between current + // and next. The turning branch demands RequireStableFooting=true, + // which forces the GroundedSegmentController completion gate to wait + // for IsSettledOnTargetBlock (footprint inside, won't leave next + // tick, horizontal speed^2 <= 0.0016). After a multi-block diagonal + // Descend the bot lands inside the target block already carrying + // ~0.02 m/tick of residual momentum that the planner can't shed + // cleanly: the per-tick yaw bias toward the next segment's heading + // pulls the bot off-axis, the bot slides off the target block, the + // template re-targets the centre, and so on for ~60 ticks until + // momentum decays. The LandingRecovery shortcut in + // GroundedSegmentController.ShouldComplete (!RequireStableFooting + + // footprint inside target) bypasses the speed gate cleanly the + // moment the bot reaches the landing column. if (exitTransition == PathTransitionType.LandingRecovery) { return new PathTransitionHints( @@ -122,6 +123,20 @@ namespace MinecraftClient.Pathing.Execution HorizonTicks: 12); } + if (turning) + { + return new PathTransitionHints( + DesiredHeadingX: next.HeadingX, + DesiredHeadingZ: next.HeadingZ, + MinExitSpeed: nextImmediatelyJumps ? 0.05 : 0.0, + MaxExitSpeed: nextImmediatelyJumps ? 0.16 : 0.05, + RequireStableFooting: !nextImmediatelyJumps, + RequireGrounded: true, + RequireJumpReady: nextImmediatelyJumps, + AllowAirBrake: true, + HorizonTicks: 12); + } + return new PathTransitionHints( DesiredHeadingX: next.HeadingX, DesiredHeadingZ: next.HeadingZ, diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index 7a4dc031..f36a0247 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -51,10 +51,12 @@ namespace MinecraftClient.Pathing.Execution /// public static bool DiagnosticsEnabled { get; set; } - private const int DiagnosticsTailSize = 64; + private const int DiagnosticsTailSize = 200; + private const int SlowSegmentDumpTickThreshold = 25; private readonly Queue _diagnosticsTail = new(DiagnosticsTailSize + 1); private PathResult? _lastPlan; private int _lastObservedSegmentIndex = -1; + private int _ticksSinceSegmentStart; public bool IsNavigating => (_executor is not null && !_executor.IsComplete) @@ -89,6 +91,7 @@ namespace MinecraftClient.Pathing.Execution var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog, _observer); _lastObservedSegmentIndex = -1; + _ticksSinceSegmentStart = 0; _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); } @@ -197,11 +200,32 @@ namespace MinecraftClient.Pathing.Execution // without relying on the bounded tail buffer. Resets on plan install. if (segIdx != _lastObservedSegmentIndex) { + if (_lastObservedSegmentIndex >= 0 + && _ticksSinceSegmentStart >= SlowSegmentDumpTickThreshold) + { + _infoLog?.Invoke( + $"[PathDiag] Slow segment {_lastObservedSegmentIndex}/{_executor.TotalSegments} took {_ticksSinceSegmentStart} ticks, dumping last {Math.Min(_diagnosticsTail.Count, _ticksSinceSegmentStart)} ticks:"); + int toDump = Math.Min(_diagnosticsTail.Count, _ticksSinceSegmentStart); + int skipCount = _diagnosticsTail.Count - toDump; + int i = 0; + foreach (string line in _diagnosticsTail) + { + if (i++ < skipCount) + continue; + _infoLog?.Invoke($"[PathDiag] t-{toDump - (i - skipCount)}: {line}"); + } + } + _lastObservedSegmentIndex = segIdx; + _ticksSinceSegmentStart = 0; _infoLog?.Invoke( $"[PathDiag] seg->{segIdx}/{_executor.TotalSegments} pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) yaw={physics.Yaw:F1} vy={physics.DeltaMovement.Y:F3} og={physics.OnGround} " + (seg is null ? "none" : $"{seg.MoveType} ({seg.Start.X:F1},{seg.Start.Y:F1},{seg.Start.Z:F1})->({seg.End.X:F1},{seg.End.Y:F1},{seg.End.Z:F1}) exit={seg.ExitTransition}")); } + else + { + _ticksSinceSegmentStart++; + } _diagnosticsTail.Enqueue( $"pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) yaw={physics.Yaw:F1} vy={physics.DeltaMovement.Y:F3} vx={physics.DeltaMovement.X:F3} vz={physics.DeltaMovement.Z:F3} og={physics.OnGround} {segStr}"); @@ -428,6 +452,7 @@ namespace MinecraftClient.Pathing.Execution _lastPlan = result; _diagnosticsTail.Clear(); _lastObservedSegmentIndex = -1; + _ticksSinceSegmentStart = 0; if (isInitial) { _executor = new PathExecutor(segments, _debugLog, _observer);