feat: add sidewall parkour planner support

This commit is contained in:
BruceChen 2026-04-18 16:26:15 +00:00
parent 724880f928
commit e23037a897
5 changed files with 342 additions and 0 deletions

View file

@ -207,6 +207,35 @@ public sealed class LivePathingRegressionTests
Assert.Empty(PathSegmentBuilder.FromPath(result.Path));
}
[Theory]
[MemberData(nameof(SidewallParkourScenarioBuilder.AcceptedCases), MemberType = typeof(SidewallParkourScenarioBuilder))]
public void AStar_SidewallAcceptedCases_PlanThroughAllThreeJumps(string scenarioId, int gap, int deltaY, int wallOffset)
{
PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.Equal(3, segments.FindAll(segment => segment.MoveType == MoveType.Parkour).Count);
Assert.All(
segments.Where(segment => segment.MoveType == MoveType.Parkour),
segment => Assert.Equal(ParkourProfile.Sidewall, segment.ParkourProfile));
Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X);
Assert.Equal(scenario.Goal.Y, segments[^1].End.Y);
Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z);
}
[Theory]
[MemberData(nameof(SidewallParkourScenarioBuilder.RejectedCases), MemberType = typeof(SidewallParkourScenarioBuilder))]
public void AStar_SidewallRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY, int wallOffset)
{
PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
Assert.Equal(PathStatus.Failed, result.Status);
Assert.Empty(PathSegmentBuilder.FromPath(result.Path));
}
[Fact]
public void AStar_LiveCoordinateLinearDescendGap3DyMinus2_PlansThroughAllThreeJumps()
{

View file

@ -0,0 +1,51 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Moves.Impl;
using MinecraftClient.Tests.Pathing.Execution;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Moves;
public sealed class MoveSidewallParkourTests
{
[Theory]
[InlineData("sidewall-flat-gap2-wo0", 2, 0, 0)]
[InlineData("sidewall-flat-gap3-wo1", 3, 0, 1)]
[InlineData("sidewall-ascend-gap2-dy+1-wo0", 2, 1, 0)]
[InlineData("sidewall-ascend-gap3-dy+1-wo1", 3, 1, 1)]
[InlineData("sidewall-descend-gap2-dy-1-wo0", 2, -1, 0)]
[InlineData("sidewall-descend-gap3-dy-1-wo1", 3, -1, 1)]
[InlineData("sidewall-descend-gap2-dy-2-wo0", 2, -2, 0)]
[InlineData("sidewall-descend-gap3-dy-2-wo1", 3, -2, 1)]
public void Calculate_AcceptsTheoryAllowedCases(string scenarioId, int gap, int deltaY, int wallOffset)
{
World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY);
MoveResult result = default;
move.Calculate(ctx, 100, 80, 100, ref result);
Assert.False(result.IsImpossible, scenarioId);
Assert.Equal(ParkourProfile.Sidewall, result.ParkourProfile);
}
[Theory]
[InlineData("sidewall-flat-gap5-wo0", 5, 0, 0)]
[InlineData("sidewall-flat-gap5-wo1", 5, 0, 1)]
[InlineData("sidewall-ascend-gap4-dy+1-wo0", 4, 1, 0)]
[InlineData("sidewall-ascend-gap4-dy+1-wo1", 4, 1, 1)]
[InlineData("sidewall-descend-gap6-dy-1-wo0", 6, -1, 0)]
[InlineData("sidewall-descend-gap6-dy-2-wo1", 6, -2, 1)]
public void Calculate_RejectsTheoryForbiddenCases(string scenarioId, int gap, int deltaY, int wallOffset)
{
World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY);
MoveResult result = default;
move.Calculate(ctx, 100, 80, 100, ref result);
Assert.True(result.IsImpossible, scenarioId);
}
}

View file

@ -96,6 +96,30 @@ namespace MinecraftClient.Pathing.Core
moves.Add(new MoveParkour(0, dz * dist, yDelta: -2));
}
// Sidewall parkour: dominant-axis sprint jumps with a one-block lateral offset.
foreach (int dx in offsets)
{
foreach (int dz in offsets)
{
foreach (int distance in new[] { 2, 3, 4, 5 })
{
moves.Add(new MoveSidewallParkour(dx, dz * distance));
moves.Add(new MoveSidewallParkour(dx * distance, dz));
if (distance <= 3)
{
moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: 1));
moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: 1));
}
moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -1));
moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -1));
moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -2));
moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -2));
}
}
}
// Diagonal parkour: sprint jumps at angles.
// Only include combinations with actual distance <= ~3.2 blocks (conservative)
foreach (int dx in offsets)

View file

@ -0,0 +1,104 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
public sealed class MoveSidewallParkour : IMove
{
public MoveType Type => MoveType.Parkour;
public int XOffset { get; }
public int ZOffset { get; }
public bool DynamicY => false;
private readonly int _yDelta;
public MoveSidewallParkour(int xOffset, int zOffset, int yDelta = 0)
{
XOffset = xOffset;
ZOffset = zOffset;
_yDelta = yDelta;
}
public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result)
{
if (!ctx.AllowParkour || !ctx.CanSprint)
{
result.SetImpossible();
return;
}
if (_yDelta > 0 && !ctx.AllowParkourAscend)
{
result.SetImpossible();
return;
}
if (_yDelta < 0 && -_yDelta > ctx.MaxFallHeight)
{
result.SetImpossible();
return;
}
if (!ParkourFeasibility.IsSidewallProfile(XOffset, ZOffset, _yDelta))
{
result.SetImpossible();
return;
}
Material standingOn = ctx.GetMaterial(x, y - 1, z);
if (standingOn.CanBeClimbedOn())
{
result.SetImpossible();
return;
}
Material atFeet = ctx.GetMaterial(x, y, z);
if (atFeet.IsLiquid())
{
result.SetImpossible();
return;
}
ParkourFeasibility.GetSidewallAxes(XOffset, ZOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ);
int destX = x + XOffset;
int destY = y + _yDelta;
int destZ = z + ZOffset;
if (!ctx.CanWalkThrough(x, y + 2, z))
{
result.SetImpossible();
return;
}
if (!ParkourFeasibility.HasDominantAxisRunUp(ctx, x, y, z, forwardX, forwardZ, XOffset, ZOffset, _yDelta))
{
result.SetImpossible();
return;
}
if (!ParkourFeasibility.HasSidewallArcClearance(ctx, x, y, z, forwardX, forwardZ, lateralX, lateralZ, XOffset, ZOffset, _yDelta))
{
result.SetImpossible();
return;
}
if (!ParkourFeasibility.HasSidewallLandingClearance(ctx, destX, destY, destZ, forwardX, forwardZ, lateralX, lateralZ))
{
result.SetImpossible();
return;
}
double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset));
double cost = _yDelta switch
{
> 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty * 2,
< 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty + ActionCosts.FallCost(-_yDelta),
_ => horizDist * ctx.SprintCost + ctx.JumpPenalty,
};
result.Set(destX, destY, destZ, cost, ParkourProfile.Sidewall);
}
}
}

View file

@ -5,6 +5,37 @@ namespace MinecraftClient.Pathing.Moves;
internal static class ParkourFeasibility
{
public static bool IsSidewallProfile(int xOffset, int zOffset, int yDelta)
{
int absX = Math.Abs(xOffset);
int absZ = Math.Abs(zOffset);
int major = Math.Max(absX, absZ);
int minor = Math.Min(absX, absZ);
return minor == 1
&& major >= 2
&& major <= 5
&& yDelta is >= -2 and <= 1;
}
public static void GetSidewallAxes(int xOffset, int zOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ)
{
if (Math.Abs(xOffset) > Math.Abs(zOffset))
{
forwardX = Math.Sign(xOffset);
forwardZ = 0;
lateralX = 0;
lateralZ = Math.Sign(zOffset);
}
else
{
forwardX = 0;
forwardZ = Math.Sign(zOffset);
lateralX = Math.Sign(xOffset);
lateralZ = 0;
}
}
public static bool HasRunUp(
CalculationContext ctx,
int x,
@ -144,6 +175,109 @@ internal static class ParkourFeasibility
return false;
}
public static bool HasDominantAxisRunUp(
CalculationContext ctx,
int x,
int y,
int z,
int forwardX,
int forwardZ,
int xOffset,
int zOffset,
int yDelta)
{
int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset));
int maxMajor = yDelta switch
{
> 0 => 3,
< 0 => 5,
_ => 4,
};
if (major > maxMajor)
return false;
bool carriedEntry = ctx.PreviousMoveType is MoveType.Parkour or MoveType.Descend;
if (carriedEntry)
return true;
for (int i = 1; i <= 2; i++)
{
int rx = x - (forwardX * i);
int rz = z - (forwardZ * i);
if (!ctx.CanWalkOn(rx, y - 1, rz) || !IsColumnPassable(ctx, rx, y, rz))
return false;
}
return true;
}
public static bool HasSidewallArcClearance(
CalculationContext ctx,
int x,
int y,
int z,
int forwardX,
int forwardZ,
int lateralX,
int lateralZ,
int xOffset,
int zOffset,
int yDelta)
{
int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset));
int insideWallDepth = 0;
for (int step = 0; step < 2; step++)
{
int wx = x + lateralX + (forwardX * step);
int wz = z + lateralZ + (forwardZ * step);
if (ctx.CanWalkThrough(wx, y, wz) && ctx.CanWalkThrough(wx, y + 1, wz))
break;
insideWallDepth++;
}
if (insideWallDepth is < 1 or > 2)
return false;
for (int step = 1; step <= major; step++)
{
int cx = x + (forwardX * step);
int cz = z + (forwardZ * step);
if (!IsColumnPassable(ctx, cx, y, cz))
return false;
}
int outsideX = x - lateralX;
int outsideZ = z - lateralZ;
return IsColumnPassable(ctx, outsideX, y, outsideZ);
}
public static bool HasSidewallLandingClearance(
CalculationContext ctx,
int destX,
int destY,
int destZ,
int forwardX,
int forwardZ,
int lateralX,
int lateralZ)
{
if (!ctx.CanWalkOn(destX, destY - 1, destZ))
return false;
if (!IsColumnPassable(ctx, destX, destY, destZ))
return false;
if (!IsColumnPassable(ctx, destX + forwardX, destY, destZ + forwardZ))
return false;
if (!IsColumnPassable(ctx, destX - lateralX, destY, destZ - lateralZ))
return false;
return true;
}
private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z)
{
return ctx.CanWalkThrough(x, y, z)