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