diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs index cabc5d8e..f8f4b69c 100644 --- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -886,4 +886,121 @@ public sealed class GroundedTemplateConvergenceTests TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, ascend.End), $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); } + + /// + /// Live regression: B->A route segment 9/41 Ascend (243.5,97,181.5)-> + /// (244.5,98,182.5) exit=Turn, fed by a Descend->PrepareJump handoff. + /// The Ascend starts mid-air with cardinal +Z momentum and yaw aimed + /// at the diagonal target. Without a post-landing completion shortcut + /// the bot lands hot (footprint partially outside target) and the + /// AscendTemplate top-level yaw smoothing toward targetYaw fights the + /// GroundedSegmentController exit-heading rotation, oscillating yaw + /// each tick until the bot drifts off the 1-block landing's edge and + /// the segment fails. With the shortcut the segment completes the + /// instant the bot lands with center inside the target column, which + /// hands off cleanly to the next template (Traverse) that snaps yaw + /// on its first tick. + /// + [Fact] + public void AscendTemplate_TurnExit_FromCarriedAirMomentum_CompletesOnTargetColumnLanding() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -4, max: 6); + FlatWorldTestBuilder.ClearBox(world, -4, 80, -4, 6, 86, 6); + // Source block (0,79,0) is the Descend's landing column; the bot + // arrives mid-jump above it. + FlatWorldTestBuilder.SetSolid(world, 0, 79, 0); + // Target block (1,80,1) (NE diagonal +1y) is the Ascend's landing. + FlatWorldTestBuilder.SetSolid(world, 1, 80, 1); + // Next-segment landing column (Traverse east from target). + FlatWorldTestBuilder.SetSolid(world, 2, 80, 1); + // Walkable shoulder block beneath the bot's overshooting +Z + // footprint so the bot doesn't drop into a 2-block hole on + // landing (matches the live world geometry where a wide platform + // existed at the target's elevation). + FlatWorldTestBuilder.SetSolid(world, 1, 80, 2); + + var ascend = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 81, 1.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints( + DesiredHeadingX: 1, + DesiredHeadingZ: 0, + MinExitSpeed: 0.0, + MaxExitSpeed: 0.05, + RequireStableFooting: true, + RequireGrounded: true, + RequireJumpReady: false, + AllowAirBrake: true, + HorizonTicks: 12), + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 81, 1.5), + End = new Location(2.5, 81, 1.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new AscendTemplate(ascend, next); + + // Seed mid-arc state mirroring the live PathDiag tick-trace at the + // first observed tick of seg9/41: bot already airborne with rising + // vy, cardinal +Z momentum from the preceding Descend, and yaw + // pointing roughly NE (the AscendTemplate snapped yaw on the + // takeoff tick). + var physics = new PlayerPhysics + { + Position = new Vec3d(0.7, 80.42, 0.92), + DeltaMovement = new Vec3d(0.0, 0.333, 0.145), + OnGround = false, + Sprinting = true, + MovementSpeed = 0.1f, + Yaw = 311f, + Pitch = 0f + }; + + var input = new MovementInput(); + TemplateState state = TemplateState.InProgress; + Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + var trace = new List(); + int elapsedTicks = 0; + for (; elapsedTicks < 80; elapsedTicks++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + state = template.Tick(pos, physics, input, world); + if (elapsedTicks < 30 || state != TemplateState.InProgress) + { + trace.Add( + $"tick={elapsedTicks} state={state} pos={pos} yaw={physics.Yaw:F1} vel={physics.DeltaMovement} " + + $"onGround={physics.OnGround} input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})"); + } + if (state != TemplateState.InProgress) + { + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + break; + } + + physics.ApplyInput(input); + physics.Tick(world); + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + } + + Assert.True( + state == TemplateState.Complete, + $"state={state} elapsed={elapsedTicks} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); + Assert.True( + physics.OnGround, + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True( + TemplateFootingHelper.IsCenterInsideTargetBlock(finalPos, ascend.End), + $"finalPos={finalPos} target={ascend.End}\n{string.Join('\n', trace)}"); + Assert.True( + elapsedTicks <= 30, + $"completion took {elapsedTicks} ticks; expected post-landing shortcut to fire promptly\n{string.Join('\n', trace)}"); + } } diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index dedb7818..5cede71f 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -43,6 +43,7 @@ namespace MinecraftClient.Pathing.Execution.Templates private Location _lastPos; private int _stuckTicks; private bool _initiatedJump; + private bool _hasBeenAirborne; private int _diagonalBrakeTicks; public AscendTemplate(PathSegment segment, PathSegment? nextSegment) @@ -182,8 +183,38 @@ namespace MinecraftClient.Pathing.Execution.Templates } } + if (!physics.OnGround) + _hasBeenAirborne = true; + if (physics.OnGround && Math.Abs(dy) < 0.2) { + // Post-landing shortcut: once the Ascend's jump arc has put the + // bot back on ground at the target's elevation with its center + // inside the target column, the segment has done its job. Hand + // off to the next template (which snaps yaw on its first tick) + // instead of trying to brake or settle to stable footing. + // + // Holding onto the segment here re-runs both the AscendTemplate + // top-level yaw smoothing toward targetYaw (a moving bearing as + // the bot drifts past End) AND GroundedSegmentController's + // segment/exit-heading rotation each tick. The two competing + // yaw targets (e.g. yaw=233 anti-velocity vs yaw=315 segment + // heading vs yaw=270 exit heading on a Descend->PrepareJump-> + // Ascend->Turn chain) oscillate the bot ~80 ticks until it + // walks off the 1-block landing's edge and the segment fails. + // Mirrors the existing "Ascend completes on PrepareJump as + // soon as center is inside the target block" gate in + // GroundedSegmentController.ShouldComplete. FinalStop is + // excluded because the last segment must come to rest at + // the goal: hand it back to GroundedSegmentController, + // which uses IsSettledAtEnd to detect a true stop. + if (_hasBeenAirborne + && _segment.ExitTransition != PathTransitionType.FinalStop + && TemplateFootingHelper.IsCenterInsideTargetBlock(pos, _segment.End)) + { + return TemplateState.Complete; + } + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) return TemplateState.Complete; diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 3cee5861..e481e361 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -150,9 +150,31 @@ namespace MinecraftClient.Pathing.Execution.Templates && (onOrPastTarget || (_hasFallen && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)))); - float airborneYaw = biasTowardExitInAir - ? TemplateHelper.GetExitHeadingYaw(_segment) - : targetYaw; + float airborneYaw; + if (biasTowardExitInAir) + { + airborneYaw = TemplateHelper.GetExitHeadingYaw(_segment); + } + else if (!isSingleStepDescend) + { + // Multi-block descend: target-tracking yaw rotates as + // the bot drifts past the landing column mid-fall (e.g. + // a diagonal 3 c2c drop with dx=-1, dz=-1 starts at + // yaw=135, the relative bearing to End flips through + // 90 -> 0 -> 315 in 6 air ticks). With Forward input + // held, the rotating yaw pushes air-control momentum + // perpendicular to the planned trajectory, drifting + // the bot ~0.5 m past the landing column and onto an + // adjacent block one tier below. Lock airborne yaw to + // the segment's start->end heading so air drift stays + // aligned with the planned diagonal; the GroundedSegment + // controller takes over once the bot is on the landing. + airborneYaw = TemplateHelper.CalculateYaw(_segment.HeadingX, _segment.HeadingZ); + } + else + { + airborneYaw = targetYaw; + } physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, airborneYaw); if (_hasFallen || YawDifference(physics.Yaw, airborneYaw) <= PreDropYawToleranceDeg)