pathing: tighten linear parkour execution

This commit is contained in:
BruceChen 2026-04-18 06:14:45 +00:00
parent 891763602a
commit d919bff91f
22 changed files with 2072 additions and 79 deletions

View file

@ -10,6 +10,30 @@ namespace MinecraftClient.Tests.Pathing.Execution;
public sealed class GroundedTemplateConvergenceTests
{
[Fact]
public void PlayerPhysics_SprintingGroundTravel_IsFasterThanWalking()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 16);
var walking = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
var sprinting = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
for (int tick = 0; tick < 8; tick++)
{
walking.ApplyInput(new MovementInput { Forward = true });
walking.Tick(world);
sprinting.ApplyInput(new MovementInput { Forward = true, Sprint = true });
sprinting.Tick(world);
}
double walkingTravel = walking.Position.X - 0.5;
double sprintingTravel = sprinting.Position.X - 0.5;
Assert.True(
sprintingTravel > walkingTravel + 0.05,
$"walkingTravel={walkingTravel:F4} sprintingTravel={sprintingTravel:F4} walkVel={walking.DeltaMovement} sprintVel={sprinting.DeltaMovement}");
}
[Fact]
public void WalkTemplate_FinalStop_Completes_WhenFootprintStaysInsideTargetBlock()
{
@ -541,6 +565,151 @@ public sealed class GroundedTemplateConvergenceTests
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
}
[Fact]
public void DescendTemplate_LandingRecovery_ChainCarry_CompletesBeforeSettling()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 360, max: 369);
FlatWorldTestBuilder.ClearBox(world, 360, 79, 358, 369, 85, 362);
FlatWorldTestBuilder.FillSolid(world, 362, 79, 360, 363, 79, 360);
FlatWorldTestBuilder.SetSolid(world, 364, 77, 360);
FlatWorldTestBuilder.SetSolid(world, 365, 75, 360);
FlatWorldTestBuilder.SetSolid(world, 366, 73, 360);
var segment = new PathSegment
{
Start = new Location(363.5, 80, 360.5),
End = new Location(364.5, 78, 360.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
};
var next = new PathSegment
{
Start = new Location(364.5, 78, 360.5),
End = new Location(365.5, 76, 360.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
};
var template = new DescendTemplate(segment, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
var input = new MovementInput();
var trace = new List<string>();
TemplateState state = TemplateState.InProgress;
Location finalPos = segment.Start;
for (int tick = 0; tick < 160; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = template.Tick(pos, physics, input, world);
trace.Add(
$"tick={tick} state={state} pos={pos} vel={physics.DeltaMovement} onGround={physics.OnGround} " +
$"input(F={input.Forward},B={input.Back},S={input.Sprint})");
if (state != TemplateState.InProgress)
{
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
break;
}
physics.ApplyInput(input);
physics.Tick(world);
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
}
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End), $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
Assert.True(physics.DeltaMovement.X >= 0.03, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
}
[Fact]
public void DescendTemplate_LandingRecovery_ShortChain_DoesNotSwingYawAfterPassingEndPlane()
{
static bool HasReachedSegmentEndPlane(Location pos, PathSegment segment, double tolerance = 0.05)
{
double dirX = Math.Sign(segment.End.X - segment.Start.X);
double dirZ = Math.Sign(segment.End.Z - segment.Start.Z);
double relX = pos.X - segment.End.X;
double relZ = pos.Z - segment.End.Z;
return relX * dirX + relZ * dirZ >= -tolerance;
}
static double HeadingPenaltyDegrees(float yaw, PathSegment segment)
{
float targetYaw = (float)(-Math.Atan2(segment.HeadingX, segment.HeadingZ) / Math.PI * 180.0);
if (targetYaw < 0f)
targetYaw += 360f;
float delta = targetYaw - yaw;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
return Math.Abs(delta);
}
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: -2, max: 10);
FlatWorldTestBuilder.ClearBox(world, -2, 1, -2, 10, 90, 2);
FlatWorldTestBuilder.FillSolid(world, 0, 79, 0, 3, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 4, 77, 0);
FlatWorldTestBuilder.SetSolid(world, 5, 75, 0);
FlatWorldTestBuilder.SetSolid(world, 6, 73, 0);
var segment = new PathSegment
{
Start = new Location(4.5, 78, 0.5),
End = new Location(5.5, 76, 0.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
};
var next = new PathSegment
{
Start = new Location(5.5, 76, 0.5),
End = new Location(6.5, 74, 0.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
};
var template = new DescendTemplate(segment, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
var input = new MovementInput();
var trace = new List<string>();
double maxHeadingPenaltyPastEnd = 0.0;
TemplateState state = TemplateState.InProgress;
Location finalPos = segment.Start;
for (int tick = 0; tick < 120; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = template.Tick(pos, physics, input, world);
bool pastEnd = HasReachedSegmentEndPlane(pos, segment);
if (!physics.OnGround && pastEnd)
maxHeadingPenaltyPastEnd = Math.Max(maxHeadingPenaltyPastEnd, HeadingPenaltyDegrees(physics.Yaw, segment));
trace.Add(
$"tick={tick} state={state} pos={pos} yaw={physics.Yaw:F1} vel={physics.DeltaMovement} " +
$"onGround={physics.OnGround} pastEnd={pastEnd} input(F={input.Forward},B={input.Back},S={input.Sprint})");
if (state != TemplateState.InProgress)
{
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
break;
}
physics.ApplyInput(input);
physics.Tick(world);
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
}
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
Assert.True(
maxHeadingPenaltyPastEnd <= 35.0,
$"maxHeadingPenaltyPastEnd={maxHeadingPenaltyPastEnd:F1} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
}
[Fact]
public void DescendTemplate_FinalStop_WithWallAndMisalignedYaw_CompletesOnLandingBlock()
{

View file

@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Pathing.Goals;
using MinecraftClient.Physics;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
@ -115,4 +117,358 @@ public sealed class LivePathingRegressionTests
double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z);
Assert.InRange(horizontalSpeed, 0.0, 0.04);
}
[Fact]
public void AStar_LinearFlatGapFourChain_PlansThroughAllThreeJumps()
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-flat-gap4", gap: 4, deltaY: 0);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.NotEmpty(segments);
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]
[InlineData("linear-ascend-gap2-dy+1", 2, 1)]
[InlineData("linear-descend-gap4-dy-1", 4, -1)]
public void AStar_LinearChainCases_PlansThroughAllThreeJumps(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.NotEmpty(segments);
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);
}
[Fact]
public void AStar_LinearDescendGap2DyMinus1_DoesNotSkipIntermediateLanding()
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.DoesNotContain(
segments,
segment => segment.MoveType == MoveType.Parkour
&& (Math.Abs(segment.End.X - segment.Start.X) + Math.Abs(segment.End.Z - segment.Start.Z)) > 3.1);
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]
[InlineData("linear-descend-gap4-dy-2", 4, -2)]
public void AStar_LinearExtendedChainCases_PlansThroughAllThreeJumps(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.NotEmpty(segments);
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]
[InlineData("linear-ascend-gap3-dy+1", 3, 1)]
[InlineData("linear-descend-gap5-dy-1", 5, -1)]
[InlineData("linear-descend-gap5-dy-2", 5, -2)]
public void AStar_LinearRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
Assert.Equal(PathStatus.Failed, result.Status);
Assert.Empty(PathSegmentBuilder.FromPath(result.Path));
}
[Fact]
public void AStar_LiveCoordinateLinearDescendGap3DyMinus2_PlansThroughAllThreeJumps()
{
const int baseX = 100;
const int baseY = 80;
const int baseZ = 180;
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 3, deltaY: -2, segments: 3);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var finder = new AStarPathFinder();
PathResult result = finder.Calculate(
ctx,
startX: baseX,
startY: baseY,
startZ: baseZ,
new GoalBlock(115, 74, 180),
CancellationToken.None,
timeoutMs: 2000);
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
Assert.Equal(PathStatus.Success, result.Status);
Assert.NotEmpty(segments);
Assert.Equal(new Location(115.5, 74, 180.5), segments[^1].End);
}
[Fact]
public void PathSegmentManager_LiveCoordinateLinearFlatGap2_CompletesWithoutReplan()
{
const int baseX = 100;
const int baseY = 80;
const int baseZ = 297;
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 2, deltaY: 0, segments: 3);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var finder = new AStarPathFinder();
PathResult planResult = finder.Calculate(
ctx,
startX: baseX,
startY: baseY,
startZ: baseZ,
new GoalBlock(112, 80, 297),
CancellationToken.None,
timeoutMs: 2000);
var debugLogs = new List<string>();
var infoLogs = new List<string>();
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(baseX + 0.5, baseY, baseZ + 0.5), yaw: 270f);
var input = new MovementInput();
var recentTrace = new Queue<string>();
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
Assert.Equal(PathStatus.Success, planResult.Status);
manager.StartNavigation(new GoalBlock(112, 80, 297), planResult);
for (int tick = 0; tick < 200 && manager.IsNavigating; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
manager.Tick(pos, physics, input, world);
recentTrace.Enqueue(
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
if (recentTrace.Count > 80)
recentTrace.Dequeue();
if (!manager.IsNavigating)
break;
physics.ApplyInput(input);
physics.Tick(world);
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
}
Assert.True(
!manager.IsNavigating
&& manager.Goal is null
&& manager.ReplanCount == 0
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, new Location(112.5, 80, 297.5)),
$"replans={manager.ReplanCount} final={finalPos}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
}
[Fact]
public void PathSegmentManager_LiveCoordinateLinearDescendGap3DyMinus2_CompletesWithoutReplan()
{
const int baseX = 100;
const int baseY = 80;
const int baseZ = 180;
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 3, deltaY: -2, segments: 3);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var finder = new AStarPathFinder();
PathResult planResult = finder.Calculate(
ctx,
startX: baseX,
startY: baseY,
startZ: baseZ,
new GoalBlock(115, 74, 180),
CancellationToken.None,
timeoutMs: 2000);
var debugLogs = new List<string>();
var infoLogs = new List<string>();
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(baseX + 0.5, baseY, baseZ + 0.5), yaw: 270f);
var input = new MovementInput();
var recentTrace = new Queue<string>();
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
Assert.Equal(PathStatus.Success, planResult.Status);
manager.StartNavigation(new GoalBlock(115, 74, 180), planResult);
for (int tick = 0; tick < 240 && manager.IsNavigating; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
manager.Tick(pos, physics, input, world);
recentTrace.Enqueue(
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
if (recentTrace.Count > 100)
recentTrace.Dequeue();
if (!manager.IsNavigating)
break;
physics.ApplyInput(input);
physics.Tick(world);
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
}
Assert.True(
!manager.IsNavigating
&& manager.Goal is null
&& manager.ReplanCount == 0
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, new Location(115.5, 74, 180.5)),
$"replans={manager.ReplanCount} final={finalPos}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
}
[Fact]
public void PathSegmentManager_LinearDescendGap0DyMinus2_DoesNotTurnInPlace()
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2);
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
World world = scenario.BuildWorld();
var debugLogs = new List<string>();
var infoLogs = new List<string>();
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
var input = new MovementInput();
var samples = new List<TurnSample>();
Assert.Equal(PathStatus.Success, planResult.Status);
manager.StartNavigation(scenario.Goal, planResult);
for (int tick = 0; tick < scenario.MaxExecutionTicks && manager.IsNavigating; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
manager.Tick(pos, physics, input, world);
physics.ApplyInput(input);
physics.Tick(world);
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
}
Assert.True(!manager.IsNavigating && manager.Goal is null && manager.ReplanCount == 0,
$"replans={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}");
int turnStalls = CountTurnStalls(samples, out string stallTrace);
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}");
}
private readonly record struct TurnSample(double X, double Y, double Z, float Yaw);
private static int CountTurnStalls(IReadOnlyList<TurnSample> samples, out string trace)
{
const int MinSamples = 4;
const double WindowMaxTravel = 0.35;
const double MinCumulativeYaw = 180.0;
const double MinPerStepYaw = 35.0;
var traces = new List<string>();
if (samples.Count < MinSamples)
{
trace = string.Empty;
return 0;
}
static double NormalizeYawDelta(float previousYaw, float currentYaw)
{
double delta = (currentYaw - previousYaw + 180.0) % 360.0 - 180.0;
return Math.Abs(delta);
}
static double HorizontalDistance(in TurnSample a, in TurnSample b)
{
double dx = a.X - b.X;
double dz = a.Z - b.Z;
return Math.Sqrt(dx * dx + dz * dz);
}
int count = 0;
int windowStart = 0;
while (windowStart <= samples.Count - MinSamples)
{
TurnSample baseSample = samples[windowStart];
double cumulativeYaw = 0.0;
int largeSwings = 0;
bool matched = false;
for (int idx = windowStart + 1; idx < samples.Count; idx++)
{
TurnSample sample = samples[idx];
if (HorizontalDistance(baseSample, sample) > WindowMaxTravel)
break;
double yawDelta = NormalizeYawDelta(samples[idx - 1].Yaw, sample.Yaw);
cumulativeYaw += yawDelta;
if (yawDelta >= MinPerStepYaw)
largeSwings++;
int sampleCount = idx - windowStart + 1;
if (sampleCount >= MinSamples
&& largeSwings >= MinSamples - 1
&& cumulativeYaw >= MinCumulativeYaw)
{
var windowSamples = new List<string>();
for (int traceIdx = windowStart; traceIdx <= idx; traceIdx++)
{
TurnSample traceSample = samples[traceIdx];
windowSamples.Add($"#{traceIdx}=({traceSample.X:F2},{traceSample.Y:F2},{traceSample.Z:F2},{traceSample.Yaw:F1})");
}
traces.Add(
$"start={windowStart} end={idx} base=({baseSample.X:F2},{baseSample.Y:F2},{baseSample.Z:F2},{baseSample.Yaw:F1}) " +
$"last=({sample.X:F2},{sample.Y:F2},{sample.Z:F2},{sample.Yaw:F1}) yaw={cumulativeYaw:F1}\n" +
string.Join(' ', windowSamples));
count++;
windowStart = idx + 1;
matched = true;
break;
}
}
if (!matched)
windowStart++;
}
trace = string.Join('\n', traces);
return count;
}
private static World BuildLiveLinearWorld(int baseX, int baseY, int baseZ, int gap, int deltaY, int segments)
{
int endX = baseX + 3 + ((gap + 1) * segments);
int min = Math.Min(baseX - 8, baseZ - 8);
int max = Math.Max(endX + 8, baseZ + 8);
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: min, max: max);
FlatWorldTestBuilder.ClearBox(world, baseX - 8, 1, baseZ - 2, endX + 8, baseY + 12, baseZ + 2);
int floorY = baseY - 1;
FlatWorldTestBuilder.FillSolid(world, baseX, floorY, baseZ, baseX + 3, floorY, baseZ);
int lastX = baseX + 3;
int lastFloorY = floorY;
for (int segment = 0; segment < segments; segment++)
{
int platformX = lastX + gap + 1;
int platformY = lastFloorY + deltaY;
FlatWorldTestBuilder.SetSolid(world, platformX, platformY, baseZ);
lastX = platformX;
lastFloorY = platformY;
}
return world;
}
}

View file

@ -2,6 +2,7 @@ using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Physics;
using System.Reflection;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
@ -46,6 +47,105 @@ public sealed class PathExecutorCompletionTests
Assert.False(input.Back);
}
[Fact]
public void Tick_PreservesCarryInput_WhenAdvancingIntoNextSegment()
{
var executor = new PathExecutor(new List<PathSegment>
{
new()
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
},
new()
{
Start = new Location(1.5, 80, 0.5),
End = new Location(4.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
}
});
var physics = new PlayerPhysics
{
Position = new Vec3d(1.48, 80.00, 0.50),
DeltaMovement = new Vec3d(0.1178, -0.0784, 0.0),
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 270f,
Pitch = 0f
};
var input = new MovementInput();
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 8);
PathExecutorState state = executor.Tick(new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), physics, input, world);
Assert.Equal(PathExecutorState.InProgress, state);
Assert.Equal(1, executor.CurrentIndex);
Assert.True(input.Forward, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
Assert.True(input.Sprint, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
Assert.False(input.Back, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
}
[Fact]
public void Tick_AdvanceFromParkourIntoParkour_IssuesJumpOnSameTick()
{
var executor = new PathExecutor(new List<PathSegment>
{
new()
{
Start = new Location(0.5, 80, 0.5),
End = new Location(3.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
},
new()
{
Start = new Location(3.5, 80, 0.5),
End = new Location(6.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
}
});
SetCurrentTemplate(
executor,
new CompletingTemplate(
new Location(0.5, 80, 0.5),
new Location(3.5, 80, 0.5)));
var physics = new PlayerPhysics
{
Position = new Vec3d(3.50, 80.00, 0.50),
DeltaMovement = new Vec3d(0.2200, 0.0, 0.0),
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 270f,
Pitch = 0f
};
var input = new MovementInput();
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 10);
FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 6, 82, 1);
FlatWorldTestBuilder.SetSolid(world, 0, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 3, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 6, 79, 0);
PathExecutorState state = executor.Tick(new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), physics, input, world);
Assert.Equal(PathExecutorState.InProgress, state);
Assert.Equal(1, executor.CurrentIndex);
Assert.True(input.Forward, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
Assert.True(input.Sprint, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
Assert.True(input.Jump, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
Assert.False(input.Back, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
}
[Fact]
public void Tick_CompletesStraightThreeSegmentFlatPath()
{
@ -115,6 +215,99 @@ public sealed class PathExecutorCompletionTests
Assert.True(state == PathExecutorState.Complete, $"state={state}\n{string.Join('\n', debugLogs)}");
}
[Fact]
public void Tick_LinearDescendGap0DyMinus2_DoesNotTurnInPlace()
{
List<PathSegment> segments = PathSegmentBuilder.FromPath(BuildNodes(
(0, 80, 0, MoveType.Traverse),
(1, 80, 0, MoveType.Traverse),
(2, 80, 0, MoveType.Traverse),
(3, 80, 0, MoveType.Traverse),
(4, 78, 0, MoveType.Descend),
(5, 76, 0, MoveType.Descend),
(6, 74, 0, MoveType.Descend)));
var debugLogs = new List<string>();
var executor = new PathExecutor(segments, debugLogs.Add);
World world = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2).BuildWorld();
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
var input = new MovementInput();
var samples = new List<TurnSample>();
PathExecutorState state = PathExecutorState.InProgress;
for (int tick = 0; tick < 420; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = executor.Tick(pos, physics, input, world);
if (state != PathExecutorState.InProgress)
break;
physics.ApplyInput(input);
physics.Tick(world);
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
}
int turnStalls = CountTurnStalls(samples, out string stallTrace);
Assert.True(state == PathExecutorState.Complete, $"state={state}\n{string.Join('\n', debugLogs)}");
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}\n{string.Join('\n', debugLogs)}");
}
[Fact]
public void Tick_PlannedLinearDescendGap0DyMinus2_DoesNotTurnInPlace()
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2);
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(planResult.Path);
var debugLogs = new List<string>();
var executor = new PathExecutor(segments, debugLogs.Add);
World world = scenario.BuildWorld();
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, yaw: scenario.StartYaw);
var input = new MovementInput();
var samples = new List<TurnSample>();
PathExecutorState state = PathExecutorState.InProgress;
for (int tick = 0; tick < scenario.MaxExecutionTicks; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = executor.Tick(pos, physics, input, world);
if (state != PathExecutorState.InProgress)
break;
physics.ApplyInput(input);
physics.Tick(world);
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
}
int turnStalls = CountTurnStalls(samples, out string stallTrace);
string segmentTrace = string.Join('\n', segments.ConvertAll(static segment => segment.ToString()));
Assert.Equal(PathStatus.Success, planResult.Status);
Assert.True(state == PathExecutorState.Complete, $"state={state}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
}
[Fact]
public void Tick_LinearFlatGap2_CompletesWithoutFailure()
{
AssertLinearScenarioCompletes("linear-flat-gap2", gap: 2, deltaY: 0);
}
[Fact]
public void Tick_LinearAscendGap2DyPlus1_CompletesWithoutFailure()
{
AssertLinearScenarioCompletes("linear-ascend-gap2-dy+1", gap: 2, deltaY: 1);
}
[Fact]
public void Tick_LinearDescendGap2DyMinus1_CompletesWithoutFailure()
{
AssertLinearScenarioCompletes("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
}
private static List<PathNode> BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw)
{
var result = new List<PathNode>(raw.Length);
@ -128,4 +321,128 @@ public sealed class PathExecutorCompletionTests
return result;
}
private readonly record struct TurnSample(double X, double Y, double Z, float Yaw);
private sealed class CompletingTemplate(Location start, Location end) : IActionTemplate
{
public Location ExpectedStart { get; } = start;
public Location ExpectedEnd { get; } = end;
public TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input, World world)
{
return TemplateState.Complete;
}
}
private static int CountTurnStalls(IReadOnlyList<TurnSample> samples, out string trace)
{
const int MinSamples = 4;
const double WindowMaxTravel = 0.35;
const double MinCumulativeYaw = 180.0;
const double MinPerStepYaw = 35.0;
var traces = new List<string>();
if (samples.Count < MinSamples)
{
trace = string.Empty;
return 0;
}
static double NormalizeYawDelta(float previousYaw, float currentYaw)
{
double delta = (currentYaw - previousYaw + 180.0) % 360.0 - 180.0;
return Math.Abs(delta);
}
static double HorizontalDistance(in TurnSample a, in TurnSample b)
{
double dx = a.X - b.X;
double dz = a.Z - b.Z;
return Math.Sqrt(dx * dx + dz * dz);
}
int count = 0;
int windowStart = 0;
while (windowStart <= samples.Count - MinSamples)
{
TurnSample baseSample = samples[windowStart];
double cumulativeYaw = 0.0;
int largeSwings = 0;
bool matched = false;
for (int idx = windowStart + 1; idx < samples.Count; idx++)
{
TurnSample sample = samples[idx];
if (HorizontalDistance(baseSample, sample) > WindowMaxTravel)
break;
double yawDelta = NormalizeYawDelta(samples[idx - 1].Yaw, sample.Yaw);
cumulativeYaw += yawDelta;
if (yawDelta >= MinPerStepYaw)
largeSwings++;
int sampleCount = idx - windowStart + 1;
if (sampleCount >= MinSamples
&& largeSwings >= MinSamples - 1
&& cumulativeYaw >= MinCumulativeYaw)
{
traces.Add(
$"start={windowStart} end={idx} base=({baseSample.X:F2},{baseSample.Y:F2},{baseSample.Z:F2},{baseSample.Yaw:F1}) " +
$"last=({sample.X:F2},{sample.Y:F2},{sample.Z:F2},{sample.Yaw:F1}) yaw={cumulativeYaw:F1}");
count++;
windowStart = idx + 1;
matched = true;
break;
}
}
if (!matched)
windowStart++;
}
trace = string.Join('\n', traces);
return count;
}
private static void SetCurrentTemplate(PathExecutor executor, IActionTemplate template)
{
FieldInfo field = typeof(PathExecutor).GetField("_currentTemplate", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("PathExecutor._currentTemplate not found");
field.SetValue(executor, template);
}
private static void AssertLinearScenarioCompletes(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
List<PathSegment> segments = PathSegmentBuilder.FromPath(planResult.Path);
var debugLogs = new List<string>();
var executor = new PathExecutor(segments, debugLogs.Add);
World world = scenario.BuildWorld();
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
var input = new MovementInput();
PathExecutorState state = PathExecutorState.InProgress;
for (int tick = 0; tick < scenario.MaxExecutionTicks; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = executor.Tick(pos, physics, input, world);
if (state != PathExecutorState.InProgress)
break;
physics.ApplyInput(input);
physics.Tick(world);
}
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
string segmentTrace = string.Join('\n', segments.ConvertAll(static segment => segment.ToString()));
Assert.Equal(PathStatus.Success, planResult.Status);
Assert.True(
state == PathExecutorState.Complete,
$"scenario={scenarioId} state={state} finalPos={finalPos} vel={physics.DeltaMovement}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
}
}

View file

@ -1,6 +1,7 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Pathing.Goals;
using MinecraftClient.Physics;
using Xunit;
@ -224,6 +225,95 @@ public sealed class PathSegmentManagerTests
$"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}");
}
[Theory]
[MemberData(nameof(LinearParkourScenarioBuilder.AcceptedCases), MemberType = typeof(LinearParkourScenarioBuilder))]
public void Tick_LinearAcceptedChain_CompletesWithoutReplan(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
World world = scenario.BuildWorld();
var debugLogs = new List<string>();
var infoLogs = new List<string>();
var observer = new RecordingPathExecutionObserver();
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add, observer);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
var input = new MovementInput();
var recentTrace = new Queue<string>();
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
manager.StartNavigation(scenario.Goal, planResult);
for (int tick = 0; tick < scenario.MaxExecutionTicks && manager.IsNavigating; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
manager.Tick(pos, physics, input, world);
recentTrace.Enqueue(
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
if (recentTrace.Count > 60)
recentTrace.Dequeue();
if (!manager.IsNavigating)
break;
physics.ApplyInput(input);
physics.Tick(world);
}
Location finalPosition = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
Location goalLocation = new(scenario.Goal.X + 0.5, scenario.Goal.Y, scenario.Goal.Z + 0.5);
Assert.True(
!manager.IsNavigating
&& manager.Goal is null
&& observer.ReplanCount == 0
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPosition, goalLocation),
$"scenario={scenarioId} completed={!manager.IsNavigating && manager.Goal is null} replans={observer.ReplanCount} final={finalPosition} " +
$"goal={goalLocation} planStatus={planResult.Status}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
}
[Fact]
public void Tick_LinearFlatGap2_CompletesWithoutReplan()
{
AssertLinearScenarioCompletesWithoutReplan("linear-flat-gap2", gap: 2, deltaY: 0);
}
[Fact]
public void Tick_LinearAscendGap2DyPlus1_CompletesWithoutReplan()
{
AssertLinearScenarioCompletesWithoutReplan("linear-ascend-gap2-dy+1", gap: 2, deltaY: 1);
}
[Fact]
public void Tick_LinearAscendGap3DyPlus1_CompletesWithoutReplan()
{
AssertLinearScenarioRejected("linear-ascend-gap3-dy+1", gap: 3, deltaY: 1);
}
[Fact]
public void Tick_LinearDescendGap2DyMinus1_CompletesWithoutReplan()
{
AssertLinearScenarioCompletesWithoutReplan("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
}
[Fact]
public void Tick_LinearDescendGap3DyMinus2_CompletesWithoutReplan()
{
AssertLinearScenarioCompletesWithoutReplan("linear-descend-gap3-dy-2", gap: 3, deltaY: -2);
}
[Fact]
public void Tick_LinearDescendGap5DyMinus1_CompletesWithoutReplan()
{
AssertLinearScenarioRejected("linear-descend-gap5-dy-1", gap: 5, deltaY: -1);
}
[Fact]
public void Tick_LinearDescendGap5DyMinus2_CompletesWithoutReplan()
{
AssertLinearScenarioRejected("linear-descend-gap5-dy-2", gap: 5, deltaY: -2);
}
private static List<PathNode> BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw)
{
var result = new List<PathNode>(raw.Length);
@ -237,4 +327,36 @@ public sealed class PathSegmentManagerTests
return result;
}
private static void AssertLinearScenarioCompletesWithoutReplan(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario);
Location goalLocation = new(scenario.Goal.X + 0.5, scenario.Goal.Y, scenario.Goal.Z + 0.5);
Assert.True(
result.Completed
&& result.ReplanCount == 0
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(result.FinalPosition, goalLocation),
$"scenario={scenarioId} completed={result.Completed} replans={result.ReplanCount} final={result.FinalPosition} " +
$"goal={goalLocation} planStatus={result.PlanResult.Status}\ninfo={string.Join('\n', result.InfoLogs)}\ndebug={string.Join('\n', result.DebugLogs)}");
}
private static void AssertLinearScenarioRejected(string scenarioId, int gap, int deltaY)
{
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
var infoLogs = new List<string>();
var manager = new PathSegmentManager(infoLog: infoLogs.Add);
manager.StartNavigation(scenario.Goal, result);
Assert.True(
result.Status == PathStatus.Failed
&& !manager.IsNavigating
&& manager.Goal is null
&& manager.ReplanCount == 0
&& infoLogs.Exists(log => log.Contains("no path found", StringComparison.OrdinalIgnoreCase)),
$"scenario={scenarioId} planStatus={result.Status}\npath={string.Join('\n', PathSegmentBuilder.FromPath(result.Path))}\ninfo={string.Join('\n', infoLogs)}");
}
}

View file

@ -0,0 +1,59 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Goals;
namespace MinecraftClient.Tests.Pathing.Execution;
public static class LinearParkourScenarioBuilder
{
private const int SegmentCount = 3;
private const int BaseY = 80;
private const int FloorY = BaseY - 1;
public static IEnumerable<object[]> AcceptedCases()
{
yield return ["linear-ascend-gap1-dy+1", 1, 1];
yield return ["linear-ascend-gap2-dy+1", 2, 1];
yield return ["linear-descend-gap2-dy-2", 2, -2];
yield return ["linear-descend-gap3-dy-1", 3, -1];
yield return ["linear-descend-gap4-dy-1", 4, -1];
yield return ["linear-flat-gap1", 1, 0];
yield return ["linear-flat-gap4", 4, 0];
}
internal static PathingExecutionScenario Create(string scenarioId, int gap, int deltaY, int maxExecutionTicks = 600)
{
int endX = 3 + ((gap + 1) * SegmentCount);
int endFloorY = FloorY + (deltaY * SegmentCount);
return new PathingExecutionScenario
{
Id = scenarioId,
BuildWorld = () => BuildWorld(gap, deltaY),
Start = new Location(0.5, BaseY, 0.5),
Goal = new GoalBlock(endX, endFloorY + 1, 0),
StartYaw = 270f,
MaxExecutionTicks = maxExecutionTicks,
};
}
internal static World BuildWorld(int gap, int deltaY)
{
int endX = 3 + ((gap + 1) * SegmentCount);
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: -8, max: endX + 8);
FlatWorldTestBuilder.ClearBox(world, -8, 1, -2, endX + 8, BaseY + 12, 2);
FlatWorldTestBuilder.FillSolid(world, 0, FloorY, 0, 3, FloorY, 0);
int lastX = 3;
int lastFloorY = FloorY;
for (int segment = 0; segment < SegmentCount; segment++)
{
int platformX = lastX + gap + 1;
int platformY = lastFloorY + deltaY;
FlatWorldTestBuilder.SetSolid(world, platformX, platformY, 0);
lastX = platformX;
lastFloorY = platformY;
}
return world;
}
}

View file

@ -1,7 +1,9 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Pathing.Moves.Impl;
using MinecraftClient.Tests.Pathing.Execution;
using System.Reflection;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Moves;
@ -13,6 +15,16 @@ public sealed class MoveParkourTests
private static CalculationContext BuildContext(World world)
=> new(world, allowParkour: true, allowParkourAscend: true);
private static void SetPreviousMoveType(CalculationContext ctx, MoveType moveType)
{
PropertyInfo? property = typeof(CalculationContext).GetProperty(
nameof(CalculationContext.PreviousMoveType),
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
MethodInfo? setter = property?.SetMethod;
Assert.NotNull(setter);
setter!.Invoke(ctx, [moveType]);
}
[Fact]
public void Rejects3x1JumpWhenRunUpMissing()
{
@ -90,4 +102,111 @@ public sealed class MoveParkourTests
Assert.True(result.IsImpossible);
}
[Fact]
public void AcceptsCarried4x1DescendingGapFromSingleBlockLanding()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 2, -1, 6, FloorY + 4, 1);
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 4, FloorY - 1, 0);
var ctx = BuildContext(world);
SetPreviousMoveType(ctx, MoveType.Parkour);
var move = new MoveParkour(4, 0, yDelta: -1);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.False(result.IsImpossible);
Assert.Equal(4, result.DestX);
Assert.Equal(FloorY, result.DestY);
}
[Fact]
public void Rejects4x1AscendingGapEvenWithRunway()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -2, FloorY, -1, 6, FloorY + 4, 1);
FlatWorldTestBuilder.FillSolid(world, -2, FloorY, 0, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 4, FloorY + 1, 0);
var ctx = BuildContext(world);
var move = new MoveParkour(4, 0, yDelta: 1);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.True(result.IsImpossible);
}
[Fact]
public void Rejects6x1DescendingGapEvenWithRunway()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -3, FloorY - 1, -1, 8, FloorY + 4, 1);
FlatWorldTestBuilder.FillSolid(world, -3, FloorY, 0, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 1, 0);
var ctx = BuildContext(world);
var move = new MoveParkour(6, 0, yDelta: -1);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.True(result.IsImpossible);
}
[Fact]
public void Rejects6x2DescendingGapEvenWithRunway()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -3, FloorY - 2, -1, 8, FloorY + 4, 1);
FlatWorldTestBuilder.FillSolid(world, -3, FloorY, 0, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 2, 0);
var ctx = BuildContext(world);
var move = new MoveParkour(6, 0, yDelta: -2);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.True(result.IsImpossible);
}
[Fact]
public void RejectsCarried6x1DescendingGapFromSingleBlockLanding()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 1, -1, 8, FloorY + 4, 1);
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 1, 0);
var ctx = BuildContext(world);
SetPreviousMoveType(ctx, MoveType.Parkour);
var move = new MoveParkour(6, 0, yDelta: -1);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.True(result.IsImpossible);
}
[Fact]
public void RejectsCarried6x2DescendingGapFromSingleBlockLanding()
{
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 2, -1, 8, FloorY + 4, 1);
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 2, 0);
var ctx = BuildContext(world);
SetPreviousMoveType(ctx, MoveType.Parkour);
var move = new MoveParkour(6, 0, yDelta: -2);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.True(result.IsImpossible);
}
}