From e23037a89729be35a708c26a0f3818d04860350f Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 18 Apr 2026 16:26:15 +0000 Subject: [PATCH] feat: add sidewall parkour planner support --- .../Execution/LivePathingRegressionTests.cs | 29 ++++ .../Pathing/Moves/MoveSidewallParkourTests.cs | 51 +++++++ .../Pathing/Core/AStarPathFinder.cs | 24 ++++ .../Pathing/Moves/Impl/MoveSidewallParkour.cs | 104 ++++++++++++++ .../Pathing/Moves/ParkourFeasibility.cs | 134 ++++++++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs index a3c38288..a93f30eb 100644 --- a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -207,6 +207,35 @@ public sealed class LivePathingRegressionTests Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); } + [Theory] + [MemberData(nameof(SidewallParkourScenarioBuilder.AcceptedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] + public void AStar_SidewallAcceptedCases_PlanThroughAllThreeJumps(string scenarioId, int gap, int deltaY, int wallOffset) + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + List segments = PathSegmentBuilder.FromPath(result.Path); + + Assert.Equal(PathStatus.Success, result.Status); + Assert.Equal(3, segments.FindAll(segment => segment.MoveType == MoveType.Parkour).Count); + Assert.All( + segments.Where(segment => segment.MoveType == MoveType.Parkour), + segment => Assert.Equal(ParkourProfile.Sidewall, segment.ParkourProfile)); + Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X); + Assert.Equal(scenario.Goal.Y, segments[^1].End.Y); + Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z); + } + + [Theory] + [MemberData(nameof(SidewallParkourScenarioBuilder.RejectedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] + public void AStar_SidewallRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY, int wallOffset) + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + + Assert.Equal(PathStatus.Failed, result.Status); + Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); + } + [Fact] public void AStar_LiveCoordinateLinearDescendGap3DyMinus2_PlansThroughAllThreeJumps() { diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs new file mode 100644 index 00000000..77329359 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs @@ -0,0 +1,51 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves.Impl; +using MinecraftClient.Tests.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +public sealed class MoveSidewallParkourTests +{ + [Theory] + [InlineData("sidewall-flat-gap2-wo0", 2, 0, 0)] + [InlineData("sidewall-flat-gap3-wo1", 3, 0, 1)] + [InlineData("sidewall-ascend-gap2-dy+1-wo0", 2, 1, 0)] + [InlineData("sidewall-ascend-gap3-dy+1-wo1", 3, 1, 1)] + [InlineData("sidewall-descend-gap2-dy-1-wo0", 2, -1, 0)] + [InlineData("sidewall-descend-gap3-dy-1-wo1", 3, -1, 1)] + [InlineData("sidewall-descend-gap2-dy-2-wo0", 2, -2, 0)] + [InlineData("sidewall-descend-gap3-dy-2-wo1", 3, -2, 1)] + public void Calculate_AcceptsTheoryAllowedCases(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.False(result.IsImpossible, scenarioId); + Assert.Equal(ParkourProfile.Sidewall, result.ParkourProfile); + } + + [Theory] + [InlineData("sidewall-flat-gap5-wo0", 5, 0, 0)] + [InlineData("sidewall-flat-gap5-wo1", 5, 0, 1)] + [InlineData("sidewall-ascend-gap4-dy+1-wo0", 4, 1, 0)] + [InlineData("sidewall-ascend-gap4-dy+1-wo1", 4, 1, 1)] + [InlineData("sidewall-descend-gap6-dy-1-wo0", 6, -1, 0)] + [InlineData("sidewall-descend-gap6-dy-2-wo1", 6, -2, 1)] + public void Calculate_RejectsTheoryForbiddenCases(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.True(result.IsImpossible, scenarioId); + } +} diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 1b6d9112..a812db43 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -96,6 +96,30 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveParkour(0, dz * dist, yDelta: -2)); } + // Sidewall parkour: dominant-axis sprint jumps with a one-block lateral offset. + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + foreach (int distance in new[] { 2, 3, 4, 5 }) + { + moves.Add(new MoveSidewallParkour(dx, dz * distance)); + moves.Add(new MoveSidewallParkour(dx * distance, dz)); + + if (distance <= 3) + { + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: 1)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: 1)); + } + + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -1)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -1)); + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -2)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -2)); + } + } + } + // Diagonal parkour: sprint jumps at angles. // Only include combinations with actual distance <= ~3.2 blocks (conservative) foreach (int dx in offsets) diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs new file mode 100644 index 00000000..d81ab65b --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs @@ -0,0 +1,104 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + public sealed class MoveSidewallParkour : IMove + { + public MoveType Type => MoveType.Parkour; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + private readonly int _yDelta; + + public MoveSidewallParkour(int xOffset, int zOffset, int yDelta = 0) + { + XOffset = xOffset; + ZOffset = zOffset; + _yDelta = yDelta; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.AllowParkour || !ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.AllowParkourAscend) + { + result.SetImpossible(); + return; + } + + if (_yDelta < 0 && -_yDelta > ctx.MaxFallHeight) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.IsSidewallProfile(XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + Material standingOn = ctx.GetMaterial(x, y - 1, z); + if (standingOn.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + Material atFeet = ctx.GetMaterial(x, y, z); + if (atFeet.IsLiquid()) + { + result.SetImpossible(); + return; + } + + ParkourFeasibility.GetSidewallAxes(XOffset, ZOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ); + + int destX = x + XOffset; + int destY = y + _yDelta; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasDominantAxisRunUp(ctx, x, y, z, forwardX, forwardZ, XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasSidewallArcClearance(ctx, x, y, z, forwardX, forwardZ, lateralX, lateralZ, XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasSidewallLandingClearance(ctx, destX, destY, destZ, forwardX, forwardZ, lateralX, lateralZ)) + { + result.SetImpossible(); + return; + } + + double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + double cost = _yDelta switch + { + > 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty * 2, + < 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty + ActionCosts.FallCost(-_yDelta), + _ => horizDist * ctx.SprintCost + ctx.JumpPenalty, + }; + + result.Set(destX, destY, destZ, cost, ParkourProfile.Sidewall); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs index d4f80fcf..c1b87a28 100644 --- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -5,6 +5,37 @@ namespace MinecraftClient.Pathing.Moves; internal static class ParkourFeasibility { + public static bool IsSidewallProfile(int xOffset, int zOffset, int yDelta) + { + int absX = Math.Abs(xOffset); + int absZ = Math.Abs(zOffset); + int major = Math.Max(absX, absZ); + int minor = Math.Min(absX, absZ); + + return minor == 1 + && major >= 2 + && major <= 5 + && yDelta is >= -2 and <= 1; + } + + public static void GetSidewallAxes(int xOffset, int zOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ) + { + if (Math.Abs(xOffset) > Math.Abs(zOffset)) + { + forwardX = Math.Sign(xOffset); + forwardZ = 0; + lateralX = 0; + lateralZ = Math.Sign(zOffset); + } + else + { + forwardX = 0; + forwardZ = Math.Sign(zOffset); + lateralX = Math.Sign(xOffset); + lateralZ = 0; + } + } + public static bool HasRunUp( CalculationContext ctx, int x, @@ -144,6 +175,109 @@ internal static class ParkourFeasibility return false; } + public static bool HasDominantAxisRunUp( + CalculationContext ctx, + int x, + int y, + int z, + int forwardX, + int forwardZ, + int xOffset, + int zOffset, + int yDelta) + { + int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + int maxMajor = yDelta switch + { + > 0 => 3, + < 0 => 5, + _ => 4, + }; + + if (major > maxMajor) + return false; + + bool carriedEntry = ctx.PreviousMoveType is MoveType.Parkour or MoveType.Descend; + if (carriedEntry) + return true; + + for (int i = 1; i <= 2; i++) + { + int rx = x - (forwardX * i); + int rz = z - (forwardZ * i); + if (!ctx.CanWalkOn(rx, y - 1, rz) || !IsColumnPassable(ctx, rx, y, rz)) + return false; + } + + return true; + } + + public static bool HasSidewallArcClearance( + CalculationContext ctx, + int x, + int y, + int z, + int forwardX, + int forwardZ, + int lateralX, + int lateralZ, + int xOffset, + int zOffset, + int yDelta) + { + int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + int insideWallDepth = 0; + + for (int step = 0; step < 2; step++) + { + int wx = x + lateralX + (forwardX * step); + int wz = z + lateralZ + (forwardZ * step); + if (ctx.CanWalkThrough(wx, y, wz) && ctx.CanWalkThrough(wx, y + 1, wz)) + break; + insideWallDepth++; + } + + if (insideWallDepth is < 1 or > 2) + return false; + + for (int step = 1; step <= major; step++) + { + int cx = x + (forwardX * step); + int cz = z + (forwardZ * step); + if (!IsColumnPassable(ctx, cx, y, cz)) + return false; + } + + int outsideX = x - lateralX; + int outsideZ = z - lateralZ; + return IsColumnPassable(ctx, outsideX, y, outsideZ); + } + + public static bool HasSidewallLandingClearance( + CalculationContext ctx, + int destX, + int destY, + int destZ, + int forwardX, + int forwardZ, + int lateralX, + int lateralZ) + { + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + return false; + + if (!IsColumnPassable(ctx, destX, destY, destZ)) + return false; + + if (!IsColumnPassable(ctx, destX + forwardX, destY, destZ + forwardZ)) + return false; + + if (!IsColumnPassable(ctx, destX - lateralX, destY, destZ - lateralZ)) + return false; + + return true; + } + private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z) { return ctx.CanWalkThrough(x, y, z)