pathing: lock Descend air-yaw and shortcut Ascend->Turn landing

Two coupled fixes for live 258<->237 / 237<->244 routes that previously
hit 5 replans and gave up on Ascend exit=Turn segments.

DescendTemplate: when biasTowardExitInAir is false on a multi-block
diagonal descend, the per-tick targetYaw rotates as the bot drifts past
the landing column mid-fall (e.g. dx=-1 dz=-1 drop yaw cycles 135 -> 90
-> 0 -> 315 over six air ticks). With Forward held, that rotating yaw
pushes air-control momentum perpendicular to the planned trajectory,
sliding the bot ~0.5 m off the landing onto an adjacent block one tier
below. Lock airborne yaw to the segment's start->end heading for those
descends so air drift stays aligned with the diagonal.

AscendTemplate: add a post-landing completion shortcut for non-FinalStop
exits. Once the jump arc puts the bot back on ground at the target's
elevation with its center inside the target column, hand off to the
next template (which snaps yaw on its first tick). Holding the segment
runs both AscendTemplate's top-level yaw smoothing toward a moving
targetYaw AND GroundedSegmentController's segment/exit-heading rotation
each tick. The competing yaw targets oscillate the bot ~80 ticks until
it walks off the 1-block landing's edge and the segment fails. Mirrors
the existing PrepareJump completion gate. FinalStop is excluded so the
last segment still uses IsSettledAtEnd to detect a true stop.

Live verification on 1.21.11 (round-trip 244<->237 plus the originally
failing 237->244 route): four navigations, 0 replans, all 25/41/24/41
segments completed.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-27 13:30:14 +00:00
parent 512693dcd0
commit c1831b2730
3 changed files with 173 additions and 3 deletions

View file

@ -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)}");
}
/// <summary>
/// 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.
/// </summary>
[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<string>();
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)}");
}
}

View file

@ -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;

View file

@ -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)