diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs index 0f147df0..cabc5d8e 100644 --- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -798,4 +798,92 @@ public sealed class GroundedTemplateConvergenceTests Assert.True(input.Back, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})"); Assert.False(input.Forward, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})"); } + + /// + /// Bug 2.1 regression: an island diagonal Ascend (heading (-X,+Z,+Y)) is + /// reached after a preceding cardinal Traverse has built up pure +Z ground + /// momentum. Without the execution-layer brake the perpendicular momentum + /// survives takeoff, collides with the +Z shoulder wall of the target + /// block, and the bot lands outside the target footprint. The template + /// must release Forward/Sprint (and engage Back when the perpendicular + /// dominates) for a few ground ticks so friction can decay the misaligned + /// component before the jump fires, and the final landing must be inside + /// the target block. + /// + [Fact] + public void AscendTemplate_IslandDiagonalFromCardinalMomentum_BrakesPerpBeforeJumpAndLandsInsideTarget() + { + // Build a small island layout at y=79 floor: + // source block (0,79,0) stands on (0,78,0) + // target block (-1,80,1) stands on (-1,79,1); approach is diagonal (-X,+Z) + // the +Z shoulder relative to the target (-1,80,2) is solid at head + // height so any over-travel along +Z bonks a wall (matches the live + // case where perpendicular momentum pushed past the target) + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -4, max: 4); + FlatWorldTestBuilder.ClearBox(world, -4, 79, -4, 4, 84, 4); + FlatWorldTestBuilder.SetSolid(world, 0, 79, 0); + FlatWorldTestBuilder.SetSolid(world, -1, 80, 1); + FlatWorldTestBuilder.SetSolid(world, -1, 81, 2); + FlatWorldTestBuilder.SetSolid(world, -1, 80, 2); + + var ascend = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(-0.5, 81, 1.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = true + }; + + var template = new AscendTemplate(ascend, null); + + // Seed pure +Z cardinal momentum at the source block center: this is + // the perpendicular axis relative to the diagonal (-X,+Z) / sqrt(2) + // heading; without the brake gate the bot would take off carrying it. + var physics = new PlayerPhysics + { + Position = new Vec3d(0.5, 80, 0.5), + DeltaMovement = new Vec3d(0.0, 0.0, 0.22), + OnGround = true, + Sprinting = true, + MovementSpeed = 0.1f, + Yaw = 0f, + Pitch = 0f + }; + + var input = new MovementInput(); + TemplateState state = TemplateState.InProgress; + Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + bool sawBrakeTick = false; + var trace = new List(); + for (int tick = 0; tick < 120; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + state = template.Tick(pos, physics, input, world); + if (physics.OnGround && !input.Forward && !input.Jump) + sawBrakeTick = true; + if (tick < 24 || state != TemplateState.InProgress) + { + trace.Add( + $"tick={tick} 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(sawBrakeTick, "expected at least one ground tick where Forward was released to decay perpendicular momentum"); + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, ascend.End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); + } } diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs new file mode 100644 index 00000000..732df4bf --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs @@ -0,0 +1,104 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves; +using MinecraftClient.Pathing.Moves.Impl; +using MinecraftClient.Tests.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +public sealed class MoveDescendTests +{ + private const int FloorY = 79; + + private static CalculationContext BuildContext(World world) + => new(world, allowParkour: true, allowParkourAscend: true); + + [Fact] + public void Accepts1BlockStepDown() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + // Raise the source column by one so a +X step descends 1 block. + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0); + + var ctx = BuildContext(world); + var move = new MoveDescend(1, 0); + var result = default(MoveResult); + + // Source feet block is FloorY+2, destination feet block is FloorY+1. + move.Calculate(ctx, 0, FloorY + 2, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(1, result.DestX); + Assert.Equal(FloorY + 1, result.DestY); + } + + /// + /// Regression: when the landing column is itself solid at y-1 (e.g. a + /// 2-block-thick platform top), MoveDescend must reject the move. + /// Previously the simple 1-block branch only checked the y-2 floor and the + /// y / y+1 body-clearance at the destination, so A* emitted a Descend that + /// the bot could never execute (it just walked onto the solid y-1 block at + /// the same feet level), producing an infinite replan loop in live play. + /// + [Fact] + public void Rejects1BlockDescendIntoSolidLandingColumn() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + // Bot stands on source pillar (0, FloorY+1), feet at FloorY+2. + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0); + // Destination column is ALSO solid at the feet-landing level (y-1 of source). + // Concretely: (1, FloorY+1) is stone, (1, FloorY) is stone, and the flat + // floor under that is still there too. There is no valid 1-block drop. + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 0); + + var ctx = BuildContext(world); + var move = new MoveDescend(1, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 2, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void Accepts2BlockDrop() + { + // Two-tier setup: source pillar at y=FloorY+2, destination floor at y=FloorY. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 2, 0); + + var ctx = BuildContext(world); + var move = new MoveDescend(1, 0); + var result = default(MoveResult); + + // Source feet block is FloorY+3, destination column drops to FloorY+1 floor. + move.Calculate(ctx, 0, FloorY + 3, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(1, result.DestX); + Assert.Equal(FloorY + 1, result.DestY); + } + + [Fact] + public void RejectsMultiBlockDropWhenFlightColumnIsBlocked() + { + // Source pillar at y=FloorY+2, but destination column has a solid + // block at y-1 that blocks the fall path entirely. The bot cannot + // enter the destination column at all. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 2, 0); + // Blocker: (1, FloorY+2) is solid -- this is the y-1 of the source feet. + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 2, 0); + + var ctx = BuildContext(world); + var move = new MoveDescend(1, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 3, 0, ref result); + + Assert.True(result.IsImpossible); + } +} diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs new file mode 100644 index 00000000..b3520d7e --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs @@ -0,0 +1,109 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves; +using MinecraftClient.Pathing.Moves.Impl; +using MinecraftClient.Tests.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +/// +/// Regression tests for the Baritone-parity cardinal-split gate in +/// 's diagonal Ascend branch. When a cardinal +/// fallback (cardinal Walk into the dx or dz shoulder + a cardinal Ascend +/// from there) exists, the diagonal Ascend must be rejected: it has no +/// physical way to redirect the preceding segment's axis-aligned ground +/// momentum into the diagonal in 2 handoff ticks, so executing it +/// overshoots the target and loops on replan. +/// +public sealed class MoveJumpDiagonalAscendTests +{ + private const int FloorY = 79; + + private static CalculationContext BuildContext(World world) + => new(world, allowParkour: true, allowParkourAscend: true); + + [Fact] + public void RejectsDiagonalAscendWhenCardinalSplitIsWalkable() + { + // Flat floor at FloorY, so the cardinal shoulders at (1, FloorY, 0) + // and (0, FloorY, 1) both have solid ground. The ascend target is a + // 1-block riser on the diagonal corner at (1, FloorY+1, 1). Either + // "walk +X first, then cardinal Ascend +Z+Y" or "walk +Z first, then + // cardinal Ascend +X+Y" produces a stable 2-step plan, so the direct + // diagonal Ascend must be rejected. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1); + + var ctx = BuildContext(world); + var move = MoveJump.DiagonalAscend(1, 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void AcceptsDiagonalAscendWhenBothCardinalShouldersLackFloor() + { + // Island configuration: the source pillar and the diagonal ascend + // riser are the only walk-on surfaces near the bot. The cardinal + // shoulders are open air, so no cardinal Walk + cardinal Ascend + // split exists and the diagonal Ascend is the genuine only option. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(1, FloorY, 0), Block.Air); + world.SetBlock(new Location(0, FloorY, 1), Block.Air); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1); + + var ctx = BuildContext(world); + var move = MoveJump.DiagonalAscend(1, 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(1, result.DestX); + Assert.Equal(FloorY + 2, result.DestY); + Assert.Equal(1, result.DestZ); + } + + [Fact] + public void RejectsDiagonalAscendWhenOnlyOneCardinalShoulderHasFloor() + { + // Only the +X shoulder has floor support; the +Z shoulder is open + // air. Even a single viable cardinal split is enough for Baritone's + // gate to forbid the diagonal Ascend, because A* can simply take + // "walk +X then cardinal Ascend +Z+Y" instead. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(0, FloorY, 1), Block.Air); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1); + + var ctx = BuildContext(world); + var move = MoveJump.DiagonalAscend(1, 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void CardinalAscendStillAcceptedOnFlatFloor() + { + // Sanity: the gate must not touch cardinal Ascend. A plain +X Ascend + // onto a 1-block riser on flat floor should still plan as before. + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 0); + + var ctx = BuildContext(world); + var move = MoveJump.Ascend(1, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(1, result.DestX); + Assert.Equal(FloorY + 2, result.DestY); + } +} diff --git a/MinecraftClient/Commands/Goto.cs b/MinecraftClient/Commands/Goto.cs index 18cf7d0f..33680535 100644 --- a/MinecraftClient/Commands/Goto.cs +++ b/MinecraftClient/Commands/Goto.cs @@ -42,7 +42,10 @@ namespace MinecraftClient.Commands Location current = handler.GetCurrentLocation(); goal.ToAbsolute(current); - var (success, message) = handler.MoveToAStar(goal); + // The A* search runs on a background task (see NavigateToGoal), so + // a generous budget no longer blocks the 20 TPS tick loop. Matches + // Baritone's multi-second default budget for interactive goto. + var (success, message) = handler.MoveToAStar(goal, timeoutMs: 15000); return r.SetAndReturn(success ? Status.Done : Status.Fail, message); } diff --git a/MinecraftClient/Commands/PathDiag.cs b/MinecraftClient/Commands/PathDiag.cs new file mode 100644 index 00000000..e324a5f0 --- /dev/null +++ b/MinecraftClient/Commands/PathDiag.cs @@ -0,0 +1,57 @@ +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Pathing.Execution; +using static MinecraftClient.CommandHandler.CmdResult; + +namespace MinecraftClient.Commands +{ + /// + /// Toggles Info-level pathing diagnostics. When enabled, + /// emits the full waypoint dump of every planned/replanned path, the recent per-tick + /// trace at segment-failure time, and the failing segment's target. Used for + /// reporting pathing bugs without permanently changing the debug log level. + /// + public class PathDiag : Command + { + public override string CmdName => "pathdiag"; + public override string CmdUsage => "pathdiag [on|off]"; + public override string CmdDesc => Translations.cmd_pathdiag_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source))) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => Toggle(r.Source)) + .Then(l => l.Literal("on") + .Executes(r => SetDiagnostics(r.Source, true))) + .Then(l => l.Literal("off") + .Executes(r => SetDiagnostics(r.Source, false))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r) + { + return r.SetAndReturn(GetCmdDescTranslated()); + } + + private static int Toggle(CmdResult r) + { + return SetDiagnostics(r, !PathSegmentManager.DiagnosticsEnabled); + } + + private static int SetDiagnostics(CmdResult r, bool enabled) + { + PathSegmentManager.DiagnosticsEnabled = enabled; + return r.SetAndReturn(Status.Done, + enabled ? Translations.cmd_pathdiag_enabled : Translations.cmd_pathdiag_disabled); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 1cba394a..6d32351e 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1724,58 +1724,33 @@ namespace MinecraftClient /// /// 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 + /// installs the plan + /// on a subsequent tick. /// 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)); } /// diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index c6f6e832..520ab8f9 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -429,34 +429,49 @@ namespace MinecraftClient.Pathing.Core int stepX = destX - current.X; int stepZ = destZ - current.Z; - ReadOnlySpan descriptors = JumpExpander.Descriptors; - for (int i = 0; i < descriptors.Length; i++) + // Sidewall candidates are generated dynamically by JumpExpander's + // cardinal probe now, so we probe each cardinal forward direction + // directly instead of scanning a descriptor table. The only shape + // TryGetRequiredStaticEntryRunupSteps flags as needing a static + // runup today is (major=5, minor=1, yDelta=-1) -- we use that + // canonical shape as the query (lateral=+1 is arbitrary; the + // helper only looks at yDelta and major). + ReadOnlySpan<(int fx, int fz)> forwards = + [ + (1, 0), + (-1, 0), + (0, 1), + (0, -1), + ]; + + for (int i = 0; i < forwards.Length; i++) { - JumpDescriptor candidate = descriptors[i]; - if (candidate.Flavor != JumpFlavor.Sidewall) + (int forwardX, int forwardZ) = forwards[i]; + if (stepX != -forwardX || stepZ != -forwardZ) continue; + int xOffset, zOffset; + if (forwardX != 0) + { + xOffset = forwardX * 5; + zOffset = 1; + } + else + { + xOffset = 1; + zOffset = forwardZ * 5; + } + if (!ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps( current.MoveUsed, - candidate.XOffset, - candidate.ZOffset, - candidate.YDelta, + xOffset, + zOffset, + yDelta: -1, out int requiredSteps)) { continue; } - ParkourFeasibility.GetSidewallAxes( - candidate.XOffset, - candidate.ZOffset, - out int forwardX, - out int forwardZ, - out _, - out _); - - if (stepX != -forwardX || stepZ != -forwardZ) - continue; - state = new EntryPreparationState( EntryPreparationKind.SidewallRunup, current.X, diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index ad9d5131..7a4dc031 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Text; using System.Threading; using System.Threading.Tasks; using MinecraftClient.Mapping; @@ -33,6 +35,7 @@ namespace MinecraftClient.Pathing.Execution private PathExecutor? _nextExecutor; private IGoal? _goal; private int _replanCount; + private bool _isInitialPlan; private Task? _pendingReplan; private CancellationTokenSource? _pendingReplanCts; @@ -40,6 +43,19 @@ namespace MinecraftClient.Pathing.Execution private CancellationTokenSource? _pendingLookaheadCts; private (int x, int y, int z)? _pendingLookaheadAnchor; + /// + /// Set to true to emit Info-level diagnostic traces (full path node dump, + /// failing-segment context, recent position tail) on every plan/replan + /// event. Users enable this via /pathdiag on when reporting pathing + /// bugs so the default log level stays quiet. + /// + public static bool DiagnosticsEnabled { get; set; } + + private const int DiagnosticsTailSize = 64; + private readonly Queue _diagnosticsTail = new(DiagnosticsTailSize + 1); + private PathResult? _lastPlan; + private int _lastObservedSegmentIndex = -1; + public bool IsNavigating => (_executor is not null && !_executor.IsComplete) || _nextExecutor is not null @@ -61,6 +77,7 @@ namespace MinecraftClient.Pathing.Execution _nextExecutor = null; _goal = goal; _replanCount = 0; + _isInitialPlan = false; if (result.Status == PathStatus.Failed || result.Path.Count < 2) { _infoLog?.Invoke("[PathMgr] Navigation rejected -- no path found."); @@ -71,9 +88,53 @@ namespace MinecraftClient.Pathing.Execution var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog, _observer); + _lastObservedSegmentIndex = -1; _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); } + /// + /// Kicks off the initial A* search on a background task and returns + /// immediately. The main tick loop drains the task via + /// , installing the executor when the + /// plan completes. Use this from interactive entry points (e.g. + /// /goto) so the 20 TPS tick loop never blocks on a long A* + /// search -- a complex climb can take the full planning budget + /// (several seconds) and freezing the tick causes the player to + /// desync, stop sending keep-alives, and miss chunk updates. + /// + public void StartNavigationAsync(IGoal goal, Location startPos, World world, long timeoutMs) + { + ArgumentNullException.ThrowIfNull(goal); + ArgumentNullException.ThrowIfNull(world); + + CancelPendingTasks(); + _executor = null; + _nextExecutor = null; + _goal = goal; + _replanCount = 0; + _isInitialPlan = true; + + int sx = (int)Math.Floor(startPos.X); + int sy = (int)Math.Floor(startPos.Y); + int sz = (int)Math.Floor(startPos.Z); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) + sy++; + + _debugLog?.Invoke($"[PathMgr] Initial plan kicked off from ({sx},{sy},{sz}) to {goal}"); + + _pendingReplanCts = new CancellationTokenSource(); + CancellationToken token = _pendingReplanCts.Token; + Action? debugLog = _debugLog; + long budget = timeoutMs; + _pendingReplan = Task.Run(() => + { + var finder = new AStarPathFinder { DebugLog = debugLog }; + return finder.Calculate(ctx, sx, sy, sz, goal, token, budget); + }, token); + } + public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { DrainPendingReplan(pos, world); @@ -93,6 +154,9 @@ namespace MinecraftClient.Pathing.Execution return; } + if (DiagnosticsEnabled) + RecordDiagnosticsSample(pos, physics); + var state = _executor.Tick(pos, physics, input, world); switch (state) @@ -102,6 +166,8 @@ namespace MinecraftClient.Pathing.Execution break; case PathExecutorState.Failed: + if (DiagnosticsEnabled) + EmitSegmentFailureDiagnostics(pos); _infoLog?.Invoke("[PathMgr] Segment failed, replanning..."); // The prepared next path assumes we finished the current segment // cleanly, so drop it when we fail. @@ -116,6 +182,64 @@ namespace MinecraftClient.Pathing.Execution } } + private void RecordDiagnosticsSample(Location pos, PlayerPhysics physics) + { + if (_executor is null) + return; + int segIdx = _executor.CurrentIndex; + PathSegment? seg = _executor.CurrentSegment; + string segStr = seg is null + ? "none" + : $"seg{segIdx}/{_executor.TotalSegments} {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})"; + + // Emit an Info-level transition event whenever the executor steps to a + // new segment so the caller can reconstruct the full execution timeline + // without relying on the bounded tail buffer. Resets on plan install. + if (segIdx != _lastObservedSegmentIndex) + { + _lastObservedSegmentIndex = segIdx; + _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}")); + } + + _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}"); + while (_diagnosticsTail.Count > DiagnosticsTailSize) + _diagnosticsTail.Dequeue(); + } + + private void EmitSegmentFailureDiagnostics(Location pos) + { + if (_executor is null) + return; + PathSegment? seg = _executor.CurrentSegment; + int segIdx = _executor.CurrentIndex; + _infoLog?.Invoke($"[PathDiag] Failure context: pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) failingSeg={segIdx}/{_executor.TotalSegments} " + + (seg is null ? "seg=" : $"seg={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}")); + if (_diagnosticsTail.Count > 0) + { + _infoLog?.Invoke($"[PathDiag] Recent tick trace (last {_diagnosticsTail.Count}):"); + int i = 0; + foreach (string line in _diagnosticsTail) + _infoLog?.Invoke($"[PathDiag] t-{_diagnosticsTail.Count - i++ - 1}: {line}"); + } + } + + private void EmitPathDumpDiagnostics(string label, PathResult result, int startIdx = 0) + { + if (!DiagnosticsEnabled) + return; + _infoLog?.Invoke($"[PathDiag] {label}: {result.Path.Count} waypoints, status={result.Status}, nodes={result.NodesExplored}, time={result.ElapsedMs}ms"); + int count = result.Path.Count; + for (int i = 0; i < count; i++) + { + var node = result.Path[i]; + string move = i == 0 ? "Start" : node.MoveUsed.ToString(); + _infoLog?.Invoke($"[PathDiag] [{startIdx + i:D2}] {move,-22} ({node.X},{node.Y},{node.Z})"); + } + } + public void Cancel() { if (_executor is not null || _pendingReplan is not null || _nextExecutor is not null) @@ -254,10 +378,16 @@ namespace MinecraftClient.Pathing.Execution _pendingReplanCts = null; cts?.Dispose(); + bool isInitial = _isInitialPlan; + _isInitialPlan = false; + if (task.IsFaulted || task.IsCanceled) { - _infoLog?.Invoke("[PathMgr] Replan task failed or was cancelled."); - _observer?.OnReplanFailed(_replanCount, pos); + _infoLog?.Invoke(isInitial + ? "[PathMgr] Initial plan failed or was cancelled." + : "[PathMgr] Replan task failed or was cancelled."); + if (!isInitial) + _observer?.OnReplanFailed(_replanCount, pos); _goal = null; _executor = null; _nextExecutor = null; @@ -283,8 +413,11 @@ namespace MinecraftClient.Pathing.Execution if (result.Status == PathStatus.Failed || result.Path.Count < 2) { - _observer?.OnReplanFailed(_replanCount, pos); - _infoLog?.Invoke("[PathMgr] Replan failed -- no path found."); + if (!isInitial) + _observer?.OnReplanFailed(_replanCount, pos); + _infoLog?.Invoke(isInitial + ? $"[PathMgr] No path found (nodes={result.NodesExplored}, time={result.ElapsedMs}ms)." + : "[PathMgr] Replan failed -- no path found."); _executor = null; _nextExecutor = null; _goal = null; @@ -292,10 +425,25 @@ namespace MinecraftClient.Pathing.Execution } var segments = PathSegmentBuilder.FromPath(result.Path); - _observer?.OnReplanSucceeded(_replanCount, segments); - _executor = new PathExecutor(segments, _debugLog, _observer); - _nextExecutor = null; - _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); + _lastPlan = result; + _diagnosticsTail.Clear(); + _lastObservedSegmentIndex = -1; + if (isInitial) + { + _executor = new PathExecutor(segments, _debugLog, _observer); + _nextExecutor = null; + string partial = result.Status == PathStatus.Partial ? " (partial)" : ""; + _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments, nodes={result.NodesExplored}, time={result.ElapsedMs}ms{partial}"); + EmitPathDumpDiagnostics("Initial plan", result); + } + else + { + _observer?.OnReplanSucceeded(_replanCount, segments); + _executor = new PathExecutor(segments, _debugLog, _observer); + _nextExecutor = null; + _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); + EmitPathDumpDiagnostics($"Replan #{_replanCount} plan", result); + } } private void MaybeStartLookahead(World world) diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 777b4d96..dedb7818 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -20,6 +20,20 @@ namespace MinecraftClient.Pathing.Execution.Templates private const double EdgeCloseDistance = 1.2; private const double LateralAlignmentTolerance = 0.2; + // Diagonal-ascend velocity alignment constants. Live-server + // regression: when A* routes through an "island" diagonal 1-block + // riser whose preceding segment delivered axis-aligned ground + // momentum (e.g. a cardinal Traverse along +Z landing at the foot of + // a -X+Z+Y riser), the 1-tick sprint-jump boost cannot redirect the + // perpendicular component onto the diagonal and the bot overshoots + // the target along the cardinal axis. Before firing Jump we hold + // Forward/Sprint off for up to a small window so ground friction can + // decay the perpendicular component; if we have not aligned within + // the window we take off anyway so the bot never stalls on the + // source block indefinitely. + private const double DiagonalTakeoffMaxPerpVelocity = 0.08; + private const int DiagonalTakeoffMaxBrakeTicks = 6; + public Location ExpectedStart { get; } public Location ExpectedEnd { get; } @@ -29,6 +43,7 @@ namespace MinecraftClient.Pathing.Execution.Templates private Location _lastPos; private int _stuckTicks; private bool _initiatedJump; + private int _diagonalBrakeTicks; public AscendTemplate(PathSegment segment, PathSegment? nextSegment) { @@ -57,7 +72,16 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetYaw = TemplateHelper.CalculateYaw(dx, dz); float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); if (!groundedPrepareJumpHandoff) - physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + { + // Snap yaw on the first tick so we don't drift sideways while + // rotating from a stale orientation (e.g. after a teleport or a + // sharp turn transition). The Ascend template also already gates + // forward input on headingReady below, but snapping removes one + // source of wasted ticks for narrow 1-block staircases. + physics.Yaw = _tickCount == 1 + ? targetYaw + : TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + } physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); float headingPenalty = YawDifference(physics.Yaw, targetYaw); bool headingReady = headingPenalty <= 8.0; @@ -75,17 +99,69 @@ namespace MinecraftClient.Pathing.Execution.Templates bool laterallyAligned = sideDist <= LateralAlignmentTolerance; bool jumpReady; - if (HasHeadBonkClear(world)) + if (diagonalAscend) + { + // Diagonal Ascend only reaches this path when the search + // layer has cleared the move (cardinal split not + // feasible). The source-center to target-center distance + // is ~sqrt(2) blocks, so the cardinal closeToEdge / + // sideDist gates below never fire and would stall the + // jump indefinitely; the bot must leap from the source + // block center as soon as its heading is aligned with + // the diagonal AND the horizontal velocity is close to + // the diagonal direction. If the bot arrives with strong + // cardinal momentum from a preceding Traverse (the + // common case for wall-shoulder islands), fire one or + // more ground ticks with Forward/Sprint released so + // friction can decay the perpendicular component before + // takeoff. Without this the preserved cardinal momentum + // leaks the landing footprint off the target block. + if (!headingReady) + { + jumpReady = false; + } + else + { + double diagLen = Math.Sqrt( + (double)_segment.HeadingX * _segment.HeadingX + + (double)_segment.HeadingZ * _segment.HeadingZ); + double dirX = _segment.HeadingX / diagLen; + double dirZ = _segment.HeadingZ / diagLen; + double vx = physics.DeltaMovement.X; + double vz = physics.DeltaMovement.Z; + double perpMag = Math.Abs(vx * dirZ - vz * dirX); + if (perpMag > DiagonalTakeoffMaxPerpVelocity + && _diagonalBrakeTicks < DiagonalTakeoffMaxBrakeTicks) + { + // Suppress this tick's acceleration so vanilla + // ground friction (~0.546/tick) alone decays the + // perpendicular component, and nudge the back + // input if the velocity is dominantly in the + // perpendicular direction - the back-input vector + // is along -yaw which is the reverse of the + // diagonal, cancelling the perpendicular faster + // than friction alone for high-speed entries. + input.Forward = false; + input.Sprint = false; + double along = vx * dirX + vz * dirZ; + if (perpMag > Math.Abs(along)) + input.Back = true; + _diagonalBrakeTicks++; + jumpReady = false; + } + else + { + jumpReady = true; + } + } + } + else if (HasHeadBonkClear(world)) { // Vertical head-room above the source block is clear, so starting the // jump early is safe and actually makes the short hop more reliable // (matches Baritone's "headBonkClear" shortcut). jumpReady = headingReady; } - else if (diagonalAscend) - { - jumpReady = headingReady; - } else { // Mirror Baritone's gate: only jump when close to the riser and diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 5eadda33..3cee5861 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -62,13 +62,46 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); + // Snap yaw on the first tick to avoid a few ticks of sideways drift + // when the bot enters this segment with a stale orientation (e.g. + // just after a teleport or after a turn). Ledge-adjacent descends + // cannot tolerate drift without falling off the wrong side. + if (_tickCount == 1) + physics.Yaw = targetYaw; + if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 1.0 : 0.6)) { TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment); + + // Fallback: after a diagonal descend landing the bot can end + // up on a support block that is not yet the target block + // (footprint still off the landing column). The braking + // planner reads "remaining <= coastStop + lead" and returns + // Coast, which zeroes every input - if the bot has already + // come to rest this means the segment hangs forever and the + // pathing manager replans. When we are stopped, not inside + // the target block, and not being asked to brake, walk + // toward the target instead of coasting so the landing + // resolves in one tick-window. + if (!decision.HoldBack + && !TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + && horizDistSq > 0.01 + && TemplateHelper.GetHorizontalSpeed(physics) < 0.03) + { + float walkYaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, walkYaw); + input.Forward = true; + input.Sprint = _needsSprint; + + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + return TemplateState.InProgress; + } + if (horizDistSq > 0.01 && !decision.HoldBack) { - bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) - || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment); float groundedYaw = onOrPastTarget ? TemplateHelper.GetExitHeadingYaw(_segment) : TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment) @@ -96,8 +129,27 @@ namespace MinecraftClient.Pathing.Execution.Templates { bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment); - bool biasTowardExitInAir = onOrPastTarget - || (_hasFallen && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)); + // Airborne bias toward the exit heading is only safe when + // the bot has effectively finished the current segment's + // horizontal travel: either the footprint is inside the + // landing block, or the vertical drop is small enough that + // lateral drift cannot miss the 1x1 landing column. For + // multi-block drops the bot is in the air for 8+ ticks; + // rotating yaw mid-fall (e.g. after crossing the end plane + // but still 1-2 blocks above landing) pushes sprint/walk + // momentum perpendicular to the segment and drifts the bot + // off the landing column into the void. Keep yaw pointed + // at the landing center through the whole fall on multi-Y + // descends; GroundedSegmentController rotates yaw once the + // bot is actually standing on the landing column. + double segmentYDrop = _segment.Start.Y - _segment.End.Y; + bool isSingleStepDescend = segmentYDrop <= 1.0; + bool footInsideTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd); + bool biasTowardExitInAir = footInsideTarget + || (isSingleStepDescend + && (onOrPastTarget + || (_hasFallen + && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)))); float airborneYaw = biasTowardExitInAir ? TemplateHelper.GetExitHeadingYaw(_segment) : targetYaw; @@ -117,7 +169,30 @@ namespace MinecraftClient.Pathing.Execution.Templates else { TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); - if (_segment.ExitHints.AllowAirBrake) + // Multi-block descend overshoot guard: when the + // fall spans 2+ Y blocks, sprint momentum will + // carry the bot roughly one extra horizontal + // block past the planned landing. If the next + // segment prepares a jump (PrepareJump exit) the + // bot MUST land inside the planned 1x1 landing + // column so the jump takeoff has a valid footing; + // overshooting drops into the void or onto a + // block 1-2 tiers below, breaking the jump. + // Once airborne and past the landing end-plane, + // release forward input so sprint momentum decays + // via air drag over the final 1-2 ticks of fall, + // pulling the bot back into the landing column. + bool riskyOvershoot = _hasFallen + && segmentYDrop >= 2.0 + && onOrPastTarget + && _segment.ExitTransition == PathTransitionType.PrepareJump; + if (riskyOvershoot) + { + input.Forward = false; + input.Sprint = false; + input.Back = true; + } + else if (_segment.ExitHints.AllowAirBrake) { TemplateHelper.ApplyDecision(input, decision); if (decision.HoldForward && _needsSprint) diff --git a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs index 516a5686..13e67da4 100644 --- a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +++ b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs @@ -25,13 +25,36 @@ namespace MinecraftClient.Pathing.Execution.Templates return; } - if (TemplateHelper.ShouldBiasTowardExitHeading(pos, segment)) - TemplateHelper.FaceExitHeading(physics, segment); - + // Compute the braking decision first so rotation and input stay + // consistent. Applying the exit-heading bias while we are still + // braking causes the Back input (which acts opposite to yaw) to + // push the bot perpendicular to the segment line, which on narrow + // 1-block walkways turns into a side-off-the-edge step. Stay on + // the segment heading for as long as we are braking and only let + // the bias rotate us once the brake has released. TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world); TemplateHelper.ApplyDecision(input, decision); if (decision.HoldBack) + { TemplateHelper.FaceSegmentHeading(physics, segment); + return; + } + + // On stable-footing Turn exits (the next segment is a walk-like + // move, not a jump) the next template snaps yaw instantly on + // its first tick, so pre-rotating here is unnecessary. On + // narrow 1-block walkways the bias combined with along-segment + // momentum pushes the bot perpendicular to the walkway and + // walks it off the edge (the bot sprint-drifts diagonally + // while yaw rotates ~45 deg mid-stride). For Turn exits into + // a jump (RequireJumpReady) we still need to align yaw before + // takeoff, so keep the bias there. + bool suppressBiasForSafeTurn = segment.ExitTransition == PathTransitionType.Turn + && segment.ExitHints.RequireStableFooting + && !segment.ExitHints.RequireJumpReady; + if (!suppressBiasForSafeTurn + && TemplateHelper.ShouldBiasTowardExitHeading(pos, segment)) + TemplateHelper.FaceExitHeading(physics, segment); } internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics) @@ -61,8 +84,26 @@ namespace MinecraftClient.Pathing.Execution.Templates } double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, segment); - bool headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) - <= (segment.ExitHints.RequireJumpReady ? 8.0 : 15.0); + // On Turn exits we deliberately do NOT pre-rotate yaw toward the + // next segment's heading (see Apply() above). The next segment's + // template snaps yaw on its first tick, so measuring heading + // readiness against the exit heading here would deadlock the + // handoff (bot is still facing segment heading, would never pass + // the 15 deg gate). Measure against segment heading for Turn + // exits with stable footing where yaw will be snapped anyway. + bool headingReady; + if (segment.ExitTransition == PathTransitionType.Turn + && segment.ExitHints.RequireStableFooting + && !segment.ExitHints.RequireJumpReady) + { + headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment.HeadingX, segment.HeadingZ) <= 25.0 + || TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) <= 15.0; + } + else + { + headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) + <= (segment.ExitHints.RequireJumpReady ? 8.0 : 15.0); + } if (!headingReady) return false; diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 1483ca00..1bb1e7a2 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -18,6 +18,7 @@ namespace MinecraftClient.Pathing.Execution.Templates private int _tickCount; private Location _lastPos; private int _stuckTicks; + private int _airborneTicks; public WalkTemplate(PathSegment segment, PathSegment? nextSegment) { @@ -35,11 +36,42 @@ namespace MinecraftClient.Pathing.Execution.Templates double dx = ExpectedEnd.X - pos.X; double dz = ExpectedEnd.Z - pos.Z; double dy = ExpectedEnd.Y - pos.Y; - float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment) - ? TemplateHelper.GetExitHeadingYaw(_segment) - : TemplateHelper.CalculateYaw(dx, dz); + // While approaching the end, steer via pos->end so lateral drift self- + // corrects. Once the center has entered the target block the pos->end + // vector becomes tiny/negative and flips yaw by ~180 degrees, which + // fights GroundedSegmentController's exit-heading rotation and locks + // yaw at a local equilibrium (e.g. 333 deg on a 1,1 diagonal) where + // HeadingPenalty never drops below the 8 deg ShouldComplete gate. + // Fall back to the stable quantized segment heading once inside the + // target block so the completion check and exit rotation converge. + // Skip the exit-heading bias on stable-footing Turn exits: it + // rotates yaw mid-segment while the bot still has along-segment + // momentum, which on a 1-block walkway drifts the bot + // perpendicular and walks it off the edge. The next segment's + // template snaps yaw on its first tick, so nothing is lost by + // deferring the rotation. Keep the bias when the next segment + // is a jump (RequireJumpReady): we need yaw aligned before + // takeoff or the jump direction will be off. + bool suppressBiasForSafeTurn = _segment.ExitTransition == PathTransitionType.Turn + && _segment.ExitHints.RequireStableFooting + && !_segment.ExitHints.RequireJumpReady; + float targetYaw; + if (!suppressBiasForSafeTurn + && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)) + targetYaw = TemplateHelper.GetExitHeadingYaw(_segment); + else if (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, _segment.End)) + targetYaw = TemplateHelper.CalculateYaw(_segment.HeadingX, _segment.HeadingZ); + else + targetYaw = TemplateHelper.CalculateYaw(dx, dz); float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); - physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + // Snap yaw on the first tick so we don't push forward input while the + // bot is still rotating from whatever yaw it had before this segment + // started (e.g. a random post-teleport orientation). Baritone-style: + // the server accepts instant yaw updates and the narrow 1-block lanes + // in parkour courses don't tolerate 3 ticks of sideways drift. + physics.Yaw = _tickCount == 1 + ? targetYaw + : TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); @@ -51,6 +83,15 @@ namespace MinecraftClient.Pathing.Execution.Templates _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; + // Walk/Diagonal is a grounded move: if the bot is airborne for more + // than a handful of ticks the platform is gone beneath us (e.g. we + // rotated toward an exit heading on a narrow 1-block walkway and + // stepped off the edge). Fail fast so the replanner can recover + // before gravity carries the bot 10+ blocks out of position. + _airborneTicks = physics.OnGround ? 0 : _airborneTicks + 1; + if (_airborneTicks > 8) + return TemplateState.Failed; + int maxTicks = _segment.ExitTransition switch { PathTransitionType.ContinueStraight => 100, diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs index 8f0036e5..f1141f17 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs @@ -35,6 +35,19 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } + // The landing feet column must also be passable. Without this, a descend + // into a column whose y-1 block is solid (e.g. a 2-block thick platform top + // where (destX,y-1) is stone and (destX,y-2) is also stone) would be + // accepted: the solid y-2 floor satisfies CanWalkOn, the y/y+1 body space + // satisfies the step, but physically the bot just walks onto the solid + // y-1 block at the same feet level and the Descend template waits forever + // for a drop that can never happen -- producing an infinite replan loop. + if (!ctx.CanWalkThrough(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + // Don't descend from ladder/vine (unreliable) Material fromDown = ctx.GetMaterial(x, y - 1, z); if (fromDown.CanBeClimbedOn()) diff --git a/MinecraftClient/Pathing/Moves/JumpExpander.cs b/MinecraftClient/Pathing/Moves/JumpExpander.cs index 3c5858c6..b07abdb7 100644 --- a/MinecraftClient/Pathing/Moves/JumpExpander.cs +++ b/MinecraftClient/Pathing/Moves/JumpExpander.cs @@ -7,34 +7,49 @@ namespace MinecraftClient.Pathing.Moves; /// /// Dynamic expander for every move in the jump family (Walk, Step, -/// SprintJump, Sidewall). Iterates a declarative descriptor table and calls -/// for each entry without allocating -/// an IMove object per direction. +/// SprintJump, Sidewall). Walk, Step, diagonal SprintJump and Sidewall are +/// still driven by a declarative descriptor table that calls +/// one entry at a time. Cardinal +/// SprintJumps are produced by , a Baritone-style +/// near-to-far scan that emits at most one candidate per direction -- letting +/// A* re-probe from each landing instead of enumerating every (distance, +/// yDelta) combination. /// /// The hot path hoists per-node guards (AllowParkour, head clearance, /// takeoff material, adjacent-wall presence) and precomputes an 8-direction /// "first-step has no floor" table so entire descriptor groups can be /// rejected in O(1) before touching . Ordinary -/// ground-walking nodes skip all ~170 jump descriptors this way; nodes -/// without any adjacent wall skip all 112 sidewall descriptors. +/// ground-walking nodes skip every jump descriptor this way; nodes without +/// any adjacent wall skip all sidewall descriptors. /// public sealed class JumpExpander : IMoveExpander { + /// + /// Extra slots in the neighbor buffer for the 4 cardinal probes. Each + /// probe may emit up to one sprint-jump candidate per yDelta (4 total) + /// plus up to one sidewall candidate per (lateral sign, yDelta) (8 + /// total), so 4 directions * (4 + 8) = 48 slots. The probe almost never + /// fills every slot; this is a generous upper bound that keeps the + /// neighbor buffer stack-allocated. + /// + private const int CardinalProbeSlots = 48; + private static readonly JumpDescriptor[] _descriptors = BuildDescriptors(); - public int MaxNeighbors => _descriptors.Length; + public int MaxNeighbors => _descriptors.Length + CardinalProbeSlots; public int Expand(CalculationContext ctx, int x, int y, int z, Span buffer) { int count = 0; MoveResult result = default; - // ---- Per-node preconditions (shared by every SprintJump + Sidewall descriptor) ---- - // These are the first checks JumpFeasibility.Evaluate* would make. Hoisting - // them once turns ~170 method calls per node into one branch in the hot path. + // ---- Per-node preconditions shared by every jump-family move ---- + // These are the first checks JumpFeasibility.Evaluate* would make. + // Hoisting them once turns many method calls per node into one + // branch in the hot path. "canSprintTakeoff" gates both the + // descriptor loop's SprintJump entries and all four cardinal probes. bool jumpFamilyAllowed = ctx.AllowParkour && ctx.CanSprint; bool canSprintTakeoff = false; - bool hasAdjacentWall = false; if (jumpFamilyAllowed) { Material standingOn = ctx.GetMaterial(x, y - 1, z); @@ -43,9 +58,6 @@ public sealed class JumpExpander : IMoveExpander !standingOn.CanBeClimbedOn() && !atFeet.IsLiquid() && ctx.CanWalkThrough(x, y + 2, z); - - if (canSprintTakeoff) - hasAdjacentWall = HasAnyAdjacentWall(ctx, x, y, z); } // ---- Per-direction gap table (SprintJump only) ---- @@ -88,9 +100,8 @@ public sealed class JumpExpander : IMoveExpander } break; case JumpFlavor.Sidewall: - if (!canSprintTakeoff || !hasAdjacentWall) - continue; - break; + // Sidewall candidates are produced by ProbeCardinal now. + continue; default: break; } @@ -104,23 +115,254 @@ public sealed class JumpExpander : IMoveExpander if (count < buffer.Length) buffer[count++] = new MoveNeighbor(result, type); } + + // ---- Cardinal SprintJump probes (Baritone-style near-to-far scan) ---- + // Each cardinal direction probes distance 2..5 and emits at most one + // candidate (the closest feasible landing). A* re-probes from that + // landing to discover longer variants, which keeps the frontier small + // while preserving reachability. + if (canSprintTakeoff) + { + ProbeCardinal(ctx, x, y, z, +1, 0, directionGapOpen, buffer, ref count, ref result); + ProbeCardinal(ctx, x, y, z, -1, 0, directionGapOpen, buffer, ref count, ref result); + ProbeCardinal(ctx, x, y, z, 0, +1, directionGapOpen, buffer, ref count, ref result); + ProbeCardinal(ctx, x, y, z, 0, -1, directionGapOpen, buffer, ref count, ref result); + } + return count; } /// - /// Conservative O(1) short-circuit for the Sidewall family. Every sidewall - /// descriptor needs a solid block one lateral step from the takeoff at - /// y or y+1, i.e. at a cardinal neighbor. If all four - /// cardinal neighbors at both heights are walk-through, there is no wall - /// to cling to and all 112 sidewall descriptors can be skipped without - /// calling . + /// Scans a single cardinal direction (fx, fz) for both sprint-jump + /// and sidewall landings. The forward air corridor is swept once + /// (Baritone-style monotonic scan with early break on obstruction) and + /// every feasible landing shape shares that sweep. Per-(lateral, yDelta) + /// sidewall candidates use the same i iteration to locate their + /// landing on the lateral column, so a single O(5) scan replaces the + /// ~8 + 16 static descriptor entries this direction used to need. + /// + /// Instead of emitting the closest valid landing (Baritone's choice), + /// the probe records the farthest valid landing per shape bucket and + /// emits one candidate each. Preferring the longer jump keeps A*'s path + /// cost low and avoids chains of short d=2 parkour jumps that MCC's + /// template can overshoot when sprint momentum is carried over. /// - private static bool HasAnyAdjacentWall(CalculationContext ctx, int x, int y, int z) + private static void ProbeCardinal( + CalculationContext ctx, + int x, int y, int z, + int fx, int fz, + ReadOnlySpan directionGapOpen, + Span buffer, + ref int count, + ref MoveResult result) { - return !ctx.CanWalkThrough(x + 1, y, z) || !ctx.CanWalkThrough(x + 1, y + 1, z) - || !ctx.CanWalkThrough(x - 1, y, z) || !ctx.CanWalkThrough(x - 1, y + 1, z) - || !ctx.CanWalkThrough(x, y, z + 1) || !ctx.CanWalkThrough(x, y + 1, z + 1) - || !ctx.CanWalkThrough(x, y, z - 1) || !ctx.CanWalkThrough(x, y + 1, z - 1); + // If the first step has a floor, a cheaper Walk move covers this + // direction already (Baritone: "don't parkour if we could just + // traverse"). Use the precomputed gap table. + int firstStepIdx = ((fx + 1) * 3) + (fz + 1); + if (!directionGapOpen[firstStepIdx]) + return; + + // The first step's column (y, y+1) must be passable; without it the + // player hits a wall before leaving the takeoff block. (y+2 over the + // takeoff itself is guaranteed by canSprintTakeoff.) + int sx1 = x + fx; + int sz1 = z + fz; + if (!ctx.CanWalkThrough(sx1, y, sz1) || !ctx.CanWalkThrough(sx1, y + 1, sz1)) + return; + + // Lateral unit vectors perpendicular to (fx, fz). Positive and + // negative sides are tracked independently so the wall presence + // short-circuit applies per side. + int lxP, lzP, lxN, lzN; + if (fx != 0) + { + lxP = 0; lzP = +1; + lxN = 0; lzN = -1; + } + else + { + lxP = +1; lzP = 0; + lxN = -1; lzN = 0; + } + + // Sidewall needs a solid block immediately lateral to the takeoff + // (step=0 in HasSidewallArcClearance). If that cell is walk-through + // at both y and y+1, no sidewall candidate from this takeoff can + // succeed along that lateral sign. + bool wallP = !ctx.CanWalkThrough(x + lxP, y, z + lzP) + || !ctx.CanWalkThrough(x + lxP, y + 1, z + lzP); + bool wallN = !ctx.CanWalkThrough(x + lxN, y, z + lzN) + || !ctx.CanWalkThrough(x + lxN, y + 1, z + lzN); + + // Farthest valid i for each sprint-jump shape. + int bestAscend = 0; + int bestFlat = 0; + int bestDescend1 = 0; + int bestDescend2 = 0; + + // Farthest valid i per (lateral sign, yDelta) for sidewall. + // yDelta indices: 0=+1, 1=0, 2=-1, 3=-2. + int bestSwP0 = 0, bestSwP1 = 0, bestSwP2 = 0, bestSwP3 = 0; + int bestSwN0 = 0, bestSwN1 = 0, bestSwN2 = 0, bestSwN3 = 0; + + const int MaxJumpDistance = 5; + for (int i = 2; i <= MaxJumpDistance; i++) + { + int dx = x + fx * i; + int dz = z + fz * i; + + // Shared head-height air corridor. If blocked the whole arc is + // interrupted; every larger i is also unreachable for both + // sprint jump and sidewall. + if (!ctx.CanWalkThrough(dx, y + 1, dz) || !ctx.CanWalkThrough(dx, y + 2, dz)) + break; + + if (!ctx.CanWalkThrough(dx, y, dz)) + { + // Foot-height is blocked. Only sprint-jump ascend is + // potentially viable here, and only for i <= 3. Sidewall's + // HasSidewallArcClearance requires a clear forward column + // at every step, so no sidewall candidate survives past + // this obstruction either. + if (i <= 3 && ctx.CanWalkOn(dx, y, dz)) + bestAscend = i; + break; + } + + // Foot-height is clear; record the best forward-axis landing. + if (ctx.CanWalkOn(dx, y - 1, dz)) + bestFlat = i; + else if (ctx.CanWalkOn(dx, y - 2, dz)) + bestDescend1 = i; + else if (ctx.CanWalkOn(dx, y - 3, dz)) + bestDescend2 = i; + + // Sidewall candidates land on the lateral column. The forward + // corridor has already been validated above; HasSidewallArc- + // Clearance's wall-depth and outside-lateral checks are deferred + // to EvaluateSidewall. + if (wallP) + TrackSidewallCandidates(ctx, dx, y, dz, lxP, lzP, i, + ref bestSwP0, ref bestSwP1, ref bestSwP2, ref bestSwP3); + if (wallN) + TrackSidewallCandidates(ctx, dx, y, dz, lxN, lzN, i, + ref bestSwN0, ref bestSwN1, ref bestSwN2, ref bestSwN3); + } + + // Emit sprint-jump bests (MoveType.Parkour). + if (bestAscend > 0) + TryEmitSprintJump(ctx, x, y, z, fx * bestAscend, fz * bestAscend, +1, buffer, ref count, ref result); + if (bestFlat > 0) + TryEmitSprintJump(ctx, x, y, z, fx * bestFlat, fz * bestFlat, 0, buffer, ref count, ref result); + if (bestDescend1 > 0) + TryEmitSprintJump(ctx, x, y, z, fx * bestDescend1, fz * bestDescend1, -1, buffer, ref count, ref result); + if (bestDescend2 > 0) + TryEmitSprintJump(ctx, x, y, z, fx * bestDescend2, fz * bestDescend2, -2, buffer, ref count, ref result); + + // Emit sidewall bests, one candidate per (lateral sign, yDelta). + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, +1, bestSwP0, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, 0, bestSwP1, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, -1, bestSwP2, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, -2, bestSwP3, buffer, ref count, ref result); + + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, +1, bestSwN0, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, 0, bestSwN1, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, -1, bestSwN2, buffer, ref count, ref result); + EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, -2, bestSwN3, buffer, ref count, ref result); + } + + /// + /// Cheap per-i pre-check for sidewall candidates. Updates the + /// per-yDelta "farthest valid i" buckets whenever the lateral landing + /// column matches the y offset. The expensive full feasibility check + /// ( etc.) is + /// still performed by + /// on emission; this pre-check just filters out trivially-impossible + /// iterations so Evaluate runs at most 8 times per direction. + /// + private static void TrackSidewallCandidates( + CalculationContext ctx, + int dx, int y, int dz, + int lateralX, int lateralZ, + int i, + ref int bestPlus1, + ref int bestFlat, + ref int bestMinus1, + ref int bestMinus2) + { + int lx = dx + lateralX; + int lz = dz + lateralZ; + + // yDelta = +1 (ascend). Only meaningful for i <= 3. + if (i <= 3 + && ctx.CanWalkOn(lx, y, lz) + && ctx.CanWalkThrough(lx, y + 1, lz) + && ctx.CanWalkThrough(lx, y + 2, lz)) + { + bestPlus1 = i; + } + + // Destination column body clearance at flat/descend heights. + if (!ctx.CanWalkThrough(lx, y, lz) || !ctx.CanWalkThrough(lx, y + 1, lz)) + return; + + if (ctx.CanWalkOn(lx, y - 1, lz)) + bestFlat = i; + else if (ctx.CanWalkOn(lx, y - 2, lz)) + bestMinus1 = i; + else if (ctx.CanWalkOn(lx, y - 3, lz)) + bestMinus2 = i; + } + + private static void EmitSidewallIfAny( + CalculationContext ctx, + int x, int y, int z, + int fx, int fz, + int lateralX, int lateralZ, + int yDelta, + int bestI, + Span buffer, + ref int count, + ref MoveResult result) + { + if (bestI <= 0) + return; + + int xOffset = fx * bestI + lateralX; + int zOffset = fz * bestI + lateralZ; + JumpDescriptor desc = new(xOffset, zOffset, yDelta, JumpFlavor.Sidewall); + result.Cost = 0; + JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result); + if (result.IsImpossible) + return; + + if (count < buffer.Length) + buffer[count++] = new MoveNeighbor(result, MoveType.Parkour); + } + + /// + /// Builds a cardinal descriptor for + /// the probed shape and delegates to . + /// The descriptor table and this probe share a single source of truth for + /// run-up, flight path, overshoot, cost, and entry preparation. + /// + private static void TryEmitSprintJump( + CalculationContext ctx, + int x, int y, int z, + int xOffset, int zOffset, int yDelta, + Span buffer, + ref int count, + ref MoveResult result) + { + JumpDescriptor desc = new(xOffset, zOffset, yDelta, JumpFlavor.SprintJump); + result.Cost = 0; + JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result); + if (result.IsImpossible) + return; + + if (count < buffer.Length) + buffer[count++] = new MoveNeighbor(result, MoveType.Parkour); } private static MoveType DeriveMoveType(JumpDescriptor d) => d.Flavor switch @@ -160,29 +402,8 @@ public sealed class JumpExpander : IMoveExpander } } - // Cardinal parkour (flat / +1 / -1 / -2) - foreach (int dx in offsets) - { - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(dx * d, 0, 0, JumpFlavor.SprintJump)); - for (int d = 2; d <= 3; d++) - list.Add(new JumpDescriptor(dx * d, 0, 1, JumpFlavor.SprintJump)); - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(dx * d, 0, -1, JumpFlavor.SprintJump)); - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(dx * d, 0, -2, JumpFlavor.SprintJump)); - } - foreach (int dz in offsets) - { - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(0, dz * d, 0, JumpFlavor.SprintJump)); - for (int d = 2; d <= 3; d++) - list.Add(new JumpDescriptor(0, dz * d, 1, JumpFlavor.SprintJump)); - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(0, dz * d, -1, JumpFlavor.SprintJump)); - for (int d = 2; d <= 5; d++) - list.Add(new JumpDescriptor(0, dz * d, -2, JumpFlavor.SprintJump)); - } + // Cardinal parkour is handled dynamically by ProbeCardinal; only the + // diagonal SprintJump variants remain as static descriptors. // Diagonal parkour foreach (int dx in offsets) @@ -205,37 +426,19 @@ public sealed class JumpExpander : IMoveExpander } } - // Sidewall parkour - foreach (int dx in offsets) - { - foreach (int dz in offsets) - { - foreach (int distance in new[] { 2, 3, 4, 5 }) - { - list.Add(new JumpDescriptor(dx, dz * distance, 0, JumpFlavor.Sidewall)); - list.Add(new JumpDescriptor(dx * distance, dz, 0, JumpFlavor.Sidewall)); - - if (distance <= 3) - { - list.Add(new JumpDescriptor(dx, dz * distance, 1, JumpFlavor.Sidewall)); - list.Add(new JumpDescriptor(dx * distance, dz, 1, JumpFlavor.Sidewall)); - } - - list.Add(new JumpDescriptor(dx, dz * distance, -1, JumpFlavor.Sidewall)); - list.Add(new JumpDescriptor(dx * distance, dz, -1, JumpFlavor.Sidewall)); - list.Add(new JumpDescriptor(dx, dz * distance, -2, JumpFlavor.Sidewall)); - list.Add(new JumpDescriptor(dx * distance, dz, -2, JumpFlavor.Sidewall)); - } - } - } + // Sidewall parkour is produced by ProbeCardinal alongside cardinal + // sprint jumps -- the probe shares a single forward-corridor scan + // with the sprint-jump candidates and emits a sidewall candidate + // whenever a lateral wall supports it. return list.ToArray(); } /// - /// Read-only snapshot of the descriptor table used by this expander. Exposed - /// for callers that need to enumerate the jump family directly (e.g. A*'s - /// sidewall-runup preparation logic). + /// Read-only snapshot of the descriptor table used by this expander. + /// Contains only moves that are enumerated statically (Walk, Step, + /// diagonal SprintJump); cardinal SprintJump and Sidewall are produced + /// dynamically by . /// public static ReadOnlySpan Descriptors => _descriptors; } diff --git a/MinecraftClient/Pathing/Moves/JumpFeasibility.cs b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs index afd783ef..86793c45 100644 --- a/MinecraftClient/Pathing/Moves/JumpFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs @@ -165,6 +165,27 @@ internal static class JumpFeasibility return; } + // Baritone-parity gate (MovementDiagonal.cost @197-200): when either + // cardinal shoulder also has solid ground below (i.e. the bot could + // walk that way first and then do a plain cardinal Ascend), refuse + // the diagonal Ascend. Executing a diagonal Ascend requires the + // bot's ground-speed momentum to already point along the diagonal at + // the moment of takeoff; when the preceding segment is a cardinal + // Walk the momentum is axis-aligned and the 2-tick yaw/input rotation + // during the handoff cannot redirect enough horizontal motion, so the + // bot consistently overshoots the target block. Forcing A* to spend + // the extra ~0.4 cost of a cardinal Walk + cardinal Ascend pair + // eliminates that execution failure while still leaving true "only + // reachable diagonally" setups (no cardinal floor support) on the + // table for scenarios that explicitly test the diagonal Step graph. + bool cardinalWalkableViaX = pathViaX && ctx.CanWalkOn(x + dx, y - 1, z); + bool cardinalWalkableViaZ = pathViaZ && ctx.CanWalkOn(x, y - 1, z + dz); + if (cardinalWalkableViaX || cardinalWalkableViaZ) + { + result.SetImpossible(); + return; + } + double diagCost = ctx.SprintCost * ActionCosts.DiagonalMultiplier + ctx.JumpPenalty; result.Set(destX, destY, destZ, diagCost); } diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs index 765b25d7..a1ef655f 100644 --- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -284,7 +284,16 @@ internal static class ParkourFeasibility int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); int insideWallDepth = 0; - for (int step = 0; step < 2; step++) + // Probe up to MaxProbeDepth cells along the forward axis at the lateral + // column to measure how thick the inner wall is. A 1- or 2-thick wall + // was the original supported case; thicker walls (3) still let the + // sidewall arc play out because the wall only provides lateral support + // during the sprint-jump — the player brushes the wall longer but the + // forward reach is unchanged. Walls thicker than MaxProbeDepth are + // rejected because they either bury the landing column or leave no + // open air for the arc to complete. + const int MaxProbeDepth = 3; + for (int step = 0; step < MaxProbeDepth; step++) { int wx = x + lateralX + (forwardX * step); int wz = z + lateralZ + (forwardZ * step); @@ -293,7 +302,7 @@ internal static class ParkourFeasibility insideWallDepth++; } - if (insideWallDepth is < 1 or > 2) + if (insideWallDepth is < 1 or > MaxProbeDepth) return false; for (int step = 1; step <= major; step++) diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index e3c200a0..11a851ae 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -3528,6 +3528,42 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Planning path in background (budget: {0}ms)... result will appear in the log. + /// + internal static string cmd_goto_planning { + get { + return ResourceManager.GetString("cmd.goto.planning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to toggle verbose pathing diagnostics (full plan dump, failure trace). + /// + internal static string cmd_pathdiag_desc { + get { + return ResourceManager.GetString("cmd.pathdiag.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pathing diagnostics enabled. Run /pathdiag off to disable. + /// + internal static string cmd_pathdiag_enabled { + get { + return ResourceManager.GetString("cmd.pathdiag.enabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pathing diagnostics disabled. + /// + internal static string cmd_pathdiag_disabled { + get { + return ResourceManager.GetString("cmd.pathdiag.disabled", resourceCulture); + } + } + /// /// Looks up a localized string similar to [PathMetric] routeStart segments={0}. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 542e62f7..e3b34fc6 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1252,6 +1252,18 @@ Change EnableEmoji=false in the settings if the display is confusing. No path found ({0} nodes explored in {1}ms) + + Planning path in background (budget: {0}ms)... result will appear in the log. + + + toggle verbose pathing diagnostics (full plan dump, failure trace). + + + Pathing diagnostics enabled. Run /pathdiag off to disable. + + + Pathing diagnostics disabled. + [PathMetric] routeStart segments={0} diff --git a/tools/pathing_data/momentum-capabilities.json b/tools/pathing_data/momentum-capabilities.json index 5e028d27..c922f44c 100644 --- a/tools/pathing_data/momentum-capabilities.json +++ b/tools/pathing_data/momentum-capabilities.json @@ -428,6 +428,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "ascend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 2, + "delta_y": 1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "ascend", @@ -441,6 +454,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "ascend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 12, + "max_reach": 3, + "delta_y": 1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "ascend", @@ -480,6 +506,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "ascend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 1, + "delta_y": 1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "ascend", @@ -493,6 +532,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "ascend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 12, + "max_reach": 2, + "delta_y": 1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -532,6 +584,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 4, + "delta_y": -2.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -545,6 +610,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 12, + "max_reach": 5, + "delta_y": -2.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -584,6 +662,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 1, + "max_reach": 4, + "delta_y": -1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -597,6 +688,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 2, + "max_mm": 12, + "max_reach": 5, + "delta_y": -1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -649,6 +753,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 2, + "delta_y": -2.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -662,6 +779,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 2, + "max_reach": 3, + "delta_y": -2.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -675,6 +805,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 3, + "max_mm": 12, + "max_reach": 4, + "delta_y": -2.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -714,6 +857,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 2, + "delta_y": -1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "descend", @@ -727,6 +883,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "descend", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 12, + "max_reach": 3, + "delta_y": -1.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "flat", @@ -766,6 +935,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "flat", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 0, + "max_reach": 3, + "delta_y": 0.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "flat", @@ -779,6 +961,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "flat", + "movement_mode": "sprint", + "capability_metric": "gap_blocks", + "min_mm": 1, + "max_mm": 12, + "max_reach": 4, + "delta_y": 0.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "flat", @@ -818,6 +1013,19 @@ "wall_offset": 1, "notes": "" }, + { + "family": "sidewall", + "subfamily": "flat", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 0, + "max_mm": 1, + "max_reach": 2, + "delta_y": 0.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" + }, { "family": "sidewall", "subfamily": "flat", @@ -830,5 +1038,18 @@ "ceiling_height": null, "wall_offset": 1, "notes": "" + }, + { + "family": "sidewall", + "subfamily": "flat", + "movement_mode": "walk", + "capability_metric": "gap_blocks", + "min_mm": 2, + "max_mm": 12, + "max_reach": 3, + "delta_y": 0.0, + "ceiling_height": null, + "wall_offset": 2, + "notes": "" } ]