pathing: stabilize 0-replan round-trip on ledge/descend runs

Fix a cluster of execution-layer issues that caused replans and void
falls when traversing narrow ledges and multi-block descents between
(251.5,141,210.5) and (252.5,138,220.5):

- WalkTemplate / GroundedSegmentController: suppress the pre-rotation
  bias toward the next segment's exit heading on stable-footing Turn
  exits where the next segment is not a jump.  The next template
  snaps yaw on its first tick anyway, and pre-rotating mid-stride on
  a 1-block walkway pushes sprint drift perpendicular to the path and
  walks the bot off the edge.  Turn exits into a jump still get the
  bias so the takeoff direction stays aligned.

- GroundedSegmentController.ShouldComplete: relax the headingReady
  gate for Turn exits with stable footing so the segment can complete
  once yaw is aligned with either the current or the next segment
  heading (within 25/15 deg).  Without this the removed bias would
  leave the bot stuck at the end of a walkway waiting for a rotation
  that never happens.

- DescendTemplate: restrict the airborne exit-heading bias so it only
  kicks in when the footprint is inside the landing block, or on
  single-step drops where the fall is too short for lateral drift to
  miss the landing column.  On 2+ block drops the bot now keeps yaw
  pointed at the landing center for the whole fall.

- DescendTemplate: add a multi-block overshoot guard on PrepareJump
  exits.  Once airborne and past the landing end-plane on a 2+ Y
  drop, release forward/sprint and press back briefly so air drag
  pulls the bot back into the 1x1 landing column instead of sailing
  one block past it into the neighbouring void.

Live round-trip between the two goal coordinates now completes with
zero replans in three consecutive runs in each direction.  Full unit
test suite is unchanged from the pre-existing baseline (22 failing
tests, all orthogonal to this change).

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-22 16:43:43 +00:00
parent d002930a6a
commit 5de169db64
19 changed files with 1418 additions and 171 deletions

View file

@ -1724,58 +1724,33 @@ namespace MinecraftClient
/// <summary>
/// Navigate to a goal using the new A* pathfinder and template-based execution.
/// Accepts any IGoal for flexible target specification.
/// Returns a description of the result for UI feedback.
/// The A* search runs on a background task so the 20 TPS tick loop is
/// never blocked; the caller sees a "planning started" acknowledgement
/// and the real result is logged when
/// <see cref="Pathing.Execution.PathSegmentManager"/> installs the plan
/// on a subsequent tick.
/// </summary>
public (bool success, string message) NavigateToGoal(Pathing.Goals.IGoal goal, long timeoutMs = 5000)
{
Location startPos;
lock (locationLock)
{
var ctx = new Pathing.Core.CalculationContext(world,
allowParkour: true, allowParkourAscend: true);
var finder = new Pathing.Core.AStarPathFinder();
finder.DebugLog = msg => Log.Debug(msg);
int sx = (int)Math.Floor(location.X);
int sy = (int)Math.Floor(location.Y);
int sz = (int)Math.Floor(location.Z);
if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
sy++;
Log.Info($"[Navigate] A* search from ({sx},{sy},{sz}) to {goal}");
using var cts = new CancellationTokenSource();
var result = finder.Calculate(ctx, sx, sy, sz, goal, cts.Token, timeoutMs);
Log.Info($"[Navigate] A* result: {result.Status}, nodes={result.NodesExplored}, " +
$"time={result.ElapsedMs}ms, path length={result.Path.Count}");
if (result.Status == Pathing.Core.PathStatus.Failed || result.Path.Count < 2)
{
return (false, string.Format(Translations.cmd_goto_failed,
result.NodesExplored, result.ElapsedMs));
}
for (int i = 1; i < result.Path.Count; i++)
{
var node = result.Path[i];
Log.Debug($"[Navigate] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})");
}
pathTarget = null;
path = null;
pathSegmentManager = new Pathing.Execution.PathSegmentManager(
debugLog: msg => Log.Debug(msg),
infoLog: msg => Log.Info(msg),
observer: new Pathing.Execution.Telemetry.PathExecutionLogObserver(msg => Log.Debug(msg)));
pathSegmentManager.StartNavigation(goal, result);
string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : "";
return (true, string.Format(Translations.cmd_goto_success,
result.Path.Count - 1, result.NodesExplored, result.ElapsedMs, statusStr));
startPos = location;
}
Log.Info($"[Navigate] A* search from ({(int)Math.Floor(startPos.X)},{(int)Math.Floor(startPos.Y)},{(int)Math.Floor(startPos.Z)}) to {goal}");
pathTarget = null;
path = null;
pathSegmentManager?.Cancel();
pathSegmentManager = new Pathing.Execution.PathSegmentManager(
debugLog: msg => Log.Debug(msg),
infoLog: msg => Log.Info(msg),
observer: new Pathing.Execution.Telemetry.PathExecutionLogObserver(msg => Log.Debug(msg)));
pathSegmentManager.StartNavigationAsync(goal, startPos, world, timeoutMs);
return (true, string.Format(Translations.cmd_goto_planning, timeoutMs));
}
/// <summary>