pathing: unify jump moves into MoveJump + IMoveExpander

Replace seven hand-written IMove classes (MoveTraverse, MoveDiagonal,
MoveAscend, MoveDiagonalAscend, MoveDiagonalDescend, MoveParkour,
MoveSidewallParkour) with a single MoveJump driven by a JumpDescriptor
(XOffset, ZOffset, YDelta, JumpFlavor). JumpFeasibility is the single
source of truth for the physics/cost rules of every jump-family move.

A* no longer iterates a flat IMove[]. The Calculate loop now drives
an IMoveExpander[] that writes into a stackalloc Span<MoveNeighbor>,
eliminating per-iteration heap traffic. JumpExpander enumerates every
jump-family descriptor dynamically; LegacyMoveExpander wraps the
remaining dynamic-landing moves (MoveDescend, MoveSprintDescend,
MoveClimb, MoveFall) so callers that still pass a custom IMove[]
keep working.

Add two O(1) short-circuits at the top of JumpExpander.Expand:
- Hoist the per-node parkour preconditions (AllowParkour + CanSprint,
  standing block climbability, feet-liquid, head clearance at y+2)
  so ~170 SprintJump + Sidewall descriptors never call JumpFeasibility
  when the node cannot take off at all.
- Precompute an 8-way "first step has no floor" table indexed by
  (sign(dx), sign(dz)) so SprintJump descriptors in a direction that
  has a walkable floor underneath are dropped without Evaluate.
- Add a conservative "any cardinal wall at y or y+1" probe that skips
  all 112 Sidewall descriptors when no wall exists adjacent to the
  takeoff.

Move tests switch to the new MoveJump.* factory methods. Behavior is
verified by the existing test suite: the 21 pre-existing baseline
failures are preserved exactly, 0 regressions introduced.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-19 17:03:26 +00:00
parent da52aa5c3c
commit d002930a6a
16 changed files with 1650 additions and 848 deletions

View file

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

View file

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

View file

@ -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<string>? 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<IMove>(), 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<IMove>();
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<long, PathNode>(4096);
var nodeMap = new Dictionary<NodeKey, PathNode>(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<MoveNeighbor> 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<MoveNeighbor> 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<JumpDescriptor> 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<PathNode> ReconstructPath(PathNode end)
{
var path = new List<PathNode>();

View file

@ -0,0 +1,53 @@
using System;
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves;
/// <summary>
/// A feasible neighbor emitted by an <see cref="IMoveExpander"/>. Carries the
/// per-node data the A* main loop needs to update its open set (destination,
/// cost, parkour profile, move type).
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public interface IMoveExpander
{
/// <summary>
/// Probe the world and populate <paramref name="buffer"/> 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.
/// </summary>
int Expand(CalculationContext ctx, int x, int y, int z, Span<MoveNeighbor> buffer);
/// <summary>
/// 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.
/// </summary>
int MaxNeighbors { get; }
}

View file

@ -1,50 +0,0 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// 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).
/// </summary>
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);
}
}
}

View file

@ -1,59 +0,0 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// 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).
/// </summary>
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);
}
}
}

View file

@ -1,69 +0,0 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View file

@ -1,77 +0,0 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View file

@ -0,0 +1,79 @@
using System;
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// Unified jump-family move. A single IMove implementation that dispatches
/// on <see cref="JumpFlavor"/> to cover every combination previously
/// implemented by seven separate classes (Traverse, Diagonal, Ascend,
/// DiagonalAscend, DiagonalDescend, Parkour, SidewallParkour).
///
/// All feasibility and cost logic lives in <see cref="JumpFeasibility"/>.
/// Factory helpers (<see cref="Traverse"/>, <see cref="Parkour"/>, ...)
/// produce the right descriptor for each use site without requiring
/// callers to remember the mapping between flavor and <see cref="MoveType"/>.
/// </summary>
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,
};
}
}
}

View file

@ -1,298 +0,0 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Moves;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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})";
}
}
}

View file

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

View file

@ -1,54 +0,0 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// Flat cardinal walk (1 block in +/-X or +/-Z, same Y).
/// Checks body+head passable and ground below destination.
/// </summary>
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);
}
}
}

View file

@ -0,0 +1,56 @@
namespace MinecraftClient.Pathing.Moves;
/// <summary>
/// Kind of jump-family move. Each flavor selects a different evaluator path
/// inside <see cref="JumpFeasibility"/> while sharing low-level primitives
/// (head clearance, destination clearance, flight-path sweep, cost model).
/// </summary>
public enum JumpFlavor
{
/// <summary>
/// Single-block cardinal or diagonal walk at the same Y. No jump input.
/// Covers the old <c>MoveTraverse</c> and <c>MoveDiagonal</c>.
/// </summary>
Walk,
/// <summary>
/// Single-block vertical step (dy = +1 up or dy = -1 down), cardinal or
/// diagonal. Covers <c>MoveAscend</c>, <c>MoveDiagonalAscend</c>, and
/// <c>MoveDiagonalDescend</c>.
/// </summary>
Step,
/// <summary>
/// Multi-block sprint jump, cardinal or diagonal. Covers <c>MoveParkour</c>.
/// </summary>
SprintJump,
/// <summary>
/// Dominant-axis sprint jump that uses an inner wall for support. Covers
/// <c>MoveSidewallParkour</c>.
/// </summary>
Sidewall,
}
/// <summary>
/// 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.
/// </summary>
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));
}

View file

@ -0,0 +1,277 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Moves.Impl;
namespace MinecraftClient.Pathing.Moves;
/// <summary>
/// Dynamic expander for every move in the jump family (Walk, Step,
/// SprintJump, Sidewall). Iterates a declarative descriptor table and calls
/// <see cref="JumpFeasibility.Evaluate"/> 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 <see cref="JumpFeasibility"/>. Ordinary
/// ground-walking nodes skip all ~170 jump descriptors this way; nodes
/// without any adjacent wall skip all 112 sidewall descriptors.
/// </summary>
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<MoveNeighbor> 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<bool> 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;
}
/// <summary>
/// Conservative O(1) short-circuit for the Sidewall family. Every sidewall
/// descriptor needs a solid block one lateral step from the takeoff at
/// <c>y</c> or <c>y+1</c>, 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 <see cref="JumpFeasibility"/>.
/// </summary>
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<JumpDescriptor>(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();
}
/// <summary>
/// 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).
/// </summary>
public static ReadOnlySpan<JumpDescriptor> Descriptors => _descriptors;
}
/// <summary>
/// Thin adapter that wraps an array of legacy <see cref="IMove"/> instances as
/// an <see cref="IMoveExpander"/>. Used for the dynamic-landing and vertical
/// move families (<c>MoveDescend</c>, <c>MoveSprintDescend</c>,
/// <c>MoveClimb</c>, <c>MoveFall</c>) which do not fit the JumpDescriptor model.
/// </summary>
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<MoveNeighbor> 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;
}
}

View file

@ -0,0 +1,574 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Moves;
/// <summary>
/// Single source of truth for jump-family feasibility and cost. Each
/// <see cref="JumpFlavor"/> 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.
/// </summary>
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);
}
}

View file

@ -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<MoveResult, MoveType, JumpDescriptor?> 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.