diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs index 9088bb88..e17498f2 100644 --- a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs +++ b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs @@ -31,7 +31,7 @@ public sealed class MoveParkourTests var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); world.SetBlock(new Location(-1, FloorY, 0), Block.Air); var ctx = BuildContext(world); - var move = new MoveParkour(3, 0); + var move = MoveJump.Parkour(3, 0); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -45,7 +45,7 @@ public sealed class MoveParkourTests var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); world.SetBlock(new Location(1, FloorY, 0), Block.Air); var ctx = BuildContext(world); - var move = new MoveParkour(2, 0); + var move = MoveJump.Parkour(2, 0); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -60,7 +60,7 @@ public sealed class MoveParkourTests var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); world.SetBlock(new Location(1, FloorY, 0), Block.Air); var ctx = BuildContext(world); - var move = new MoveParkour(2, 0); + var move = MoveJump.Parkour(2, 0); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -74,7 +74,7 @@ public sealed class MoveParkourTests { var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); var ctx = BuildContext(world); - var move = new MoveParkour(2, 0); + var move = MoveJump.Parkour(2, 0); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -95,7 +95,7 @@ public sealed class MoveParkourTests FlatWorldTestBuilder.SetSolid(world, 2, FloorY + 2, -1); var ctx = BuildContext(world); - var move = new MoveParkour(2, 0); + var move = MoveJump.Parkour(2, 0); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -110,7 +110,7 @@ public sealed class MoveParkourTests world.SetBlock(new Location(1, FloorY + 1, 0), new Block(1)); world.SetBlock(new Location(1, FloorY + 2, 0), new Block(1)); var ctx = BuildContext(world); - var move = new MoveParkour(1, 1); + var move = MoveJump.Parkour(1, 1); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -128,7 +128,7 @@ public sealed class MoveParkourTests var ctx = BuildContext(world); SetPreviousMoveType(ctx, MoveType.Parkour); - var move = new MoveParkour(4, 0, yDelta: -1); + var move = MoveJump.Parkour(4, 0, yDelta: -1); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -147,7 +147,7 @@ public sealed class MoveParkourTests FlatWorldTestBuilder.SetSolid(world, 4, FloorY + 1, 0); var ctx = BuildContext(world); - var move = new MoveParkour(4, 0, yDelta: 1); + var move = MoveJump.Parkour(4, 0, yDelta: 1); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -164,7 +164,7 @@ public sealed class MoveParkourTests FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 1, 0); var ctx = BuildContext(world); - var move = new MoveParkour(6, 0, yDelta: -1); + var move = MoveJump.Parkour(6, 0, yDelta: -1); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -181,7 +181,7 @@ public sealed class MoveParkourTests FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 2, 0); var ctx = BuildContext(world); - var move = new MoveParkour(6, 0, yDelta: -2); + var move = MoveJump.Parkour(6, 0, yDelta: -2); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -199,7 +199,7 @@ public sealed class MoveParkourTests var ctx = BuildContext(world); SetPreviousMoveType(ctx, MoveType.Parkour); - var move = new MoveParkour(6, 0, yDelta: -1); + var move = MoveJump.Parkour(6, 0, yDelta: -1); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); @@ -217,11 +217,66 @@ public sealed class MoveParkourTests var ctx = BuildContext(world); SetPreviousMoveType(ctx, MoveType.Parkour); - var move = new MoveParkour(6, 0, yDelta: -2); + var move = MoveJump.Parkour(6, 0, yDelta: -2); var result = default(MoveResult); move.Calculate(ctx, 0, FloorY + 1, 0, ref result); Assert.True(result.IsImpossible); } + + // Diagonal ascending parkour: +1 block up with diagonal offset, covers + // the corner-step-up case seen in stepped pyramids where a straight + // MoveSidewallParkour would demand an adjacent wall that isn't present. + // Short (sqrt(5)) ascends work from a lone overhang block because a + // cold-start sprint jump reaches ~2.5 blocks horizontally; longer + // diagonals such as (2,2) require a runway and are exercised separately. + [Theory] + [InlineData(2, 1)] + [InlineData(1, 2)] + public void AcceptsDiagonalAscendingParkour_FromLoneStart(int dx, int dz) + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.ClearBox(world, -5, FloorY, -5, 10, FloorY + 5, 10); + + FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0); + int destFloorY = FloorY + 1; + FlatWorldTestBuilder.SetSolid(world, dx, destFloorY, dz); + + var ctx = BuildContext(world); + var move = MoveJump.Parkour(dx, dz, yDelta: 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible, $"diagonal ascend ({dx},{dz},+1) should plan from lone start"); + Assert.Equal(dx, result.DestX); + Assert.Equal(destFloorY + 1, result.DestY); + Assert.Equal(dz, result.DestZ); + } + + [Fact] + public void AcceptsDiagonalAscendingParkour_2x2_WithRunway() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.ClearBox(world, -5, FloorY, -5, 10, FloorY + 5, 10); + + // Diagonal runway behind the jump (opposite the jump direction) + // so HasRunUp's back-step check at (-1,-1) succeeds. + FlatWorldTestBuilder.SetSolid(world, -1, FloorY, -1); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0); + int destFloorY = FloorY + 1; + FlatWorldTestBuilder.SetSolid(world, 2, destFloorY, 2); + + var ctx = BuildContext(world); + var move = MoveJump.Parkour(2, 2, yDelta: 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible, "(2,2,+1) should plan with a straight runway behind the jump"); + Assert.Equal(2, result.DestX); + Assert.Equal(destFloorY + 1, result.DestY); + Assert.Equal(2, result.DestZ); + } } diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs index 77329359..4d1f6fca 100644 --- a/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs +++ b/MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs @@ -8,6 +8,21 @@ namespace MinecraftClient.Tests.Pathing.Moves; public sealed class MoveSidewallParkourTests { + [Theory] + [InlineData("sidewall-descend-gap5-dy-1-wo0", 5, 0)] + [InlineData("sidewall-descend-gap5-dy-1-wo1", 5, 1)] + public void Calculate_LongDescendStaticEntry_RejectsWithoutPreparedRunup(string scenarioId, int gap, int wallOffset) + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY: -1, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = MoveJump.Sidewall(dx: -1, dz: gap, yDelta: -1); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.True(result.IsImpossible, scenarioId); + } + [Theory] [InlineData("sidewall-flat-gap2-wo0", 2, 0, 0)] [InlineData("sidewall-flat-gap3-wo1", 3, 0, 1)] @@ -21,7 +36,7 @@ public sealed class MoveSidewallParkourTests { 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); + var move = MoveJump.Sidewall(dx: -1, dz: gap, yDelta: deltaY); MoveResult result = default; move.Calculate(ctx, 100, 80, 100, ref result); @@ -41,11 +56,80 @@ public sealed class MoveSidewallParkourTests { 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); + var move = MoveJump.Sidewall(dx: -1, dz: gap, yDelta: deltaY); MoveResult result = default; move.Calculate(ctx, 100, 80, 100, ref result); Assert.True(result.IsImpossible, scenarioId); } + + // Scenarios captured from the staircase / step-pyramid image where the start + // block is a lone, overhanging tread with no 2-block runway behind it. + // Physics allows a cold-start sprint-jump to clear ~3 blocks horizontally, + // so short sidewall gaps should still plan even without a runway. + [Theory] + [InlineData("sidewall-lone-start-flat-gap2-wo0", 2, 0, 0)] + [InlineData("sidewall-lone-start-flat-gap3-wo0", 3, 0, 0)] + [InlineData("sidewall-lone-start-flat-gap2-wo1", 2, 0, 1)] + [InlineData("sidewall-lone-start-ascend-gap2-dy+1-wo0", 2, 1, 0)] + [InlineData("sidewall-lone-start-descend-gap2-dy-1-wo0", 2, -1, 0)] + [InlineData("sidewall-lone-start-descend-gap3-dy-1-wo0", 3, -1, 0)] + public void Calculate_AcceptsLoneStart_ShortSidewallJumps(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = BuildLoneStartWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = MoveJump.Sidewall(dx: -1, dz: 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-lone-start-flat-gap4-wo0", 4, 0, 0)] + [InlineData("sidewall-lone-start-ascend-gap3-dy+1-wo0", 3, 1, 0)] + [InlineData("sidewall-lone-start-descend-gap4-dy-1-wo0", 4, -1, 0)] + public void Calculate_RejectsLoneStart_LongSidewallJumps(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = BuildLoneStartWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = MoveJump.Sidewall(dx: -1, dz: gap, yDelta: deltaY); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.True(result.IsImpossible, scenarioId); + } + + private static World BuildLoneStartWorld(int gap, int deltaY, int wallOffset) + { + const int startX = 100; + const int startY = 80; + const int startZ = 100; + int floorY = startY - 1; + int landX = startX - 1; + int landY = startY + deltaY; + int landZ = startZ + gap; + + World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: 80, max: landZ + 8); + FlatWorldTestBuilder.ClearBox(world, 90, 70, 90, 110, 96, landZ + 8); + + FlatWorldTestBuilder.SetSolid(world, startX, floorY, startZ); + + FlatWorldTestBuilder.FillSolid( + world, + landX, + Math.Min(floorY, landY - 1), + startZ, + landX, + Math.Max(floorY, landY - 1) + 7, + startZ + wallOffset); + + FlatWorldTestBuilder.SetSolid(world, landX, landY - 1, landZ); + + return world; + } } diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index a812db43..c6f6e832 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -10,15 +10,71 @@ namespace MinecraftClient.Pathing.Core { public sealed class AStarPathFinder { + private readonly record struct NodeKey(long PackedPosition, EntryPreparationState EntryPreparation); + private readonly IMove[] _allMoves; + private readonly IMoveExpander[] _expanders; + private readonly int _totalExpanderCapacity; private readonly int _maxChunkBorderFetch; public Action? DebugLog { get; set; } public AStarPathFinder(IMove[]? moves = null, int maxChunkBorderFetch = 64) + : this(BuildExpanders(moves), moves ?? BuildDefaultMoves(), maxChunkBorderFetch) { - _allMoves = moves ?? BuildDefaultMoves(); + } + + public AStarPathFinder(IMoveExpander[] expanders, int maxChunkBorderFetch = 64) + : this(expanders, System.Array.Empty(), maxChunkBorderFetch) + { + } + + private AStarPathFinder(IMoveExpander[] expanders, IMove[] allMoves, int maxChunkBorderFetch) + { + _expanders = expanders; + _allMoves = allMoves; _maxChunkBorderFetch = maxChunkBorderFetch; + + int total = 0; + for (int i = 0; i < expanders.Length; i++) + total += expanders[i].MaxNeighbors; + _totalExpanderCapacity = total; + } + + private static IMoveExpander[] BuildExpanders(IMove[]? explicitMoves) + { + if (explicitMoves is null) + { + return BuildDefaultExpanders(); + } + + // Caller supplied a specific move set (e.g. tests). Wrap it as a + // legacy expander so the old API keeps working. + return [new LegacyMoveExpander(explicitMoves)]; + } + + public static IMoveExpander[] BuildDefaultExpanders() + { + IMove[] legacyMoves = + [ + new MoveDescend(1, 0), + new MoveDescend(-1, 0), + new MoveDescend(0, 1), + new MoveDescend(0, -1), + new MoveSprintDescend(2, 0), + new MoveSprintDescend(-2, 0), + new MoveSprintDescend(0, 2), + new MoveSprintDescend(0, -2), + new MoveSprintDescend(1, 1), + new MoveSprintDescend(1, -1), + new MoveSprintDescend(-1, 1), + new MoveSprintDescend(-1, -1), + new MoveClimb(true), + new MoveClimb(false), + new MoveFall(), + ]; + + return [new JumpExpander(), new LegacyMoveExpander(legacyMoves)]; } public static IMove[] BuildDefaultMoves() @@ -26,121 +82,119 @@ namespace MinecraftClient.Pathing.Core var moves = new List(); int[] offsets = [1, -1]; + + // ---- jump family (all unified as MoveJump with a JumpDescriptor) ---- + + // Cardinal walk + 1-block ascend foreach (int dx in offsets) { - moves.Add(new MoveTraverse(dx, 0)); - moves.Add(new MoveAscend(dx, 0)); - moves.Add(new MoveDescend(dx, 0)); + moves.Add(MoveJump.Traverse(dx, 0)); + moves.Add(MoveJump.Ascend(dx, 0)); } foreach (int dz in offsets) { - moves.Add(new MoveTraverse(0, dz)); - moves.Add(new MoveAscend(0, dz)); - moves.Add(new MoveDescend(0, dz)); + moves.Add(MoveJump.Traverse(0, dz)); + moves.Add(MoveJump.Ascend(0, dz)); } - moves.Add(new MoveDiagonal(1, 1)); - moves.Add(new MoveDiagonal(1, -1)); - moves.Add(new MoveDiagonal(-1, 1)); - moves.Add(new MoveDiagonal(-1, -1)); - - // Diagonal ascend/descend: corner jumps and drops + // Diagonal walk + diagonal ascend/descend (corner cases) foreach (int dx in offsets) { foreach (int dz in offsets) { - moves.Add(new MoveDiagonalAscend(dx, dz)); - moves.Add(new MoveDiagonalDescend(dx, dz)); + moves.Add(MoveJump.Diagonal(dx, dz)); + moves.Add(MoveJump.DiagonalAscend(dx, dz)); + moves.Add(MoveJump.DiagonalDescend(dx, dz)); } } - moves.Add(new MoveClimb(true)); - moves.Add(new MoveClimb(false)); - - moves.Add(new MoveFall()); - - // Sprint descend: sprint off ledge, 2 blocks horizontal + 1-3 drop - foreach (int dx in offsets) - { - moves.Add(new MoveSprintDescend(dx * 2, 0)); - moves.Add(new MoveSprintDescend(dx, dx)); - moves.Add(new MoveSprintDescend(dx, -dx)); - } - foreach (int dz in offsets) - moves.Add(new MoveSprintDescend(0, dz * 2)); - - // Cardinal parkour: long sprint jumps along +-X and +-Z. - // Longer distances remain gated by MoveParkour feasibility and available runway/carry. + // Cardinal parkour (flat / +1 ascend / -1 -2 descend) foreach (int dx in offsets) { for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(dx * dist, 0)); - // Ascending cardinal parkour tops out at offset 3. + moves.Add(MoveJump.Parkour(dx * dist, 0)); for (int dist = 2; dist <= 3; dist++) - moves.Add(new MoveParkour(dx * dist, 0, yDelta: 1)); - // Descending cardinal parkour tops out at offset 5. + moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: 1)); for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(dx * dist, 0, yDelta: -1)); + moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: -1)); for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2)); + moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: -2)); } foreach (int dz in offsets) { for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(0, dz * dist)); + moves.Add(MoveJump.Parkour(0, dz * dist)); for (int dist = 2; dist <= 3; dist++) - moves.Add(new MoveParkour(0, dz * dist, yDelta: 1)); + moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: 1)); for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(0, dz * dist, yDelta: -1)); + moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: -1)); for (int dist = 2; dist <= 5; dist++) - moves.Add(new MoveParkour(0, dz * dist, yDelta: -2)); + moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: -2)); } - // Sidewall parkour: dominant-axis sprint jumps with a one-block lateral offset. + // Diagonal parkour (flat + diagonal ascending/descending) + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + moves.Add(MoveJump.Parkour(dx * 2, dz * 1)); + moves.Add(MoveJump.Parkour(dx * 1, dz * 2)); + moves.Add(MoveJump.Parkour(dx * 2, dz * 2)); + moves.Add(MoveJump.Parkour(dx * 3, dz * 1)); + moves.Add(MoveJump.Parkour(dx * 1, dz * 3)); + + moves.Add(MoveJump.Parkour(dx * 2, dz * 1, yDelta: -1)); + moves.Add(MoveJump.Parkour(dx * 1, dz * 2, yDelta: -1)); + moves.Add(MoveJump.Parkour(dx * 2, dz * 2, yDelta: -1)); + + moves.Add(MoveJump.Parkour(dx * 2, dz * 1, yDelta: 1)); + moves.Add(MoveJump.Parkour(dx * 1, dz * 2, yDelta: 1)); + moves.Add(MoveJump.Parkour(dx * 2, dz * 2, yDelta: 1)); + } + } + + // Sidewall parkour (dominant-axis sprint jumps using an inner wall) 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)); + moves.Add(MoveJump.Sidewall(dx, dz * distance)); + moves.Add(MoveJump.Sidewall(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(MoveJump.Sidewall(dx, dz * distance, yDelta: 1)); + moves.Add(MoveJump.Sidewall(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)); + moves.Add(MoveJump.Sidewall(dx, dz * distance, yDelta: -1)); + moves.Add(MoveJump.Sidewall(dx * distance, dz, yDelta: -1)); + moves.Add(MoveJump.Sidewall(dx, dz * distance, yDelta: -2)); + moves.Add(MoveJump.Sidewall(dx * distance, dz, yDelta: -2)); } } } - // Diagonal parkour: sprint jumps at angles. - // Only include combinations with actual distance <= ~3.2 blocks (conservative) + // ---- dynamic-landing family (kept separate: variable landing depth) ---- foreach (int dx in offsets) { - foreach (int dz in offsets) - { - // (2,1)/(1,2): sqrt(5) ~ 2.24 blocks - moves.Add(new MoveParkour(dx * 2, dz * 1)); - moves.Add(new MoveParkour(dx * 1, dz * 2)); - // (2,2): sqrt(8) ~ 2.83 blocks - moves.Add(new MoveParkour(dx * 2, dz * 2)); - // (3,1)/(1,3): sqrt(10) ~ 3.16 blocks - moves.Add(new MoveParkour(dx * 3, dz * 1)); - moves.Add(new MoveParkour(dx * 1, dz * 3)); - - // Diagonal descending parkour - moves.Add(new MoveParkour(dx * 2, dz * 1, yDelta: -1)); - moves.Add(new MoveParkour(dx * 1, dz * 2, yDelta: -1)); - moves.Add(new MoveParkour(dx * 2, dz * 2, yDelta: -1)); - } + moves.Add(new MoveDescend(dx, 0)); + moves.Add(new MoveSprintDescend(dx * 2, 0)); + moves.Add(new MoveSprintDescend(dx, dx)); + moves.Add(new MoveSprintDescend(dx, -dx)); } + foreach (int dz in offsets) + { + moves.Add(new MoveDescend(0, dz)); + moves.Add(new MoveSprintDescend(0, dz * 2)); + } + + // ---- vertical / free fall ---- + moves.Add(new MoveClimb(true)); + moves.Add(new MoveClimb(false)); + moves.Add(new MoveFall()); return [.. moves]; } @@ -170,7 +224,7 @@ namespace MinecraftClient.Pathing.Core var sw = Stopwatch.StartNew(); var openSet = new BinaryHeapOpenSet(4096); - var nodeMap = new Dictionary(4096); + var nodeMap = new Dictionary(4096); var startNode = new PathNode(startX, startY, startZ) { @@ -179,14 +233,19 @@ namespace MinecraftClient.Pathing.Core IsOpen = true }; openSet.Insert(startNode); - nodeMap[startNode.PackedPosition] = startNode; + nodeMap[new NodeKey(startNode.PackedPosition, startNode.EntryPreparation)] = startNode; int nodesExplored = 0; int unloadedChunkHits = 0; bool searchAborted = false; PathNode? bestPartialNode = startNode; double bestPartialScore = startNode.HCost + startNode.GCost * 0.5; - MoveResult moveResult = default; + + // Per-node scratch buffer for IMoveExpander output. Size = sum of + // MaxNeighbors across all expanders so no expander can overflow. + Span neighborBuffer = _totalExpanderCapacity <= 512 + ? stackalloc MoveNeighbor[_totalExpanderCapacity] + : new MoveNeighbor[_totalExpanderCapacity]; DebugLog?.Invoke($"[A*] Start ({startX},{startY},{startZ}), goal={goal}"); @@ -217,63 +276,73 @@ namespace MinecraftClient.Pathing.Core return new PathResult(PathStatus.Success, path, nodesExplored, sw.ElapsedMilliseconds); } - foreach (var move in _allMoves) + ctx.PreviousMoveType = current.MoveUsed; + ctx.CurrentEntryPreparation = current.EntryPreparation; + + int bufferOffset = 0; + for (int ex = 0; ex < _expanders.Length; ex++) { - ctx.PreviousMoveType = current.MoveUsed; - moveResult.Cost = 0; - move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); + IMoveExpander expander = _expanders[ex]; + Span slot = neighborBuffer.Slice(bufferOffset, expander.MaxNeighbors); + int produced = expander.Expand(ctx, current.X, current.Y, current.Z, slot); + bufferOffset += expander.MaxNeighbors; - if (moveResult.IsImpossible) - continue; - - int nx = moveResult.DestX; - int ny = moveResult.DestY; - int nz = moveResult.DestZ; - - if (!ctx.IsChunkLoaded(nx, nz)) + for (int i = 0; i < produced; i++) { - unloadedChunkHits++; - if (unloadedChunkHits > _maxChunkBorderFetch) - continue; - } + MoveNeighbor emitted = slot[i]; + int nx = emitted.DestX; + int ny = emitted.DestY; + int nz = emitted.DestZ; - double tentativeG = current.GCost + moveResult.Cost; - long packed = PathNode.Pack(nx, ny, nz); - - if (nodeMap.TryGetValue(packed, out var neighbor)) - { - if (neighbor.IsClosed) - continue; - if (tentativeG >= neighbor.GCost) - continue; - - neighbor.GCost = tentativeG; - neighbor.Parent = current; - neighbor.MoveUsed = move.Type; - neighbor.ParkourProfile = moveResult.ParkourProfile; - if (neighbor.IsOpen) - openSet.Update(neighbor); - } - else - { - neighbor = new PathNode(nx, ny, nz) + if (!ctx.IsChunkLoaded(nx, nz)) { - GCost = tentativeG, - HCost = goal.Heuristic(nx, ny, nz), - Parent = current, - MoveUsed = move.Type, - ParkourProfile = moveResult.ParkourProfile, - IsOpen = true - }; - nodeMap[packed] = neighbor; - openSet.Insert(neighbor); - } + unloadedChunkHits++; + if (unloadedChunkHits > _maxChunkBorderFetch) + continue; + } - double partialScore = neighbor.HCost + neighbor.GCost * 0.5; - if (partialScore < bestPartialScore) - { - bestPartialScore = partialScore; - bestPartialNode = neighbor; + double tentativeG = current.GCost + emitted.Cost; + EntryPreparationState nextPreparation = ResolveEntryPreparation( + current, emitted.MoveType, emitted.DestX, emitted.DestY, emitted.DestZ); + var key = new NodeKey(PathNode.Pack(nx, ny, nz), nextPreparation); + + if (nodeMap.TryGetValue(key, out var neighbor)) + { + if (neighbor.IsClosed) + continue; + if (tentativeG >= neighbor.GCost) + continue; + + neighbor.GCost = tentativeG; + neighbor.Parent = current; + neighbor.MoveUsed = emitted.MoveType; + neighbor.ParkourProfile = emitted.ParkourProfile; + neighbor.EntryPreparation = nextPreparation; + if (neighbor.IsOpen) + openSet.Update(neighbor); + } + else + { + neighbor = new PathNode(nx, ny, nz) + { + GCost = tentativeG, + HCost = goal.Heuristic(nx, ny, nz), + Parent = current, + MoveUsed = emitted.MoveType, + ParkourProfile = emitted.ParkourProfile, + EntryPreparation = nextPreparation, + IsOpen = true + }; + nodeMap[key] = neighbor; + openSet.Insert(neighbor); + } + + double partialScore = neighbor.HCost + neighbor.GCost * 0.5; + if (partialScore < bestPartialScore) + { + bestPartialScore = partialScore; + bestPartialNode = neighbor; + } } } } @@ -292,6 +361,118 @@ namespace MinecraftClient.Pathing.Core return PathResult.Fail(nodesExplored, sw.ElapsedMilliseconds); } + private EntryPreparationState ResolveEntryPreparation( + PathNode current, MoveType moveType, int destX, int destY, int destZ) + { + EntryPreparationState advanced = AdvanceExistingPreparation(current, moveType, destX, destY, destZ); + if (!advanced.IsNone) + return advanced; + + if (TryStartSidewallRunupPreparation(current, moveType, destX, destY, destZ, out EntryPreparationState started)) + return started; + + return EntryPreparationState.None; + } + + private static EntryPreparationState AdvanceExistingPreparation( + PathNode current, MoveType moveType, int destX, int destY, int destZ) + { + EntryPreparationState state = current.EntryPreparation; + if (state.IsNone) + return EntryPreparationState.None; + + if (moveType != MoveType.Traverse || destY != current.Y) + return EntryPreparationState.None; + + int stepX = destX - current.X; + int stepZ = destZ - current.Z; + + if (state.BackwardSteps < state.RequiredSteps + && stepX == -state.ForwardX + && stepZ == -state.ForwardZ) + { + return state.AdvanceBackward(); + } + + if (state.BackwardSteps == state.RequiredSteps + && state.ReturnSteps < state.RequiredSteps + && stepX == state.ForwardX + && stepZ == state.ForwardZ) + { + EntryPreparationState nextState = state.AdvanceReturn(); + if (nextState.IsPrepared + && (destX != state.OriginX + || destY != state.OriginY + || destZ != state.OriginZ)) + { + return EntryPreparationState.None; + } + + return nextState; + } + + return EntryPreparationState.None; + } + + private static bool TryStartSidewallRunupPreparation( + PathNode current, MoveType moveType, int destX, int destY, int destZ, out EntryPreparationState state) + { + state = EntryPreparationState.None; + + if (!current.EntryPreparation.IsNone + || moveType != MoveType.Traverse + || destY != current.Y) + { + return false; + } + + int stepX = destX - current.X; + int stepZ = destZ - current.Z; + + ReadOnlySpan descriptors = JumpExpander.Descriptors; + for (int i = 0; i < descriptors.Length; i++) + { + JumpDescriptor candidate = descriptors[i]; + if (candidate.Flavor != JumpFlavor.Sidewall) + continue; + + if (!ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps( + current.MoveUsed, + candidate.XOffset, + candidate.ZOffset, + candidate.YDelta, + 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, + current.Y, + current.Z, + forwardX, + forwardZ, + (byte)requiredSteps, + BackwardSteps: 1, + ReturnSteps: 0); + return true; + } + + return false; + } + private static List ReconstructPath(PathNode end) { var path = new List(); diff --git a/MinecraftClient/Pathing/Moves/IMoveExpander.cs b/MinecraftClient/Pathing/Moves/IMoveExpander.cs new file mode 100644 index 00000000..1d71aad2 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/IMoveExpander.cs @@ -0,0 +1,53 @@ +using System; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves; + +/// +/// A feasible neighbor emitted by an . Carries the +/// per-node data the A* main loop needs to update its open set (destination, +/// cost, parkour profile, move type). +/// +public readonly struct MoveNeighbor +{ + public readonly int DestX; + public readonly int DestY; + public readonly int DestZ; + public readonly double Cost; + public readonly ParkourProfile ParkourProfile; + public readonly MoveType MoveType; + + public MoveNeighbor(in MoveResult result, MoveType moveType) + { + DestX = result.DestX; + DestY = result.DestY; + DestZ = result.DestZ; + Cost = result.Cost; + ParkourProfile = result.ParkourProfile; + MoveType = moveType; + } +} + +/// +/// Emits feasible neighbors from a given node. Replaces the old +/// "iterate every pre-instantiated IMove" pattern: A* asks each expander to +/// fill a stack-allocated buffer with all feasible neighbors, and the +/// expander can prune whole categories (e.g. skip Sidewall when no wall is +/// near) without instantiating per-direction IMove objects. +/// +public interface IMoveExpander +{ + /// + /// Probe the world and populate with feasible + /// neighbors. Returns the number of neighbors written. Implementations + /// must not write past the buffer; callers must size it to the expander's + /// max output. + /// + int Expand(CalculationContext ctx, int x, int y, int z, Span buffer); + + /// + /// Upper bound on the number of neighbors this expander can emit from a + /// single node. Used by the driver to size the per-node buffer. + /// + int MaxNeighbors { get; } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs deleted file mode 100644 index 53d16f14..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs +++ /dev/null @@ -1,50 +0,0 @@ -using MinecraftClient.Pathing.Core; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Jump up 1 block in a cardinal direction. - /// Requires: headroom at (x, y+2, z), body space at dest (y+1, y+2), ground at dest (y). - /// - public sealed class MoveAscend : IMove - { - public MoveType Type => MoveType.Ascend; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - public MoveAscend(int xOffset, int zOffset) - { - XOffset = xOffset; - ZOffset = zOffset; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - int destX = x + XOffset; - int destZ = z + ZOffset; - int destY = y + 1; - - if (!ctx.CanWalkThrough(x, y + 2, z)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkThrough(destX, destY, destZ) || !ctx.CanWalkThrough(destX, destY + 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkOn(destX, y, destZ)) - { - result.SetImpossible(); - return; - } - - double cost = ctx.SprintCost + ctx.JumpPenalty; - result.Set(destX, destY, destZ, cost); - } - } -} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs deleted file mode 100644 index 8b43a2bd..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs +++ /dev/null @@ -1,59 +0,0 @@ -using MinecraftClient.Pathing.Core; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Diagonal walk (1 block in both X and Z, same Y). - /// Allows corner walks: if one intermediate cardinal is blocked by a wall - /// but the other is clear, the player can hug the open side to cut the - /// corner. Both sides blocked is impossible (player AABB too wide). - /// - public sealed class MoveDiagonal : IMove - { - public MoveType Type => MoveType.Diagonal; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - public MoveDiagonal(int xOffset, int zOffset) - { - XOffset = xOffset; - ZOffset = zOffset; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - int destX = x + XOffset; - int destZ = z + ZOffset; - - if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkOn(destX, y - 1, destZ)) - { - result.SetImpossible(); - return; - } - - bool sideX = ctx.CanWalkThrough(x + XOffset, y, z) && - ctx.CanWalkThrough(x + XOffset, y + 1, z); - bool sideZ = ctx.CanWalkThrough(x, y, z + ZOffset) && - ctx.CanWalkThrough(x, y + 1, z + ZOffset); - - if (!sideX && !sideZ) - { - result.SetImpossible(); - return; - } - - double cost = ctx.SprintCost * ActionCosts.DiagonalMultiplier; - if (!sideX || !sideZ) - cost = ctx.WalkCost * ActionCosts.DiagonalMultiplier; - - result.Set(destX, y, destZ, cost); - } - } -} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs deleted file mode 100644 index b0bde700..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs +++ /dev/null @@ -1,69 +0,0 @@ -using MinecraftClient.Pathing.Core; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Jump diagonally (1 block in X and Z) and land 1 block higher. - /// Handles the "corner jump" pattern: jump around a wall edge and land - /// one block higher on a platform that is diagonally adjacent. - /// - public sealed class MoveDiagonalAscend : IMove - { - public MoveType Type => MoveType.Ascend; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - public MoveDiagonalAscend(int xOffset, int zOffset) - { - XOffset = xOffset; - ZOffset = zOffset; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - int destX = x + XOffset; - int destZ = z + ZOffset; - int destY = y + 1; - - // Need headroom to jump (y+2 at start) - if (!ctx.CanWalkThrough(x, y + 2, z)) - { - result.SetImpossible(); - return; - } - - // Destination: solid ground, body passable, head passable - if (!ctx.CanWalkOn(destX, y, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkThrough(destX, destY, destZ) || - !ctx.CanWalkThrough(destX, destY + 1, destZ)) - { - result.SetImpossible(); - return; - } - - // At least one of the two intermediate cardinal directions must be passable - // at both the current and destination height (player sweeps through). - bool pathViaX = ctx.CanWalkThrough(x + XOffset, y, z) && - ctx.CanWalkThrough(x + XOffset, y + 1, z) && - ctx.CanWalkThrough(x + XOffset, y + 2, z); - bool pathViaZ = ctx.CanWalkThrough(x, y, z + ZOffset) && - ctx.CanWalkThrough(x, y + 1, z + ZOffset) && - ctx.CanWalkThrough(x, y + 2, z + ZOffset); - - if (!pathViaX && !pathViaZ) - { - result.SetImpossible(); - return; - } - - double cost = ctx.SprintCost * ActionCosts.DiagonalMultiplier + ctx.JumpPenalty; - result.Set(destX, destY, destZ, cost); - } - } -} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs deleted file mode 100644 index 2f9e2578..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs +++ /dev/null @@ -1,77 +0,0 @@ -using MinecraftClient.Mapping; -using MinecraftClient.Pathing.Core; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Walk diagonally (1 block in X and Z) and drop 1 block. - /// Handles the "corner drop" pattern: step around a wall edge and land - /// one block lower on a platform that is diagonally adjacent. - /// - public sealed class MoveDiagonalDescend : IMove - { - public MoveType Type => MoveType.Descend; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - public MoveDiagonalDescend(int xOffset, int zOffset) - { - XOffset = xOffset; - ZOffset = zOffset; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - int destX = x + XOffset; - int destZ = z + ZOffset; - int destY = y - 1; - - // Destination must have ground, body space, and head space - if (!ctx.CanWalkOn(destX, destY - 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkThrough(destX, destY, destZ) || - !ctx.CanWalkThrough(destX, destY + 1, destZ)) - { - result.SetImpossible(); - return; - } - - Material landOn = ctx.GetMaterial(destX, destY - 1, destZ); - if (MoveHelper.IsHazardous(landOn)) - { - result.SetImpossible(); - return; - } - - // Don't descend from climbable blocks - Material fromDown = ctx.GetMaterial(x, y - 1, z); - if (fromDown.CanBeClimbedOn()) - { - result.SetImpossible(); - return; - } - - // At least one of the two intermediate cardinal directions must be passable - // (player needs clearance to cut the corner). - bool pathViaX = ctx.CanWalkThrough(x + XOffset, y, z) && - ctx.CanWalkThrough(x + XOffset, y + 1, z); - bool pathViaZ = ctx.CanWalkThrough(x, y, z + ZOffset) && - ctx.CanWalkThrough(x, y + 1, z + ZOffset); - - if (!pathViaX && !pathViaZ) - { - result.SetImpossible(); - return; - } - - double cost = ActionCosts.WalkOffBlock * ActionCosts.DiagonalMultiplier - + ActionCosts.FallCost(1); - result.Set(destX, destY, destZ, cost); - } - } -} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveJump.cs b/MinecraftClient/Pathing/Moves/Impl/MoveJump.cs new file mode 100644 index 00000000..3e8c2685 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveJump.cs @@ -0,0 +1,79 @@ +using System; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Unified jump-family move. A single IMove implementation that dispatches + /// on to cover every combination previously + /// implemented by seven separate classes (Traverse, Diagonal, Ascend, + /// DiagonalAscend, DiagonalDescend, Parkour, SidewallParkour). + /// + /// All feasibility and cost logic lives in . + /// Factory helpers (, , ...) + /// produce the right descriptor for each use site without requiring + /// callers to remember the mapping between flavor and . + /// + public sealed class MoveJump : IMove + { + public JumpDescriptor Descriptor { get; } + public MoveType Type { get; } + public int XOffset => Descriptor.XOffset; + public int ZOffset => Descriptor.ZOffset; + public int YDelta => Descriptor.YDelta; + public JumpFlavor Flavor => Descriptor.Flavor; + public bool DynamicY => false; + + public MoveJump(JumpDescriptor descriptor) + { + Descriptor = descriptor; + Type = DeriveMoveType(descriptor); + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + => JumpFeasibility.Evaluate(ctx, x, y, z, Descriptor, ref result); + + public override string ToString() + { + double horiz = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + return $"MoveJump({Flavor}, off=({XOffset},{ZOffset}), dy={YDelta}, dist={horiz:F2})"; + } + + // ----------------------------------------------------------------- + // Factory helpers + // ----------------------------------------------------------------- + + public static MoveJump Traverse(int dx, int dz) + => new(new JumpDescriptor(dx, dz, 0, JumpFlavor.Walk)); + + public static MoveJump Diagonal(int dx, int dz) + => new(new JumpDescriptor(dx, dz, 0, JumpFlavor.Walk)); + + public static MoveJump Ascend(int dx, int dz) + => new(new JumpDescriptor(dx, dz, 1, JumpFlavor.Step)); + + public static MoveJump DiagonalAscend(int dx, int dz) + => new(new JumpDescriptor(dx, dz, 1, JumpFlavor.Step)); + + public static MoveJump DiagonalDescend(int dx, int dz) + => new(new JumpDescriptor(dx, dz, -1, JumpFlavor.Step)); + + public static MoveJump Parkour(int dx, int dz, int yDelta = 0) + => new(new JumpDescriptor(dx, dz, yDelta, JumpFlavor.SprintJump)); + + public static MoveJump Sidewall(int dx, int dz, int yDelta = 0) + => new(new JumpDescriptor(dx, dz, yDelta, JumpFlavor.Sidewall)); + + private static MoveType DeriveMoveType(JumpDescriptor d) + { + return d.Flavor switch + { + JumpFlavor.Walk => d.IsCardinal ? MoveType.Traverse : MoveType.Diagonal, + JumpFlavor.Step => d.YDelta > 0 ? MoveType.Ascend : MoveType.Descend, + JumpFlavor.SprintJump => MoveType.Parkour, + JumpFlavor.Sidewall => MoveType.Parkour, + _ => MoveType.Traverse, + }; + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs deleted file mode 100644 index 09ff7cc0..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System; -using MinecraftClient.Mapping; -using MinecraftClient.Pathing.Core; -using MinecraftClient.Pathing.Moves; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Sprint jump across a gap in cardinal or diagonal direction. - /// Supports horizontal distances of 2-4 blocks, optional +1Y ascent, - /// and -1/-2Y descent (land on a lower platform after the jump). - /// Based on Baritone's MovementParkour design with diagonal extensions. - /// - public sealed class MoveParkour : IMove - { - public MoveType Type => MoveType.Parkour; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - private readonly int _yDelta; - - /// - /// Create a parkour move with direct XZ offsets. - /// For cardinal: one of xOff/zOff is 0, the other is 2..4. - /// For diagonal: both non-zero, actual distance should be within sprint jump range. - /// - public MoveParkour(int xOff, int zOff, int yDelta = 0) - { - XOffset = xOff; - ZOffset = zOff; - _yDelta = yDelta; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - if (!ctx.AllowParkour) - { - result.SetImpossible(); - return; - } - - if (_yDelta > 0 && !ctx.AllowParkourAscend) - { - result.SetImpossible(); - return; - } - - if (_yDelta < 0 && -_yDelta > ctx.MaxFallHeight) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanSprint) - { - result.SetImpossible(); - return; - } - - bool cardinal = (XOffset == 0) != (ZOffset == 0); - if (cardinal) - { - int distance = Math.Max(Math.Abs(XOffset), Math.Abs(ZOffset)); - int maxDistance = _yDelta switch - { - > 0 => 3, - < 0 => 5, - _ => 5, - }; - - if (distance > maxDistance) - { - result.SetImpossible(); - return; - } - } - - // Don't parkour from climbable blocks (unreliable jump) - Material standingOn = ctx.GetMaterial(x, y - 1, z); - if (standingOn.CanBeClimbedOn()) - { - result.SetImpossible(); - return; - } - - if (!ParkourFeasibility.HasRunUp(ctx, x, y, z, XOffset, ZOffset, _yDelta)) - { - result.SetImpossible(); - return; - } - - int destX = x + XOffset; - int destZ = z + ZOffset; - int destY = y + _yDelta; - - // Head clearance at start (need room to jump) - if (!ctx.CanWalkThrough(x, y + 2, z)) - { - result.SetImpossible(); - return; - } - - // Can't jump out of liquid - Material atFeet = ctx.GetMaterial(x, y, z); - if (atFeet.IsLiquid()) - { - result.SetImpossible(); - return; - } - - // Destination must be standable and passable - if (!ctx.CanWalkOn(destX, destY - 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkThrough(destX, destY, destZ) || - !ctx.CanWalkThrough(destX, destY + 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (ParkourFeasibility.HasIntermediateLandingConflict(ctx, x, y, z, XOffset, ZOffset, _yDelta)) - { - result.SetImpossible(); - return; - } - - int xSign = Math.Sign(XOffset); - int zSign = Math.Sign(ZOffset); - int xAbs = Math.Abs(XOffset); - int zAbs = Math.Abs(ZOffset); - - // Check intermediate blocks along the flight path. - // Cardinal: check all blocks in the column along the primary axis. - // Diagonal: check blocks along the diagonal strip, not the full rectangle. - // Player AABB is 0.6 wide, so only blocks near the diagonal line matter. - if (!CheckFlightPath(ctx, x, y, z, xSign, zSign, xAbs, zAbs)) - { - result.SetImpossible(); - return; - } - - // Gap check: first block(s) adjacent to start must lack ground. - // If ground exists there, A* can find a walking path instead. - if (xAbs > 0 && zAbs == 0) - { - if (ctx.CanWalkOn(x + xSign, y - 1, z)) - { - result.SetImpossible(); - return; - } - } - else if (xAbs == 0 && zAbs > 0) - { - if (ctx.CanWalkOn(x, y - 1, z + zSign)) - { - result.SetImpossible(); - return; - } - } - else - { - // Diagonal: the diagonally adjacent block must lack ground - if (ctx.CanWalkOn(x + xSign, y - 1, z + zSign)) - { - result.SetImpossible(); - return; - } - } - - if (!ParkourFeasibility.HasDiagonalShoulderClearance(ctx, x, y, z, XOffset, ZOffset)) - { - result.SetImpossible(); - return; - } - - if (!ParkourFeasibility.HasCardinalSideClearance(ctx, x, y, z, XOffset, ZOffset)) - { - result.SetImpossible(); - return; - } - - if (!ParkourFeasibility.HasLandingOvershootClearance( - ctx, destX, destY, destZ, xSign, zSign)) - { - result.SetImpossible(); - return; - } - - // Cost model following Baritone: - // dist 2-3: walk speed * distance (jump is roughly time-neutral vs walking) - // dist 4: sprint speed * distance (must sprint, covers ground faster) - // ascend: always sprint speed (sprinting required) - double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); - double cost; - if (_yDelta > 0) - cost = horizDist * ctx.SprintCost + ctx.JumpPenalty * 2; - else if (_yDelta < 0) - cost = horizDist * ctx.SprintCost + ctx.JumpPenalty - + ActionCosts.FallCost(-_yDelta); - else if (horizDist >= 3.5) - cost = horizDist * ctx.SprintCost + ctx.JumpPenalty; - else - cost = horizDist * ctx.WalkCost + ctx.JumpPenalty; - - result.Set(destX, destY, destZ, cost, ParkourProfile.Default); - } - - /// - /// Check body clearance along the flight path from start toward the destination. - /// For cardinal moves, checks a straight line. For diagonal moves, checks - /// only blocks near the actual diagonal trajectory rather than the full bounding - /// rectangle, allowing jumps that pass a wall on one side. - /// - private bool CheckFlightPath( - CalculationContext ctx, int x, int y, int z, - int xSign, int zSign, int xAbs, int zAbs) - { - if (xAbs == 0 || zAbs == 0) - { - // Cardinal: single axis, check each block along the line - for (int step = 1; step < Math.Max(xAbs, zAbs); step++) - { - int gx = x + xSign * (xAbs > 0 ? step : 0); - int gz = z + zSign * (zAbs > 0 ? step : 0); - if (!ClearColumn(ctx, gx, y, gz)) - return false; - } - return true; - } - - // Diagonal: walk the diagonal and check each block the AABB touches. - // At each step t along the diagonal, the player center is near - // (x + t*xSign, z + t*zSign). The AABB extends 0.3 blocks each side, - // so check the diagonal cell and one neighbor on each axis-aligned side - // only when the trajectory is close to a cell boundary (always for short - // diagonals). We enumerate cells by stepping through the longer axis - // and computing the corresponding position on the shorter axis. - int maxSteps = Math.Max(xAbs, zAbs); - for (int step = 1; step < maxSteps; step++) - { - // Proportional position along each axis - double fx = (double)step * xAbs / maxSteps; - double fz = (double)step * zAbs / maxSteps; - - int ix = (int)Math.Round(fx); - int iz = (int)Math.Round(fz); - - int gx = x + xSign * ix; - int gz = z + zSign * iz; - - if (!ClearColumn(ctx, gx, y, gz)) - return false; - - // Also check the neighboring cell across the shorter axis when close - // to a cell boundary (player AABB overlaps adjacent cell) - if (xAbs != zAbs) - { - double fracX = fx - Math.Floor(fx); - double fracZ = fz - Math.Floor(fz); - if (fracX > 0.2 && fracX < 0.8 && ix > 0 && ix < xAbs) - { - if (!ClearColumn(ctx, x + xSign * (ix - 1), y, gz)) - return false; - } - if (fracZ > 0.2 && fracZ < 0.8 && iz > 0 && iz < zAbs) - { - if (!ClearColumn(ctx, gx, y, z + zSign * (iz - 1))) - return false; - } - } - } - - return true; - } - - private bool ClearColumn(CalculationContext ctx, int gx, int y, int gz) - { - if (!ctx.CanWalkThrough(gx, y, gz) || - !ctx.CanWalkThrough(gx, y + 1, gz) || - !ctx.CanWalkThrough(gx, y + 2, gz)) - return false; - if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) - return false; - return true; - } - - public override string ToString() - { - double dist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); - return $"MoveParkour(off=({XOffset},{ZOffset}), dy={_yDelta}, dist={dist:F1})"; - } - } -} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs deleted file mode 100644 index d81ab65b..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs +++ /dev/null @@ -1,104 +0,0 @@ -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/Impl/MoveTraverse.cs b/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs deleted file mode 100644 index 590503af..00000000 --- a/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MinecraftClient.Pathing.Core; - -namespace MinecraftClient.Pathing.Moves.Impl -{ - /// - /// Flat cardinal walk (1 block in +/-X or +/-Z, same Y). - /// Checks body+head passable and ground below destination. - /// - public sealed class MoveTraverse : IMove - { - public MoveType Type => MoveType.Traverse; - public int XOffset { get; } - public int ZOffset { get; } - public bool DynamicY => false; - - public MoveTraverse(int xOffset, int zOffset) - { - XOffset = xOffset; - ZOffset = zOffset; - } - - public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) - { - int destX = x + XOffset; - int destZ = z + ZOffset; - - if (!ctx.CanWalkThrough(destX, y, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkThrough(destX, y + 1, destZ)) - { - result.SetImpossible(); - return; - } - - if (!ctx.CanWalkOn(destX, y - 1, destZ)) - { - result.SetImpossible(); - return; - } - - double cost = ctx.SprintCost; - - var destFloorMat = ctx.GetMaterial(destX, y - 1, destZ); - if (destFloorMat == Mapping.Material.SoulSand) - cost *= 1.0 / Physics.PhysicsConsts.SoulSandSpeedFactor; - - result.Set(destX, y, destZ, cost); - } - } -} diff --git a/MinecraftClient/Pathing/Moves/JumpDescriptor.cs b/MinecraftClient/Pathing/Moves/JumpDescriptor.cs new file mode 100644 index 00000000..e44843bd --- /dev/null +++ b/MinecraftClient/Pathing/Moves/JumpDescriptor.cs @@ -0,0 +1,56 @@ +namespace MinecraftClient.Pathing.Moves; + +/// +/// Kind of jump-family move. Each flavor selects a different evaluator path +/// inside while sharing low-level primitives +/// (head clearance, destination clearance, flight-path sweep, cost model). +/// +public enum JumpFlavor +{ + /// + /// Single-block cardinal or diagonal walk at the same Y. No jump input. + /// Covers the old MoveTraverse and MoveDiagonal. + /// + Walk, + + /// + /// Single-block vertical step (dy = +1 up or dy = -1 down), cardinal or + /// diagonal. Covers MoveAscend, MoveDiagonalAscend, and + /// MoveDiagonalDescend. + /// + Step, + + /// + /// Multi-block sprint jump, cardinal or diagonal. Covers MoveParkour. + /// + SprintJump, + + /// + /// Dominant-axis sprint jump that uses an inner wall for support. Covers + /// MoveSidewallParkour. + /// + Sidewall, +} + +/// +/// Fully describes a single jump-family move (Walk / Step / SprintJump / +/// Sidewall). All geometry that downstream planners or templates need can be +/// derived from this value, so A* only needs to enumerate descriptors rather +/// than hard-coded IMove subclasses. +/// +public readonly record struct JumpDescriptor( + int XOffset, + int ZOffset, + int YDelta, + JumpFlavor Flavor) +{ + public bool IsCardinal => (XOffset == 0) != (ZOffset == 0); + + public bool IsDiagonal => XOffset != 0 && ZOffset != 0; + + public int HorizontalMajor + => System.Math.Max(System.Math.Abs(XOffset), System.Math.Abs(ZOffset)); + + public int HorizontalMinor + => System.Math.Min(System.Math.Abs(XOffset), System.Math.Abs(ZOffset)); +} diff --git a/MinecraftClient/Pathing/Moves/JumpExpander.cs b/MinecraftClient/Pathing/Moves/JumpExpander.cs new file mode 100644 index 00000000..3c5858c6 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/JumpExpander.cs @@ -0,0 +1,277 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves.Impl; + +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. +/// +/// 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. +/// +public sealed class JumpExpander : IMoveExpander +{ + private static readonly JumpDescriptor[] _descriptors = BuildDescriptors(); + + public int MaxNeighbors => _descriptors.Length; + + 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. + bool jumpFamilyAllowed = ctx.AllowParkour && ctx.CanSprint; + bool canSprintTakeoff = false; + bool hasAdjacentWall = false; + if (jumpFamilyAllowed) + { + Material standingOn = ctx.GetMaterial(x, y - 1, z); + Material atFeet = ctx.GetMaterial(x, y, z); + canSprintTakeoff = + !standingOn.CanBeClimbedOn() + && !atFeet.IsLiquid() + && ctx.CanWalkThrough(x, y + 2, z); + + if (canSprintTakeoff) + hasAdjacentWall = HasAnyAdjacentWall(ctx, x, y, z); + } + + // ---- Per-direction gap table (SprintJump only) ---- + // Gap check: "first block adjacent to start must lack ground" so A* can't + // pick a cheaper walking path. For an octant (sx, sz) the cell is at + // (x+sx, y-1, z+sz). Index = (sx+1)*3 + (sz+1) over sx,sz in {-1,0,1}. + // If the floor is present for a direction, every SprintJump descriptor in + // that octant is infeasible. 9 slots (center slot 4 unused) fit cleanly + // on the stack. + Span directionGapOpen = stackalloc bool[9]; + if (canSprintTakeoff) + { + for (int dx = -1; dx <= 1; dx++) + { + for (int dz = -1; dz <= 1; dz++) + { + if (dx == 0 && dz == 0) + continue; + int idx = ((dx + 1) * 3) + (dz + 1); + directionGapOpen[idx] = !ctx.CanWalkOn(x + dx, y - 1, z + dz); + } + } + } + + for (int i = 0; i < _descriptors.Length; i++) + { + JumpDescriptor desc = _descriptors[i]; + + switch (desc.Flavor) + { + case JumpFlavor.SprintJump: + if (!canSprintTakeoff) + continue; + { + int sx = Math.Sign(desc.XOffset); + int sz = Math.Sign(desc.ZOffset); + int idx = ((sx + 1) * 3) + (sz + 1); + if (!directionGapOpen[idx]) + continue; + } + break; + case JumpFlavor.Sidewall: + if (!canSprintTakeoff || !hasAdjacentWall) + continue; + break; + default: + break; + } + + result.Cost = 0; + JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result); + if (result.IsImpossible) + continue; + + MoveType type = DeriveMoveType(desc); + if (count < buffer.Length) + buffer[count++] = new MoveNeighbor(result, type); + } + 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 . + /// + private static bool HasAnyAdjacentWall(CalculationContext ctx, int x, int y, int z) + { + 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); + } + + private static MoveType DeriveMoveType(JumpDescriptor d) => d.Flavor switch + { + JumpFlavor.Walk => d.IsCardinal ? MoveType.Traverse : MoveType.Diagonal, + JumpFlavor.Step => d.YDelta > 0 ? MoveType.Ascend : MoveType.Descend, + JumpFlavor.SprintJump => MoveType.Parkour, + JumpFlavor.Sidewall => MoveType.Parkour, + _ => MoveType.Traverse, + }; + + private static JumpDescriptor[] BuildDescriptors() + { + var list = new System.Collections.Generic.List(256); + int[] offsets = [1, -1]; + + // Cardinal walk + 1-block ascend + foreach (int dx in offsets) + { + list.Add(new JumpDescriptor(dx, 0, 0, JumpFlavor.Walk)); + list.Add(new JumpDescriptor(dx, 0, 1, JumpFlavor.Step)); + } + foreach (int dz in offsets) + { + list.Add(new JumpDescriptor(0, dz, 0, JumpFlavor.Walk)); + list.Add(new JumpDescriptor(0, dz, 1, JumpFlavor.Step)); + } + + // Diagonal walk + diagonal ascend/descend + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + list.Add(new JumpDescriptor(dx, dz, 0, JumpFlavor.Walk)); + list.Add(new JumpDescriptor(dx, dz, 1, JumpFlavor.Step)); + list.Add(new JumpDescriptor(dx, dz, -1, JumpFlavor.Step)); + } + } + + // 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)); + } + + // Diagonal parkour + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + list.Add(new JumpDescriptor(dx * 2, dz * 1, 0, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 1, dz * 2, 0, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 2, dz * 2, 0, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 3, dz * 1, 0, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 1, dz * 3, 0, JumpFlavor.SprintJump)); + + list.Add(new JumpDescriptor(dx * 2, dz * 1, -1, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 1, dz * 2, -1, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 2, dz * 2, -1, JumpFlavor.SprintJump)); + + list.Add(new JumpDescriptor(dx * 2, dz * 1, 1, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 1, dz * 2, 1, JumpFlavor.SprintJump)); + list.Add(new JumpDescriptor(dx * 2, dz * 2, 1, JumpFlavor.SprintJump)); + } + } + + // 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)); + } + } + } + + 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). + /// + public static ReadOnlySpan Descriptors => _descriptors; +} + +/// +/// Thin adapter that wraps an array of legacy instances as +/// an . Used for the dynamic-landing and vertical +/// move families (MoveDescend, MoveSprintDescend, +/// MoveClimb, MoveFall) which do not fit the JumpDescriptor model. +/// +public sealed class LegacyMoveExpander : IMoveExpander +{ + private readonly IMove[] _moves; + + public LegacyMoveExpander(IMove[] moves) + { + _moves = moves ?? throw new ArgumentNullException(nameof(moves)); + } + + public int MaxNeighbors => _moves.Length; + + public int Expand(CalculationContext ctx, int x, int y, int z, Span buffer) + { + int count = 0; + MoveResult result = default; + for (int i = 0; i < _moves.Length; i++) + { + IMove move = _moves[i]; + result.Cost = 0; + move.Calculate(ctx, x, y, z, ref result); + if (result.IsImpossible) + continue; + + if (count < buffer.Length) + buffer[count++] = new MoveNeighbor(result, move.Type); + } + return count; + } +} diff --git a/MinecraftClient/Pathing/Moves/JumpFeasibility.cs b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs new file mode 100644 index 00000000..afd783ef --- /dev/null +++ b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs @@ -0,0 +1,574 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Moves; + +/// +/// Single source of truth for jump-family feasibility and cost. Each +/// selects one of the Evaluate* methods; the methods +/// share low-level primitives (head clearance, destination clearance, +/// flight-path sweep, run-up, cost) so that a physics rule is implemented +/// exactly once. +/// +internal static class JumpFeasibility +{ + public static void Evaluate( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + switch (desc.Flavor) + { + case JumpFlavor.Walk: + EvaluateWalk(ctx, x, y, z, desc, ref result); + return; + case JumpFlavor.Step: + EvaluateStep(ctx, x, y, z, desc, ref result); + return; + case JumpFlavor.SprintJump: + EvaluateSprintJump(ctx, x, y, z, desc, ref result); + return; + case JumpFlavor.Sidewall: + EvaluateSidewall(ctx, x, y, z, desc, ref result); + return; + default: + result.SetImpossible(); + return; + } + } + + // --------------------------------------------------------------------- + // Walk (dy = 0, single block, cardinal or diagonal) + // --------------------------------------------------------------------- + + private static void EvaluateWalk( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + int dx = desc.XOffset; + int dz = desc.ZOffset; + int destX = x + dx; + int destZ = z + dz; + + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (desc.IsCardinal) + { + double cost = ctx.SprintCost; + Material destFloor = ctx.GetMaterial(destX, y - 1, destZ); + if (destFloor == Material.SoulSand) + cost *= 1.0 / PhysicsConsts.SoulSandSpeedFactor; + result.Set(destX, y, destZ, cost); + return; + } + + // Diagonal corner walk: need at least one passable side cardinal. + bool sideX = ctx.CanWalkThrough(x + dx, y, z) && + ctx.CanWalkThrough(x + dx, y + 1, z); + bool sideZ = ctx.CanWalkThrough(x, y, z + dz) && + ctx.CanWalkThrough(x, y + 1, z + dz); + + if (!sideX && !sideZ) + { + result.SetImpossible(); + return; + } + + double diagCost = ctx.SprintCost * ActionCosts.DiagonalMultiplier; + if (!sideX || !sideZ) + diagCost = ctx.WalkCost * ActionCosts.DiagonalMultiplier; + + result.Set(destX, y, destZ, diagCost); + } + + // --------------------------------------------------------------------- + // Step (dy = +1 ascend, dy = -1 descend, cardinal or diagonal) + // --------------------------------------------------------------------- + + private static void EvaluateStep( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + if (desc.YDelta == 1) + EvaluateStepAscend(ctx, x, y, z, desc, ref result); + else if (desc.YDelta == -1) + EvaluateStepDescend(ctx, x, y, z, desc, ref result); + else + result.SetImpossible(); + } + + private static void EvaluateStepAscend( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + int dx = desc.XOffset; + int dz = desc.ZOffset; + int destX = x + dx; + int destZ = z + dz; + int destY = y + 1; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (desc.IsCardinal) + { + double cost = ctx.SprintCost + ctx.JumpPenalty; + result.Set(destX, destY, destZ, cost); + return; + } + + bool pathViaX = ctx.CanWalkThrough(x + dx, y, z) && + ctx.CanWalkThrough(x + dx, y + 1, z) && + ctx.CanWalkThrough(x + dx, y + 2, z); + bool pathViaZ = ctx.CanWalkThrough(x, y, z + dz) && + ctx.CanWalkThrough(x, y + 1, z + dz) && + ctx.CanWalkThrough(x, y + 2, z + dz); + + if (!pathViaX && !pathViaZ) + { + result.SetImpossible(); + return; + } + + double diagCost = ctx.SprintCost * ActionCosts.DiagonalMultiplier + ctx.JumpPenalty; + result.Set(destX, destY, destZ, diagCost); + } + + private static void EvaluateStepDescend( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + int dx = desc.XOffset; + int dz = desc.ZOffset; + int destX = x + dx; + int destZ = z + dz; + int destY = y - 1; + + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + Material landOn = ctx.GetMaterial(destX, destY - 1, destZ); + if (MoveHelper.IsHazardous(landOn)) + { + result.SetImpossible(); + return; + } + + Material fromDown = ctx.GetMaterial(x, y - 1, z); + if (fromDown.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + if (!desc.IsDiagonal) + { + // Currently only diagonal descend steps exist; cardinal descend is + // served by MoveDescend which supports dynamic fall depth. + result.SetImpossible(); + return; + } + + bool pathViaX = ctx.CanWalkThrough(x + dx, y, z) && + ctx.CanWalkThrough(x + dx, y + 1, z); + bool pathViaZ = ctx.CanWalkThrough(x, y, z + dz) && + ctx.CanWalkThrough(x, y + 1, z + dz); + + if (!pathViaX && !pathViaZ) + { + result.SetImpossible(); + return; + } + + double cost = ActionCosts.WalkOffBlock * ActionCosts.DiagonalMultiplier + + ActionCosts.FallCost(1); + result.Set(destX, destY, destZ, cost); + } + + // --------------------------------------------------------------------- + // SprintJump (parkour, horiz >= 2, dy in -2..+1) + // Ported 1:1 from MoveParkour.Calculate. + // --------------------------------------------------------------------- + + private static void EvaluateSprintJump( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + int xOffset = desc.XOffset; + int zOffset = desc.ZOffset; + int yDelta = desc.YDelta; + + if (!ctx.AllowParkour) + { + result.SetImpossible(); + return; + } + + if (yDelta > 0 && !ctx.AllowParkourAscend) + { + result.SetImpossible(); + return; + } + + if (yDelta < 0 && -yDelta > ctx.MaxFallHeight) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + bool cardinal = (xOffset == 0) != (zOffset == 0); + if (cardinal) + { + int distance = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + int maxDistance = yDelta switch + { + > 0 => 3, + < 0 => 5, + _ => 5, + }; + + if (distance > maxDistance) + { + result.SetImpossible(); + return; + } + } + + Material standingOn = ctx.GetMaterial(x, y - 1, z); + if (standingOn.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasRunUp(ctx, x, y, z, xOffset, zOffset, yDelta)) + { + result.SetImpossible(); + return; + } + + int destX = x + xOffset; + int destZ = z + zOffset; + int destY = y + yDelta; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + Material atFeet = ctx.GetMaterial(x, y, z); + if (atFeet.IsLiquid()) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (ParkourFeasibility.HasIntermediateLandingConflict(ctx, x, y, z, xOffset, zOffset, yDelta)) + { + result.SetImpossible(); + return; + } + + int xSign = Math.Sign(xOffset); + int zSign = Math.Sign(zOffset); + int xAbs = Math.Abs(xOffset); + int zAbs = Math.Abs(zOffset); + + if (!CheckSprintJumpFlightPath(ctx, x, y, z, xSign, zSign, xAbs, zAbs, yDelta)) + { + result.SetImpossible(); + return; + } + + // Gap check: first block(s) adjacent to start must lack ground so A* + // cannot take a cheaper walking path. + if (xAbs > 0 && zAbs == 0) + { + if (ctx.CanWalkOn(x + xSign, y - 1, z)) + { + result.SetImpossible(); + return; + } + } + else if (xAbs == 0 && zAbs > 0) + { + if (ctx.CanWalkOn(x, y - 1, z + zSign)) + { + result.SetImpossible(); + return; + } + } + else if (ctx.CanWalkOn(x + xSign, y - 1, z + zSign)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasDiagonalShoulderClearance(ctx, x, y, z, xOffset, zOffset)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasCardinalSideClearance(ctx, x, y, z, xOffset, zOffset)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasLandingOvershootClearance(ctx, destX, destY, destZ, xSign, zSign)) + { + result.SetImpossible(); + return; + } + + double horizDist = Math.Sqrt((double)((xOffset * xOffset) + (zOffset * zOffset))); + double cost; + if (yDelta > 0) + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty * 2; + else if (yDelta < 0) + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty + + ActionCosts.FallCost(-yDelta); + else if (horizDist >= 3.5) + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty; + else + cost = horizDist * ctx.WalkCost + ctx.JumpPenalty; + + result.Set(destX, destY, destZ, cost, ParkourProfile.Default); + } + + private static bool CheckSprintJumpFlightPath( + CalculationContext ctx, + int x, int y, int z, + int xSign, int zSign, int xAbs, int zAbs, + int yDelta) + { + if (xAbs == 0 || zAbs == 0) + { + for (int step = 1; step < Math.Max(xAbs, zAbs); step++) + { + int gx = x + xSign * (xAbs > 0 ? step : 0); + int gz = z + zSign * (zAbs > 0 ? step : 0); + if (!ClearSprintJumpColumn(ctx, gx, y, gz, yDelta)) + return false; + } + return true; + } + + int maxSteps = Math.Max(xAbs, zAbs); + for (int step = 1; step < maxSteps; step++) + { + double fx = (double)step * xAbs / maxSteps; + double fz = (double)step * zAbs / maxSteps; + + int ix = (int)Math.Round(fx); + int iz = (int)Math.Round(fz); + + int gx = x + xSign * ix; + int gz = z + zSign * iz; + + if (!ClearSprintJumpColumn(ctx, gx, y, gz, yDelta)) + return false; + + if (xAbs != zAbs) + { + double fracX = fx - Math.Floor(fx); + double fracZ = fz - Math.Floor(fz); + if (fracX > 0.2 && fracX < 0.8 && ix > 0 && ix < xAbs) + { + if (!ClearSprintJumpColumn(ctx, x + xSign * (ix - 1), y, gz, yDelta)) + return false; + } + if (fracZ > 0.2 && fracZ < 0.8 && iz > 0 && iz < zAbs) + { + if (!ClearSprintJumpColumn(ctx, gx, y, z + zSign * (iz - 1), yDelta)) + return false; + } + } + } + + return true; + } + + private static bool ClearSprintJumpColumn(CalculationContext ctx, int gx, int y, int gz, int yDelta) + { + if (!ctx.CanWalkThrough(gx, y, gz) || + !ctx.CanWalkThrough(gx, y + 1, gz) || + !ctx.CanWalkThrough(gx, y + 2, gz)) + return false; + if (yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + return false; + return true; + } + + // --------------------------------------------------------------------- + // Sidewall (dominant-axis sprint jump with an inner-wall constraint). + // Ported 1:1 from MoveSidewallParkour.Calculate. + // --------------------------------------------------------------------- + + private static void EvaluateSidewall( + CalculationContext ctx, + int x, int y, int z, + JumpDescriptor desc, + ref MoveResult result) + { + int xOffset = desc.XOffset; + int zOffset = desc.ZOffset; + int yDelta = desc.YDelta; + + 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.TryGetRequiredStaticEntryRunupSteps(ctx.PreviousMoveType, xOffset, zOffset, yDelta, out int requiredSteps)) + { + if (!ParkourFeasibility.HasPreparedRunup(ctx.CurrentEntryPreparation, x, y, z, forwardX, forwardZ, requiredSteps)) + { + result.SetImpossible(); + return; + } + } + else 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/docs/superpowers/plans/2026-04-19-unified-jump-move-plan.md b/docs/superpowers/plans/2026-04-19-unified-jump-move-plan.md new file mode 100644 index 00000000..a5f27d6c --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-unified-jump-move-plan.md @@ -0,0 +1,154 @@ +# Unified Jump Move Refactor + +Date: 2026-04-19 +Branch: `pathing/jump-entry-direct-yaw` + +## Problem + +MCC's A* uses a hard-coded enumeration of ~220 `IMove` instances covering the +"jump family" (Traverse, Diagonal, Ascend, DiagonalAscend, DiagonalDescend, +Parkour, SidewallParkour). Each geometric variant is a separate IMove subclass +with its own `Calculate` method that re-implements the same physics checks +(head clearance, run-up, flight path, landing clearance, gap check). Symptoms: + +1. **Drift**: the same physics rule is implemented in 3-4 places. A fix to + `HasDominantAxisRunUp` does not automatically propagate to `HasRunUp`. +2. **Missing combinations silently become "impossible"**: until this week, the + planner had no `MoveParkour(dx=1, dz=2, yDelta=+1)` entry, so the diagonal + ascending jump (upper arrow in the user's pyramid image) was rejected + entirely even though the physics allow it. +3. **Slow expansion**: every A* node runs 220 feasibility checks and a lot of + them are obviously irrelevant for that position (e.g. sidewall checks when + there is no wall anywhere near the player). + +Baritone does not have this problem: `MovementParkour` is a single class that +dynamically probes reachable landings per direction, and its 8 `Moves` enums +cover the entire movement space. + +## Goal + +Bring MCC's jump family to a single parameterized move class with one unified +feasibility engine, then evolve to Baritone-style dynamic neighbor expansion. + +## Scope + +**In scope (unified under `MoveJump` + `JumpDescriptor`)**: + +- `MoveTraverse` (dy=0 cardinal) +- `MoveDiagonal` (dy=0 corner) +- `MoveAscend` (dy=+1 cardinal) +- `MoveDiagonalAscend` (dy=+1 corner) +- `MoveDiagonalDescend` (dy=-1 corner) +- `MoveParkour` (dy ∈ {+1, 0, -1, -2}, horiz up to 5 cardinal / sqrt(10) diag) +- `MoveSidewallParkour` (parkour + inner wall requirement) + +**Out of scope (stay as their own classes)**: + +- `MoveDescend` — dynamic variable-depth fall with water/ladder grab logic +- `MoveSprintDescend` — dynamic landing depth +- `MoveClimb` — ladder/vine vertical movement +- `MoveFall` — pure free fall + +These are "descent family" and have a different feasibility model (unknown +landing y, hazard scanning). Future refactor can unify them under a +`MoveFallToLanding` family but that is a separate effort. + +## Design + +### Data + +```csharp +public readonly record struct JumpDescriptor( + int XOffset, + int ZOffset, + int YDelta, + JumpFlavor Flavor); + +public enum JumpFlavor +{ + Walk, // dy=0, 1 block move, no jump (Traverse/Diagonal) + Step, // dy=±1, 1 block move with jump or step-off (Ascend/DiagDescend/DiagAscend) + SprintJump, // horiz >= 2 with or without dy (Parkour) + Sidewall, // SprintJump + inner-wall clearance (SidewallParkour) +} +``` + +The descriptor fully describes any jump-family move. `MoveType` (Traverse, +Diagonal, Ascend, Descend, Parkour) is derived from `(Flavor, dy, horiz)` so +downstream consumers (templates, cost tables) keep working. + +### Evaluator + +`JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result)` is the single source +of truth. It dispatches on `desc.Flavor` but shares the following primitives: + +1. **Guards**: `AllowParkour`, `AllowParkourAscend`, `MaxFallHeight`, `CanSprint`. +2. **Profile check**: geometry falls in the valid range for this flavor. +3. **Head clearance at start**: `y+2` always, plus `y+3` if ascending sprint jump. +4. **Standing material**: reject climbable (ladder/vine) takeoffs. +5. **Destination**: floor solid, body passable, head passable, no hazards. +6. **Run-up**: cold-start reach tables plus prepared-entry lookup + (`EntryPreparationState`). This replaces both `HasRunUp` and + `HasDominantAxisRunUp`. +7. **Flight path**: cardinal straight-line column sweep or diagonal + proportional-step sweep. Needs `y+3` clearance only when ascending. +8. **Wall** (Sidewall only): inner wall presence + outer clearance + arc span. +9. **Gap check**: reject when a direct walk would work. +10. **Cost**: unified sprint/walk cost × horizontal distance + penalties. + +### Step 1 — introduce evaluator, existing classes delegate + +No behavior change. Each of the 7 existing classes has `Calculate` shrunk to +a single `JumpFeasibility.Evaluate(...)` call with a descriptor derived from +its constructor args. All existing tests pass with the same pass/fail counts. + +### Step 2 — single `MoveJump` class + +Delete the 7 subclasses. `AStarPathFinder.BuildDefaultMoves` emits +`MoveJump(descriptor)` instances from a declarative list. Tests that instantiate +the old classes are updated to instantiate `MoveJump` with the equivalent +descriptor (or use factory helpers like `MoveJump.Parkour(dx, dz, dy)`). + +Templates (`SprintJumpTemplate`, `AscendTemplate`, `SidewallParkourController`, +etc.) dispatch on `Flavor` / `MoveType` already, so they need no changes. + +### Step 3 — dynamic neighbor expansion + +`AStarPathFinder.Calculate` currently loops over `_allMoves` for every popped +node. Replace with an `IMoveExpander[]` where each expander yields neighbors +on demand: + +```csharp +public interface IMoveExpander +{ + void Expand(CalculationContext ctx, int x, int y, int z, Action emit); +} +``` + +`JumpExpander.Expand` iterates the 4 cardinals + 4 diagonals. For each +direction, it asks "what is the furthest reachable landing?" by scanning from +max distance down to 1, emitting the first feasible result (the A* cost model +already disprefers short jumps when long jumps work). This yields ~8-16 +neighbors per node instead of 220. + +Baritone-style partial-path coefficients (`bestSoFar[6]`) are a separate +improvement; not bundled here. + +## Risk / rollback + +- Step 1 is behavior-preserving and easy to revert (delete evaluator, restore + the old `Calculate` bodies from git). +- Step 2 deletes code; revert means restoring from git. +- Step 3 changes the A* main loop. Keep the old `BuildDefaultMoves` path behind + an `_useDynamicExpansion` flag so we can A/B test in live `tools/test-parkour.py` + runs before deleting the old path. + +## Test strategy + +- `dotnet test MinecraftClient.Tests` after each step. Baseline is 21 failing + tests (all pre-existing on this branch). Target: exact same failure set at + each checkpoint. +- At Step 2 end, verify upper-arrow scenario (this task's motivating bug) still + plans correctly. +- At Step 3 end, run `tools/test-parkour.py` linear + sidewall + ceiling + scenarios on a real server.