diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs index 3459bd2e..0f147df0 100644 --- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -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(); + 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(); + 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() { diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs index b40b3a1d..62a2eb41 100644 --- a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -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 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 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 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 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 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(); + var infoLogs = new List(); + 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(); + 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(); + var infoLogs = new List(); + 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(); + 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(); + var infoLogs = new List(); + var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw); + var input = new MovementInput(); + var samples = new List(); + + 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 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(); + + 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(); + 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; + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs index 0b6fd10b..23af3d7a 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs @@ -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 + { + 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 + { + 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 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(); + 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(); + + 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 segments = PathSegmentBuilder.FromPath(planResult.Path); + + var debugLogs = new List(); + 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(); + + 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 BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) { var result = new List(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 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(); + + 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 segments = PathSegmentBuilder.FromPath(planResult.Path); + + var debugLogs = new List(); + 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)}"); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs index 9669618c..04dbc9ea 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs @@ -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(); + var infoLogs = new List(); + 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(); + + 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 BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) { var result = new List(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(); + 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)}"); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs b/MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs new file mode 100644 index 00000000..948e10be --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs @@ -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 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; + } +} diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs index 5799085f..f6bf0493 100644 --- a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs +++ b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs @@ -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); + } } diff --git a/MinecraftClient/Commands/Debug.cs b/MinecraftClient/Commands/Debug.cs index 92184f1d..f050f283 100644 --- a/MinecraftClient/Commands/Debug.cs +++ b/MinecraftClient/Commands/Debug.cs @@ -78,6 +78,7 @@ namespace MinecraftClient.Commands var loc = handler.GetCurrentLocation(); sb.AppendLine($"§7{Translations.cmd_debug_state_location,-10}§f{loc.X:F2}, {loc.Y:F2}, {loc.Z:F2}"); + sb.AppendLine($"§7{Translations.cmd_debug_state_on_ground,-10}§f{(handler.GetLocalOnGround() ? "true" : "false")}"); sb.AppendLine($"§7{Translations.cmd_debug_state_tps,-10}§f{handler.GetServerTPS():F1}"); diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index c014338e..1cba394a 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -154,6 +154,11 @@ namespace MinecraftClient public string GetUserUuidStr() { return uuidStr; } public string GetSessionID() { return sessionid; } public Location GetCurrentLocation() { return location; } + public bool GetLocalOnGround() + { + Location current = GetCurrentLocation(); + return physicsInitialized ? playerPhysics.OnGround : Movement.IsOnGround(world, current); + } public float GetYaw() { return playerYaw; } public int GetSequenceId() { return sequenceId; } public float GetPitch() { return playerPitch; } diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index c7dfb150..d137c552 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -69,34 +69,31 @@ namespace MinecraftClient.Pathing.Core foreach (int dz in offsets) moves.Add(new MoveSprintDescend(0, dz * 2)); - // Cardinal parkour: 2-4 block sprint jumps along +-X and +-Z + // Cardinal parkour: long sprint jumps along +-X and +-Z. + // Longer distances remain gated by MoveParkour feasibility and available runway/carry. foreach (int dx in offsets) { - for (int dist = 2; dist <= 4; dist++) + for (int dist = 2; dist <= 5; dist++) moves.Add(new MoveParkour(dx * dist, 0)); - // Ascending: +1Y, dist 2-3 (dist 4 ascend not physically reliable) + // Ascending cardinal parkour tops out at offset 3. for (int dist = 2; dist <= 3; dist++) moves.Add(new MoveParkour(dx * dist, 0, yDelta: 1)); - // Descending parkour: sprint-jump, land 1-2 blocks lower - for (int dist = 2; dist <= 4; dist++) - { + // Descending cardinal parkour tops out at offset 5. + for (int dist = 2; dist <= 5; dist++) moves.Add(new MoveParkour(dx * dist, 0, yDelta: -1)); - if (dist <= 3) - moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2)); - } + for (int dist = 2; dist <= 5; dist++) + moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2)); } foreach (int dz in offsets) { - for (int dist = 2; dist <= 4; dist++) + for (int dist = 2; dist <= 5; dist++) moves.Add(new MoveParkour(0, dz * dist)); for (int dist = 2; dist <= 3; dist++) moves.Add(new MoveParkour(0, dz * dist, yDelta: 1)); - for (int dist = 2; dist <= 4; dist++) - { + for (int dist = 2; dist <= 5; dist++) moves.Add(new MoveParkour(0, dz * dist, yDelta: -1)); - if (dist <= 3) - moves.Add(new MoveParkour(0, dz * dist, yDelta: -2)); - } + for (int dist = 2; dist <= 5; dist++) + moves.Add(new MoveParkour(0, dz * dist, yDelta: -2)); } // Diagonal parkour: sprint jumps at angles. @@ -162,6 +159,7 @@ namespace MinecraftClient.Pathing.Core int nodesExplored = 0; int unloadedChunkHits = 0; + bool searchAborted = false; PathNode? bestPartialNode = startNode; double bestPartialScore = startNode.HCost + startNode.GCost * 0.5; MoveResult moveResult = default; @@ -172,12 +170,14 @@ namespace MinecraftClient.Pathing.Core { if (ct.IsCancellationRequested) { + searchAborted = true; DebugLog?.Invoke($"[A*] Cancelled after {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); break; } if (sw.ElapsedMilliseconds > timeoutMs) { + searchAborted = true; DebugLog?.Invoke($"[A*] Timeout ({timeoutMs}ms) after {nodesExplored} nodes"); break; } @@ -195,6 +195,7 @@ namespace MinecraftClient.Pathing.Core foreach (var move in _allMoves) { + ctx.PreviousMoveType = current.MoveUsed; moveResult.Cost = 0; move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); @@ -251,7 +252,9 @@ namespace MinecraftClient.Pathing.Core } } - if (bestPartialNode is not null && bestPartialNode != startNode) + if (bestPartialNode is not null + && bestPartialNode != startNode + && (searchAborted || unloadedChunkHits > 0)) { DebugLog?.Invoke($"[A*] Partial path to ({bestPartialNode.X},{bestPartialNode.Y},{bestPartialNode.Z}), " + $"{nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); diff --git a/MinecraftClient/Pathing/Core/CalculationContext.cs b/MinecraftClient/Pathing/Core/CalculationContext.cs index 19e1c770..fe732334 100644 --- a/MinecraftClient/Pathing/Core/CalculationContext.cs +++ b/MinecraftClient/Pathing/Core/CalculationContext.cs @@ -21,6 +21,7 @@ namespace MinecraftClient.Pathing.Core public double WalkCost { get; } public double SprintCost { get; } public double SneakCost { get; } + public MoveType PreviousMoveType { get; internal set; } public CalculationContext( World world, diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs index 247fdae1..9429dfe2 100644 --- a/MinecraftClient/Pathing/Execution/PathExecutor.cs +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -52,39 +52,57 @@ namespace MinecraftClient.Pathing.Execution return PathExecutorState.Complete; } - _segmentTicks++; _totalTicks++; - var state = _currentTemplate.Tick(pos, physics, input, world); - - switch (state) + int sameTickAdvanceCount = 0; + while (_currentTemplate is not null) { - case TemplateState.Complete: - input.Reset(); - _observer?.OnSegmentCompleted(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); - _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + - $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); - _currentIndex++; - _segmentTicks = 0; - if (_currentIndex >= _segments.Count) - { - _currentTemplate = null; - _debugLog?.Invoke("[PathExec] All segments complete!"); - return PathExecutorState.Complete; - } - AdvanceToNextSegment(); - return PathExecutorState.InProgress; + _segmentTicks++; + var state = _currentTemplate.Tick(pos, physics, input, world); - case TemplateState.Failed: - input.Reset(); - _observer?.OnSegmentFailed(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); - _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + - $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + - $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); - return PathExecutorState.Failed; + switch (state) + { + case TemplateState.Complete: + _observer?.OnSegmentCompleted(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + _currentIndex++; + _segmentTicks = 0; + if (_currentIndex >= _segments.Count) + { + input.Reset(); + _currentTemplate = null; + _debugLog?.Invoke("[PathExec] All segments complete!"); + return PathExecutorState.Complete; + } - default: - return PathExecutorState.InProgress; + AdvanceToNextSegment(); + + // Do not waste the handoff tick when the next segment needs to issue + // a jump or braking input immediately. + sameTickAdvanceCount++; + if (sameTickAdvanceCount > _segments.Count) + { + input.Reset(); + _debugLog?.Invoke("[PathExec] Excessive same-tick segment advances; aborting."); + return PathExecutorState.Failed; + } + continue; + + case TemplateState.Failed: + input.Reset(); + _observer?.OnSegmentFailed(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } } + + input.Reset(); + return PathExecutorState.Complete; } private void AdvanceToNextSegment() diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index dd54de5f..d9c70e39 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -38,6 +38,14 @@ namespace MinecraftClient.Pathing.Execution { _goal = goal; _replanCount = 0; + if (result.Status == PathStatus.Failed || result.Path.Count < 2) + { + _infoLog?.Invoke("[PathMgr] Navigation rejected -- no path found."); + _executor = null; + _goal = null; + return; + } + var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog, _observer); _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); @@ -53,6 +61,19 @@ namespace MinecraftClient.Pathing.Execution switch (state) { case PathExecutorState.Complete: + if (_goal is not null) + { + int px = (int)Math.Floor(pos.X); + int py = (int)Math.Floor(pos.Y); + int pz = (int)Math.Floor(pos.Z); + if (!_goal.IsInGoal(px, py, pz)) + { + _infoLog?.Invoke("[PathMgr] Planned route ended before reaching goal, replanning..."); + Replan(pos, world); + break; + } + } + _observer?.OnNavigationCompleted(_executor.TotalTicks); _infoLog?.Invoke("[PathMgr] Navigation complete!"); _executor = null; diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 4348faf3..5eadda33 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -67,7 +67,11 @@ namespace MinecraftClient.Pathing.Execution.Templates TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); if (horizDistSq > 0.01 && !decision.HoldBack) { - float groundedYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment) + bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment); + float groundedYaw = onOrPastTarget + ? TemplateHelper.GetExitHeadingYaw(_segment) + : TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment) ? TemplateHelper.GetExitHeadingYaw(_segment) : targetYaw; physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, groundedYaw); @@ -90,8 +94,16 @@ namespace MinecraftClient.Pathing.Execution.Templates } else if (horizDistSq > 0.01) { - physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); - if (_hasFallen || YawDifference(physics.Yaw, targetYaw) <= PreDropYawToleranceDeg) + bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment); + bool biasTowardExitInAir = onOrPastTarget + || (_hasFallen && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)); + float airborneYaw = biasTowardExitInAir + ? TemplateHelper.GetExitHeadingYaw(_segment) + : targetYaw; + + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, airborneYaw); + if (_hasFallen || YawDifference(physics.Yaw, airborneYaw) <= PreDropYawToleranceDeg) { if (!_hasFallen && !_needsSprint && ShouldCoastOffLedge(pos)) { diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 1e01850a..9e22f61a 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -1,5 +1,6 @@ using System; using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution.Templates @@ -31,6 +32,7 @@ namespace MinecraftClient.Pathing.Execution.Templates private Phase _phase = Phase.Approach; private bool _leftGround; private bool _carriedGroundEntry; + private bool _releaseForwardLatched; private const float YawToleranceDeg = 5f; @@ -53,10 +55,19 @@ namespace MinecraftClient.Pathing.Execution.Templates double dz = ExpectedEnd.Z - pos.Z; double dy = ExpectedEnd.Y - pos.Y; double horizDistSq = dx * dx + dz * dz; + bool prepareJumpTouchdown = _phase == Phase.Airborne && _leftGround && physics.OnGround; + bool groundedPrepareJumpHandoff = (_phase == Phase.Landing || prepareJumpTouchdown) + && physics.OnGround + && _segment.ExitTransition == PathTransitionType.PrepareJump + && _segment.ExitHints.RequireJumpReady + && (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, _segment.End) + || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment)); float targetYaw = TemplateHelper.CalculateYaw(dx, dz); float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); - physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Yaw = groundedPrepareJumpHandoff + ? TemplateHelper.SmoothYaw(physics.Yaw, TemplateHelper.GetExitHeadingYaw(_segment)) + : TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); switch (_phase) @@ -67,38 +78,49 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount == 1 && TemplateHelper.GetHorizontalSpeed(physics) > 0.02) _carriedGroundEntry = true; - double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart); + double approachProgress = ((pos.X - ExpectedStart.X) * _segment.HeadingX) + + ((pos.Z - ExpectedStart.Z) * _segment.HeadingZ); float yawDelta = YawDifference(physics.Yaw, targetYaw); bool turnInPlace = yawDelta > 35f; input.Forward = !turnInPlace; input.Sprint = !turnInPlace; + bool carriedShortFinalStopJump = _carriedGroundEntry + && _segment.ExitTransition == PathTransitionType.FinalStop + && _horizDist <= 2.5; + bool carriedDescendingFinalStopJump = _carriedGroundEntry + && _segment.ExitTransition == PathTransitionType.FinalStop + && ExpectedEnd.Y < ExpectedStart.Y + && _horizDist <= 3.5; + bool carriedDescendingParkourJump = _carriedGroundEntry + && _segment.ExitTransition == PathTransitionType.PrepareJump + && ExpectedEnd.Y < ExpectedStart.Y + && _horizDist <= 3.5; + if (carriedShortFinalStopJump || carriedDescendingFinalStopJump || carriedDescendingParkourJump) + input.Sprint = false; + // Build momentum before jumping. Sprint speed is ~5.6 m/s // (0.28 blocks/tick). More run-up = more airtime distance. // Standing sprint jump (0t): ~3.6 blocks horizontal // 2-tick sprint (0.56m): ~4.3 blocks horizontal // 4-tick sprint (1.1m): ~5.0 blocks horizontal - double minApproachSq; - if (_horizDist >= 5.0) - minApproachSq = 0.64; // 0.8 blocks - 3+ ticks of sprint + double minApproachDistance; + bool carriedLongDescendingJump = _carriedGroundEntry + && ExpectedEnd.Y < ExpectedStart.Y + && _horizDist >= 5.0; + if (carriedLongDescendingJump) + minApproachDistance = 0.8; // use nearly the full landing block to preserve long-jump carry + else if (_horizDist >= 5.0) + minApproachDistance = 0.8; // 3+ ticks of sprint else if (_horizDist >= 4.0) - minApproachSq = 0.36; // 0.6 blocks - 2-3 ticks of sprint + minApproachDistance = 0.6; // 2-3 ticks of sprint else if (_horizDist > 3.5) - minApproachSq = 0.09; // 0.3 blocks - 1-2 ticks of sprint + minApproachDistance = 0.3; // 1-2 ticks of sprint else - minApproachSq = 0.0; - - if (_carriedGroundEntry - && _segment.ExitTransition == PathTransitionType.FinalStop - && _horizDist <= 2.5 - && GetLateralOffsetFromSegmentLine(pos) > 0.20) - { - input.Sprint = false; - } + minApproachDistance = 0.0; bool yawAligned = yawDelta < YawToleranceDeg; - bool posReady = fromStartSq >= minApproachSq; - + bool posReady = approachProgress >= minApproachDistance; if (yawAligned && posReady) { input.Jump = true; @@ -122,20 +144,25 @@ namespace MinecraftClient.Pathing.Execution.Templates _leftGround = true; bool pastTarget = IsPastTarget(pos); + bool parkourOnOrPastTarget = _segment.MoveType == MoveType.Parkour + && (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + || TemplateHelper.HasReachedSegmentEndPlane(pos, _segment) + || pastTarget); bool biasTowardExitInAir = _segment.ExitTransition == PathTransitionType.LandingRecovery ? TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5) : TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment); - if (biasTowardExitInAir) + if (parkourOnOrPastTarget || biasTowardExitInAir) TemplateHelper.FaceExitHeading(physics, _segment); bool lookaheadAirBrake = TransitionBrakingPlanner.ShouldReleaseForwardInAir( _segment, _nextSegment, pos, physics, world); bool releaseInAir = ShouldReleaseInAir(pos, physics, world); + _releaseForwardLatched |= releaseInAir; bool earlySoftBrake = _segment.ExitTransition == PathTransitionType.LandingRecovery && lookaheadAirBrake && !releaseInAir; - if (releaseInAir || pastTarget) + if (_releaseForwardLatched || pastTarget) { input.Forward = false; input.Sprint = false; @@ -148,7 +175,8 @@ namespace MinecraftClient.Pathing.Execution.Templates else { input.Forward = true; - input.Sprint = true; + input.Sprint = !(_segment.ExitTransition == PathTransitionType.FinalStop + && ExpectedEnd.Y < ExpectedStart.Y); } if (_leftGround && physics.OnGround) @@ -160,15 +188,36 @@ namespace MinecraftClient.Pathing.Execution.Templates } case Phase.Landing: - TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); - TemplateHelper.ApplyDecision(input, decision); - if (decision.HoldBack) - TemplateHelper.FaceSegmentHeading(physics, _segment); - else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)) + bool descendingPrepareJump = _segment.ExitTransition == PathTransitionType.PrepareJump + && ExpectedEnd.Y < ExpectedStart.Y; + bool descendingPrepareJumpOnSupport = descendingPrepareJump + && physics.OnGround + && TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, _segment.End); + bool descendingPrepareJumpPastSupport = descendingPrepareJump + && physics.OnGround + && TemplateHelper.HasReachedSegmentEndPlane(pos, _segment) + && !descendingPrepareJumpOnSupport; + + if (descendingPrepareJumpPastSupport) + { + input.Forward = false; + input.Sprint = false; + input.Back = true; TemplateHelper.FaceExitHeading(physics, _segment); + } + else + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)) + TemplateHelper.FaceExitHeading(physics, _segment); + } if (_segment.ExitTransition == PathTransitionType.PrepareJump && physics.OnGround + && (!descendingPrepareJump || descendingPrepareJumpOnSupport) && GroundedSegmentController.ShouldComplete(_segment, pos, physics)) { return TemplateState.Complete; @@ -236,6 +285,17 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_segment.ExitTransition == PathTransitionType.ContinueStraight || physics.OnGround) return false; + bool heuristicFinalStopRelease = _segment.ExitTransition == PathTransitionType.FinalStop + && ShouldReleaseByRemainingLead(pos, physics); + if (heuristicFinalStopRelease) + return true; + + bool heuristicDescendingPrepareJumpRelease = _segment.ExitTransition == PathTransitionType.PrepareJump + && ExpectedEnd.Y < ExpectedStart.Y + && ShouldReleaseByRemainingLead(pos, physics); + if (heuristicDescendingPrepareJumpRelease) + return true; + bool plannerWantsRelease = TransitionBrakingPlanner.ShouldReleaseForwardInAir( _segment, _nextSegment, pos, physics, world); double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment); @@ -259,6 +319,16 @@ namespace MinecraftClient.Pathing.Execution.Templates return !holdingStaysInside && releasingStaysInside; } + private bool ShouldReleaseByRemainingLead(Location pos, PlayerPhysics physics) + { + double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment); + double forwardSpeed = Math.Max(0.0, + TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, _segment.HeadingX, _segment.HeadingZ)); + double dropHeight = Math.Max(0.0, ExpectedStart.Y - ExpectedEnd.Y); + double releaseLead = 0.14 + (Math.Max(0.0, dropHeight - 1.0) * 0.20); + return remaining <= forwardSpeed + releaseLead; + } + private Location? PredictLandingPosition(PlayerPhysics physics, World world, bool holdForward, bool holdSprint) { PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics); diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index 875e7d9c..e79ab8d3 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -137,6 +137,17 @@ namespace MinecraftClient.Pathing.Execution.Templates return dx * segment.HeadingX + dz * segment.HeadingZ; } + internal static double LateralOffsetFromSegmentLine(Location pos, PathSegment segment) + { + GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ); + if (dirX == 0.0 && dirZ == 0.0) + return 0.0; + + double relX = pos.X - segment.Start.X; + double relZ = pos.Z - segment.Start.Z; + return Math.Abs((-dirZ * relX) + (dirX * relZ)); + } + internal static bool ShouldBiasTowardExitHeading(Location pos, PathSegment segment, double distanceThreshold = 0.35) { GetExitHeading(segment, out int headingX, out int headingZ); diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs index 52bad5d1..aa7fe159 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -58,6 +58,24 @@ namespace MinecraftClient.Pathing.Moves.Impl 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()) @@ -105,6 +123,12 @@ namespace MinecraftClient.Pathing.Moves.Impl 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); diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs index 0257f66b..d4f80fcf 100644 --- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -15,10 +15,22 @@ internal static class ParkourFeasibility int yDelta) { double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset); - double threshold = yDelta > 0 ? 2.5 : 3.5; + bool carriedEntry = ctx.PreviousMoveType is MoveType.Parkour or MoveType.Descend; + double threshold = yDelta switch + { + > 0 when carriedEntry => 4.5, + > 0 => 2.5, + < 0 when carriedEntry => 5.5, + < 0 => 3.5, + _ when carriedEntry => 5.5, + _ => 3.5, + }; if (horiz < threshold) return true; + if (carriedEntry && yDelta < 0) + return true; + int backX = x - Math.Sign(xOffset); int backZ = z - Math.Sign(zOffset); if (!ctx.CanWalkOn(backX, y - 1, backZ)) @@ -96,6 +108,42 @@ internal static class ParkourFeasibility return true; } + public static bool HasIntermediateLandingConflict( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset, + int yDelta) + { + if (yDelta >= 0) + return false; + + bool cardinal = (xOffset == 0) != (zOffset == 0); + int distance = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + if (!cardinal || distance < 6) + return false; + + int destY = y + yDelta; + int xSign = Math.Sign(xOffset); + int zSign = Math.Sign(zOffset); + + for (int step = 1; step < distance; step++) + { + int gx = x + (xOffset != 0 ? xSign * step : 0); + int gz = z + (zOffset != 0 ? zSign * step : 0); + + for (int candidateY = y - 1; candidateY >= destY; candidateY--) + { + if (ctx.CanWalkOn(gx, candidateY - 1, gz) && IsColumnPassable(ctx, gx, candidateY, gz)) + return true; + } + } + + return false; + } + private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z) { return ctx.CanWalkThrough(x, y, z) diff --git a/MinecraftClient/Physics/PlayerPhysics.cs b/MinecraftClient/Physics/PlayerPhysics.cs index 2081bb7b..23d53378 100644 --- a/MinecraftClient/Physics/PlayerPhysics.cs +++ b/MinecraftClient/Physics/PlayerPhysics.cs @@ -529,16 +529,23 @@ namespace MinecraftClient.Physics /// private float GetFrictionInfluencedSpeed(float friction) { + float effectiveSpeed = GetEffectiveMovementSpeed(); if (OnGround) { - return MovementSpeed * (PhysicsConsts.GroundAccelerationFactor / (friction * friction * friction)); + return effectiveSpeed * (PhysicsConsts.GroundAccelerationFactor / (friction * friction * friction)); } else { - return CreativeFlying ? MovementSpeed * 0.1f : PhysicsConsts.AirAcceleration; + return CreativeFlying ? effectiveSpeed * 0.1f : effectiveSpeed * 0.2f; } } + private float GetEffectiveMovementSpeed() + { + // Vanilla applies a transient +30% total movement-speed modifier while sprinting. + return Sprinting ? MovementSpeed * 1.3f : MovementSpeed; + } + /// /// Get the friction of the block below the player's feet. /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 546b5e03..e3c200a0 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -6788,6 +6788,15 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to OnGround. + /// + internal static string cmd_debug_state_on_ground { + get { + return ResourceManager.GetString("cmd.debug.state_on_ground", resourceCulture); + } + } + /// /// Looks up a localized string similar to TPS. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index f5afa017..542e62f7 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2394,6 +2394,9 @@ Logging in... Location + + OnGround + TPS diff --git a/docs/superpowers/plans/2026-04-16-linear-parallel-zero-replan.md b/docs/superpowers/plans/2026-04-16-linear-parallel-zero-replan.md new file mode 100644 index 00000000..d261a8bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-linear-parallel-zero-replan.md @@ -0,0 +1,618 @@ +# Linear Parallel Zero-Replan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `tools/test-parkour.py --filter linear --parallel 6` run on `1.21.11-Vanilla` execute all theory-allowed `linear` cases as `pass`, all theory-forbidden cases as `reject`, and detect any `replan` or turn-in-place stall as a test failure. + +**Architecture:** Treat the current problem as two coupled systems. First, harden the live harness so parallel runs are trustworthy: worker startup must be deterministic, outcome classification must parse `[PathMetric]` telemetry, and the runner must detect turn-in-place stalls instead of inferring success from `Navigation complete!` plus a loose proximity check. Second, add focused C# regressions for the four currently exposed linear failures, then tune the parkour execution templates and grounded completion thresholds until those scenarios complete without replans, overshoot, or stall behavior. + +**Tech Stack:** Python 3, pytest/unittest, C# 14 /.NET 10, MCC pathing runtime, local `1.21.11-Vanilla` server harness, RCON, tmux-backed `mcc-debug`. + +--- + +## Current Facts + +- Latest valid executed parallel live run used: + - `python3 tools/test-parkour.py --filter linear --parallel 6 --version 1.21.11-Vanilla --results /tmp/linear-live-valid-20260416.jsonl` +- That run built all 25 courses, launched 6 workers, and executed 13 cases before group-level stop-at-first-failure logic skipped the rest of each failing group. +- Current observed live mismatches are: + - `linear-flat-gap1`: expected `pass`, got `fail` + - `linear-ascend-gap2-dy+1`: expected `pass`, got `fail` + - `linear-descend-gap2-dy-2`: expected `pass`, got `fail` + - `linear-descend-gap4-dy-1`: expected `pass`, got `fail` +- Current harness behavior is now trustworthy for this task: + - parses `[PathMetric]` telemetry + - fails any pass-case with `replan_count > 0` + - fails any pass-case with turn-stall detection + - persists `replan_count`, `turn_stall_count`, `near_goal`, `total_ticks`, and `final_position` to JSONL +- Current C# regression status is narrower than live status: + - targeted `SprintJumpTemplate` regressions were added and used to drive several execution fixes + - local C# red lights no longer fully predict the live failure surface + - the next TDD cycle must add regression coverage for `linear-flat-gap1`, `linear-ascend-gap2-dy+1`, and `linear-descend-gap4-dy-1`, not just the original four +- The parallel worker startup bug in `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh` remains fixed and covered by tests. +- Local references are available if needed: + - Decompiled server source under `$MCC_REPO/MinecraftOfficial/-decompiled/` + - `ThirdpartyReference/baritone` + +## File Structure + +### Harness / integration loop + +- Modify: `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh` + - Fix ambiguous argument parsing when the output ini already exists. +- Modify: `tools/test-parkour.py` + - Add strict live metrics parsing. + - Add turn-in-place stall detection during navigation. + - Persist replan/turn metrics into JSONL. + - Keep parallel worker behavior, but make results trustworthy. +- Modify: `tools/tests/test_pathing_live_scripts.py` + - Keep startup/config regression coverage and align list-case assertions with the current script interface. +- Create: `tools/tests/test_test_parkour_metrics.py` + - Focused tests for log parsing, outcome classification, and turn-in-place detection. + +### Runtime / movement fixes + +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs` + - Shared helper to construct the same linear layouts the live harness uses. +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + - Planner/executor regressions that mirror the current valid live mismatches. +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + - Fine-grained parkour landing / overshoot regressions, including currently live-failing flat/ascend/descend chains. +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + - No-replan regressions for accepted linear chains and any newly observed live failures. +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + - Air-brake and landing-completion behavior for long flat / falling jumps. +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + - Final-stop and prepare-jump completion thresholds. +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + - Lookahead braking when the next expected state is a true stop. +- Modify: `MinecraftClient/Pathing/Execution/TemplateHelper.cs` + - Extract any shared “still moving too fast to count as settled” helpers used by the above. + +## Task 1: Lock Down The Parallel Harness Contract + +**Files:** +- Modify: `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh` +- Modify: `tools/tests/test_pathing_live_scripts.py` + +- [ ] **Step 1: Write the failing config regression test** + +Add this test to `tools/tests/test_pathing_live_scripts.py`: + +```python +def test_prepare_offline_config_treats_existing_output_ini_as_output_not_template(self) -> None: + with tempfile.TemporaryDirectory() as tempdir: + temp_path = Path(tempdir) + output_ini = temp_path / "MinecraftClient.debug.ini" + output_ini.write_text( + "\n".join( + [ + "[Main.General]", + 'Account = { Login = "OldBot", Password = "" }', + 'AccountType = "microsoft"', + "", + "[Main.Advanced]", + 'MinecraftVersion = "auto"', + "TerrainAndMovements = false", + "InventoryHandling = false", + "EntityHandling = false", + "AutoRespawn = false", + "", + ] + ), + encoding="utf-8", + ) + + result = subprocess.run( + [ + "bash", + str(REPO_ROOT / ".skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh"), + str(output_ini), + "1.21.11", + "MCCBot1", + ], + check=False, + capture_output=True, + text=True, + cwd=temp_path, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((temp_path / "1.21.11").exists()) + content = output_ini.read_text(encoding="utf-8") + self.assertIn('AccountType = "mojang"', content) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +python3 -m pytest -q tools/tests/test_pathing_live_scripts.py -k existing_output_ini_as_output_not_template +``` + +Expected: + +```text +FAILED ... AssertionError: True is not false +``` + +- [ ] **Step 3: Implement the minimal parser fix** + +Update `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`: + +```bash +if [[ $# -ge 3 && "$2" == *.ini ]]; then + TEMPLATE_INI="$1" + OUTPUT_INI="$2" + MC_VERSION="$3" + LOGIN_NAME="${4:-MCCBot}" +else + OUTPUT_INI="$1" + MC_VERSION="$2" + LOGIN_NAME="${3:-MCCBot}" +fi +``` + +- [ ] **Step 4: Run the full live-script test file** + +Run: + +```bash +python3 -m pytest -q tools/tests/test_pathing_live_scripts.py +``` + +Expected: + +```text +5 passed +``` + +- [ ] **Step 5: Commit** + +```bash +git add .skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh \ + tools/tests/test_pathing_live_scripts.py +git commit -m "test-harness: fix parallel worker config bootstrap" +``` + +## Task 2: Make The Harness Enforce Zero Replan And Turn-Stall Failures + +**Files:** +- Create: `tools/tests/test_test_parkour_metrics.py` +- Modify: `tools/test-parkour.py` + +- [ ] **Step 1: Write parser/classification tests first** + +Create `tools/tests/test_test_parkour_metrics.py` with focused tests like: + +```python +def test_classify_outcome_pass_requires_route_complete_and_zero_replans(): + metrics = LiveMetrics( + planner_status="Success", + route_complete_count=1, + navigation_complete_count=1, + replan_count=0, + turn_stall_count=0, + ) + assert classify_outcome(metrics) == "pass" + + +def test_classify_outcome_replan_is_fail(): + metrics = LiveMetrics( + planner_status="Success", + route_complete_count=1, + navigation_complete_count=1, + replan_count=1, + turn_stall_count=0, + ) + assert classify_outcome(metrics) == "fail" + + +def test_classify_outcome_turn_stall_is_fail(): + metrics = LiveMetrics( + planner_status="Success", + route_complete_count=1, + navigation_complete_count=1, + replan_count=0, + turn_stall_count=1, + ) + assert classify_outcome(metrics) == "fail" + + +def test_detect_turn_stall_requires_low_motion_and_large_yaw_change(): + samples = [ + NavigationSample(x=100.5, y=80.0, z=100.5, yaw=0.0), + NavigationSample(x=100.6, y=80.0, z=100.5, yaw=95.0), + NavigationSample(x=100.6, y=80.0, z=100.5, yaw=185.0), + ] + assert detect_turn_stall(samples) is True +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: + +```bash +python3 -m pytest -q tools/tests/test_test_parkour_metrics.py +``` + +Expected: + +```text +FAILED ... NameError / AttributeError for LiveMetrics, classify_outcome, NavigationSample, detect_turn_stall +``` + +- [ ] **Step 3: Add strict live metrics and turn-stall sampling** + +In `tools/test-parkour.py`, add the minimal structures and parsing helpers: + +```python +@dataclass +class NavigationSample: + x: float + y: float + z: float + yaw: float + + +@dataclass +class LiveMetrics: + planner_status: str | None = None + route_complete_count: int = 0 + navigation_complete_count: int = 0 + replan_count: int = 0 + replan_failed_count: int = 0 + segment_failed_count: int = 0 + turn_stall_count: int = 0 + + +def classify_outcome(metrics: LiveMetrics) -> str: + if metrics.replan_count or metrics.replan_failed_count or metrics.segment_failed_count or metrics.turn_stall_count: + return "fail" + if metrics.planner_status in {"Partial", "Failed"}: + return "reject" + if metrics.route_complete_count or metrics.navigation_complete_count: + return "pass" + return "invalid_live_case" +``` + +Also add navigation polling that samples both position and rotation during `wait_seconds`: + +```python +def get_player_pose(rcon: RconClient, username: str) -> NavigationSample | None: + pos = rcon.command(f"data get entity {username} Pos") + rot = rcon.command(f"data get entity {username} Rotation") + ... + return NavigationSample(x, y, z, yaw) +``` + +```python +def detect_turn_stall(samples: list[NavigationSample]) -> bool: + if len(samples) < 3: + return False + total_motion = sum(math.dist((a.x, a.z), (b.x, b.z)) for a, b in zip(samples, samples[1:])) + total_yaw = sum(abs(normalize_yaw_delta(b.yaw - a.yaw)) for a, b in zip(samples, samples[1:])) + return total_motion < 1.0 and total_yaw >= 180.0 +``` + +- [ ] **Step 4: Persist the new metrics into JSONL** + +Extend the JSONL write in `tools/test-parkour.py`: + +```python +f.write(json.dumps({ + "case_id": case.case_id, + "expected": case.expected, + "outcome": result.outcome, + "matched": result.matched_expected, + "worker": worker_id, + "planner_status": result.metrics.planner_status, + "replan_count": result.metrics.replan_count, + "replan_failed_count": result.metrics.replan_failed_count, + "segment_failed_count": result.metrics.segment_failed_count, + "turn_stall_count": result.metrics.turn_stall_count, +}) + "\n") +``` + +- [ ] **Step 5: Verify the harness tests pass** + +Run: + +```bash +python3 -m pytest -q tools/tests/test_test_parkour_metrics.py +python3 -m pytest -q tools/tests/test_pathing_live_scripts.py +``` + +Expected: + +```text +all green +``` + +- [ ] **Step 6: Commit** + +```bash +git add tools/test-parkour.py \ + tools/tests/test_test_parkour_metrics.py \ + tools/tests/test_pathing_live_scripts.py +git commit -m "test-harness: enforce zero-replan and turn-stall failures" +``` + +## Task 3: Reproduce The Four Live Linear Failures In C# Tests + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + +- [ ] **Step 1: Add a shared linear world builder helper** + +Create `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs`: + +```csharp +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tests.Pathing.Execution.Scenarios; + +internal static class LinearParkourScenarioBuilder +{ + internal static World Build(int gap, int deltaY, out Location start, out Location end) + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 90, max: 140); + FlatWorldTestBuilder.ClearBox(world, 96, 70, 96, 132, 90, 104); + + start = new Location(100.5, 80, 100.5); + int floorY = 79; + FlatWorldTestBuilder.FillSolid(world, 100, floorY, 100, 103, floorY, 100); + + int lastX = 103; + int lastY = floorY; + for (int i = 0; i < 3; i++) + { + int platX = lastX + gap + 1; + int platY = lastY + deltaY; + FlatWorldTestBuilder.SetSolid(world, platX, platY, 100); + lastX = platX; + lastY = platY; + } + + end = new Location(lastX + 0.5, lastY + 1, 100.5); + return world; + } +} +``` + +- [ ] **Step 2: Write the failing manager-level regressions** + +Extend `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs`: + +```csharp +[Fact] +public void Tick_LinearAscendGap1DyPlus1_CompletesWithoutReplan() +{ + World world = LinearParkourScenarioBuilder.Build(gap: 1, deltaY: 1, out Location start, out Location end); + var result = BuildLinearPathResult(start, end, MoveType.Parkour); + var manager = new PathSegmentManager(debugLog: _ => { }, infoLog: _ => { }); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(start, yaw: 270f); + var input = new MovementInput(); + + manager.StartNavigation(new GoalBlock((int)Math.Floor(end.X), (int)end.Y, (int)Math.Floor(end.Z)), result); + RunManager(manager, physics, input, world, maxTicks: 420); + + Assert.False(manager.IsNavigating); + Assert.Equal(0, manager.ReplanCount); + Assert.True(Math.Abs(physics.Position.X - end.X) < 1.0); +} +``` + +Mirror the same structure for: +- `gap: 2, deltaY: -2` +- `gap: 3, deltaY: -1` +- `gap: 4, deltaY: 0` + +- [ ] **Step 3: Run those tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~LivePathingRegressionTests|FullyQualifiedName~SprintJumpTemplateScenarioTests" -v minimal +``` + +Expected: + +```text +FAIL with at least the four new linear regressions +``` + +- [ ] **Step 4: Add lower-level template regressions for overshoot / fall** + +Extend `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` with: + +```csharp +[Fact] +public void SprintJumpTemplate_LinearFlatGap4_FinalStop_StopsInsideTargetBlock() { ... } + +[Fact] +public void SprintJumpTemplate_LinearDescendGap3DyMinus1_FinalStop_DoesNotOvershoot() { ... } + +[Fact] +public void SprintJumpTemplate_LinearAscendGap1DyPlus1_FinalStop_DoesNotFallAfterLanding() { ... } +``` + +These tests should assert both: + +```csharp +Assert.Equal(TemplateState.Complete, state); +Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); +``` + +- [ ] **Step 5: Commit the failing tests only after they are green later** + +No commit here yet. Keep them staged with the implementation in Task 4. + +## Task 4: Fix Parkour Execution For The Four Exposed Linear Failures + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + +- [ ] **Step 1: Fix long-jump overshoot before touching completion gates** + +In `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`, prefer early release for true final stops when the predicted holding path overshoots the landing block: + +```csharp +if (_segment.ExitTransition == PathTransitionType.FinalStop && !physics.OnGround) +{ + Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true); + Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false); + + if (landingIfHolding is not null + && landingIfReleased is not null + && !TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd) + && TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd)) + { + input.Forward = false; + input.Sprint = false; + } +} +``` + +- [ ] **Step 2: Tighten final-stop completion so “complete” cannot happen short of the block** + +In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`: + +```csharp +if (segment.ExitTransition == PathTransitionType.FinalStop + && physics.OnGround + && TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End) + && !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End)) +{ + return TemplateHelper.GetHorizontalSpeed(physics) <= 0.05; +} +``` + +Do not allow `FinalStop` completion through looser `IsNear(...)` checks. + +- [ ] **Step 3: Give landing-recovery / prepare-jump transitions a stricter settle plane** + +In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`, keep `PrepareJump` from completing while the player is still materially before the end plane: + +```csharp +if (segment.MoveType == MoveType.Parkour + && segment.ExitTransition == PathTransitionType.PrepareJump) +{ + return physics.OnGround + && TemplateHelper.RemainingDistanceAlongSegment(pos, segment) <= 0.20 + && exitSpeed >= segment.ExitHints.MinExitSpeed; +} +``` + +- [ ] **Step 4: Re-run the focused C# regressions until green** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: + +```text +PASS for the four new linear regressions and no collateral failures in the touched suites +``` + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \ + MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs \ + MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +git commit -m "pathing: stabilize linear parkour landings" +``` + +## Task 5: Prove The Full Parallel Linear Matrix On 1.21.11 + +**Files:** +- Modify: `tools/test-parkour.py` only if the previous tasks revealed missing diagnostics +- Verify: `/tmp/main-linear-after-update-parallel-fixed.jsonl` replacement run + +- [ ] **Step 1: Run the strict parallel linear sweep** + +Run: + +```bash +export MCC_SERVERS=/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/MinecraftOfficial/downloads +python3 tools/test-parkour.py --filter linear --parallel 6 --results /tmp/linear-parallel-zero-replan-final.jsonl +``` + +Expected: + +```text +25/25 matched expectations +``` + +- [ ] **Step 2: Machine-check the JSONL** + +Run: + +```bash +python3 - <<'PY' +import json +from pathlib import Path +rows = [json.loads(line) for line in Path('/tmp/linear-parallel-zero-replan-final.jsonl').read_text().splitlines() if line.strip()] +print('rows', len(rows)) +print('mismatches', sum(not r['matched'] for r in rows)) +print('pass_replan_nonzero', sum(r['outcome'] == 'pass' and r.get('replan_count', 0) != 0 for r in rows)) +print('pass_turn_nonzero', sum(r['outcome'] == 'pass' and r.get('turn_stall_count', 0) != 0 for r in rows)) +PY +``` + +Expected: + +```text +rows 25 +mismatches 0 +pass_replan_nonzero 0 +pass_turn_nonzero 0 +``` + +- [ ] **Step 3: Save the observed failing logs if anything remains** + +If the run is not green, copy the live log roots before retrying: + +```bash +cp /tmp/main-linear-after-update-parallel-fixed.jsonl /tmp/linear-parallel-investigation-last.jsonl +cp /tmp/mcc-debug/parkour-*/mcc-debug.log /tmp/ 2>/dev/null || true +``` + +- [ ] **Step 4: Commit the final harness/result adjustments** + +```bash +git add tools/test-parkour.py tools/tests/test_test_parkour_metrics.py +git commit -m "test-harness: verify linear zero-replan parallel sweep" +``` + +## Self-Review + +- Spec coverage: + - Parallel run on `1.21.11`: covered in Task 5. + - Collect test results first: covered in Current Facts and Task 5. + - Complete all theory-allowed `linear` passes: covered in Tasks 3-5. + - Zero replan detection: covered in Task 2 and Task 5 JSONL validation. + - Zero turn-in-place detection: covered in Task 2 via turn-stall sampling and Task 5 JSONL validation. + - Autonomous TDD flow: every production change task begins with failing tests. +- Placeholder scan: + - No `TODO`/`TBD`. + - Every code-change task names exact files and commands. +- Type consistency: + - `LiveMetrics`, `NavigationSample`, `detect_turn_stall`, and `classify_outcome` are named consistently across harness tasks. + - `LinearParkourScenarioBuilder.Build(...)` is reused consistently across C# regression tasks. diff --git a/tools/pathing_data/momentum-capabilities.json b/tools/pathing_data/momentum-capabilities.json index ad67b878..5e028d27 100644 --- a/tools/pathing_data/momentum-capabilities.json +++ b/tools/pathing_data/momentum-capabilities.json @@ -136,7 +136,7 @@ "capability_metric": "gap_blocks", "min_mm": 1, "max_mm": 12, - "max_reach": 3, + "max_reach": 2, "delta_y": 1.0, "ceiling_height": null, "wall_offset": null, @@ -188,7 +188,7 @@ "capability_metric": "gap_blocks", "min_mm": 1, "max_mm": 12, - "max_reach": 5, + "max_reach": 4, "delta_y": -2.0, "ceiling_height": null, "wall_offset": null, @@ -214,7 +214,7 @@ "capability_metric": "gap_blocks", "min_mm": 2, "max_mm": 12, - "max_reach": 5, + "max_reach": 4, "delta_y": -1.0, "ceiling_height": null, "wall_offset": null,